//! End-to-end tests for `anodizer bump`.
//!
//! Each test builds a minimal cargo workspace inside a `TempDir`, initializes
//! a git repo, and shells out to the compiled `anodizer` binary via
//! `CARGO_BIN_EXE_anodizer`. Workspaces only contain manifests (no sources) —
//! `bump` never invokes cargo, it only reads/writes `Cargo.toml`.

use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;

fn anodizer() -> Command {
    Command::new(env!("CARGO_BIN_EXE_anodizer"))
}

fn run_git(dir: &Path, args: &[&str]) {
    let out = anodizer_core::test_helpers::output_with_spawn_retry(
        || {
            let mut cmd = Command::new("git");
            cmd.current_dir(dir).args(args);
            cmd
        },
        "git",
    );
    assert!(
        out.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&out.stderr)
    );
}

fn git_init(dir: &Path) {
    run_git(dir, &["init", "-q"]);
    run_git(dir, &["config", "user.email", "test@test.com"]);
    run_git(dir, &["config", "user.name", "Test"]);
    run_git(dir, &["config", "commit.gpgsign", "false"]);
}

fn git_add_commit(dir: &Path, message: &str) {
    run_git(dir, &["add", "-A"]);
    run_git(dir, &["commit", "-q", "-m", message]);
}

fn git_commit_empty_on_path(dir: &Path, relpath: &str, content: &str, message: &str) {
    let full = dir.join(relpath);
    if let Some(parent) = full.parent() {
        fs::create_dir_all(parent).unwrap();
    }
    fs::write(&full, content).unwrap();
    run_git(dir, &["add", "-A"]);
    run_git(dir, &["commit", "-q", "-m", message]);
}

// ---------------------------------------------------------------------------
// Workspace builders
// ---------------------------------------------------------------------------

/// Single-crate workspace at `tmp/Cargo.toml` with literal version "0.1.0".
fn single_crate_workspace(tmp: &Path) {
    fs::write(
        tmp.join("Cargo.toml"),
        r#"[package]
name = "demo"
version = "0.1.0"
edition = "2024"
"#,
    )
    .unwrap();
}

/// Two-member workspace: `core` (0.1.0) and `cli` (0.1.0), cli depends on core.
fn two_crate_workspace(tmp: &Path) {
    fs::write(
        tmp.join("Cargo.toml"),
        r#"[workspace]
members = ["crates/core", "crates/cli"]
resolver = "2"
"#,
    )
    .unwrap();
    fs::create_dir_all(tmp.join("crates/core")).unwrap();
    fs::create_dir_all(tmp.join("crates/cli")).unwrap();
    fs::write(
        tmp.join("crates/core/Cargo.toml"),
        r#"[package]
name = "core"
version = "0.1.0"
edition = "2024"
"#,
    )
    .unwrap();
    fs::write(
        tmp.join("crates/cli/Cargo.toml"),
        r#"[package]
name = "cli"
version = "0.1.0"
edition = "2024"

[dependencies]
core = { path = "../core", version = "0.1.0" }
"#,
    )
    .unwrap();
}

/// Workspace with `version.workspace = true` inheritance on both members.
fn inheriting_workspace(tmp: &Path) {
    fs::write(
        tmp.join("Cargo.toml"),
        r#"[workspace]
members = ["crates/a", "crates/b"]
resolver = "2"

[workspace.package]
version = "0.3.0"
"#,
    )
    .unwrap();
    fs::create_dir_all(tmp.join("crates/a")).unwrap();
    fs::create_dir_all(tmp.join("crates/b")).unwrap();
    fs::write(
        tmp.join("crates/a/Cargo.toml"),
        r#"[package]
name = "a"
version.workspace = true
edition = "2024"
"#,
    )
    .unwrap();
    fs::write(
        tmp.join("crates/b/Cargo.toml"),
        r#"[package]
name = "b"
version.workspace = true
edition = "2024"
"#,
    )
    .unwrap();
}

/// Workspace where one member is `publish = false`.
fn workspace_with_private_member(tmp: &Path) {
    fs::write(
        tmp.join("Cargo.toml"),
        r#"[workspace]
members = ["crates/pub", "crates/priv"]
resolver = "2"
"#,
    )
    .unwrap();
    fs::create_dir_all(tmp.join("crates/pub")).unwrap();
    fs::create_dir_all(tmp.join("crates/priv")).unwrap();
    fs::write(
        tmp.join("crates/pub/Cargo.toml"),
        r#"[package]
name = "pub"
version = "0.1.0"
edition = "2024"
"#,
    )
    .unwrap();
    fs::write(
        tmp.join("crates/priv/Cargo.toml"),
        r#"[package]
name = "priv"
version = "0.1.0"
edition = "2024"
publish = false
"#,
    )
    .unwrap();
}

fn read_version(manifest: &Path) -> String {
    let text = fs::read_to_string(manifest).unwrap();
    for line in text.lines() {
        if let Some(rest) = line.trim().strip_prefix("version")
            && let Some(eq) = rest.find('=')
        {
            let raw = rest[eq + 1..].trim();
            // strip quotes if present
            if let Some(s) = raw
                .trim_start_matches('"')
                .split('"')
                .next()
                .filter(|s| !s.is_empty() && s.chars().next().unwrap().is_ascii_digit())
            {
                return s.to_string();
            }
        }
    }
    panic!("no version in {}", manifest.display());
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[test]
fn patch_bumps_single_crate() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "bump failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert_eq!(read_version(&tmp.path().join("Cargo.toml")), "0.1.1");
}

#[test]
fn minor_explicit_then_major() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    anodizer()
        .current_dir(tmp.path())
        .args(["bump", "minor", "-y"])
        .status()
        .unwrap();
    assert_eq!(read_version(&tmp.path().join("Cargo.toml")), "0.2.0");
    // Tree is now dirty from the first bump; second bump must pass --allow-dirty.
    anodizer()
        .current_dir(tmp.path())
        .args(["bump", "major", "-y", "--allow-dirty"])
        .status()
        .unwrap();
    assert_eq!(read_version(&tmp.path().join("Cargo.toml")), "1.0.0");
}

#[test]
fn dry_run_writes_nothing() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let before = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "minor", "--dry-run"])
        .output()
        .unwrap();
    assert!(out.status.success());
    let stdout = String::from_utf8_lossy(&out.stdout);
    // The plan table should mention the old→new transition.
    assert!(stdout.contains("0.1.0"), "stdout missing 0.1.0: {stdout}");
    assert!(stdout.contains("0.2.0"), "stdout missing 0.2.0: {stdout}");
    let after = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
    assert_eq!(before, after, "--dry-run should not touch the manifest");
}

#[test]
fn dry_run_json_is_parseable() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "minor", "--dry-run", "--output", "json"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "json dry-run failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout must be JSON");
    let arr = v.as_array().expect("json root must be an array");
    assert_eq!(arr.len(), 1);
    let row = &arr[0];
    assert_eq!(row["crate"], "demo");
    assert_eq!(row["current"], "0.1.0");
    assert_eq!(row["next"], "0.2.0");
    assert_eq!(row["level"], "minor");
}

#[test]
fn dirty_tree_refused_without_allow_dirty() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");
    // Introduce a dirty change.
    fs::write(tmp.path().join("dirty.txt"), "hello").unwrap();

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "-y"])
        .output()
        .unwrap();
    assert!(
        !out.status.success(),
        "bump should refuse a dirty tree; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(
        err.contains("uncommitted") || err.contains("dirty"),
        "error should mention uncommitted changes: {err}"
    );
}

#[test]
fn publish_false_skipped_from_workspace() {
    let tmp = TempDir::new().unwrap();
    workspace_with_private_member(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "--workspace", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "bump --workspace failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert_eq!(
        read_version(&tmp.path().join("crates/pub/Cargo.toml")),
        "0.1.1"
    );
    // publish = false crate should be untouched.
    assert_eq!(
        read_version(&tmp.path().join("crates/priv/Cargo.toml")),
        "0.1.0"
    );
}

#[test]
fn workspace_package_inheritance_bumps_root_only() {
    let tmp = TempDir::new().unwrap();
    inheriting_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "minor", "--workspace", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "bump --workspace failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    // Root [workspace.package].version → 0.4.0.
    let root = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
    assert!(
        root.contains("version = \"0.4.0\""),
        "root should be bumped to 0.4.0: {root}"
    );
    // Members must still say version.workspace — we don't rewrite them.
    let a = fs::read_to_string(tmp.path().join("crates/a/Cargo.toml")).unwrap();
    assert!(a.contains("version.workspace = true"), "member a: {a}");
}

#[test]
fn exact_skips_dep_propagation() {
    let tmp = TempDir::new().unwrap();
    two_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "-p", "core", "--exact", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "{:?}",
        String::from_utf8_lossy(&out.stderr)
    );
    // core bumped.
    assert_eq!(
        read_version(&tmp.path().join("crates/core/Cargo.toml")),
        "0.1.1"
    );
    // cli's [dependencies] core = "0.1.0" left untouched.
    let cli = fs::read_to_string(tmp.path().join("crates/cli/Cargo.toml")).unwrap();
    assert!(
        cli.contains("version = \"0.1.0\""),
        "cli dep on core should NOT be rewritten under --exact: {cli}"
    );
}

#[test]
fn propagation_rewrites_sibling_dep() {
    let tmp = TempDir::new().unwrap();
    two_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "-p", "core", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    // core bumped.
    assert_eq!(
        read_version(&tmp.path().join("crates/core/Cargo.toml")),
        "0.1.1"
    );
    // cli's own version is untouched.
    assert_eq!(
        read_version(&tmp.path().join("crates/cli/Cargo.toml")),
        "0.1.0"
    );
    // cli's [dependencies] core version is rewritten.
    let cli = fs::read_to_string(tmp.path().join("crates/cli/Cargo.toml")).unwrap();
    assert!(
        cli.contains("version = \"0.1.1\""),
        "cli dep on core should be rewritten: {cli}"
    );
}

#[test]
fn commit_flag_creates_single_commit() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let before = anodizer_core::test_helpers::output_with_spawn_retry(
        || {
            let mut cmd = Command::new("git");
            cmd.current_dir(tmp.path())
                .args(["rev-list", "--count", "HEAD"]);
            cmd
        },
        "git",
    );
    let before_n: u32 = String::from_utf8_lossy(&before.stdout)
        .trim()
        .parse()
        .unwrap();

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "--commit", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "bump --commit failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let after = anodizer_core::test_helpers::output_with_spawn_retry(
        || {
            let mut cmd = Command::new("git");
            cmd.current_dir(tmp.path())
                .args(["rev-list", "--count", "HEAD"]);
            cmd
        },
        "git",
    );
    let after_n: u32 = String::from_utf8_lossy(&after.stdout)
        .trim()
        .parse()
        .unwrap();
    assert_eq!(after_n, before_n + 1, "exactly one new commit expected");

    // Commit message should include the new version.
    let msg = anodizer_core::test_helpers::output_with_spawn_retry(
        || {
            let mut cmd = Command::new("git");
            cmd.current_dir(tmp.path())
                .args(["log", "-1", "--pretty=%B"]);
            cmd
        },
        "git",
    );
    let msg = String::from_utf8_lossy(&msg.stdout);
    assert!(
        msg.contains("0.1.1"),
        "commit message missing version: {msg}"
    );
    assert!(
        msg.contains("demo"),
        "commit message missing crate name: {msg}"
    );
}

#[test]
fn infer_picks_per_crate_level_from_commits() {
    let tmp = TempDir::new().unwrap();
    two_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");
    // Seed a tag for each crate so the inference range starts there.
    run_git(tmp.path(), &["tag", "core-v0.1.0"]);
    run_git(tmp.path(), &["tag", "cli-v0.1.0"]);

    // Commit that only touches core (a feat) → minor bump for core.
    git_commit_empty_on_path(
        tmp.path(),
        "crates/core/new_feature.rs",
        "pub fn f() {}",
        "feat(core): add new feature",
    );
    // Commit that only touches cli (a fix) → patch bump for cli.
    git_commit_empty_on_path(
        tmp.path(),
        "crates/cli/bugfix.rs",
        "pub fn g() {}",
        "fix(cli): correct bug",
    );

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "--workspace", "--dry-run", "--output", "json"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "infer dry-run failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
    let rows = v.as_array().unwrap();
    let by_name: std::collections::HashMap<&str, &serde_json::Value> = rows
        .iter()
        .map(|r| (r["crate"].as_str().unwrap(), r))
        .collect();
    assert_eq!(by_name["core"]["level"], "minor");
    assert_eq!(by_name["core"]["next"], "0.2.0");
    assert_eq!(by_name["cli"]["level"], "patch");
    assert_eq!(by_name["cli"]["next"], "0.1.1");
}

/// A crate living AT the workspace root strips to an empty relative path;
/// git rejects an empty pathspec, and the swallowed failure made
/// `bump --dry-run` preview Skip ("no commits touching this crate") on
/// every single-crate-at-root repo.
#[test]
fn infer_previews_minor_for_root_level_crate() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");
    git_commit_empty_on_path(
        tmp.path(),
        "feature.rs",
        "pub fn f() {}",
        "feat: add feature",
    );

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "--dry-run", "--output", "json"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "root-crate infer dry-run failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
    let row = &v.as_array().unwrap()[0];
    assert_eq!(row["crate"], "demo");
    assert_eq!(row["level"], "minor", "feat commit must preview minor: {v}");
    assert_eq!(row["next"], "0.2.0");
}

/// bump's discovery falls back to Cargo.toml defaults like release/tag do —
/// and must emit the SAME operator warn for that event (it was silent).
#[test]
fn bump_warns_on_cargo_toml_defaults_fallback() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "--dry-run"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "bump dry-run failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("no anodizer config found; using defaults from Cargo.toml"),
        "missing Cargo.toml-fallback warn on stderr: {stderr}"
    );
}

#[test]
fn multi_crate_without_selection_errors() {
    let tmp = TempDir::new().unwrap();
    two_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "-y"])
        .output()
        .unwrap();
    assert!(
        !out.status.success(),
        "multi-crate bump without selection should error"
    );
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(
        err.contains("-p") || err.contains("--workspace"),
        "error should suggest -p or --workspace: {err}"
    );
}

#[test]
fn release_strips_prerelease() {
    let tmp = TempDir::new().unwrap();
    fs::write(
        tmp.path().join("Cargo.toml"),
        r#"[package]
name = "demo"
version = "1.0.0-rc.1"
edition = "2024"
"#,
    )
    .unwrap();
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "release", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "release bump failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert_eq!(read_version(&tmp.path().join("Cargo.toml")), "1.0.0");
}

#[test]
fn pre_appends_prerelease() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "minor", "--pre", "rc.1", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "pre bump failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let text = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
    assert!(
        text.contains("version = \"0.2.0-rc.1\""),
        "expected 0.2.0-rc.1: {text}"
    );
}

// ---------------------------------------------------------------------------
// Bundled changelog (via stage-changelog::render_crate_section)
// ---------------------------------------------------------------------------

/// `bump --commit --changelog` folds the rendered `## [<version>]` section into
/// the same bump commit as the Cargo.toml edits. The refresh is opt-in: it runs
/// only because `--changelog` is passed alongside the configured `changelog:`
/// block.
#[test]
fn commit_bundles_changelog_when_configured() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    fs::write(
        tmp.path().join(".anodizer.yaml"),
        r#"version: 2
project_name: demo
crates:
  - name: demo
    path: .
    tag_template: "v{{ Version }}"
changelog:
  sort: asc
  groups:
    - title: Features
      regexp: "^feat"
      order: 0
    - title: Bug Fixes
      regexp: "^fix"
      order: 1
"#,
    )
    .unwrap();
    git_init(tmp.path());
    git_add_commit(tmp.path(), "feat: groundwork before the release");
    // Tag the initial state with the `v` scheme declared by tag_template so the
    // changelog inference range resolves the prefix from the template (not the
    // hardcoded `{crate}-v`) and starts at this tag.
    run_git(tmp.path(), &["tag", "v0.1.0"]);
    // A feat commit since the tag — this is what the rendered section must
    // include.
    git_commit_empty_on_path(
        tmp.path(),
        "src/feature.rs",
        "pub fn f() {}",
        "feat: add a sparkly new feature",
    );

    let before = anodizer_core::test_helpers::output_with_spawn_retry(
        || {
            let mut cmd = Command::new("git");
            cmd.current_dir(tmp.path())
                .args(["rev-list", "--count", "HEAD"]);
            cmd
        },
        "git",
    );
    let before_n: u32 = String::from_utf8_lossy(&before.stdout)
        .trim()
        .parse()
        .unwrap();

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "--commit", "--changelog", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "bump --commit failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let after = anodizer_core::test_helpers::output_with_spawn_retry(
        || {
            let mut cmd = Command::new("git");
            cmd.current_dir(tmp.path())
                .args(["rev-list", "--count", "HEAD"]);
            cmd
        },
        "git",
    );
    let after_n: u32 = String::from_utf8_lossy(&after.stdout)
        .trim()
        .parse()
        .unwrap();
    assert_eq!(
        after_n,
        before_n + 1,
        "exactly one new commit must include the bundled changelog"
    );

    // Diff of the new commit must touch BOTH Cargo.toml and CHANGELOG.md.
    let diff = anodizer_core::test_helpers::output_with_spawn_retry(
        || {
            let mut cmd = Command::new("git");
            cmd.current_dir(tmp.path()).args([
                "diff-tree",
                "--no-commit-id",
                "--name-only",
                "-r",
                "HEAD",
            ]);
            cmd
        },
        "git",
    );
    let names = String::from_utf8_lossy(&diff.stdout);
    assert!(
        names.lines().any(|l| l == "Cargo.toml"),
        "commit must touch Cargo.toml: {names}"
    );
    assert!(
        names.lines().any(|l| l == "CHANGELOG.md"),
        "commit must touch CHANGELOG.md: {names}"
    );

    // Sanity: the new section is present on disk.
    let cl = fs::read_to_string(tmp.path().join("CHANGELOG.md")).unwrap();
    assert!(cl.contains("[0.1.1]"), "changelog missing version: {cl}");
    assert!(
        cl.contains("sparkly new feature"),
        "changelog missing feat description: {cl}"
    );
    // The range must START at v0.1.0 (resolved from tag_template), so the
    // pre-tag `groundwork` commit is EXCLUDED. Against the old hardcoded
    // `demo-v` prefix, `find_last_tag_for_prefix("demo-v")` returns None →
    // from_tag spans all history → this commit would leak into the section.
    assert!(
        !cl.contains("groundwork before the release"),
        "section must exclude pre-v0.1.0 history (from_tag resolved wrong): {cl}"
    );
}

/// The new default: `bump --commit` WITHOUT `--changelog` writes no CHANGELOG.md
/// even though `changelog:` is configured (the refresh is opt-in). The bump
/// commit still lands the Cargo.toml edit; it just never touches CHANGELOG.md.
#[test]
fn commit_default_no_flag_skips_changelog() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    fs::write(
        tmp.path().join(".anodizer.yaml"),
        r#"version: 2
project_name: demo
crates:
  - name: demo
    path: .
    tag_template: "v{{ Version }}"
changelog:
  sort: asc
"#,
    )
    .unwrap();
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");
    run_git(tmp.path(), &["tag", "v0.1.0"]);
    git_commit_empty_on_path(
        tmp.path(),
        "src/feature.rs",
        "pub fn f() {}",
        "feat: add a sparkly new feature",
    );

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "--commit", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "bump --commit failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // Without --changelog, no CHANGELOG.md is written...
    assert!(
        !tmp.path().join("CHANGELOG.md").exists(),
        "without --changelog, bump --commit must not write a CHANGELOG.md"
    );

    // ...and the bump commit touches only Cargo.toml, never CHANGELOG.md.
    let diff = anodizer_core::test_helpers::output_with_spawn_retry(
        || {
            let mut cmd = Command::new("git");
            cmd.current_dir(tmp.path()).args([
                "diff-tree",
                "--no-commit-id",
                "--name-only",
                "-r",
                "HEAD",
            ]);
            cmd
        },
        "git",
    );
    let names = String::from_utf8_lossy(&diff.stdout);
    assert!(
        names.lines().any(|l| l == "Cargo.toml"),
        "commit must still touch Cargo.toml: {names}"
    );
    assert!(
        !names.lines().any(|l| l == "CHANGELOG.md"),
        "without --changelog, commit must NOT touch CHANGELOG.md: {names}"
    );
}

/// A `v{{ Version }}` (no `{crate}-v`) template: the bump-produced root section
/// heading is the bare `## [<version>]` and the rendered range starts at the
/// `v`-prefixed previous tag. Against the old hardcoded `demo-v` prefix the
/// previous-tag lookup misses `v0.1.0`, the range spans all history, and the
/// pre-tag commit leaks in. This isolates the `from_tag` prefix-resolution fix.
#[test]
fn bump_changelog_v_template_resolves_prefix_and_range() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    fs::write(
        tmp.path().join(".anodizer.yaml"),
        r#"version: 2
project_name: demo
crates:
  - name: demo
    path: .
    tag_template: "v{{ Version }}"
changelog:
  sort: asc
"#,
    )
    .unwrap();
    git_init(tmp.path());
    git_add_commit(tmp.path(), "feat: pre-release groundwork");
    run_git(tmp.path(), &["tag", "v0.1.0"]);
    git_commit_empty_on_path(
        tmp.path(),
        "src/added.rs",
        "pub fn g() {}",
        "feat: shiny post-tag addition",
    );

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "--commit", "--changelog", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "bump --commit failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let cl = fs::read_to_string(tmp.path().join("CHANGELOG.md")).unwrap();
    // Heading is the bare version (`## [0.1.1]`), never a `{crate}-v`/`v`-tagged
    // heading like `## [demo-v0.1.1]`.
    assert!(
        cl.contains("## [0.1.1]"),
        "expected a bare-version heading `## [0.1.1]`: {cl}"
    );
    assert!(
        !cl.contains("## [demo-v"),
        "heading must not carry the {{crate}}-v prefix: {cl}"
    );
    // Range starts at v0.1.0: post-tag commit IN, pre-tag commit OUT.
    assert!(
        cl.contains("shiny post-tag addition"),
        "post-tag feat must be in the section: {cl}"
    );
    assert!(
        !cl.contains("pre-release groundwork"),
        "pre-v0.1.0 history must be excluded (prefix resolved to `v`): {cl}"
    );
}

/// `changelog: { skip: true }` suppresses the bundled CHANGELOG.md on
/// `bump --commit --changelog` even with the refresh opted in (skip wins),
/// matching how `tag` honors the same gate. The bump commit is still produced
/// (Cargo.toml edit), but no CHANGELOG.md is written or committed.
#[test]
fn commit_skips_changelog_when_skip_true() {
    let tmp = TempDir::new().unwrap();
    single_crate_workspace(tmp.path());
    fs::write(
        tmp.path().join(".anodizer.yaml"),
        r#"version: 2
project_name: demo
crates:
  - name: demo
    path: .
    tag_template: "v{{ Version }}"
changelog:
  skip: true
"#,
    )
    .unwrap();
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");
    run_git(tmp.path(), &["tag", "demo-v0.1.0"]);
    git_commit_empty_on_path(
        tmp.path(),
        "src/feature.rs",
        "pub fn f() {}",
        "feat: add a sparkly new feature",
    );

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "patch", "--commit", "--changelog", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "bump --commit failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // No CHANGELOG.md written when the changelog refresh is skipped.
    assert!(
        !tmp.path().join("CHANGELOG.md").exists(),
        "changelog.skip: true must not write a CHANGELOG.md"
    );

    // The bump commit must NOT reference a CHANGELOG.md.
    let diff = anodizer_core::test_helpers::output_with_spawn_retry(
        || {
            let mut cmd = Command::new("git");
            cmd.current_dir(tmp.path()).args([
                "diff-tree",
                "--no-commit-id",
                "--name-only",
                "-r",
                "HEAD",
            ]);
            cmd
        },
        "git",
    );
    let names = String::from_utf8_lossy(&diff.stdout);
    assert!(
        names.lines().any(|l| l == "Cargo.toml"),
        "commit must still touch Cargo.toml: {names}"
    );
    assert!(
        !names.lines().any(|l| l == "CHANGELOG.md"),
        "commit must NOT touch CHANGELOG.md when skipped: {names}"
    );
}

// ---------------------------------------------------------------------------
// Inference must honor each crate's `tag_template` (cfgd-style monorepos
// have crates whose tag is bare `v{{ Version }}` — not `<name>-v…`).
// ---------------------------------------------------------------------------

#[test]
fn inference_respects_tag_template_from_anodizer_yaml() {
    let tmp = TempDir::new().unwrap();
    two_crate_workspace(tmp.path());
    // .anodizer.yaml gives `core` a custom `core-v` prefix and `cli` the bare
    // `v` prefix (the cfgd top-crate convention). Without the tag-template
    // wiring, bump's inference would fall back to `core-v` and `cli-v` and
    // miss the actual tags below.
    fs::write(
        tmp.path().join(".anodizer.yaml"),
        r#"version: 2
project_name: tag-template-fixture
crates:
  - name: core
    path: crates/core
    tag_template: "core-v{{ Version }}"
  - name: cli
    path: crates/cli
    tag_template: "v{{ Version }}"
"#,
    )
    .unwrap();
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");
    // Tag with the templates' actual prefixes — `core-v` and bare `v`.
    run_git(tmp.path(), &["tag", "core-v0.1.0"]);
    run_git(tmp.path(), &["tag", "v0.1.0"]);
    // After-tag commits: core gets a feat (→ minor), cli gets a chore (→ skip).
    git_commit_empty_on_path(
        tmp.path(),
        "crates/core/feature.rs",
        "pub fn f() {}",
        "feat(core): add feature",
    );
    git_commit_empty_on_path(
        tmp.path(),
        "crates/cli/notes.rs",
        "// notes",
        "chore(cli): housekeeping",
    );

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "--workspace", "--dry-run", "--output", "json"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "infer dry-run failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
    let by_name: std::collections::HashMap<&str, &serde_json::Value> = v
        .as_array()
        .unwrap()
        .iter()
        .map(|r| (r["crate"].as_str().unwrap(), r))
        .collect();
    // `cli`'s tag prefix is bare `v` per .anodizer.yaml; the v0.1.0 tag must
    // be discovered and the only post-tag commit is a chore → skip. With
    // the fallback `<name>-v` prefix this row would scan all-of-history,
    // see the chore, classify it as skip ANYWAY, but the reason string would
    // mention "no commits since cli-v" instead of `since v0.1.0`. Pin to
    // the reason text so the regression cannot hide.
    assert_eq!(
        by_name["cli"]["level"], "skip",
        "cli should skip — no feat/fix since v0.1.0"
    );
    let cli_reason = by_name["cli"]["reason"].as_str().unwrap();
    assert!(
        cli_reason.contains("since v0.1.0"),
        "cli reason must reference the actual v0.1.0 tag, not <name>-v fallback: {cli_reason}"
    );
    // core uses `core-v` template; the post-tag feat → minor, reason cites
    // core-v0.1.0 specifically.
    assert_eq!(by_name["core"]["level"], "minor");
    assert_eq!(by_name["core"]["next"], "0.2.0");
    let core_reason = by_name["core"]["reason"].as_str().unwrap();
    assert!(
        core_reason.contains("since core-v0.1.0"),
        "core reason must reference core-v0.1.0: {core_reason}"
    );
}

/// `bump` must discover the config through the same well-known-name candidate
/// walk every other command uses — a repo configured via `anodizer.yaml`
/// (a non-`.anodizer.yaml` candidate) gets its `tag_template` honored, not
/// silently degraded to the `<name>-v` fallback family.
#[test]
fn bump_discovers_non_dot_anodizer_yaml_config() {
    let tmp = TempDir::new().unwrap();
    two_crate_workspace(tmp.path());
    fs::write(
        tmp.path().join("anodizer.yaml"),
        r#"version: 2
project_name: non-dot-config-fixture
crates:
  - name: cli
    path: crates/cli
    tag_template: "v{{ Version }}"
"#,
    )
    .unwrap();
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");
    run_git(tmp.path(), &["tag", "v0.1.0"]);
    git_commit_empty_on_path(
        tmp.path(),
        "crates/cli/feature.rs",
        "pub fn f() {}",
        "feat(cli): add feature",
    );

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "--workspace", "--dry-run", "--output", "json"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "infer dry-run failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
    let cli_row = v
        .as_array()
        .unwrap()
        .iter()
        .find(|r| r["crate"] == "cli")
        .expect("cli row");
    assert_eq!(cli_row["level"], "minor");
    let reason = cli_row["reason"].as_str().unwrap();
    assert!(
        reason.contains("since v0.1.0"),
        "anodizer.yaml's tag_template must be honored (range bounded at \
         v0.1.0, not the cli-v fallback family): {reason}"
    );
}

// ---------------------------------------------------------------------------
// Strict-mode version-pin enforcement
// ---------------------------------------------------------------------------

/// Two-crate workspace with `core` pinned to `0.1.0` in `.anodizer.yaml`. The
/// `cli` crate is unpinned.
fn pinned_two_crate_workspace(tmp: &Path, pin_core: bool) {
    two_crate_workspace(tmp);
    let core_pin = if pin_core {
        "    version: \"0.1.0\"\n"
    } else {
        ""
    };
    let yaml = format!(
        r#"version: 2
project_name: pinned
crates:
  - name: core
    path: crates/core
    tag_template: "v{{{{ Version }}}}"
{core_pin}  - name: cli
    path: crates/cli
    tag_template: "v{{{{ Version }}}}"
"#,
        core_pin = core_pin,
    );
    fs::write(tmp.join(".anodizer.yaml"), yaml).unwrap();
}

#[test]
fn strict_refuses_bump_when_version_pinned() {
    let tmp = TempDir::new().unwrap();
    pinned_two_crate_workspace(tmp.path(), true);
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["--strict", "bump", "minor", "-p", "core", "-y"])
        .output()
        .unwrap();
    assert!(
        !out.status.success(),
        "strict bump should fail when version is pinned; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("core"), "error must mention crate name: {err}");
    assert!(
        err.contains("0.1.0"),
        "error must mention pinned version: {err}"
    );
    assert!(
        err.contains("0.2.0"),
        "error must mention proposed version: {err}"
    );
    // Manifest must NOT have been touched.
    assert_eq!(
        read_version(&tmp.path().join("crates/core/Cargo.toml")),
        "0.1.0"
    );
}

#[test]
fn strict_allows_bump_when_no_pin() {
    let tmp = TempDir::new().unwrap();
    pinned_two_crate_workspace(tmp.path(), false);
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["--strict", "bump", "minor", "-p", "core", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "strict bump should succeed without a pin; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert_eq!(
        read_version(&tmp.path().join("crates/core/Cargo.toml")),
        "0.2.0"
    );
}

#[test]
fn non_strict_warns_but_proceeds() {
    let tmp = TempDir::new().unwrap();
    pinned_two_crate_workspace(tmp.path(), true);
    git_init(tmp.path());
    git_add_commit(tmp.path(), "initial");

    let out = anodizer()
        .current_dir(tmp.path())
        .args(["bump", "minor", "-p", "core", "-y"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "non-strict bump should proceed despite pin; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(
        err.to_lowercase().contains("warn"),
        "stderr must include a warning when bumping a pinned crate: {err}"
    );
    assert_eq!(
        read_version(&tmp.path().join("crates/core/Cargo.toml")),
        "0.2.0"
    );
}
