diff --git a/.changelog/good-clouds-buzz.md b/.changelog/good-clouds-buzz.md new file mode 100644 index 0000000..4893fab --- /dev/null +++ b/.changelog/good-clouds-buzz.md @@ -0,0 +1,5 @@ +--- +changelogs: patch +--- + +Added support for unified versioning in root changelog format by implicitly treating all workspace packages as a fixed group, merging duplicate version headings and deduplicating changelog entries. Added Rust workspace version inheritance support for reading and writing versions via `version.workspace = true`. diff --git a/Cargo.lock b/Cargo.lock index 1d78c29..369bf47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,7 +156,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "changelogs" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "cargo_metadata", diff --git a/src/changelog_writer.rs b/src/changelog_writer.rs index 7042f7e..dc20186 100644 --- a/src/changelog_writer.rs +++ b/src/changelog_writer.rs @@ -5,6 +5,7 @@ use crate::error::Result; use crate::plan::PackageRelease; use crate::workspace::Workspace; use chrono::Utc; +use std::collections::{BTreeMap, HashSet}; use std::path::Path; use std::process::Command; @@ -471,11 +472,117 @@ pub fn write_changelogs_with_date( } } ChangelogFormat::Root => { + // Group releases by version so fixed-group packages sharing the same + // version get a single heading instead of duplicate `## version` blocks. + let mut by_version: BTreeMap> = BTreeMap::new(); + for release in releases { + by_version + .entry(release.new_version.to_string()) + .or_default() + .push(release); + } + let mut combined_entry = String::new(); - for release in releases { - let entry = generate_entry_with_date(release, changelogs, changelog_dir, date); - combined_entry.push_str(&entry); + for (version, group) in &by_version { + if group.len() == 1 { + // Single release at this version — use existing per-package generation. + let entry = generate_entry_with_date(group[0], changelogs, changelog_dir, date); + combined_entry.push_str(&entry); + } else { + // Multiple releases share this version — merge into one heading + // and deduplicate changelog entries that appear in multiple packages. + combined_entry.push_str(&format!("## {} ({})\n\n", version, date)); + + let github_url = get_github_url(); + + let mut major_changes = Vec::new(); + let mut minor_changes = Vec::new(); + let mut patch_changes = Vec::new(); + let mut seen_changelog_ids: HashSet<&str> = HashSet::new(); + + for release in group { + for changelog in changelogs { + if !release.changelog_ids.contains(&changelog.id) { + continue; + } + if !seen_changelog_ids.insert(&changelog.id) { + continue; + } + + // Find the highest bump level for this changelog across + // all packages in the group. + let bump = group + .iter() + .filter(|r| r.changelog_ids.contains(&changelog.id)) + .flat_map(|r| { + changelog + .releases + .iter() + .filter(|rel| rel.package == r.name) + .map(|rel| rel.bump) + }) + .max() + .unwrap_or(BumpType::Patch); + + let summary = changelog.summary.trim().to_string(); + + let (link_info, authors) = github_url + .as_ref() + .and_then(|base| { + let info = changelog_entry::get_commit_info( + changelog_dir, + &changelog.id, + )?; + let link_info = if let Some(pr) = info.pr_number { + Some((format!("{}/pull/{}", base, pr), format!("#{}", pr))) + } else { + let short_sha = + &info.commit_sha[..7.min(info.commit_sha.len())]; + Some(( + format!("{}/commit/{}", base, short_sha), + short_sha.to_string(), + )) + }; + Some((link_info, info.authors)) + }) + .unwrap_or((None, Vec::new())); + + let change = ChangeWithMeta { + summary, + link: link_info, + authors, + }; + match bump { + BumpType::Major => major_changes.push(change), + BumpType::Minor => minor_changes.push(change), + BumpType::Patch => patch_changes.push(change), + } + } + } + + if !major_changes.is_empty() { + combined_entry.push_str("### Major Changes\n\n"); + for change in major_changes { + write_change_lines(&mut combined_entry, &change); + } + combined_entry.push('\n'); + } + if !minor_changes.is_empty() { + combined_entry.push_str("### Minor Changes\n\n"); + for change in minor_changes { + write_change_lines(&mut combined_entry, &change); + } + combined_entry.push('\n'); + } + if !patch_changes.is_empty() { + combined_entry.push_str("### Patch Changes\n\n"); + for change in patch_changes { + write_change_lines(&mut combined_entry, &change); + } + combined_entry.push('\n'); + } + } } let changelog_path = workspace.root.join("CHANGELOG.md"); diff --git a/src/ecosystems/rust.rs b/src/ecosystems/rust.rs index a528c76..f7a5baf 100644 --- a/src/ecosystems/rust.rs +++ b/src/ecosystems/rust.rs @@ -60,6 +60,25 @@ impl EcosystemAdapter for RustAdapter { let content = std::fs::read_to_string(manifest_path)?; let doc: DocumentMut = content.parse()?; + // Check if version uses workspace inheritance (version.workspace = true) + if Self::is_workspace_inherited(&doc, "version") { + let root = Self::find_workspace_root(manifest_path)?; + let root_manifest = root.join("Cargo.toml"); + let root_content = std::fs::read_to_string(&root_manifest)?; + let root_doc: DocumentMut = root_content.parse()?; + + let version_str = root_doc + .get("workspace") + .and_then(|w| w.get("package")) + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + crate::error::Error::VersionNotFound(root_manifest.display().to_string()) + })?; + + return Ok(version_str.parse()?); + } + let version_str = doc["package"]["version"].as_str().ok_or_else(|| { crate::error::Error::VersionNotFound(manifest_path.display().to_string()) })?; @@ -69,8 +88,22 @@ impl EcosystemAdapter for RustAdapter { fn write_version(manifest_path: &Path, version: &Version) -> Result<()> { let content = std::fs::read_to_string(manifest_path)?; - let mut doc: DocumentMut = content.parse()?; + let doc: DocumentMut = content.parse()?; + // If version is inherited from workspace, write to root Cargo.toml instead + if Self::is_workspace_inherited(&doc, "version") { + let root = Self::find_workspace_root(manifest_path)?; + let root_manifest = root.join("Cargo.toml"); + let root_content = std::fs::read_to_string(&root_manifest)?; + let mut root_doc: DocumentMut = root_content.parse()?; + + root_doc["workspace"]["package"]["version"] = toml_edit::value(version.to_string()); + + std::fs::write(&root_manifest, root_doc.to_string())?; + return Ok(()); + } + + let mut doc: DocumentMut = content.parse()?; doc["package"]["version"] = toml_edit::value(version.to_string()); std::fs::write(manifest_path, doc.to_string())?; @@ -182,6 +215,43 @@ impl EcosystemAdapter for RustAdapter { } impl RustAdapter { + /// Check if a field in `[package]` uses workspace inheritance (e.g., `version.workspace = true`). + fn is_workspace_inherited(doc: &DocumentMut, field: &str) -> bool { + doc.get("package") + .and_then(|p| p.get(field)) + .and_then(|v| v.as_table_like()) + .and_then(|t| t.get("workspace")) + .and_then(|w| w.as_bool()) + .unwrap_or(false) + } + + /// Walk up from a crate's manifest to find the workspace root containing `[workspace]`. + fn find_workspace_root(manifest_path: &Path) -> Result { + let mut current = manifest_path + .parent() + .ok_or_else(|| crate::error::Error::VersionNotFound("no parent directory".to_string()))? + .to_path_buf(); + + loop { + let candidate = current.join("Cargo.toml"); + if candidate.exists() && candidate != manifest_path { + let content = std::fs::read_to_string(&candidate)?; + if content.contains("[workspace]") { + return Ok(current); + } + } + + match current.parent() { + Some(parent) => current = parent.to_path_buf(), + None => { + return Err(crate::error::Error::VersionNotFound( + "workspace root not found".to_string(), + )); + } + } + } + } + fn update_dep_version_in_item(dep: &mut toml_edit::Item, new_version: &Version) -> bool { if let Some(table) = dep.as_inline_table_mut() { if table.contains_key("version") { @@ -461,6 +531,63 @@ my-dep = { version = \"1.0.0\" }\n"; assert_eq!(result, PublishResult::Skipped(SkipReason::NotPublishable)); } + #[test] + fn test_read_version_workspace_inherited() { + let dir = TempDir::new().unwrap(); + + // Root Cargo.toml with workspace.package.version + std::fs::write( + dir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/foo\"]\n\n[workspace.package]\nversion = \"2.5.0\"\n", + ) + .unwrap(); + + // Crate with version.workspace = true + let crate_dir = dir.path().join("crates").join("foo"); + std::fs::create_dir_all(&crate_dir).unwrap(); + let manifest = crate_dir.join("Cargo.toml"); + std::fs::write( + &manifest, + "[package]\nname = \"foo\"\nversion.workspace = true\n", + ) + .unwrap(); + + let version = RustAdapter::read_version(&manifest).unwrap(); + assert_eq!(version, Version::new(2, 5, 0)); + } + + #[test] + fn test_write_version_workspace_inherited() { + let dir = TempDir::new().unwrap(); + + // Root Cargo.toml with workspace.package.version + std::fs::write( + dir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/foo\"]\n\n[workspace.package]\nversion = \"1.0.0\"\n", + ) + .unwrap(); + + // Crate with version.workspace = true + let crate_dir = dir.path().join("crates").join("foo"); + std::fs::create_dir_all(&crate_dir).unwrap(); + let manifest = crate_dir.join("Cargo.toml"); + std::fs::write( + &manifest, + "[package]\nname = \"foo\"\nversion.workspace = true\n", + ) + .unwrap(); + + RustAdapter::write_version(&manifest, &Version::new(3, 0, 0)).unwrap(); + + // Root should be updated + let root_content = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap(); + assert!(root_content.contains("version = \"3.0.0\"")); + + // Crate should be untouched + let crate_content = std::fs::read_to_string(&manifest).unwrap(); + assert!(crate_content.contains("version.workspace = true")); + } + #[test] fn publish_failed_error_includes_context() { let err = crate::error::Error::PublishFailed( diff --git a/src/plan.rs b/src/plan.rs index 2a6c859..14e74c6 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -1,6 +1,6 @@ use crate::BumpType; use crate::changelog_entry::Changelog; -use crate::config::{Config, DependentBump}; +use crate::config::{ChangelogFormat, Config, DependentBump}; use crate::graph::DependencyGraph; use crate::workspace::Workspace; use semver::Version; @@ -56,16 +56,32 @@ pub fn assemble(workspace: &Workspace, changelogs: Vec, config: &Conf } } - for group in &config.fixed { - let max_bump = group - .members + // Build effective fixed groups: explicit ones plus, when using root format, + // an implicit group of all non-ignored workspace packages. + let mut fixed_groups: Vec> = + config.fixed.iter().map(|g| g.members.clone()).collect(); + + if config.changelog.format == ChangelogFormat::Root { + let all_members: Vec = workspace + .package_names() + .into_iter() + .filter(|n| !config.ignore.contains(&n.to_string())) + .map(|n| n.to_string()) + .collect(); + if all_members.len() > 1 { + fixed_groups.push(all_members); + } + } + + for members in &fixed_groups { + let max_bump = members .iter() .filter_map(|m| bump_map.get(m)) .max() .copied(); if let Some(bump) = max_bump { - for member in &group.members { + for member in members { if !config.ignore.contains(member) { bump_map.insert(member.clone(), bump); } diff --git a/tests/fixtures/root-changelog/expected/CHANGELOG.md b/tests/fixtures/root-changelog/expected/CHANGELOG.md index c63db69..df49327 100644 --- a/tests/fixtures/root-changelog/expected/CHANGELOG.md +++ b/tests/fixtures/root-changelog/expected/CHANGELOG.md @@ -1,11 +1,5 @@ # Changelog -## 0.2.1 (2025-01-15) - -### Patch Changes - -- Added streaming support. - ## 0.3.0 (2025-01-15) ### Minor Changes diff --git a/tests/fixtures/root-fixed-group/changelog/add-close.md b/tests/fixtures/root-fixed-group/changelog/add-close.md new file mode 100644 index 0000000..c1cbc14 --- /dev/null +++ b/tests/fixtures/root-fixed-group/changelog/add-close.md @@ -0,0 +1,5 @@ +--- +wallet: patch +--- + +Add session close progress output. diff --git a/tests/fixtures/root-fixed-group/changelog/fix-auth.md b/tests/fixtures/root-fixed-group/changelog/fix-auth.md new file mode 100644 index 0000000..12c0188 --- /dev/null +++ b/tests/fixtures/root-fixed-group/changelog/fix-auth.md @@ -0,0 +1,6 @@ +--- +common: patch +wallet: patch +--- + +Fix authentication token refresh logic. diff --git a/tests/fixtures/root-fixed-group/changelog/fix-payment.md b/tests/fixtures/root-fixed-group/changelog/fix-payment.md new file mode 100644 index 0000000..914ed12 --- /dev/null +++ b/tests/fixtures/root-fixed-group/changelog/fix-payment.md @@ -0,0 +1,5 @@ +--- +request: patch +--- + +Fix payment challenge parsing. diff --git a/tests/fixtures/root-fixed-group/config.toml b/tests/fixtures/root-fixed-group/config.toml new file mode 100644 index 0000000..96ceb04 --- /dev/null +++ b/tests/fixtures/root-fixed-group/config.toml @@ -0,0 +1,7 @@ +dependent_bump = "none" + +[[fixed]] +members = ["common", "wallet", "request"] + +[changelog] +format = "root" diff --git a/tests/fixtures/root-fixed-group/expected/CHANGELOG.md b/tests/fixtures/root-fixed-group/expected/CHANGELOG.md new file mode 100644 index 0000000..973e67e --- /dev/null +++ b/tests/fixtures/root-fixed-group/expected/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 0.1.1 (2025-01-15) + +### Patch Changes + +- Fix authentication token refresh logic. +- Fix payment challenge parsing. +- Add session close progress output. + diff --git a/tests/fixtures/root-fixed-group/expected/releases.txt b/tests/fixtures/root-fixed-group/expected/releases.txt new file mode 100644 index 0000000..223807a --- /dev/null +++ b/tests/fixtures/root-fixed-group/expected/releases.txt @@ -0,0 +1,3 @@ +common: 0.1.0 -> 0.1.1 (patch) +request: 0.1.0 -> 0.1.1 (patch) +wallet: 0.1.0 -> 0.1.1 (patch) diff --git a/tests/fixtures/root-fixed-group/packages.toml b/tests/fixtures/root-fixed-group/packages.toml new file mode 100644 index 0000000..ec58160 --- /dev/null +++ b/tests/fixtures/root-fixed-group/packages.toml @@ -0,0 +1,13 @@ +[[packages]] +name = "common" +version = "0.1.0" + +[[packages]] +name = "wallet" +version = "0.1.0" +deps = ["common"] + +[[packages]] +name = "request" +version = "0.1.0" +deps = ["common"] diff --git a/tests/golden.rs b/tests/golden.rs index b0ca311..0af4684 100644 --- a/tests/golden.rs +++ b/tests/golden.rs @@ -302,6 +302,11 @@ fn golden_multi_crate_multi_changelog() { run_golden_test("multi-crate-multi-changelog"); } +#[test] +fn golden_root_fixed_group() { + run_golden_test("root-fixed-group"); +} + #[test] fn golden_invalid_frontmatter() { let fixture = fixtures_root().join("invalid-frontmatter");