pub mod changeset;
pub mod git_lifecycle;
pub mod github;
pub mod linked_versions;
pub mod propagation;
pub mod release_files;
pub mod version;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Args;
use log::info;
use semver::Version;
use crate::model::changeset::{ChangeType, Changeset};
use crate::model::config::Config;
use crate::package_manager::Project;
use changeset::{aggregate_changesets, resolve_commit_references};
use git_lifecycle::{GitContext, finalize_git_lifecycle, preflight_checks, setup_git_context};
use linked_versions::{
reconcile_linked_versions, resolve_linked_groups, sync_linked_groups_after_propagation,
};
use propagation::apply_dependency_propagation;
use release_files::prepare_release_files;
#[derive(Args, Default)]
pub struct PrepareArgs {
#[arg(short = 'p', long = "package")]
pub packages: Vec<String>,
#[arg(long)]
pub no_git: bool,
#[arg(long)]
pub branch: Option<String>,
}
#[derive(Debug)]
pub(super) struct ReleaseInfo {
pub(super) package_name: String,
pub(super) new_version: Version,
pub(super) changelog_entry: String,
}
pub(super) type PackageChanges = Vec<(
ChangeType,
Option<String>,
Option<crate::model::changelog::CommitReference>,
)>;
pub(super) type PropagationResult = (BTreeMap<String, Vec<String>>, Vec<PathBuf>);
pub(super) type PropagationMap = BTreeMap<String, (ChangeType, BTreeSet<String>)>;
#[derive(Debug)]
pub(super) struct PrepareOutput {
pub(super) release_infos: Vec<ReleaseInfo>,
pub(super) modified_files: Vec<PathBuf>,
}
pub(super) struct VersionPlan {
pub(super) aggregated: BTreeMap<String, ChangeType>,
pub(super) changes_per_package: BTreeMap<String, PackageChanges>,
pub(super) version_overrides: BTreeMap<String, Version>,
pub(super) dep_entries: BTreeMap<String, Vec<String>>,
pub(super) propagation_changeset_paths: Vec<PathBuf>,
}
async fn compute_version_plan(
changesets: &[(crate::path::AbsolutePath, Changeset)],
args: &PrepareArgs,
env: &crate::Env,
config: &Config,
projects: &[Project],
git_ctx: &GitContext,
dry_run: bool,
) -> anyhow::Result<VersionPlan> {
let git = env.git();
let commit_refs = resolve_commit_references(changesets, git, git_ctx.enabled).await;
let (mut aggregated, mut changes_per_package) =
aggregate_changesets(changesets, &args.packages, projects, &commit_refs)?;
let linked_groups = resolve_linked_groups(config, args, projects)?;
let mut version_overrides = reconcile_linked_versions(
&mut aggregated,
&mut changes_per_package,
&linked_groups,
projects,
);
let (dep_entries, propagation_changeset_paths) = apply_dependency_propagation(
projects,
&mut aggregated,
&version_overrides,
&args.packages,
config.prepare.dependency_bump,
env,
dry_run,
)
.await?;
sync_linked_groups_after_propagation(
&mut aggregated,
&mut changes_per_package,
&mut version_overrides,
&linked_groups,
projects,
);
Ok(VersionPlan {
aggregated,
changes_per_package,
version_overrides,
dep_entries,
propagation_changeset_paths,
})
}
pub(crate) async fn cmd_prepare(
args: &PrepareArgs,
dry_run: bool,
env: &crate::Env,
config: Config,
) -> anyhow::Result<ExitCode> {
let git = env.git();
let adapters = config.create_adapters(env)?;
let projects = config.load_projects_for_adapters(&adapters).await?;
let changesets = Changeset::read_all(env).await?;
if changesets.is_empty() {
info!("No pending changesets found. Nothing to prepare.");
return Ok(ExitCode::SUCCESS);
}
let git_ctx = setup_git_context(&config, args);
let plan = compute_version_plan(
&changesets,
args,
env,
&config,
&projects,
&git_ctx,
dry_run,
)
.await?;
let branches = preflight_checks(git, &config, env, args, &git_ctx, dry_run).await?;
let output =
prepare_release_files(&adapters, &projects, &changesets, plan, dry_run, env.fs()).await?;
finalize_git_lifecycle(git, &config, env, &output, &branches, &git_ctx, dry_run).await?;
Ok(ExitCode::SUCCESS)
}
#[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;
use super::*;
fn make_runner() -> Arc<dyn CommandRunner> {
Arc::new(RecordingCommandRunner::new(0))
}
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(),
)),
)
}
#[tokio::test]
async fn cmd_prepare_no_changesets_succeeds() {
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"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let args = PrepareArgs::default();
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 result = cmd_prepare(&args, false, &env, config).await.unwrap();
assert_eq!(result, ExitCode::SUCCESS);
}
#[tokio::test]
async fn cmd_prepare_unknown_package_in_changeset_fails() {
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"),
"[package]\nname = \"real-project\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let cursus_dir = dir.path().join(".cursus");
std::fs::write(
cursus_dir.join("test.md"),
"+++\nnonexistent-package = \"minor\"\n+++\n\nSome change\n",
)
.unwrap();
let args = PrepareArgs::default();
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 result = cmd_prepare(&args, false, &env, config).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("not found in projects")
);
}
#[tokio::test]
async fn cmd_prepare_unknown_package_flag_fails() {
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"),
"[package]\nname = \"real-project\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let cursus_dir = dir.path().join(".cursus");
std::fs::write(
cursus_dir.join("test.md"),
"+++\nreal-project = \"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 = PrepareArgs {
packages: vec!["nonexistent".to_string()],
no_git: true,
..PrepareArgs::default()
};
let result = cmd_prepare(&args, false, &env, config).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unknown package: nonexistent")
);
}
}