mod common;
use std::process::Command;
use common::{
git_enabled_config, git_tag_exists, git_tags, run_cursus, temp_git_repo,
temp_git_repo_with_project, temp_real_git_repo_with_cargo_workspace,
temp_real_git_repo_with_config, write_changeset,
};
use cursus::model::config::PackageManager;
use cursus::test_logging::{init_test_logger, take_logs};
fn git_tag(dir: &std::path::Path, tag: &str) {
let out = Command::new("git")
.args(["tag", tag])
.current_dir(dir)
.output()
.unwrap();
assert!(
out.status.success(),
"git tag failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[tokio::test]
async fn ci_with_changesets_runs_release() {
init_test_logger();
let _ = take_logs();
let dir = temp_git_repo_with_project(PackageManager::Cargo).await;
write_changeset(
dir.path(),
"change.md",
"+++\ntest-project = \"minor\"\n+++\n\nA feature\n",
);
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter().any(|(level, m)| *level == log::Level::Info
&& m.contains("pending changesets found")
&& m.contains("prepare")),
"Expected info 'pending changesets found, running prepare' log, got: {logs:?}"
);
let cargo_toml = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
cargo_toml.contains("version = \"0.1.0\""),
"Dry-run should not bump the version"
);
}
#[tokio::test]
async fn ci_when_all_tags_present_nothing_to_do() {
init_test_logger();
let _ = take_logs();
let dir =
temp_real_git_repo_with_cargo_workspace(&[("my-app", "1.0.0")], git_enabled_config()).await;
git_tag(dir.path(), "v1.0.0");
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter()
.any(|(level, m)| *level == log::Level::Info && m.contains("nothing to do")),
"Expected info 'ci: nothing to do' log, got: {logs:?}"
);
let tags = git_tags(dir.path());
assert_eq!(tags, vec!["v1.0.0"], "No new tags should have been created");
}
#[tokio::test]
async fn ci_git_disabled_no_changesets_nothing_to_do() {
init_test_logger();
let _ = take_logs();
let dir = temp_git_repo_with_project(PackageManager::Cargo).await;
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter()
.any(|(level, m)| *level == log::Level::Info && m.contains("nothing to do")),
"Expected info 'ci: nothing to do' log, got: {logs:?}"
);
}
#[tokio::test]
async fn ci_tags_missing_triggers_publish_dry_run() {
init_test_logger();
let _ = take_logs();
let dir =
temp_real_git_repo_with_cargo_workspace(&[("my-app", "1.0.0")], git_enabled_config()).await;
std::fs::write(dir.path().join("my-app/CHANGELOG.md"), "# Changelog\n").unwrap();
assert!(!git_tag_exists(dir.path(), "v1.0.0"));
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter().any(|(level, m)| *level == log::Level::Info
&& m.contains("unpublished tags detected")
&& m.contains("publish")),
"Expected info 'no changesets but unpublished tags detected, running publish' log, got: {logs:?}"
);
assert!(
git_tags(dir.path()).is_empty(),
"Dry-run publish should not create tags, got: {:?}",
git_tags(dir.path())
);
}
#[tokio::test]
async fn ci_no_git_skips_tag_detection() {
let dir =
temp_real_git_repo_with_cargo_workspace(&[("my-app", "1.0.0")], git_enabled_config()).await;
assert!(!git_tag_exists(dir.path(), "v1.0.0"));
let result = run_cursus(["cursus", "--no-interactive", "ci", "--no-git"], dir.path()).await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
assert!(
git_tags(dir.path()).is_empty(),
"--no-git should skip publish, got: {:?}",
git_tags(dir.path())
);
}
#[tokio::test]
async fn ci_is_always_non_interactive() {
let dir = temp_git_repo_with_project(PackageManager::Cargo).await;
let result = run_cursus(["cursus", "ci", "--dry-run"], dir.path()).await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
}
#[tokio::test]
async fn ci_dry_run_does_not_consume_changesets() {
let dir = temp_git_repo_with_project(PackageManager::Cargo).await;
write_changeset(
dir.path(),
"change.md",
"+++\ntest-project = \"minor\"\n+++\n\nA feature\n",
);
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let changeset_exists = dir.path().join(".cursus").join("change.md").exists();
assert!(changeset_exists, "Dry-run should not consume changesets");
}
#[tokio::test]
async fn ci_parses_from_cli() {
let dir = temp_git_repo_with_project(PackageManager::Cargo).await;
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
}
#[tokio::test]
async fn ci_multi_package_partial_tags_triggers_publish() {
init_test_logger();
let _ = take_logs();
let dir = temp_real_git_repo_with_cargo_workspace(
&[("pkg-a", "1.0.0"), ("pkg-b", "2.0.0")],
git_enabled_config(),
)
.await;
std::fs::write(dir.path().join("pkg-a/CHANGELOG.md"), "# Changelog\n").unwrap();
std::fs::write(dir.path().join("pkg-b/CHANGELOG.md"), "# Changelog\n").unwrap();
git_tag(dir.path(), "pkg-a@1.0.0");
assert!(!git_tag_exists(dir.path(), "pkg-b@2.0.0"));
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter().any(|(level, m)| *level == log::Level::Info
&& m.contains("unpublished tags detected")
&& m.contains("publish")),
"Expected 'unpublished tags detected, running publish' log, got: {logs:?}"
);
}
#[tokio::test]
async fn ci_multi_package_all_tags_present_nothing_to_do() {
let dir = temp_real_git_repo_with_cargo_workspace(
&[("pkg-a", "1.0.0"), ("pkg-b", "2.0.0")],
git_enabled_config(),
)
.await;
git_tag(dir.path(), "pkg-a@1.0.0");
git_tag(dir.path(), "pkg-b@2.0.0");
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let tags = git_tags(dir.path());
assert_eq!(tags.len(), 2, "No new tags should have been created");
}
#[tokio::test]
async fn ci_package_filter_only_checks_selected_packages() {
let dir = temp_real_git_repo_with_cargo_workspace(
&[("pkg-a", "1.0.0"), ("pkg-b", "2.0.0")],
git_enabled_config(),
)
.await;
git_tag(dir.path(), "pkg-a@1.0.0");
assert!(!git_tag_exists(dir.path(), "pkg-b@2.0.0"));
let result = run_cursus(
[
"cursus",
"--no-interactive",
"ci",
"--dry-run",
"-p",
"pkg-a",
],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let tags = git_tags(dir.path());
assert_eq!(tags, vec!["pkg-a@1.0.0"], "Only pkg-a@1.0.0 should exist");
}
#[tokio::test]
async fn ci_no_git_with_changesets_runs_release_no_git() {
let dir = temp_real_git_repo_with_config(PackageManager::Cargo, git_enabled_config()).await;
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"my-pkg\"\nversion = \"1.0.0\"\nedition = \"2024\"\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/lib.rs"), "").unwrap();
write_changeset(
dir.path(),
"change.md",
"+++\nmy-pkg = \"patch\"\n+++\n\nFix\n",
);
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
}
#[tokio::test]
async fn ci_fails_when_no_config() {
let dir = temp_git_repo();
let result = run_cursus(["cursus", "--no-interactive", "ci"], dir.path()).await;
assert!(result.is_err(), "Expected Err when config is missing");
}
#[tokio::test]
async fn ci_multi_package_all_tags_present_logs_nothing_to_do() {
init_test_logger();
let _ = take_logs();
let dir = temp_real_git_repo_with_cargo_workspace(
&[("pkg-a", "1.0.0"), ("pkg-b", "2.0.0")],
git_enabled_config(),
)
.await;
git_tag(dir.path(), "pkg-a@1.0.0");
git_tag(dir.path(), "pkg-b@2.0.0");
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter()
.any(|(level, m)| *level == log::Level::Info && m.contains("nothing to do")),
"Expected 'ci: nothing to do' when all multi-package tags are present, got: {logs:?}"
);
assert!(
!logs.iter().any(|(_, m)| m.contains("running publish")),
"Should not trigger publish when all tags are present, got: {logs:?}"
);
}
#[tokio::test]
async fn ci_all_packages_lack_changelog_nothing_to_do() {
init_test_logger();
let _ = take_logs();
let dir = temp_real_git_repo_with_cargo_workspace(
&[("pkg-a", "1.0.0"), ("pkg-b", "2.0.0")],
git_enabled_config(),
)
.await;
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter()
.any(|(level, m)| *level == log::Level::Info && m.contains("nothing to do")),
"Expected 'ci: nothing to do' when no packages have CHANGELOG.md, got: {logs:?}"
);
assert!(
!logs.iter().any(|(_, m)| m.contains("running publish")),
"Should not trigger publish when no packages have CHANGELOG.md, got: {logs:?}"
);
}
#[tokio::test]
async fn ci_no_changelog_package_excluded_from_tag_check() {
init_test_logger();
let _ = take_logs();
let dir = temp_real_git_repo_with_cargo_workspace(
&[("pkg-a", "1.0.0"), ("pkg-b", "2.0.0")],
git_enabled_config(),
)
.await;
std::fs::write(dir.path().join("pkg-a/CHANGELOG.md"), "# Changelog\n").unwrap();
assert!(!git_tag_exists(dir.path(), "pkg-a@1.0.0"));
assert!(!git_tag_exists(dir.path(), "pkg-b@2.0.0"));
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter().any(|(level, m)| *level == log::Level::Info
&& m.contains("unpublished tags detected")
&& m.contains("publish")),
"Expected publish triggered by pkg-a, got: {logs:?}"
);
}
#[tokio::test]
async fn ci_fails_when_package_filter_names_unknown_package() {
let dir =
temp_real_git_repo_with_cargo_workspace(&[("my-app", "1.0.0")], git_enabled_config()).await;
let result = run_cursus(
[
"cursus",
"--no-interactive",
"ci",
"--dry-run",
"-p",
"nonexistent",
],
dir.path(),
)
.await;
assert!(
result.is_err(),
"Expected Err for unknown package filter, got: {result:?}"
);
}
#[tokio::test]
async fn ci_changesets_present_but_package_filter_matches_no_changeset() {
let dir = temp_real_git_repo_with_cargo_workspace(
&[("pkg-a", "1.0.0"), ("pkg-b", "2.0.0")],
git_enabled_config(),
)
.await;
write_changeset(
dir.path(),
"change.md",
"+++\npkg-a = \"patch\"\n+++\n\nFix\n",
);
let result = run_cursus(
[
"cursus",
"--no-interactive",
"ci",
"--dry-run",
"--no-git",
"-p",
"pkg-b",
],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let toml = std::fs::read_to_string(dir.path().join("pkg-b/Cargo.toml")).unwrap();
assert!(
toml.contains("version = \"2.0.0\""),
"pkg-b version should not change when it has no changeset"
);
}
#[tokio::test]
async fn ci_git_disabled_with_changelog_no_changesets_nothing_to_do() {
init_test_logger();
let _ = take_logs();
let dir =
temp_real_git_repo_with_cargo_workspace(&[("my-app", "1.0.0")], git_enabled_config()).await;
let config_dir = dir.path().join(".cursus");
std::fs::write(config_dir.join("config.toml"), "[cargo]\nenabled = true\n").unwrap();
std::fs::write(dir.path().join("my-app/CHANGELOG.md"), "# Changelog\n").unwrap();
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter()
.any(|(level, m)| *level == log::Level::Info && m.contains("nothing to do")),
"Expected 'ci: nothing to do' when git is disabled, got: {logs:?}"
);
}
#[tokio::test]
async fn ci_single_package_with_changelog_and_tag_nothing_to_do() {
init_test_logger();
let _ = take_logs();
let dir =
temp_real_git_repo_with_cargo_workspace(&[("my-app", "1.0.0")], git_enabled_config()).await;
std::fs::write(dir.path().join("my-app/CHANGELOG.md"), "# Changelog\n").unwrap();
git_tag(dir.path(), "v1.0.0");
let result = run_cursus(
["cursus", "--no-interactive", "ci", "--dry-run"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let logs = take_logs();
assert!(
logs.iter()
.any(|(level, m)| *level == log::Level::Info && m.contains("nothing to do")),
"Expected 'ci: nothing to do' when tag exists, got: {logs:?}"
);
}