mod common;
use std::process::ExitCode;
use common::{
add_local_remote, git_cmd, git_current_branch, git_log, git_push_to_remote,
git_set_remote_head, temp_real_git_repo_with_cargo_workspace, temp_real_git_repo_with_project,
};
use cursus::model::config::{CargoConfig, GitConfig, PackageManager};
async fn setup_auto_repo(pm: PackageManager) -> (tempfile::TempDir, tempfile::TempDir) {
let dir = temp_real_git_repo_with_project(pm).await;
let remote = add_local_remote(dir.path());
git_push_to_remote(dir.path());
let branch = git_current_branch(dir.path());
git_set_remote_head(dir.path(), &branch);
(dir, remote)
}
fn make_conventional_commit(dir: &std::path::Path, file: &str, msg: &str) {
std::fs::write(dir.join(file), format!("// changed by: {msg}")).unwrap();
git_cmd(dir, &["add", "."]);
git_cmd(dir, &["commit", "-m", msg]);
}
fn find_changesets(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let cursus_dir = dir.join(".cursus");
if !cursus_dir.is_dir() {
return vec![];
}
std::fs::read_dir(&cursus_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
.map(|e| e.path())
.collect()
}
#[tokio::test]
async fn change_auto_fix_commit_creates_patch_changeset() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
make_conventional_commit(dir.path(), "src/lib.rs", "fix: resolve null pointer");
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), ExitCode::SUCCESS);
let changesets = find_changesets(dir.path());
assert_eq!(changesets.len(), 1, "Expected one changeset file");
let content = std::fs::read_to_string(&changesets[0]).unwrap();
assert!(
content.contains("test-project = \"patch\""),
"Expected patch changeset, got: {content}"
);
assert!(
content.contains("resolve null pointer"),
"Expected description in changeset, got: {content}"
);
}
#[tokio::test]
async fn change_auto_feat_commit_creates_minor_changeset() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
make_conventional_commit(dir.path(), "src/lib.rs", "feat: add new feature");
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok());
let changesets = find_changesets(dir.path());
assert_eq!(changesets.len(), 1);
let content = std::fs::read_to_string(&changesets[0]).unwrap();
assert!(
content.contains("test-project = \"minor\""),
"Expected minor changeset, got: {content}"
);
}
#[tokio::test]
async fn change_auto_breaking_bang_creates_major_changeset() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
make_conventional_commit(dir.path(), "src/lib.rs", "feat!: redesign public API");
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok());
let changesets = find_changesets(dir.path());
assert_eq!(changesets.len(), 1);
let content = std::fs::read_to_string(&changesets[0]).unwrap();
assert!(
content.contains("test-project = \"major\""),
"Expected major changeset, got: {content}"
);
}
#[tokio::test]
async fn change_auto_chore_commit_skips_changeset() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
make_conventional_commit(dir.path(), "src/lib.rs", "chore: update dependencies");
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), ExitCode::SUCCESS);
let changesets = find_changesets(dir.path());
assert!(
changesets.is_empty(),
"Expected no changeset for chore commit"
);
}
#[tokio::test]
async fn change_auto_multiple_commits_skips() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
make_conventional_commit(dir.path(), "src/lib.rs", "fix: first fix");
make_conventional_commit(dir.path(), "src/lib.rs", "fix: second fix");
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), ExitCode::SUCCESS);
let changesets = find_changesets(dir.path());
assert!(
changesets.is_empty(),
"Expected no changeset with multiple commits"
);
}
#[tokio::test]
async fn change_auto_zero_commits_ahead_fails() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("No commits ahead"),
"Expected 'No commits ahead' error, got: {err}"
);
}
#[tokio::test]
async fn change_auto_invalid_commit_message_fails() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
make_conventional_commit(dir.path(), "src/lib.rs", "not a conventional commit");
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn change_auto_dry_run_writes_nothing() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
make_conventional_commit(dir.path(), "src/lib.rs", "fix: fix a bug");
let result = common::run_cursus(
[
"cursus",
"--no-interactive",
"--dry-run",
"change",
"--auto",
"--no-git",
],
dir.path(),
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), ExitCode::SUCCESS);
let changesets = find_changesets(dir.path());
assert!(
changesets.is_empty(),
"Expected no changeset written in dry-run mode"
);
}
#[tokio::test]
async fn change_auto_changeset_includes_body() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
std::fs::write(dir.path().join("src/lib.rs"), "// modified").unwrap();
git_cmd(dir.path(), &["add", "."]);
git_cmd(
dir.path(),
&[
"commit",
"-m",
"fix: fix the thing\n\nThis fixes the issue described in #42.",
],
);
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok());
let changesets = find_changesets(dir.path());
assert_eq!(changesets.len(), 1);
let content = std::fs::read_to_string(&changesets[0]).unwrap();
assert!(
content.contains("fix the thing"),
"Expected description in changeset, got: {content}"
);
assert!(
content.contains("fixes the issue"),
"Expected body in changeset, got: {content}"
);
}
#[tokio::test]
async fn change_auto_strips_trailers_from_body() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
std::fs::write(dir.path().join("src/lib.rs"), "// modified").unwrap();
git_cmd(dir.path(), &["add", "."]);
git_cmd(
dir.path(),
&[
"commit",
"-m",
"fix: fix the thing\n\nThis fixes the issue described in #42.\n\nSigned-off-by: Test User <test@example.com>\nCo-authored-by: Bot <bot@example.com>",
],
);
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok());
let changesets = find_changesets(dir.path());
assert_eq!(changesets.len(), 1);
let content = std::fs::read_to_string(&changesets[0]).unwrap();
assert!(
content.contains("fixes the issue"),
"Expected body prose in changeset, got: {content}"
);
assert!(
!content.contains("Signed-off-by"),
"Expected trailers stripped from changeset, got: {content}"
);
assert!(
!content.contains("Co-authored-by"),
"Expected trailers stripped from changeset, got: {content}"
);
}
#[tokio::test]
async fn change_auto_no_matching_projects_skips() {
let dir =
temp_real_git_repo_with_cargo_workspace(&[("pkg-a", "0.1.0")], GitConfig::default()).await;
let _remote = add_local_remote(dir.path());
git_push_to_remote(dir.path());
let branch = git_current_branch(dir.path());
git_set_remote_head(dir.path(), &branch);
std::fs::write(dir.path().join(".github-actions.yml"), "# ci").unwrap();
git_cmd(dir.path(), &["add", "."]);
git_cmd(dir.path(), &["commit", "-m", "fix: update ci config"]);
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), ExitCode::SUCCESS);
let changesets = find_changesets(dir.path());
assert!(
changesets.is_empty(),
"Expected no changeset when no project matched"
);
}
#[tokio::test]
async fn change_auto_monorepo_only_affected_project_in_changeset() {
let dir = temp_real_git_repo_with_cargo_workspace(
&[("pkg-a", "0.1.0"), ("pkg-b", "0.1.0")],
GitConfig::default(),
)
.await;
let _remote = add_local_remote(dir.path());
git_push_to_remote(dir.path());
let branch = git_current_branch(dir.path());
git_set_remote_head(dir.path(), &branch);
std::fs::write(dir.path().join("pkg-a/src/lib.rs"), "// modified").unwrap();
git_cmd(dir.path(), &["add", "."]);
git_cmd(dir.path(), &["commit", "-m", "fix: fix pkg-a only"]);
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok());
let changesets = find_changesets(dir.path());
assert_eq!(changesets.len(), 1);
let content = std::fs::read_to_string(&changesets[0]).unwrap();
assert!(
content.contains("pkg-a"),
"Expected pkg-a in changeset, got: {content}"
);
assert!(
!content.contains("pkg-b"),
"Expected pkg-b NOT in changeset, got: {content}"
);
}
#[tokio::test]
async fn change_auto_no_git_skips_commit() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
make_conventional_commit(dir.path(), "src/lib.rs", "fix: a small bugfix");
let commits_before = git_log(dir.path()).len();
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto", "--no-git"],
dir.path(),
)
.await;
assert!(result.is_ok());
let changesets = find_changesets(dir.path());
assert_eq!(changesets.len(), 1, "Changeset should be written");
let commits_after = git_log(dir.path()).len();
assert_eq!(
commits_after, commits_before,
"Expected no new commit with --no-git"
);
}
#[tokio::test]
async fn change_auto_with_git_commits_and_pushes() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
make_conventional_commit(dir.path(), "src/lib.rs", "fix: another fix");
let env = common::test_env(dir.path());
let config = cursus::model::config::Config::new()
.with_cargo(CargoConfig::enabled())
.with_git(GitConfig::enabled_config());
config.save(env.fs(), env.git().path()).await.unwrap();
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected success, got: {:?}", result.err());
let changesets = find_changesets(dir.path());
assert_eq!(changesets.len(), 1, "Expected one changeset");
let log = git_log(dir.path());
assert!(
log.iter().any(|s| s.contains("add changeset")),
"Expected 'add changeset' commit in log, got: {log:?}"
);
}
#[tokio::test]
async fn change_auto_git_disabled_in_config_no_commit() {
let (dir, _remote) = setup_auto_repo(PackageManager::Cargo).await;
make_conventional_commit(dir.path(), "src/lib.rs", "fix: small fix for config test");
let commits_before = git_log(dir.path()).len();
let result = common::run_cursus(
["cursus", "--no-interactive", "change", "--auto"],
dir.path(),
)
.await;
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
let changesets = find_changesets(dir.path());
assert_eq!(changesets.len(), 1, "Changeset should be written");
let commits_after = git_log(dir.path()).len();
assert_eq!(
commits_after, commits_before,
"Expected no new commit when git disabled in config (without --no-git flag)"
);
}