cargo-changeset 0.1.2

A cargo subcommand for managing changesets
use std::fs;
use std::process::Command;

use predicates::str::contains;
use tempfile::TempDir;

fn init_git_repo(dir: &TempDir) {
    Command::new("git")
        .args(["init", "--initial-branch=main"])
        .current_dir(dir.path())
        .output()
        .expect("failed to init git repo");

    Command::new("git")
        .args(["config", "user.email", "test@example.com"])
        .current_dir(dir.path())
        .output()
        .expect("failed to configure git email");

    Command::new("git")
        .args(["config", "user.name", "Test"])
        .current_dir(dir.path())
        .output()
        .expect("failed to configure git name");
}

fn git_add_and_commit(dir: &TempDir, message: &str) {
    Command::new("git")
        .args(["add", "-A"])
        .current_dir(dir.path())
        .output()
        .expect("failed to git add");

    Command::new("git")
        .args(["commit", "-m", message])
        .current_dir(dir.path())
        .output()
        .expect("failed to git commit");
}

fn create_tag(dir: &TempDir, tag_name: &str, message: &str) {
    Command::new("git")
        .args(["tag", "-a", tag_name, "-m", message])
        .current_dir(dir.path())
        .output()
        .expect("failed to create tag");
}

fn create_single_package_with_git() -> TempDir {
    let dir = TempDir::new().expect("create temp dir");

    init_git_repo(&dir);

    fs::write(
        dir.path().join("Cargo.toml"),
        r#"[package]
name = "my-crate"
version = "1.0.0"
edition = "2021"
"#,
    )
    .expect("write Cargo.toml");

    fs::create_dir_all(dir.path().join("src")).expect("create src dir");
    fs::write(dir.path().join("src/lib.rs"), "").expect("write lib.rs");

    fs::create_dir_all(dir.path().join(".changeset/changesets"))
        .expect("create .changeset/changesets dir");

    git_add_and_commit(&dir, "Initial commit");

    dir
}

fn create_workspace_with_two_crates() -> TempDir {
    let dir = TempDir::new().expect("create temp dir");

    init_git_repo(&dir);

    fs::write(
        dir.path().join("Cargo.toml"),
        r#"[workspace]
members = ["crates/*"]
resolver = "2"
"#,
    )
    .expect("write workspace Cargo.toml");

    fs::create_dir_all(dir.path().join("crates/crate-a/src")).expect("create crate-a dir");
    fs::write(
        dir.path().join("crates/crate-a/Cargo.toml"),
        r#"[package]
name = "crate-a"
version = "1.0.0"
edition = "2021"
"#,
    )
    .expect("write crate-a Cargo.toml");
    fs::write(dir.path().join("crates/crate-a/src/lib.rs"), "").expect("write lib.rs");

    fs::create_dir_all(dir.path().join("crates/crate-b/src")).expect("create crate-b dir");
    fs::write(
        dir.path().join("crates/crate-b/Cargo.toml"),
        r#"[package]
name = "crate-b"
version = "2.0.0"
edition = "2021"
"#,
    )
    .expect("write crate-b Cargo.toml");
    fs::write(dir.path().join("crates/crate-b/src/lib.rs"), "").expect("write lib.rs");

    fs::create_dir_all(dir.path().join(".changeset/changesets"))
        .expect("create .changeset/changesets dir");

    git_add_and_commit(&dir, "Initial commit");

    dir
}

fn write_changeset(dir: &TempDir, filename: &str, package: &str, bump: &str, summary: &str) {
    let content = format!(
        r#"---
"{package}": {bump}
---

{summary}
"#
    );
    fs::write(
        dir.path().join(".changeset/changesets").join(filename),
        content,
    )
    .expect("write changeset");
}

fn write_multi_package_changeset(
    dir: &TempDir,
    filename: &str,
    packages: &[(&str, &str)],
    summary: &str,
) {
    let package_entries: String = packages
        .iter()
        .map(|(pkg, bump)| format!("\"{pkg}\": {bump}"))
        .collect::<Vec<_>>()
        .join("\n");

    let content = format!(
        r#"---
{package_entries}
---

{summary}
"#
    );
    fs::write(
        dir.path().join(".changeset/changesets").join(filename),
        content,
    )
    .expect("write changeset");
}

macro_rules! cargo_changeset {
    () => {
        assert_cmd::cargo::cargo_bin_cmd!("cargo-changeset")
    };
}

#[test]
fn release_saga_failure_shows_failed_step_and_rollback_message() {
    let workspace = create_single_package_with_git();
    write_changeset(&workspace, "fix.md", "my-crate", "patch", "Fix a bug");
    git_add_and_commit(&workspace, "Add changeset");

    create_tag(&workspace, "v1.0.1", "Pre-existing conflicting tag");

    cargo_changeset!()
        .arg("release")
        .current_dir(workspace.path())
        .assert()
        .failure()
        .stderr(contains("Error: Release failed at step"))
        .stderr(contains("create_tags"))
        .stderr(contains("Rollback completed successfully"))
        .stderr(contains("restored to its original state"));
}

#[test]
fn release_saga_failure_message_includes_step_name() {
    let workspace = create_single_package_with_git();
    write_changeset(&workspace, "fix.md", "my-crate", "patch", "Fix a bug");
    git_add_and_commit(&workspace, "Add changeset");

    create_tag(&workspace, "v1.0.1", "Pre-existing conflicting tag");

    cargo_changeset!()
        .arg("release")
        .current_dir(workspace.path())
        .assert()
        .failure()
        .stderr(contains("'create_tags'"));
}

#[test]
fn release_saga_failure_with_rollback_restores_version_in_manifest() {
    let workspace = create_single_package_with_git();
    write_changeset(&workspace, "fix.md", "my-crate", "patch", "Fix a bug");
    git_add_and_commit(&workspace, "Add changeset");

    create_tag(&workspace, "v1.0.1", "Pre-existing conflicting tag");

    cargo_changeset!()
        .arg("release")
        .current_dir(workspace.path())
        .assert()
        .failure();

    let manifest_content =
        fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml");
    assert!(
        manifest_content.contains("version = \"1.0.0\""),
        "version should be restored to original after rollback"
    );
}

#[test]
fn release_saga_failure_multi_package_shows_proper_error_format() {
    let workspace = create_workspace_with_two_crates();
    write_multi_package_changeset(
        &workspace,
        "multi.md",
        &[("crate-a", "patch"), ("crate-b", "patch")],
        "Fix bugs in both crates",
    );
    git_add_and_commit(&workspace, "Add changeset");

    create_tag(
        &workspace,
        "crate-b@v2.0.1",
        "Pre-existing conflicting tag for crate-b",
    );

    cargo_changeset!()
        .arg("release")
        .current_dir(workspace.path())
        .assert()
        .failure()
        .stderr(contains("Error: Release failed at step"))
        .stderr(contains("Rollback completed successfully"));
}

#[test]
fn release_saga_failure_error_includes_cause_chain() {
    let workspace = create_single_package_with_git();
    write_changeset(&workspace, "fix.md", "my-crate", "patch", "Fix a bug");
    git_add_and_commit(&workspace, "Add changeset");

    create_tag(&workspace, "v1.0.1", "Pre-existing conflicting tag");

    cargo_changeset!()
        .arg("release")
        .current_dir(workspace.path())
        .assert()
        .failure()
        .stderr(contains("->"));
}