use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
use log::info;
use crate::model::changelog::Changelog;
use crate::model::changeset::{ChangeType, Changeset};
use crate::package_manager::{PackageManagerAdapter, Project};
use crate::utils::today_iso_date;
use super::version::bump_version;
use super::{PackageChanges, PrepareOutput, ReleaseInfo, VersionPlan};
pub(super) async fn prepare_release_files(
adapters: &[Arc<dyn PackageManagerAdapter>],
projects: &[Project],
changesets: &[(crate::path::AbsolutePath, Changeset)],
plan: VersionPlan,
dry_run: bool,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<PrepareOutput> {
let (release_infos, mut files) = bump_versions_and_generate_changelogs(
&plan.aggregated,
&plan.changes_per_package,
projects,
&plan.version_overrides,
&plan.dep_entries,
dry_run,
fs,
)
.await?;
files.extend(propagate_dependency_updates(projects, &release_infos, dry_run).await?);
files.extend(update_lock_files(adapters).await?);
let released: BTreeSet<String> = plan.aggregated.keys().cloned().collect();
files.extend(consume_changesets(changesets, &released, dry_run, fs).await?);
files.extend(plan.propagation_changeset_paths);
files.sort();
files.dedup();
Ok(PrepareOutput {
release_infos,
modified_files: files,
})
}
pub(super) async fn bump_versions_and_generate_changelogs(
aggregated: &BTreeMap<String, ChangeType>,
changes_per_package: &BTreeMap<String, PackageChanges>,
projects: &[Project],
version_overrides: &BTreeMap<String, semver::Version>,
dep_entries: &BTreeMap<String, Vec<String>>,
dry_run: bool,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<(Vec<ReleaseInfo>, Vec<PathBuf>)> {
let mut release_infos: Vec<ReleaseInfo> = Vec::new();
let mut modified_files: Vec<PathBuf> = Vec::new();
for (pkg_name, change_type) in aggregated {
let project = projects
.iter()
.find(|p| p.name() == pkg_name)
.with_context(|| {
format!("Package '{pkg_name}' from changeset not found in projects")
})?;
let current_version = project.version();
let new_version = version_overrides
.get(pkg_name)
.cloned()
.unwrap_or_else(|| bump_version(current_version, *change_type));
modified_files.push(project.path().join("CHANGELOG.md"));
let changes = changes_per_package
.get(pkg_name)
.cloned()
.unwrap_or_default();
let pkg_dep_entries = dep_entries.get(pkg_name).cloned().unwrap_or_default();
let changelog = Changelog::new(
new_version.clone(),
today_iso_date(),
changes,
project.path().clone(),
)
.with_dependency_entries(pkg_dep_entries);
let changelog_entry = changelog.format_sections();
modified_files.extend(project.write_version(&new_version, dry_run).await?);
changelog.update(dry_run, fs).await?;
info!("{pkg_name}: {current_version} -> {new_version} ({change_type})");
release_infos.push(ReleaseInfo {
package_name: pkg_name.clone(),
new_version,
changelog_entry,
});
}
Ok((release_infos, modified_files))
}
pub(super) async fn propagate_dependency_updates(
projects: &[Project],
release_infos: &[ReleaseInfo],
dry_run: bool,
) -> anyhow::Result<Vec<PathBuf>> {
let bumped_versions: BTreeMap<String, semver::Version> = release_infos
.iter()
.map(|info| (info.package_name.clone(), info.new_version.clone()))
.collect();
let update_verb = if dry_run { "would update" } else { "update" };
let mut additional_files: Vec<PathBuf> = Vec::new();
for project in projects {
for dep_name in project.dependency_names() {
let Some(new_version) = bumped_versions.get(dep_name.as_str()) else {
continue;
};
let paths = project
.update_dependency_version(dep_name, new_version, dry_run)
.await?;
if !paths.is_empty() {
info!(
" {}: {update_verb} dependency {} to {}",
project.name(),
dep_name,
new_version
);
additional_files.extend(paths);
}
}
}
Ok(additional_files)
}
pub(super) async fn update_lock_files(
adapters: &[Arc<dyn PackageManagerAdapter>],
) -> anyhow::Result<Vec<PathBuf>> {
let mut files: Vec<PathBuf> = Vec::new();
for adapter in adapters {
if let Some(path) = adapter.update_lock_file().await? {
files.push(path);
}
}
Ok(files)
}
pub(super) async fn consume_changesets(
changesets: &[(crate::path::AbsolutePath, Changeset)],
released: &BTreeSet<String>,
dry_run: bool,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<Vec<PathBuf>> {
let mut additional_files: Vec<PathBuf> = Vec::new();
for (path, cs) in changesets {
let released_pkgs: Vec<&String> = cs
.packages
.keys()
.filter(|name| released.contains(*name))
.collect();
if !released_pkgs.is_empty() {
additional_files.push(path.clone().into_path_buf());
}
if dry_run {
if !released_pkgs.is_empty() {
let pkg_list = released_pkgs
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ");
info!("Would consume changeset {}: {pkg_list}", path.display());
}
} else {
cs.consume(path, released, fs).await?;
}
}
Ok(additional_files)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::command::CommandRunner;
use crate::command::test_support::RecordingCommandRunner;
use crate::filesystem::LocalFilesystem;
use crate::model::config;
fn make_runner() -> Arc<dyn CommandRunner> {
Arc::new(RecordingCommandRunner::new(0))
}
async fn load_projects_for_dir(
dir: &tempfile::TempDir,
) -> Vec<crate::package_manager::Project> {
let runner = make_runner();
let path = crate::path::AbsolutePath::new(dir.path()).unwrap();
let env = crate::Env::new(
Arc::clone(&runner) as Arc<dyn CommandRunner>,
Arc::new(LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(runner, path)),
);
config::load(env.fs(), env.git().path())
.await
.unwrap()
.unwrap()
.load_projects(&env)
.await
.unwrap()
}
fn make_test_env(dir: &std::path::Path) -> crate::Env {
let r = Arc::new(crate::command::test_support::RecordingCommandRunner::new(0))
as Arc<dyn CommandRunner>;
crate::Env::new(
Arc::clone(&r),
Arc::new(LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(
r,
crate::path::AbsolutePath::new(dir).unwrap(),
)),
)
}
async fn setup_two_package_workspace() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let setup_env = make_test_env(dir.path());
crate::model::config::Config::new()
.with_cargo(crate::model::config::CargoConfig::enabled())
.save(setup_env.fs(), setup_env.git().path())
.await
.unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"pkg-a\", \"pkg-b\"]\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("pkg-a/src")).unwrap();
std::fs::write(
dir.path().join("pkg-a/Cargo.toml"),
"[package]\nname = \"pkg-a\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
std::fs::write(dir.path().join("pkg-a/src/lib.rs"), "").unwrap();
std::fs::create_dir_all(dir.path().join("pkg-b/src")).unwrap();
std::fs::write(
dir.path().join("pkg-b/Cargo.toml"),
"[package]\nname = \"pkg-b\"\nversion = \"0.2.0\"\nedition = \"2024\"\n",
)
.unwrap();
std::fs::write(dir.path().join("pkg-b/src/lib.rs"), "").unwrap();
dir
}
async fn setup_workspace_with_dependency() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let setup_env = make_test_env(dir.path());
crate::model::config::Config::new()
.with_cargo(crate::model::config::CargoConfig::enabled())
.save(setup_env.fs(), setup_env.git().path())
.await
.unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"pkg-a\", \"pkg-b\"]\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("pkg-a/src")).unwrap();
std::fs::write(
dir.path().join("pkg-a/Cargo.toml"),
"[package]\nname = \"pkg-a\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
)
.unwrap();
std::fs::write(dir.path().join("pkg-a/src/lib.rs"), "").unwrap();
std::fs::create_dir_all(dir.path().join("pkg-b/src")).unwrap();
std::fs::write(
dir.path().join("pkg-b/Cargo.toml"),
"[package]\nname = \"pkg-b\"\nversion = \"1.0.0\"\nedition = \"2024\"\n\n[dependencies]\npkg-a = { path = \"../pkg-a\", version = \"1.0.0\" }\n",
)
.unwrap();
std::fs::write(dir.path().join("pkg-b/src/lib.rs"), "").unwrap();
dir
}
#[tokio::test]
async fn cmd_prepare_package_flag_filters_packages() {
let dir = setup_two_package_workspace().await;
let cursus_dir = dir.path().join(".cursus");
std::fs::create_dir_all(&cursus_dir).unwrap();
let changeset_path = cursus_dir.join("test.md");
std::fs::write(
&changeset_path,
"+++\npkg-a = \"patch\"\npkg-b = \"minor\"\n+++\n\nSome change\n",
)
.unwrap();
let runner = make_runner();
let dir_abs = crate::path::AbsolutePath::new(dir.path()).unwrap();
let env = crate::Env::new(
Arc::clone(&runner) as Arc<dyn CommandRunner>,
Arc::new(LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(
Arc::clone(&runner) as Arc<dyn CommandRunner>,
dir_abs.clone(),
)),
);
let config = config::load(env.fs(), env.git().path())
.await
.unwrap()
.unwrap();
let args = super::super::PrepareArgs {
packages: vec!["pkg-a".to_string()],
no_git: true,
..super::super::PrepareArgs::default()
};
let result = super::super::cmd_prepare(&args, false, &env, config).await;
assert!(result.is_ok());
assert!(
changeset_path.exists(),
"Changeset should still exist (partially consumed)"
);
let content = std::fs::read_to_string(&changeset_path).unwrap();
assert!(
content.contains("pkg-b = \"minor\""),
"pkg-b should remain in changeset, got: {content}"
);
assert!(
!content.contains("pkg-a"),
"pkg-a should be removed from changeset, got: {content}"
);
}
#[tokio::test]
async fn propagate_dependency_updates_returns_modified_manifest_paths() {
let dir = setup_workspace_with_dependency().await;
let projects = load_projects_for_dir(&dir).await;
let release_infos = vec![super::ReleaseInfo {
package_name: "pkg-a".to_string(),
new_version: "1.0.1".parse().unwrap(),
changelog_entry: String::new(),
}];
let modified_paths = super::propagate_dependency_updates(&projects, &release_infos, false)
.await
.unwrap();
assert!(
modified_paths
.iter()
.any(|p| p.to_string_lossy().contains("pkg-b")),
"Expected pkg-b manifest in modified paths, got: {modified_paths:?}"
);
}
#[tokio::test]
async fn cmd_prepare_package_flag_with_dry_run_leaves_changeset_untouched() {
let dir = setup_two_package_workspace().await;
let cursus_dir = dir.path().join(".cursus");
std::fs::create_dir_all(&cursus_dir).unwrap();
let changeset_path = cursus_dir.join("test.md");
let original = "+++\npkg-a = \"patch\"\npkg-b = \"minor\"\n+++\n\nSome change\n";
std::fs::write(&changeset_path, original).unwrap();
let runner = make_runner();
let dir_abs = crate::path::AbsolutePath::new(dir.path()).unwrap();
let env = crate::Env::new(
Arc::clone(&runner) as Arc<dyn CommandRunner>,
Arc::new(LocalFilesystem),
Arc::new(crate::git::GitWorkdir::new(
Arc::clone(&runner) as Arc<dyn CommandRunner>,
dir_abs.clone(),
)),
);
let config = config::load(env.fs(), env.git().path())
.await
.unwrap()
.unwrap();
let args = super::super::PrepareArgs {
packages: vec!["pkg-a".to_string()],
no_git: true,
..super::super::PrepareArgs::default()
};
let result = super::super::cmd_prepare(&args, true, &env, config).await;
assert!(result.is_ok());
let content = std::fs::read_to_string(&changeset_path).unwrap();
assert_eq!(
content, original,
"Changeset should be untouched in dry-run"
);
}
async fn setup_workspace_version_workspace_true() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let setup_env = make_test_env(dir.path());
crate::model::config::Config::new()
.with_cargo(crate::model::config::CargoConfig::enabled())
.with_linked_versions(crate::model::config::LinkedVersionsConfig {
enabled: Some(true),
groups: Vec::new(),
})
.save(setup_env.fs(), setup_env.git().path())
.await
.unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"pkg-a\", \"pkg-b\"]\n\n[workspace.package]\nversion = \"0.1.0\"\n",
)
.unwrap();
for pkg in ["pkg-a", "pkg-b"] {
std::fs::create_dir_all(dir.path().join(pkg).join("src")).unwrap();
std::fs::write(
dir.path().join(pkg).join("Cargo.toml"),
format!(
"[package]\nname = \"{pkg}\"\nversion.workspace = true\nedition = \"2024\"\n"
),
)
.unwrap();
std::fs::write(dir.path().join(pkg).join("src").join("lib.rs"), "").unwrap();
}
dir
}
#[tokio::test]
async fn bump_versions_includes_workspace_root_when_version_workspace_true() {
let dir = setup_workspace_version_workspace_true().await;
let projects = load_projects_for_dir(&dir).await;
let aggregated: std::collections::BTreeMap<String, crate::model::changeset::ChangeType> = [
(
"pkg-a".to_string(),
crate::model::changeset::ChangeType::Patch,
),
(
"pkg-b".to_string(),
crate::model::changeset::ChangeType::Patch,
),
]
.into_iter()
.collect();
let (_, modified_files) = super::bump_versions_and_generate_changelogs(
&aggregated,
&std::collections::BTreeMap::new(),
&projects,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
false,
&crate::filesystem::LocalFilesystem,
)
.await
.unwrap();
let root_cargo = dir.path().join("Cargo.toml");
assert!(
modified_files.contains(&root_cargo),
"Workspace root Cargo.toml must be in modified_files so it is staged for git.\n\
Got: {modified_files:?}"
);
for pkg in ["pkg-a", "pkg-b"] {
let member_cargo = dir.path().join(pkg).join("Cargo.toml");
assert!(
!modified_files.contains(&member_cargo),
"Member Cargo.toml ({pkg}) must not be in modified_files — it was not modified.\n\
Got: {modified_files:?}"
);
}
}
}