mod common;
use std::process::ExitCode;
use common::{temp_git_repo_with_cargo_workspace, write_changeset};
use cursus::model::config::{LinkedVersionGroup, LinkedVersionsConfig};
use cursus::path::AbsolutePath;
use cursus::test_logging::{init_test_logger, take_logs};
async fn add_linked_versions_to_config(dir: &std::path::Path, lv: LinkedVersionsConfig) {
let env = make_env_with_git(dir);
let mut config = cursus::model::config::load(env.fs(), env.git().path())
.await
.unwrap()
.unwrap();
config.linked_versions = lv;
config.save(env.fs(), env.git().path()).await.unwrap();
}
fn make_env_with_git(dir: &std::path::Path) -> cursus::Env {
let runner = std::sync::Arc::new(cursus::command::RealCommandRunner)
as std::sync::Arc<dyn cursus::command::CommandRunner>;
let path = AbsolutePath::new(dir).unwrap();
cursus::Env::new(
std::sync::Arc::clone(&runner),
std::sync::Arc::new(cursus::filesystem::LocalFilesystem),
std::sync::Arc::new(cursus::git::GitWorkdir::new(runner, path)),
)
}
fn read_pkg_version(dir: &std::path::Path, pkg: &str) -> String {
let cargo_toml = std::fs::read_to_string(dir.join(format!("{pkg}/Cargo.toml"))).unwrap();
for line in cargo_toml.lines() {
if let Some(v) = line.strip_prefix("version = \"") {
return v.trim_end_matches('"').to_string();
}
}
panic!("version not found in {pkg}/Cargo.toml");
}
fn global_config() -> LinkedVersionsConfig {
LinkedVersionsConfig {
enabled: Some(true),
groups: vec![],
}
}
fn group_config(packages: Vec<Vec<&str>>) -> LinkedVersionsConfig {
LinkedVersionsConfig {
enabled: None,
groups: packages
.into_iter()
.map(|pkgs| LinkedVersionGroup {
packages: pkgs.into_iter().map(str::to_string).collect(),
})
.collect(),
}
}
#[tokio::test]
async fn global_linking_bumps_all_to_max() {
init_test_logger();
let _ = take_logs();
let dir = temp_git_repo_with_cargo_workspace(&[
("pkg-a", "1.0.0"),
("pkg-b", "1.0.0"),
("pkg-c", "1.0.0"),
])
.await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"minor\"\n+++\n\nA feature\n",
);
add_linked_versions_to_config(dir.path(), global_config()).await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), ExitCode::SUCCESS);
assert_eq!(read_pkg_version(dir.path(), "pkg-a"), "1.1.0");
assert_eq!(read_pkg_version(dir.path(), "pkg-b"), "1.1.0");
assert_eq!(read_pkg_version(dir.path(), "pkg-c"), "1.1.0");
}
#[tokio::test]
async fn global_linking_with_package_filter_errors() {
let dir = temp_git_repo_with_cargo_workspace(&[("pkg-a", "1.0.0"), ("pkg-b", "1.0.0")]).await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"patch\"\n+++\n\nA fix\n",
);
add_linked_versions_to_config(dir.path(), global_config()).await;
let result = common::run_cursus(
[
"cursus",
"--no-interactive",
"prepare",
"--package",
"pkg-a",
],
dir.path(),
)
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("global linked-versions")
);
}
#[tokio::test]
async fn group_linking_bumps_only_group_members() {
let dir = temp_git_repo_with_cargo_workspace(&[
("pkg-a", "1.0.0"),
("pkg-b", "1.0.0"),
("standalone", "2.0.0"),
])
.await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"minor\"\n+++\n\nA feature\n",
);
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["pkg-a", "pkg-b"]])).await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_ok());
assert_eq!(read_pkg_version(dir.path(), "pkg-a"), "1.1.0");
assert_eq!(read_pkg_version(dir.path(), "pkg-b"), "1.1.0");
assert_eq!(read_pkg_version(dir.path(), "standalone"), "2.0.0");
}
#[tokio::test]
async fn max_version_wins_with_diverged_versions() {
let dir = temp_git_repo_with_cargo_workspace(&[
("pkg-a", "2.1.0"),
("pkg-b", "2.0.0"), ])
.await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"patch\"\n+++\n\nA fix\n",
);
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["pkg-a", "pkg-b"]])).await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_ok());
assert_eq!(read_pkg_version(dir.path(), "pkg-a"), "2.1.1");
assert_eq!(read_pkg_version(dir.path(), "pkg-b"), "2.1.1");
}
#[tokio::test]
async fn changeset_on_lower_version_package_advances_whole_group() {
let dir = temp_git_repo_with_cargo_workspace(&[
("pkg-a", "2.3.4"), ("pkg-b", "1.2.3"), ])
.await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-b = \"patch\"\n+++\n\nA fix\n",
);
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["pkg-a", "pkg-b"]])).await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_ok(), "Expected success, got: {result:?}");
assert_eq!(read_pkg_version(dir.path(), "pkg-a"), "2.3.5");
assert_eq!(read_pkg_version(dir.path(), "pkg-b"), "2.3.5");
}
#[tokio::test]
async fn scoped_prepare_partial_group_overlap_errors() {
let dir = temp_git_repo_with_cargo_workspace(&[("pkg-a", "1.0.0"), ("pkg-b", "1.0.0")]).await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"patch\"\n+++\n\nA fix\n",
);
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["pkg-a", "pkg-b"]])).await;
let result = common::run_cursus(
[
"cursus",
"--no-interactive",
"prepare",
"--package",
"pkg-a",
],
dir.path(),
)
.await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("partially overlaps"),
"Expected 'partially overlaps' error, got: {msg}"
);
assert!(
msg.contains("pkg-b"),
"Expected missing pkg-b listed in error"
);
}
#[tokio::test]
async fn scoped_prepare_full_group_in_scope_succeeds() {
let dir = temp_git_repo_with_cargo_workspace(&[
("pkg-a", "1.0.0"),
("pkg-b", "1.0.0"),
("standalone", "1.0.0"),
])
.await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"patch\"\n+++\n\nA fix\n",
);
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["pkg-a", "pkg-b"]])).await;
let result = common::run_cursus(
[
"cursus",
"--no-interactive",
"prepare",
"--package",
"pkg-a",
"--package",
"pkg-b",
],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected success: {result:?}");
assert_eq!(read_pkg_version(dir.path(), "pkg-a"), "1.0.1");
assert_eq!(read_pkg_version(dir.path(), "pkg-b"), "1.0.1");
assert_eq!(read_pkg_version(dir.path(), "standalone"), "1.0.0");
}
#[tokio::test]
async fn linked_packages_get_sync_changelog_entry() {
let dir = temp_git_repo_with_cargo_workspace(&[("pkg-a", "1.0.0"), ("pkg-b", "1.0.0")]).await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"minor\"\n+++\n\nFeature for A\n",
);
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["pkg-a", "pkg-b"]])).await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_ok());
let changelog_b = std::fs::read_to_string(dir.path().join("pkg-b/CHANGELOG.md")).unwrap();
assert!(
changelog_b.contains("version sync"),
"pkg-b changelog should mention version sync, got: {changelog_b}"
);
assert!(
changelog_b.contains("1.1.0"),
"pkg-b changelog should contain the new version, got: {changelog_b}"
);
}
#[tokio::test]
async fn disabled_linking_does_not_sync() {
let dir = temp_git_repo_with_cargo_workspace(&[("pkg-a", "1.0.0"), ("pkg-b", "1.0.0")]).await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"minor\"\n+++\n\nA feature\n",
);
add_linked_versions_to_config(
dir.path(),
LinkedVersionsConfig {
enabled: Some(false),
groups: vec![LinkedVersionGroup {
packages: vec!["pkg-a".to_string(), "pkg-b".to_string()],
}],
},
)
.await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_ok());
assert_eq!(read_pkg_version(dir.path(), "pkg-a"), "1.1.0");
assert_eq!(read_pkg_version(dir.path(), "pkg-b"), "1.0.0");
}
#[tokio::test]
async fn empty_packages_array_errors() {
let dir = temp_git_repo_with_cargo_workspace(&[("pkg-a", "1.0.0")]).await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"patch\"\n+++\n\nA fix\n",
);
add_linked_versions_to_config(
dir.path(),
LinkedVersionsConfig {
enabled: None,
groups: vec![LinkedVersionGroup { packages: vec![] }],
},
)
.await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("empty 'packages' array")
);
}
#[tokio::test]
async fn package_in_multiple_groups_errors() {
let dir = temp_git_repo_with_cargo_workspace(&[("pkg-a", "1.0.0"), ("pkg-b", "1.0.0")]).await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"patch\"\n+++\n\nA fix\n",
);
add_linked_versions_to_config(
dir.path(),
group_config(vec![vec!["pkg-a", "pkg-b"], vec!["pkg-*"]]),
)
.await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("multiple linked-versions groups")
);
}
#[tokio::test]
async fn pattern_matching_no_packages_warns_but_succeeds() {
init_test_logger();
let _ = take_logs();
let dir = temp_git_repo_with_cargo_workspace(&[("pkg-a", "1.0.0")]).await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"patch\"\n+++\n\nA fix\n",
);
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["nonexistent-*"]])).await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_ok(), "Expected success, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter()
.any(|(level, m)| *level == log::Level::Warn && m.contains("matches no packages")),
"Expected warning about pattern matching no packages, got: {logs:?}"
);
assert_eq!(read_pkg_version(dir.path(), "pkg-a"), "1.0.1");
}
#[tokio::test]
async fn dry_run_with_linked_versions_does_not_write() {
let dir = temp_git_repo_with_cargo_workspace(&[("pkg-a", "1.0.0"), ("pkg-b", "1.0.0")]).await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"minor\"\n+++\n\nA feature\n",
);
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["pkg-a", "pkg-b"]])).await;
let result = common::run_cursus(
["cursus", "--no-interactive", "--dry-run", "prepare"],
dir.path(),
)
.await;
assert!(result.is_ok());
assert_eq!(read_pkg_version(dir.path(), "pkg-a"), "1.0.0");
assert_eq!(read_pkg_version(dir.path(), "pkg-b"), "1.0.0");
}
#[tokio::test]
async fn highest_change_type_across_group_members_wins() {
let dir = temp_git_repo_with_cargo_workspace(&[("pkg-a", "1.0.0"), ("pkg-b", "1.0.0")]).await;
write_changeset(
dir.path(),
"cs1.md",
"+++\npkg-a = \"minor\"\n+++\n\nA feature\n",
);
write_changeset(
dir.path(),
"cs2.md",
"+++\npkg-b = \"major\"\n+++\n\nA breaking change\n",
);
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["pkg-a", "pkg-b"]])).await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_ok(), "Expected success, got: {result:?}");
assert_eq!(read_pkg_version(dir.path(), "pkg-a"), "2.0.0");
assert_eq!(read_pkg_version(dir.path(), "pkg-b"), "2.0.0");
}
#[tokio::test]
async fn glob_pattern_matches_prefix() {
let dir = temp_git_repo_with_cargo_workspace(&[
("sdk-core", "1.0.0"),
("sdk-utils", "1.0.0"),
("other", "1.0.0"),
])
.await;
write_changeset(
dir.path(),
"cs1.md",
"+++\nsdk-core = \"minor\"\n+++\n\nCore update\n",
);
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["sdk-*"]])).await;
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_ok());
assert_eq!(read_pkg_version(dir.path(), "sdk-core"), "1.1.0");
assert_eq!(read_pkg_version(dir.path(), "sdk-utils"), "1.1.0");
assert_eq!(read_pkg_version(dir.path(), "other"), "1.0.0");
}
#[tokio::test]
async fn propagation_into_linked_group_bumps_all_group_members() {
init_test_logger();
let _ = take_logs();
let dir = temp_git_repo_with_cargo_workspace(&[
("pkg-a", "0.2.5"),
("pkg-b", "0.2.5"),
("pkg-c", "1.2.3"),
])
.await;
std::fs::write(
dir.path().join("pkg-a/Cargo.toml"),
"[package]\nname = \"pkg-a\"\nversion = \"0.2.5\"\nedition = \"2024\"\n\n[dependencies]\npkg-c = { path = \"../pkg-c\", version = \"1.2.3\" }\n",
)
.unwrap();
add_linked_versions_to_config(dir.path(), group_config(vec![vec!["pkg-a", "pkg-b"]])).await;
write_changeset(
dir.path(),
"cs.md",
"+++\npkg-c = \"minor\"\n+++\n\nNew feature in pkg-c\n",
);
let result = common::run_cursus(["cursus", "--no-interactive", "prepare"], dir.path()).await;
assert!(result.is_ok(), "prepare failed: {result:?}");
assert_eq!(result.unwrap(), ExitCode::SUCCESS);
assert_eq!(read_pkg_version(dir.path(), "pkg-c"), "1.3.0");
assert_eq!(read_pkg_version(dir.path(), "pkg-a"), "0.2.6");
assert_eq!(read_pkg_version(dir.path(), "pkg-b"), "0.2.6");
}