//! End-to-end tests for the `codemmet` binary: real process, real exit codes.

// Expected outputs like `${2:i}` look like format args to this lint; they
// are LSP snippet syntax, not formatting mistakes.
#![allow(clippy::literal_string_with_formatting_args)]

use std::io::Write as _;
use std::process::{Command, Output, Stdio};

mod common;
use common::scratch_dir;

/// A `Command` for the compiled `codemmet` binary.
fn bin() -> Command {
    Command::new(env!("CARGO_BIN_EXE_codemmet"))
}

/// Run the binary with `args`, pointing `--config` at an empty scratch dir so
/// the developer's real user snippets never leak into assertions.
fn run(name: &str, args: &[&str]) -> Output {
    let dir = scratch_dir(name);
    bin()
        .args(args)
        .args(["--config", dir.to_str().expect("scratch path is UTF-8")])
        .output()
        .expect("binary should run")
}

/// The process's exit code, defaulting the assertion message to the output.
fn code(output: &Output) -> i32 {
    output.status.code().expect("process should not be killed")
}

fn stdout(output: &Output) -> String {
    String::from_utf8(output.stdout.clone()).expect("stdout should be UTF-8")
}

fn stderr(output: &Output) -> String {
    String::from_utf8(output.stderr.clone()).expect("stderr should be UTF-8")
}

#[test]
fn expand_strip_is_default_and_exact() {
    let out = run(
        "expand-strip",
        &["expand", "--lang", "rust", "fn:main>fori10>pr"],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(
        stdout(&out),
        "fn main() {\n    for i in 0..10 {\n        println!(\"\");\n    }\n}\n"
    );
    assert_eq!(stderr(&out), "");
}

#[test]
fn expand_keep_emits_snippet_syntax() {
    let out = run(
        "expand-keep",
        &[
            "expand",
            "--lang",
            "rust",
            "--tabstops",
            "keep",
            "fn:main>fori10>pr",
        ],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(
        stdout(&out),
        "fn main($1) {\n    for ${2:i} in 0..10 {\n        println!(\"$0\");\n    }\n}\n"
    );
}

#[test]
fn expand_reads_abbreviation_from_stdin_dash() {
    let dir = scratch_dir("expand-stdin");
    let mut child = bin()
        .args(["expand", "--lang", "rust", "-"])
        .args(["--config", dir.to_str().expect("scratch path is UTF-8")])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("binary should spawn");
    child
        .stdin
        .take()
        .expect("stdin should be piped")
        .write_all(b"fori10>pr\n")
        .expect("stdin should accept the abbreviation");
    let out = child.wait_with_output().expect("binary should finish");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(stdout(&out), "for i in 0..10 {\n    println!(\"\");\n}\n");
}

#[test]
fn expand_json_reports_text_cursor_and_snippet_flag() {
    let out = run("expand-json", &["expand", "--lang", "rust", "--json", "pr"]);
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let text = stdout(&out);
    assert_eq!(text.lines().count(), 1, "JSON should be one line: {text}");
    let value: serde_json::Value = serde_json::from_str(&text).expect("stdout should be JSON");
    assert_eq!(value["text"], "println!(\"\");");
    assert_eq!(value["is_snippet"], false);
    assert_eq!(value["cursor"]["line"], 0);
    assert_eq!(value["cursor"]["character"], 10);
}

#[test]
fn expand_json_keep_has_null_cursor() {
    let out = run(
        "expand-json-keep",
        &[
            "expand",
            "--lang",
            "rust",
            "--json",
            "--tabstops",
            "keep",
            "pr",
        ],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let value: serde_json::Value =
        serde_json::from_str(&stdout(&out)).expect("stdout should be JSON");
    assert_eq!(value["text"], "println!(\"$0\");");
    assert_eq!(value["is_snippet"], true);
    assert!(value["cursor"].is_null());
}

#[test]
fn expand_honors_indent_and_base_indent() {
    let out = run(
        "expand-indent",
        &[
            "expand",
            "--lang",
            "rust",
            "--indent",
            "\t",
            "--base-indent",
            "  ",
            "fn:main>pr",
        ],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(stdout(&out), "fn main() {\n  \tprintln!(\"\");\n  }\n");
}

#[test]
fn expand_parse_error_exits_1() {
    let out = run("expand-parse-err", &["expand", "--lang", "rust", ">pr"]);
    assert_eq!(code(&out), 1);
    assert_eq!(stdout(&out), "", "diagnostics must not reach stdout");
    let err = stderr(&out);
    assert!(err.contains("`>pr`"), "stderr was: {err}");
}

#[test]
fn expand_unknown_trigger_exits_2_with_suggestion() {
    let out = run(
        "expand-unknown-trigger",
        &["expand", "--lang", "rust", "forj"],
    );
    assert_eq!(code(&out), 2);
    assert_eq!(stdout(&out), "", "diagnostics must not reach stdout");
    let err = stderr(&out);
    assert!(err.contains("unknown trigger `forj`"), "stderr was: {err}");
    assert!(err.contains("did you mean"), "stderr was: {err}");
}

#[test]
fn expand_unknown_language_exits_3() {
    let out = run("expand-unknown-lang", &["expand", "--lang", "cobol", "pr"]);
    assert_eq!(code(&out), 3);
    assert!(
        stderr(&out).contains("unknown language `cobol`"),
        "stderr was: {}",
        stderr(&out)
    );
}

#[test]
fn expand_bad_indent_exits_3() {
    let out = run(
        "expand-bad-indent",
        &["expand", "--lang", "rust", "--indent", "ab", "pr"],
    );
    assert_eq!(code(&out), 3);
    assert!(
        stderr(&out).contains("--indent"),
        "stderr was: {}",
        stderr(&out)
    );
}

#[test]
fn list_plain_is_sorted_with_descriptions() {
    let out = run("list-plain", &["list", "--lang", "rust"]);
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let text = stdout(&out);
    let triggers: Vec<&str> = text
        .lines()
        .map(|line| line.split_whitespace().next().unwrap_or(""))
        .collect();
    let mut sorted = triggers.clone();
    sorted.sort_unstable();
    assert_eq!(triggers, sorted, "listing should be sorted");
    let fori = text
        .lines()
        .find(|line| line.starts_with("fori"))
        .expect("fori should be listed");
    assert!(
        fori.contains("for loop over integer range"),
        "line was: {fori}"
    );
}

#[test]
fn list_json_is_an_array_of_trigger_objects() {
    let out = run("list-json", &["list", "--lang", "rust", "--json"]);
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let value: serde_json::Value =
        serde_json::from_str(&stdout(&out)).expect("stdout should be JSON");
    let items = value.as_array().expect("output should be a JSON array");
    assert!(!items.is_empty());
    let pr = items
        .iter()
        .find(|item| item["trigger"] == "pr")
        .expect("pr should be listed");
    assert_eq!(pr["description"], "println! to stdout");
}

#[test]
fn expand_uses_config_override_directory() {
    let dir = scratch_dir("expand-override");
    std::fs::write(
        dir.join("rust.toml"),
        "[meta]\nlanguage = \"rust\"\n\n[snippets.zz]\nbody = \"zz($0)\"\n",
    )
    .expect("override pack should be writable");
    let out = bin()
        .args(["expand", "--lang", "rust", "zz"])
        .args(["--config", dir.to_str().expect("scratch path is UTF-8")])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(stdout(&out), "zz()\n");
}

#[test]
fn scaffold_writes_a_multi_file_snippet() {
    let dir = scratch_dir("scaffold-multifile");
    std::fs::write(
        dir.join("python.toml"),
        concat!(
            "[meta]\nlanguage = \"python\"\n\n",
            "[snippets.feat]\nparams = [{ name = \"app\", kind = \"ident\", default = \"app\" }]\n\n",
            "[snippets.feat.files.model]\npath = \"${app}/models.py\"\n",
            "body = \"class ${app}Model:\\n\\tpass$0\"\n\n",
            "[snippets.feat.files.urls]\npath = \"${app}/urls.py\"\n",
            "body = \"route('${app}')\"\n",
        ),
    )
    .expect("pack should be writable");
    let root = dir.join("proj");
    let out = bin()
        .args(["scaffold", "--lang", "python", "--no-project", "--write"])
        .args(["--root", root.to_str().expect("path is UTF-8")])
        .arg("feat:blog")
        .args(["--config", dir.to_str().expect("path is UTF-8")])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(
        std::fs::read_to_string(root.join("blog/models.py")).expect("model written"),
        "class blogModel:\n    pass\n"
    );
    assert_eq!(
        std::fs::read_to_string(root.join("blog/urls.py")).expect("urls written"),
        "route('blog')\n"
    );
    // The `--write` output marks exactly one primary file, which the editor
    // wrapper (`:CodemmetScaffold`) parses to know which file to open.
    let out_text = stdout(&out);
    let primary: Vec<&str> = out_text
        .lines()
        .filter(|l| l.contains("(primary)"))
        .collect();
    assert_eq!(
        primary.len(),
        1,
        "exactly one primary line; stdout:\n{out_text}"
    );
    assert!(
        primary[0].contains("models.py"),
        "primary line: {}",
        primary[0]
    );
}

#[test]
#[cfg(feature = "tree-sitter")]
fn scaffold_injects_into_an_existing_class() {
    let dir = scratch_dir("scaffold-inject");
    std::fs::write(
        dir.join("cpp.toml"),
        concat!(
            "[meta]\nlanguage = \"cpp\"\n\n",
            "[snippets.method]\nparams = [{ name = \"name\", kind = \"ident\", default = \"fn\" }]\n\n",
            "[snippets.method.files.decl]\ntarget = \"self\"\non_exists = \"inject\"\n",
            "body = \"int ${name}();\"\n",
        ),
    )
    .expect("pack should be writable");
    let hpp = dir.join("widget.hpp");
    std::fs::write(
        &hpp,
        "class Widget {\npublic:\n    int width() const;\n};\n",
    )
    .expect("header should be writable");
    let out = bin()
        .args(["scaffold", "--lang", "cpp", "--no-project", "--write"])
        .args(["--from", hpp.to_str().expect("path is UTF-8")])
        .arg("method:area")
        .args(["--config", dir.to_str().expect("path is UTF-8")])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(
        std::fs::read_to_string(&hpp).expect("header written"),
        "class Widget {\npublic:\n    int width() const;\n    int area();\n};\n"
    );
}

#[test]
fn expand_builtin_composite_classp() {
    let out = run(
        "composite-classp",
        &["expand", "--lang", "python", "classp:Point"],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(
        stdout(&out),
        "class Point:\n    def __init__(self):\n        \n    \
         def __str__(self) -> str:\n        return f\"\"\n    \
         def __repr__(self) -> str:\n        return f\"{type(self).__name__}()\"\n"
    );
}

#[test]
fn expand_composite_bare_leaves_a_tabstop_for_the_name() {
    // Unbound composite param passes through to `class`'s own param, which
    // renders as a real tabstop rather than substituted literal text.
    let out = run(
        "composite-bare-tabstop",
        &["expand", "--lang", "python", "--tabstops", "keep", "classp"],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert!(
        stdout(&out).starts_with("class ${1:Name}$2:\n"),
        "stdout: {}",
        stdout(&out)
    );
}

#[test]
fn expand_composite_bound_name_is_literal() {
    let out = run(
        "composite-bound-literal",
        &[
            "expand",
            "--lang",
            "python",
            "--tabstops",
            "keep",
            "classp:Point",
        ],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert!(
        stdout(&out).starts_with("class Point$1:\n"),
        "stdout: {}",
        stdout(&out)
    );
}

#[test]
fn expand_composite_with_option_and_freeform_child() {
    let out = run(
        "composite-merge",
        &["expand", "--lang", "python", "classp:Point,eq>def:validate"],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let text = stdout(&out);
    assert!(text.contains("def __eq__(self):"), "stdout: {text}");
    assert!(text.contains("def validate(self):"), "stdout: {text}");
    // Order: base dunders, then the option, then the freeform child.
    let eq = text.find("__eq__").expect("has __eq__");
    let validate = text.find("validate").expect("has validate");
    assert!(
        eq < validate,
        "option should precede freeform child: {text}"
    );
}

#[test]
fn expand_composite_unknown_option_exits_2() {
    let out = run(
        "composite-badopt",
        &["expand", "--lang", "python", "classp:Point,nope"],
    );
    assert_eq!(code(&out), 2, "stderr: {}", stderr(&out));
    assert!(
        stderr(&out).contains("has no option `nope`"),
        "stderr: {}",
        stderr(&out)
    );
}

#[test]
fn check_reports_composite_cycle_exits_3() {
    let dir = scratch_dir("check-composite-cycle");
    std::fs::write(
        dir.join("python.toml"),
        "[meta]\nlanguage = \"python\"\n[snippets.a]\nexpands = \"b\"\n[snippets.b]\nexpands = \"a\"\n",
    )
    .expect("override pack should be writable");
    let out = bin()
        .args([
            "check",
            "--config",
            dir.to_str().expect("scratch path is UTF-8"),
        ])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 3, "stderr: {}", stderr(&out));
    assert!(
        stderr(&out).contains("composite cycle"),
        "stderr: {}",
        stderr(&out)
    );
}

#[test]
fn check_reports_composite_unknown_trigger_exits_3() {
    let dir = scratch_dir("check-composite-unknown");
    std::fs::write(
        dir.join("python.toml"),
        "[meta]\nlanguage = \"python\"\n[snippets.bad]\nexpands = \"class>nosuchtrigger\"\n",
    )
    .expect("override pack should be writable");
    let out = bin()
        .args([
            "check",
            "--config",
            dir.to_str().expect("scratch path is UTF-8"),
        ])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 3, "stderr: {}", stderr(&out));
    assert!(
        stderr(&out).contains("unknown trigger `nosuchtrigger`"),
        "stderr: {}",
        stderr(&out)
    );
}

#[test]
fn check_valid_override_dir_exits_0() {
    let dir = scratch_dir("check-valid");
    std::fs::write(
        dir.join("rust.toml"),
        "[meta]\nlanguage = \"rust\"\n\n[snippets.zz]\nbody = \"zz($0)\"\n",
    )
    .expect("override pack should be writable");
    let out = bin()
        .args([
            "check",
            "--config",
            dir.to_str().expect("scratch path is UTF-8"),
        ])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let text = stdout(&out);
    assert!(text.contains("7 languages"), "stdout was: {text}");
    assert!(text.contains("triggers"), "stdout was: {text}");
}

#[test]
fn check_warns_about_misnamed_pack_files() {
    let dir = scratch_dir("check-misnamed");
    std::fs::write(
        dir.join("rs.toml"),
        "[meta]\nlanguage = \"rust\"\n\n[snippets.zz]\nbody = \"zz($0)\"\n",
    )
    .expect("override pack should be writable");
    let out = bin()
        .args([
            "check",
            "--config",
            dir.to_str().expect("scratch path is UTF-8"),
        ])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert!(stdout(&out).contains("ok:"), "stdout was: {}", stdout(&out));
    let err = stderr(&out);
    assert!(
        err.contains("does not match any language pack name"),
        "stderr was: {err}"
    );
    assert!(err.contains("rs.toml"), "stderr was: {err}");
}

#[test]
fn check_correctly_named_pack_emits_no_warning() {
    let dir = scratch_dir("check-well-named");
    std::fs::write(
        dir.join("rust.toml"),
        "[meta]\nlanguage = \"rust\"\n\n[snippets.zz]\nbody = \"zz($0)\"\n",
    )
    .expect("override pack should be writable");
    let out = bin()
        .args([
            "check",
            "--config",
            dir.to_str().expect("scratch path is UTF-8"),
        ])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(stderr(&out), "");
}

#[test]
fn check_invalid_toml_exits_3() {
    let dir = scratch_dir("check-invalid");
    std::fs::write(dir.join("rust.toml"), "not toml [").expect("override pack should be writable");
    let out = bin()
        .args([
            "check",
            "--config",
            dir.to_str().expect("scratch path is UTF-8"),
        ])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 3);
    assert!(
        stderr(&out).contains("invalid snippet pack"),
        "stderr was: {}",
        stderr(&out)
    );
}

#[test]
fn check_missing_dir_exits_0_with_note() {
    let dir =
        std::env::temp_dir().join(format!("codemmet-cli-check-missing-{}", std::process::id()));
    let out = bin()
        .args([
            "check",
            "--config",
            dir.to_str().expect("scratch path is UTF-8"),
        ])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert!(
        stdout(&out).contains("no user snippets at"),
        "stdout was: {}",
        stdout(&out)
    );
}

#[test]
fn project_pack_auto_loads_and_no_project_disables_it() {
    // A project pack under `.codemmet/snippets` overrides the builtin `pr`.
    let user = scratch_dir("proj-user");
    let project = scratch_dir("proj-root");
    let snippets = project.join(".codemmet").join("snippets");
    std::fs::create_dir_all(&snippets).expect("project snippets dir should be creatable");
    std::fs::write(
        snippets.join("rust.toml"),
        "[meta]\nlanguage = \"rust\"\n[snippets.pr]\nbody = \"PROJECT!($0);\"\n",
    )
    .expect("project pack should be writable");
    let nested = project.join("src");
    std::fs::create_dir_all(&nested).expect("nested dir should be creatable");
    let user_arg = user.to_str().expect("scratch path is UTF-8");

    // Run from a nested subdir: discovery walks up to the project pack.
    let out = bin()
        .current_dir(&nested)
        .args(["expand", "--lang", "rust", "pr", "--config", user_arg])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(stdout(&out), "PROJECT!();\n", "project pack should win");

    // --no-project falls back to the builtin println!.
    let out = bin()
        .current_dir(&nested)
        .args([
            "expand",
            "--lang",
            "rust",
            "--no-project",
            "pr",
            "--config",
            user_arg,
        ])
        .output()
        .expect("binary should run");
    assert_eq!(
        stdout(&out),
        "println!(\"\");\n",
        "--no-project ignores the pack"
    );

    std::fs::remove_dir_all(&project).ok();
    std::fs::remove_dir_all(&user).ok();
}

/// Run `train` with `stdin` piped in, isolated from real user/project packs.
#[cfg(feature = "train")]
fn run_train(name: &str, args: &[&str], stdin_text: &str) -> Output {
    let dir = scratch_dir(name);
    let mut child = bin()
        .args(["train", "--no-project"])
        .args(args)
        .args(["--config", dir.to_str().expect("scratch path is UTF-8")])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("binary should spawn");
    child
        .stdin
        .take()
        .expect("stdin is piped")
        .write_all(stdin_text.as_bytes())
        .expect("write stdin");
    child.wait_with_output().expect("process should complete")
}

#[test]
#[cfg(feature = "train")]
fn train_runs_a_session_and_summarizes() {
    // Quitting immediately still prints a session summary and exits cleanly.
    let out = run_train(
        "train-quit",
        &["--lang", "rust", "--level", "1", "--seed", "1"],
        ":quit\n",
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert!(
        stdout(&out).contains("session:"),
        "expected a summary, got:\n{}",
        stdout(&out)
    );
}

#[test]
#[cfg(feature = "train")]
fn train_rejects_an_out_of_range_level() {
    let out = run_train("train-bad-level", &["--level", "10"], "");
    assert_eq!(code(&out), 3);
    assert!(stderr(&out).contains("between 1 and 9"));
}

#[test]
fn expand_data_driven_repeat_fills_arms_from_flag() {
    let out = run(
        "expand-repeat",
        &[
            "expand",
            "--lang",
            "rust",
            "--no-project",
            "match:Msg>arm*",
            "--repeat",
            "Msg=Quit,Move,Write",
        ],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(
        stdout(&out),
        "match Msg {\n    Quit => ,\n    Move => ,\n    Write => ,\n}\n"
    );
}

#[test]
fn expand_data_driven_repeat_falls_back_without_data() {
    let out = run(
        "expand-repeat-fallback",
        &["expand", "--lang", "rust", "--no-project", "match:X>arm*"],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(stdout(&out), "match X {\n    pattern => ,\n}\n");
}

#[test]
fn expand_member_flag_zips_a_parallel_list() {
    // The ZIPPED_COLLECTIONS_PLAN.md flagship: two lists in, paired items out.
    let out = run(
        "expand-member-zip",
        &[
            "expand",
            "--lang",
            "rust",
            "--no-project",
            "struct:W>field*@P",
            "--repeat",
            "P=host,port",
            "--member",
            "P.type=String,u16",
        ],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(
        stdout(&out),
        "struct W {\n    host: String,\n    port: u16,\n}\n"
    );
}

#[test]
fn expand_member_flag_empty_slot_skips_an_item() {
    // A sparse slot leaves the member absent: that item's `${value.type:Type}`
    // falls back to its default (rendered as placeholder text in strip mode).
    let out = run(
        "expand-member-sparse",
        &[
            "expand",
            "--lang",
            "rust",
            "--no-project",
            "struct:W>field*@P",
            "--repeat",
            "P=a,b",
            "--member",
            "P.type=i32,",
        ],
    );
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert_eq!(stdout(&out), "struct W {\n    a: i32,\n    b: Type,\n}\n");
}

#[test]
fn expand_member_flag_rejects_length_mismatch() {
    let out = run(
        "expand-member-mismatch",
        &[
            "expand",
            "--lang",
            "rust",
            "--no-project",
            "struct:W>field*@P",
            "--repeat",
            "P=a,b,c",
            "--member",
            "P.type=i32,f64",
        ],
    );
    assert_eq!(code(&out), 3);
    assert!(
        stderr(&out).contains("--member P.type: 2 value(s) for 3 item(s)"),
        "stderr: {}",
        stderr(&out)
    );
}

#[test]
fn expand_member_flag_rejects_unknown_collection_and_bad_spec() {
    // No `--repeat P=…` to attach to.
    let out = run(
        "expand-member-unknown",
        &[
            "expand",
            "--lang",
            "rust",
            "--no-project",
            "field*@P",
            "--member",
            "P.type=i32",
        ],
    );
    assert_eq!(code(&out), 3);
    assert!(
        stderr(&out).contains("no `--repeat P=…` collection"),
        "stderr: {}",
        stderr(&out)
    );

    // Malformed spec: no `.key` before the `=`.
    let out = run(
        "expand-member-malformed",
        &[
            "expand",
            "--lang",
            "rust",
            "--no-project",
            "field*@P",
            "--repeat",
            "P=a",
            "--member",
            "P=i32",
        ],
    );
    assert_eq!(code(&out), 3);
    assert!(
        stderr(&out).contains("expected NAME.key"),
        "stderr: {}",
        stderr(&out)
    );
}

#[test]
fn expand_enum_with_variants_defines_and_data_fills() {
    // The definition side, symmetric with `match … > arm*`: `variant` nests in
    // `enum` and fills from a collection (falling back to a `Name` tabstop).
    let scaffold = run(
        "expand-variant-scaffold",
        &[
            "expand",
            "--lang",
            "rust",
            "--no-project",
            "enum:Color>variant*3",
        ],
    );
    assert_eq!(code(&scaffold), 0, "stderr: {}", stderr(&scaffold));
    assert_eq!(
        stdout(&scaffold),
        "enum Color {\n    Name,\n    Name,\n    Name,\n}\n"
    );

    let filled = run(
        "expand-variant-filled",
        &[
            "expand",
            "--lang",
            "rust",
            "--no-project",
            "enum:Color>variant*@Src",
            "--repeat",
            "Src=Red,Green,Blue",
        ],
    );
    assert_eq!(code(&filled), 0, "stderr: {}", stderr(&filled));
    assert_eq!(
        stdout(&filled),
        "enum Color {\n    Red,\n    Green,\n    Blue,\n}\n"
    );
}

// ---- capture-from-example (`codemmet capture`) --------------------------

/// Capture code into the `--config` (user) dir, then expand the new trigger
/// back: the round-trip reproduces the original.
#[test]
fn capture_into_user_dir_round_trips() {
    let src = "fn make() -> Widget {\n    Widget::new()\n}";
    let captured = run(
        "capture-roundtrip",
        &[
            "capture",
            "--lang",
            "rust",
            "--name",
            "mkw",
            "--user",
            "--no-project",
            src,
        ],
    );
    assert_eq!(code(&captured), 0, "stderr: {}", stderr(&captured));
    // `Widget` repeats, so it is generalized into one parameter.
    assert!(
        stdout(&captured).contains("captured `mkw` (1 parameter(s): Widget)"),
        "stdout: {}",
        stdout(&captured)
    );

    // Same scratch dir (keyed by test name): expand the captured trigger back.
    let expanded = run(
        "capture-roundtrip",
        &["expand", "--lang", "rust", "--no-project", "mkw"],
    );
    assert_eq!(code(&expanded), 0, "stderr: {}", stderr(&expanded));
    assert_eq!(
        stdout(&expanded),
        "fn make() -> Widget {\n    Widget::new()\n}\n"
    );
}

/// A glued identifier argument overrides the captured parameter's default, so
/// the same pattern stamps out a differently-named variant.
#[test]
fn capture_then_expand_with_glued_argument() {
    let src = "struct Widget {\n    id: u32,\n}\nimpl Widget {}";
    let captured = run(
        "capture-glued",
        &[
            "capture",
            "--lang",
            "rust",
            "--name",
            "mkw",
            "--user",
            "--no-project",
            src,
        ],
    );
    assert_eq!(code(&captured), 0, "stderr: {}", stderr(&captured));

    let expanded = run(
        "capture-glued",
        &["expand", "--lang", "rust", "--no-project", "mkwGadget"],
    );
    assert_eq!(code(&expanded), 0, "stderr: {}", stderr(&expanded));
    assert_eq!(
        stdout(&expanded),
        "struct Gadget {\n    id: u32,\n}\nimpl Gadget {}\n"
    );
}

/// Capturing an existing trigger conflicts (exit 3) unless `--force` is given.
#[test]
fn capture_conflict_needs_force() {
    let base = &[
        "capture",
        "--lang",
        "rust",
        "--name",
        "dup",
        "--user",
        "--no-project",
        "let a = a;",
    ];
    assert_eq!(code(&run("capture-conflict", base)), 0);

    let conflict = run("capture-conflict", base);
    assert_eq!(code(&conflict), 3, "stdout: {}", stdout(&conflict));
    assert!(
        stderr(&conflict).contains("already exists"),
        "stderr: {}",
        stderr(&conflict)
    );

    let forced = run(
        "capture-conflict",
        &[
            "capture",
            "--lang",
            "rust",
            "--name",
            "dup",
            "--user",
            "--no-project",
            "--force",
            "let b = b;",
        ],
    );
    assert_eq!(code(&forced), 0, "stderr: {}", stderr(&forced));
}

/// A pack written by `capture` passes `codemmet check`.
#[test]
fn captured_pack_passes_check() {
    let captured = run(
        "capture-check",
        &[
            "capture",
            "--lang",
            "rust",
            "--name",
            "logit",
            "--user",
            "--no-project",
            "--description",
            "log helper",
            "tracing::info!(\"x\");",
        ],
    );
    assert_eq!(code(&captured), 0, "stderr: {}", stderr(&captured));

    let checked = run("capture-check", &["check", "--no-project"]);
    assert_eq!(code(&checked), 0, "stderr: {}", stderr(&checked));
    assert!(
        stdout(&checked).starts_with("ok:"),
        "stdout: {}",
        stdout(&checked)
    );
}

/// Capturing an unknown language is a config error (exit 3).
#[test]
fn capture_unknown_language_is_config_error() {
    let out = run(
        "capture-unknown",
        &[
            "capture",
            "--lang",
            "cobol",
            "--name",
            "x",
            "--user",
            "--no-project",
            "y",
        ],
    );
    assert_eq!(code(&out), 3);
    assert!(
        stderr(&out).contains("unknown language"),
        "stderr: {}",
        stderr(&out)
    );
}

#[test]
fn global_flag_before_subcommand_still_selects_the_subcommand() {
    // Regression: with the file-mode `.codemmet` positional at the top level,
    // `codemmet --<global-flag> <subcommand>` must still parse the subcommand
    // (`args_conflicts_with_subcommands` routed it into the positional and
    // rejected it). `run()` appends --config, so pass a flag-first invocation
    // explicitly.
    let dir = scratch_dir("flag-first-subcommand");
    let out = bin()
        .args(["--config", dir.to_str().expect("scratch path is UTF-8")])
        .args(["--no-project", "list", "--lang", "rust"])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert!(stdout(&out).contains("fori"), "stdout: {}", stdout(&out));
}

/// `--json` reports each collected requires spec with its formatted line.
#[test]
fn expand_json_reports_requires_with_formatted_lines() {
    let dir = scratch_dir("expand-json-requires");
    std::fs::write(
        dir.join("rust.toml"),
        concat!(
            "[meta]\nlanguage = \"rust\"\n",
            "[snippets.zz]\n",
            "requires = [\"std::collections::HashMap\"]\n",
            "body = \"let m = HashMap::new();$0\"\n",
        ),
    )
    .expect("override pack should be writable");
    let out = bin()
        .args(["expand", "--lang", "rust", "--json", "zz"])
        .args(["--config", dir.to_str().expect("scratch path is UTF-8")])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let value: serde_json::Value =
        serde_json::from_str(&stdout(&out)).expect("stdout should be JSON");
    assert_eq!(value["requires"][0]["spec"], "std::collections::HashMap");
    assert_eq!(
        value["requires"][0]["line"],
        "use std::collections::HashMap;"
    );
    // Plain-text mode stays pipeable code: no requires anywhere.
    let plain = bin()
        .args(["expand", "--lang", "rust", "zz"])
        .args(["--config", dir.to_str().expect("scratch path is UTF-8")])
        .output()
        .expect("binary should run");
    assert_eq!(stdout(&plain), "let m = HashMap::new();\n");
}

/// A malformed requires spec is a config error (exit 3) from any command.
#[test]
fn check_reports_invalid_requires_spec_exits_3() {
    let dir = scratch_dir("check-bad-requires");
    std::fs::write(
        dir.join("rust.toml"),
        "[meta]\nlanguage = \"rust\"\n[snippets.zz]\nrequires = [\"use std::fmt;\"]\nbody = \"x$0\"\n",
    )
    .expect("override pack should be writable");
    let out = bin()
        .args([
            "check",
            "--config",
            dir.to_str().expect("scratch path is UTF-8"),
        ])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 3, "stderr: {}", stderr(&out));
    assert!(
        stderr(&out).contains("invalid requires spec"),
        "stderr: {}",
        stderr(&out)
    );
}

/// Requires on a language without an import formatter loads fine but `check`
/// warns that editors ignore it.
#[test]
fn check_warns_on_requires_for_unsupported_language() {
    let dir = scratch_dir("check-js-requires");
    std::fs::write(
        dir.join("javascript.toml"),
        "[meta]\nlanguage = \"javascript\"\n[snippets.zz]\nrequires = [\"node:fs\"]\nbody = \"x$0\"\n",
    )
    .expect("override pack should be writable");
    let out = bin()
        .args([
            "check",
            "--config",
            dir.to_str().expect("scratch path is UTF-8"),
        ])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    assert!(
        stderr(&out).contains("not implemented for javascript"),
        "stderr: {}",
        stderr(&out)
    );
    assert!(stderr(&out).contains("zz"), "stderr: {}", stderr(&out));
}

#[test]
fn scaffold_created_file_carries_required_imports() {
    // Finding A regression: an `expands` piece pulling in a requires-bearing
    // trigger (builtin `hmap`) must yield a created file WITH the import —
    // un-qualified bodies assume it.
    let dir = scratch_dir("scaffold-requires-create");
    std::fs::write(
        dir.join("rust.toml"),
        concat!(
            "[meta]\nlanguage = \"rust\"\n\n",
            "[snippets.mcache]\n\n",
            "[snippets.mcache.files.module]\npath = \"cache.rs\"\nprimary = true\n",
            "expands = \"hmap\"\n",
        ),
    )
    .expect("pack should be writable");
    let root = dir.join("proj");
    let out = bin()
        .args(["scaffold", "--lang", "rust", "--no-project", "--write"])
        .args(["--root", root.to_str().expect("path is UTF-8")])
        .arg("mcache")
        .args(["--config", dir.to_str().expect("path is UTF-8")])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let cache = std::fs::read_to_string(root.join("cache.rs")).expect("cache written");
    assert!(
        cache.starts_with("use std::collections::HashMap;\n"),
        "created file should lead with the import:\n{cache}"
    );
    assert!(cache.contains("HashMap::new()"), "{cache}");
}

#[test]
fn scaffold_append_dedups_required_imports() {
    // Appending into a file that already imports the requirement must not
    // duplicate it; appending into one that lacks it prefixes the block.
    let dir = scratch_dir("scaffold-requires-append");
    std::fs::write(
        dir.join("rust.toml"),
        concat!(
            "[meta]\nlanguage = \"rust\"\n\n",
            "[snippets.mcache]\n\n",
            "[snippets.mcache.files.module]\npath = \"lib.rs\"\non_exists = \"append\"\n",
            "expands = \"hmap\"\n",
        ),
    )
    .expect("pack should be writable");
    let root = dir.join("proj");
    std::fs::create_dir_all(&root).expect("root");
    std::fs::write(
        root.join("lib.rs"),
        "use std::collections::HashMap;\n\nfn seed() {}\n",
    )
    .expect("seed file");
    let run_scaffold = || {
        bin()
            .args(["scaffold", "--lang", "rust", "--no-project", "--write"])
            .args(["--root", root.to_str().expect("path is UTF-8")])
            .arg("mcache")
            .args(["--config", dir.to_str().expect("path is UTF-8")])
            .output()
            .expect("binary should run")
    };
    let out = run_scaffold();
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let lib = std::fs::read_to_string(root.join("lib.rs")).expect("lib read");
    assert_eq!(
        lib.matches("use std::collections::HashMap;").count(),
        1,
        "already-satisfied import must not duplicate:\n{lib}"
    );
    // Now a file lacking the import: the appended block is prefixed with it.
    std::fs::write(root.join("lib.rs"), "fn seed() {}\n").expect("reset");
    let out = run_scaffold();
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let lib = std::fs::read_to_string(root.join("lib.rs")).expect("lib read");
    let import_pos = lib
        .find("use std::collections::HashMap;")
        .expect("import present");
    let body_pos = lib.find("HashMap::new()").expect("body present");
    assert!(
        import_pos < body_pos,
        "import precedes the appended body:\n{lib}"
    );
}

#[test]
fn scaffold_body_applies_case_transforms_to_params() {
    // Finding B regression: `${param|transform}` in a `[files.*]` body (and
    // path) renders the transformed value, not an empty string.
    let dir = scratch_dir("scaffold-transform");
    std::fs::write(
        dir.join("rust.toml"),
        concat!(
            "[meta]\nlanguage = \"rust\"\n\n",
            "[snippets.mget]\nparams = [{ name = \"field\", kind = \"ident\", default = \"x\" }]\n\n",
            "[snippets.mget.files.g]\npath = \"${field|snake}.rs\"\nprimary = true\n",
            "body = \"fn get_${field|snake}() {}\"\n",
        ),
    )
    .expect("pack should be writable");
    let root = dir.join("proj");
    let out = bin()
        .args(["scaffold", "--lang", "rust", "--no-project", "--write"])
        .args(["--root", root.to_str().expect("path is UTF-8")])
        .arg("mget:maxSize")
        .args(["--config", dir.to_str().expect("path is UTF-8")])
        .output()
        .expect("binary should run");
    assert_eq!(code(&out), 0, "stderr: {}", stderr(&out));
    let g = std::fs::read_to_string(root.join("max_size.rs")).expect("transformed path written");
    assert!(g.contains("fn get_max_size()"), "{g}");
}

#[test]
fn version_flags_basic_and_full() {
    // `-v` / `--version`: exactly one line, the version (+ hash when built in
    // a checkout).
    for flag in ["-v", "--version"] {
        let out = bin().arg(flag).output().expect("binary should run");
        assert_eq!(code(&out), 0, "{flag} stderr: {}", stderr(&out));
        let text = stdout(&out);
        assert_eq!(text.lines().count(), 1, "{flag} output:\n{text}");
        assert!(text.starts_with("codemmet "), "{flag} output:\n{text}");
    }
    // `-V` / `--version-full`: the detailed report. Values may be `unknown`
    // outside a checkout, but the fields are always present.
    for flag in ["-V", "--version-full"] {
        let out = bin().arg(flag).output().expect("binary should run");
        assert_eq!(code(&out), 0, "{flag} stderr: {}", stderr(&out));
        let text = stdout(&out);
        assert!(text.starts_with("codemmet "), "{flag} output:\n{text}");
        for field in [
            "commit:",
            "branch:",
            "profile:",
            "target:",
            "rustc:",
            "features:",
        ] {
            assert!(text.contains(field), "{flag} missing `{field}`:\n{text}");
        }
    }
}
