cargo-changeset 0.1.5

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

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 lockfile_hash(dir: &TempDir) -> Vec<u8> {
    let output = Command::new("git")
        .args(["hash-object", "Cargo.lock"])
        .current_dir(dir.path())
        .output()
        .expect("failed to hash Cargo.lock");

    output.stdout
}

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 setup_workspace_with_dependency() -> 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"

[workspace.dependencies]
crate-a = { path = "crates/crate-a", version = "1.0.0" }
"#,
    )
    .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 crate-a 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"

[dependencies]
crate-a = { workspace = true }
"#,
    )
    .expect("write crate-b Cargo.toml");
    fs::write(dir.path().join("crates/crate-b/src/lib.rs"), "").expect("write crate-b lib.rs");

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

    let output = Command::new("cargo")
        .args(["generate-lockfile"])
        .current_dir(dir.path())
        .output()
        .expect("failed to run cargo generate-lockfile");
    assert!(
        output.status.success(),
        "cargo generate-lockfile failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    git_add_and_commit(&dir, "Initial commit");

    dir
}

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

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

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

    let hash_before = lockfile_hash(&workspace);

    let check_output = Command::new("cargo")
        .args(["check"])
        .current_dir(workspace.path())
        .output()
        .expect("failed to run cargo check");
    assert!(
        check_output.status.success(),
        "cargo check failed: {}",
        String::from_utf8_lossy(&check_output.stderr)
    );

    let hash_after = lockfile_hash(&workspace);

    assert_eq!(
        hash_before, hash_after,
        "cargo check should not change Cargo.lock after release — \
         the release commit must include an up-to-date lockfile"
    );
}

#[test]
fn release_lockfile_correct_after_minor_bump_with_workspace_dep() {
    let workspace = setup_workspace_with_dependency();
    write_changeset(&workspace, "feat.md", "crate-a", "minor", "Add a feature");
    git_add_and_commit(&workspace, "Add changeset");

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

    let hash_before = lockfile_hash(&workspace);

    let check_output = Command::new("cargo")
        .args(["check"])
        .current_dir(workspace.path())
        .output()
        .expect("failed to run cargo check");
    assert!(
        check_output.status.success(),
        "cargo check failed: {}",
        String::from_utf8_lossy(&check_output.stderr)
    );

    let hash_after = lockfile_hash(&workspace);

    assert_eq!(
        hash_before, hash_after,
        "cargo check should not change Cargo.lock after minor bump release"
    );
}