use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command as ProcessCommand, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::{Duration, Instant};

use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::tempdir;
use wait_timeout::ChildExt;
use walkdir::WalkDir;

fn cache_dir(root: &Path) -> PathBuf {
    root.join("shared-cache")
}

fn configure_env_cache(cmd: &mut Command, root: &Path) {
    cmd.env("SHUCK_CACHE_DIR", cache_dir(root));
}

fn configure_default_cache_env(cmd: &mut Command, root: &Path) {
    let home = root.join("home");
    let xdg_cache = root.join("xdg-cache");
    let appdata = root.join("appdata").join("Roaming");
    let local_appdata = root.join("appdata").join("Local");

    cmd.env_remove("SHUCK_CACHE_DIR");
    cmd.env("HOME", &home);
    cmd.env("USERPROFILE", &home);
    cmd.env("XDG_CACHE_HOME", xdg_cache);
    cmd.env("APPDATA", appdata);
    cmd.env("LOCALAPPDATA", local_appdata);
}

fn run_check_output(root: &Path, args: &[&str]) -> std::process::Output {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, root);
    cmd.current_dir(root).args(args).output().unwrap()
}

fn stdout_string(output: &std::process::Output) -> String {
    String::from_utf8(output.stdout.clone()).unwrap()
}

#[test]
fn server_exits_cleanly_when_stdin_reaches_eof() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.arg("server").write_stdin("");
    cmd.assert().success().stdout("").stderr("");
}

#[test]
fn server_reports_invalid_initialization_without_waiting_for_stdin_eof() {
    let mut child = ProcessCommand::new(assert_cmd::cargo::cargo_bin("shuck"))
        .arg("server")
        .stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .unwrap();
    let mut stdin = child.stdin.take().unwrap();
    let body = r#"{"jsonrpc":"2.0","method":"initialize","params":null,"id":1}"#;
    write!(stdin, "Content-Length: {}\r\n\r\n{body}", body.len()).unwrap();
    stdin.flush().unwrap();

    let Some(exit) = child.wait_timeout(Duration::from_secs(2)).unwrap() else {
        stop_child(&mut child);
        panic!("invalid initialization should exit while stdin remains open");
    };
    assert_eq!(exit.code(), Some(2));
}

fn platform_path(path: &str) -> String {
    if std::path::MAIN_SEPARATOR == '/' {
        path.to_owned()
    } else {
        path.replace('/', std::path::MAIN_SEPARATOR_STR)
    }
}

#[derive(Clone, Copy)]
enum StreamKind {
    Stdout,
    Stderr,
}

#[derive(Default)]
struct CapturedOutput {
    stdout: String,
    stderr: String,
}

fn spawn_output_reader<R>(
    reader: R,
    kind: StreamKind,
    tx: mpsc::Sender<(StreamKind, String)>,
) -> thread::JoinHandle<()>
where
    R: std::io::Read + Send + 'static,
{
    thread::spawn(move || {
        let mut reader = BufReader::new(reader);
        loop {
            let mut line = String::new();
            match reader.read_line(&mut line) {
                Ok(0) => break,
                Ok(_) => {
                    if tx.send((kind, line)).is_err() {
                        break;
                    }
                }
                Err(_) => break,
            }
        }
    })
}

fn wait_for_output<F>(
    rx: &Receiver<(StreamKind, String)>,
    captured: &mut CapturedOutput,
    timeout: Duration,
    predicate: F,
) -> bool
where
    F: Fn(&CapturedOutput) -> bool,
{
    if predicate(captured) {
        return true;
    }

    let deadline = Instant::now() + timeout;
    loop {
        let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
            return predicate(captured);
        };

        match rx.recv_timeout(remaining) {
            Ok((kind, line)) => match kind {
                StreamKind::Stdout => captured.stdout.push_str(&line),
                StreamKind::Stderr => captured.stderr.push_str(&line),
            },
            Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => {
                return predicate(captured);
            }
        }

        if predicate(captured) {
            return true;
        }
    }
}

fn stop_child(child: &mut std::process::Child) {
    if child.try_wait().unwrap().is_none() {
        let _ = child.kill();
    }
    if child
        .wait_timeout(Duration::from_secs(5))
        .unwrap()
        .is_none()
    {
        let _ = child.kill();
        let _ = child.wait();
    }
}

#[test]
fn help_shows_commands() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.arg("--help");
    cmd.assert()
        .success()
        .stdout(predicate::str::contains("check"))
        .stdout(predicate::str::contains("--config <CONFIG_OPTION>"))
        .stdout(predicate::str::contains("--isolated"))
        .stdout(predicate::str::contains("--color <WHEN>"))
        .stdout(predicate::str::contains("Format shell files"))
        .stdout(predicate::str::contains("clean"));
}

#[test]
fn clean_help_describes_project_cache_entries() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.args(["clean", "--help"]);
    cmd.assert()
        .success()
        .stdout(predicate::str::contains(
            "Remove shuck cache entries for the provided paths' projects",
        ))
        .stdout(predicate::str::contains(
            "Files or directories whose project caches should be removed",
        ));
}

#[test]
fn check_help_shows_file_selection_options() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.args(["check", "--help"]);
    cmd.assert()
        .success()
        .stdout(predicate::str::contains("File selection"))
        .stdout(predicate::str::contains("--exclude <FILE_PATTERN>"))
        .stdout(predicate::str::contains("--extend-exclude <FILE_PATTERN>"))
        .stdout(predicate::str::contains("--respect-gitignore"))
        .stdout(predicate::str::contains("--force-exclude"));
}

#[test]
fn check_help_shows_rule_selection_options() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.args(["check", "--help"]);
    cmd.assert()
        .success()
        .stdout(predicate::str::contains("Rule selection"))
        .stdout(predicate::str::contains("--select <RULE_CODE>"))
        .stdout(predicate::str::contains("--ignore <RULE_CODE>"))
        .stdout(predicate::str::contains("--extend-select <RULE_CODE>"))
        .stdout(predicate::str::contains(
            "--per-file-ignores <PER_FILE_IGNORES>",
        ))
        .stdout(predicate::str::contains(
            "--extend-per-file-ignores <EXTEND_PER_FILE_IGNORES>",
        ))
        .stdout(predicate::str::contains(
            "--per-file-shell <PER_FILE_SHELL>",
        ))
        .stdout(predicate::str::contains(
            "--extend-per-file-shell <EXTEND_PER_FILE_SHELL>",
        ))
        .stdout(predicate::str::contains("--fixable <RULE_CODE>"))
        .stdout(predicate::str::contains("--unfixable <RULE_CODE>"))
        .stdout(predicate::str::contains("--extend-fixable <RULE_CODE>"));
}

#[test]
fn format_help_shows_file_selection_options() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.args(["format", "--help"]);
    cmd.assert()
        .success()
        .stdout(predicate::str::contains("File selection"))
        .stdout(predicate::str::contains("--exclude <FILE_PATTERN>"))
        .stdout(predicate::str::contains("--extend-exclude <FILE_PATTERN>"))
        .stdout(predicate::str::contains("--respect-gitignore"))
        .stdout(predicate::str::contains("--force-exclude"));
}

#[test]
fn check_help_includes_add_ignore_flag() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.args(["check", "--help"]);
    cmd.assert()
        .success()
        .stdout(predicate::str::contains("--add-ignore"))
        .stdout(predicate::str::contains("-w, --watch"))
        .stdout(predicate::str::contains("shuck ignore directives"))
        .stdout(predicate::str::contains("--add-noqa").not());
}

#[test]
fn config_file_and_isolated_conflict() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("shuck.toml"), "[format]\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.current_dir(tempdir.path())
        .arg("--isolated")
        .arg("--config")
        .arg("shuck.toml")
        .arg("check");
    cmd.assert()
        .code(2)
        .stderr(predicate::str::contains("cannot be used with `--isolated`"));
}

#[test]
fn format_subcommand_is_available_without_env() {
    let tempdir = tempdir().unwrap();
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("format");
    cmd.assert().success().stdout("");
}

#[test]
fn check_good_file_succeeds() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("check");
    cmd.assert().success().stdout("");
}

#[test]
fn check_dash_reads_from_stdin() {
    let tempdir = tempdir().unwrap();
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise", "-"])
        .write_stdin("#!/bin/bash\necho $foo\n");
    cmd.assert()
        .code(1)
        .stdout("-:2:6: error[C006] variable `foo` is referenced before assignment\n")
        .stderr("");
    assert!(!cache_dir(tempdir.path()).exists());
}

#[test]
fn check_stdin_filename_is_optional_and_controls_per_file_settings() {
    let tempdir = tempdir().unwrap();
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args([
            "check",
            "--stdin-filename",
            "ignored.sh",
            "--per-file-ignores",
            "ignored.sh:C006",
            "--output-format",
            "concise",
        ])
        .write_stdin("#!/bin/bash\necho $foo\n");
    cmd.assert().success().stdout("").stderr("");
}

#[test]
fn check_stdin_loads_project_configuration() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join(".shuck.toml"),
        "[lint]\nignore = ['C006']\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "-"])
        .write_stdin("#!/bin/bash\necho $foo\n");
    cmd.assert().success().stdout("").stderr("");
}

#[test]
fn check_fix_stdin_writes_fixed_source_to_stdout() {
    let tempdir = tempdir().unwrap();
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--fix", "--select", "S074", "-"])
        .write_stdin("#!/bin/bash\nprintf '%s\\n' x &;\n");
    cmd.assert()
        .success()
        .stdout("#!/bin/bash\nprintf '%s\\n' x &\n")
        .stderr("Applied 1 fix.\n");
}

#[test]
fn check_fix_stdin_routes_remaining_diagnostics_to_stderr() {
    let tempdir = tempdir().unwrap();
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args([
            "check",
            "--fix",
            "--select",
            "C006",
            "--output-format",
            "concise",
            "-",
        ])
        .write_stdin("#!/bin/bash\necho $foo\n");
    cmd.assert()
        .code(1)
        .stdout("#!/bin/bash\necho $foo\n")
        .stderr("-:2:6: error[C006] variable `foo` is referenced before assignment\n");
}

#[test]
fn check_fix_stdin_keeps_json_diagnostics_parseable() {
    let tempdir = tempdir().unwrap();
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args([
            "check",
            "--fix",
            "--select",
            "S074",
            "--output-format",
            "json",
            "-",
        ])
        .write_stdin("#!/bin/bash\nprintf '%s\\n' x &;\n");
    let output = cmd.output().unwrap();

    assert!(output.status.success());
    assert_eq!(
        String::from_utf8(output.stdout).unwrap(),
        "#!/bin/bash\nprintf '%s\\n' x &\n"
    );
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert_eq!(stderr, "[]\n");
    serde_json::from_str::<serde_json::Value>(&stderr).unwrap();
}

#[test]
fn check_stdin_rejects_mixed_paths_and_non_streaming_modes() {
    let tempdir = tempdir().unwrap();
    for args in [
        vec!["check", "-", "script.sh"],
        vec!["check", "--stdin-filename", "input.sh", "script.sh"],
        vec!["check", "--watch", "-"],
        vec!["check", "--add-ignore", "-"],
    ] {
        let mut cmd = Command::cargo_bin("shuck").unwrap();
        configure_env_cache(&mut cmd, tempdir.path());
        cmd.current_dir(tempdir.path()).args(args).write_stdin("");
        cmd.assert().code(2);
    }
}

#[test]
fn check_fix_rewrites_safe_s074_and_bypasses_cache() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    fs::write(&script, "#!/bin/bash\nprintf '%s\\n' x &;\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--fix", "--select", "S074"]);
    cmd.assert().success().stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "#!/bin/bash\nprintf '%s\\n' x &\n"
    );
    assert!(!cache_dir(tempdir.path()).exists());
}

#[test]
fn check_without_fix_leaves_s074_file_unchanged() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    let source = "#!/bin/bash\nprintf '%s\\n' x &;\n";
    fs::write(&script, source).unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--select", "S074"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[S074]:"));

    assert_eq!(fs::read_to_string(script).unwrap(), source);
}

#[test]
fn check_exit_non_zero_on_fix_returns_failure_when_fix_is_applied() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    fs::write(&script, "#!/bin/bash\nprintf '%s\\n' x &;\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).args([
        "check",
        "--fix",
        "--exit-non-zero-on-fix",
        "--select",
        "S074",
    ]);
    cmd.assert().code(1).stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "#!/bin/bash\nprintf '%s\\n' x &\n"
    );
}

#[test]
fn check_unsafe_fixes_applies_safe_s074_fix() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    fs::write(&script, "#!/bin/bash\nprintf '%s\\n' x &;\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--unsafe-fixes", "--select", "S074"]);
    cmd.assert().success().stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "#!/bin/bash\nprintf '%s\\n' x &\n"
    );
}

#[test]
fn check_fix_leaves_unsafe_s059_file_unchanged() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    let source = "#!/bin/sh\ntempfile -n \"$TMPDIR/Xauthority\"\n";
    fs::write(&script, source).unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--fix", "--select", "S059"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[S059]:"));

    assert_eq!(fs::read_to_string(script).unwrap(), source);
}

#[test]
fn check_unsafe_fixes_applies_s059_fix() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    fs::write(&script, "#!/bin/sh\ntempfile -n \"$TMPDIR/Xauthority\"\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--unsafe-fixes", "--select", "S059"]);
    cmd.assert().success().stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "#!/bin/sh\nmktemp -n \"$TMPDIR/Xauthority\"\n"
    );
}

#[test]
fn check_fix_leaves_unsafe_s060_file_unchanged() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    let source = "#!/bin/sh\negrep foo file\n";
    fs::write(&script, source).unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--fix", "--select", "S060"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[S060]:"));

    assert_eq!(fs::read_to_string(script).unwrap(), source);
}

#[test]
fn check_unsafe_fixes_applies_s060_fix() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    fs::write(&script, "#!/bin/sh\negrep foo file\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--unsafe-fixes", "--select", "S060"]);
    cmd.assert().success().stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "#!/bin/sh\ngrep -E foo file\n"
    );
}

#[test]
fn check_fix_leaves_unsafe_s061_file_unchanged() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    let source = "#!/bin/sh\nfgrep foo file\n";
    fs::write(&script, source).unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--fix", "--select", "S061"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[S061]:"));

    assert_eq!(fs::read_to_string(script).unwrap(), source);
}

#[test]
fn check_unsafe_fixes_applies_s061_fix() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    fs::write(&script, "#!/bin/sh\nfgrep foo file\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--unsafe-fixes", "--select", "S061"]);
    cmd.assert().success().stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "#!/bin/sh\ngrep -F foo file\n"
    );
}

#[test]
fn check_cli_select_replaces_config_selection() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "[lint]\nselect = ['C001']\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("script.sh"),
        "#!/bin/sh\nunused=1\nread\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).args([
        "check",
        "--select",
        "S036",
        "--output-format",
        "concise",
    ]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[S036]"))
        .stdout(predicate::str::contains("warning[C001]").not());
}

#[test]
fn check_c001_flags_indirect_only_targets_by_default() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "\
[lint]
select = ['C001']
",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("script.sh"),
        "#!/bin/bash\ntarget=ok\nname=target\nprintf '%s\\n' \"${!name}\"\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise", "script.sh"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[C001]"))
        .stdout(predicate::str::contains("target"));
}

#[test]
fn check_config_rule_option_can_keep_indirect_c001_targets_live() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "\
[lint]
select = ['C001']

[lint.rule-options.c001]
treat-indirect-expansion-targets-as-used = true
",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("script.sh"),
        "#!/bin/bash\ntarget=ok\nname=target\nprintf '%s\\n' \"${!name}\"\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise", "script.sh"]);
    cmd.assert()
        .code(0)
        .stdout(predicate::str::contains("warning[C001]").not());
}

#[test]
fn check_c001_flags_declaration_only_targets_by_default() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "\
[lint]
select = ['C001']
",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("script.sh"),
        "#!/bin/bash\nf(){\n  local cur\n  declare words\n}\nf\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise", "script.sh"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[C001]"))
        .stdout(predicate::str::contains("cur"))
        .stdout(predicate::str::contains("words"));
}

#[test]
fn check_per_file_ignores_skip_matching_files() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("ignored.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("kept.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).args([
        "check",
        "--per-file-ignores",
        "ignored.sh:C001",
        "--output-format",
        "concise",
    ]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("kept.sh:2:1: warning[C001]"))
        .stdout(predicate::str::contains("ignored.sh:").not());
}

#[test]
fn check_unfixable_prevents_fixing_matching_rules() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    let source = "#!/bin/bash\nprintf '%s\\n' x &;\n";
    fs::write(&script, source).unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).args([
        "check",
        "--fix",
        "--select",
        "S074",
        "--unfixable",
        "S074",
        "--output-format",
        "concise",
    ]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[S074]"));

    assert_eq!(fs::read_to_string(script).unwrap(), source);
}

#[test]
fn check_unterminated_quote_reports_opening_quote_location() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("broken.sh"),
        "#!/bin/bash\nvalue=abc\"unterminated\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("check");
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("error[parse-error]:"))
        .stdout(predicate::str::contains("--> broken.sh:2:10"))
        .stdout(predicate::str::contains("2 | value=abc\"unterminated"))
        .stdout(predicate::str::contains("^"));
}

#[test]
fn check_missing_then_reports_c064_lint() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("broken.sh"), "#!/bin/bash\nif true\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("check");
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[C064]:"))
        .stdout(predicate::str::contains("--> broken.sh:2:1"))
        .stdout(predicate::str::contains("2 | if true"))
        .stdout(predicate::str::contains("| ^"));
}

#[test]
fn check_reports_lint_with_full_snippet_by_default() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("check");
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[C001]:"))
        .stdout(predicate::str::contains("--> warn.sh:2:1"))
        .stdout(predicate::str::contains("2 | unused=1"))
        .stdout(predicate::str::contains("| ^"));
}

#[test]
fn check_concise_output_preserves_legacy_one_line_format() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    cmd.assert()
        .code(1)
        .stdout("warn.sh:2:1: warning[C001] variable `unused` is assigned but never used\n");
}

#[test]
fn check_concise_output_reports_realistic_issue_workflow_lint() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/issues.yml"),
        r#"on:
  issues:
    types: [opened, edited]
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - name: Build summary
        run: |
          summary="${{ github.event.issue.title }}"
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    let expected = format!(
        "{}:10:11: warning[C001] jobs.triage.steps[0].run: variable `summary` is assigned but never used\n",
        platform_path(".github/workflows/issues.yml")
    );
    cmd.assert().code(1).stdout(expected);
}

#[test]
fn check_concise_output_allows_placeholder_like_intentional_unused_names() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/collision.yml"),
        r#"on: push
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - run: |
          _SHUCK_GHA_1=1
          echo "${{ github.ref }}"
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    cmd.assert().code(0).stdout("");
}

#[test]
fn check_concise_output_reports_realistic_issue_comment_workflow_parse_rule() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/comment.yml"),
        r#"on:
  issue_comment:
    types: [created]
jobs:
  reply:
    runs-on: ubuntu-latest
    steps:
      - name: Render comment
        run: |
          if [ -n "${{ github.event.comment.body }}" ]; then
            echo ready
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    let path = platform_path(".github/workflows/comment.yml");
    let expected = format!(
        "{path}:10:11: error[C034] jobs.reply.steps[0].run: this `if` block is not closed with `fi`\n\
         {path}:12:11: error[C035] jobs.reply.steps[0].run: this `if` block is missing a closing `fi`\n"
    );
    cmd.assert().code(1).stdout(expected);
}

#[test]
fn check_concise_output_uses_implicit_github_actions_errexit() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/deploy.yml"),
        r#"on: push
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: |
          cd /tmp
          echo ready
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    cmd.assert().code(0).stdout("");
}

#[test]
fn check_concise_output_reports_mapping_form_runs_on_workflow_lint() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/mapping.yml"),
        r#"on: push
jobs:
  triage:
    runs-on:
      group: hosted
      labels: ubuntu-latest
    steps:
      - run: |
          unused=1
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    let expected = format!(
        "{}:9:11: warning[C001] jobs.triage.steps[0].run: variable `unused` is assigned but never used\n",
        platform_path(".github/workflows/mapping.yml")
    );
    cmd.assert().code(1).stdout(expected);
}

#[test]
fn check_concise_output_ignores_windows_substrings_in_self_hosted_runner_labels() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/self-hosted.yml"),
        r#"on: push
jobs:
  triage:
    runs-on:
      - self-hosted
      - linux
      - windows-tools
    steps:
      - run: |
          unused=1
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    let expected = format!(
        "{}:10:11: warning[C001] jobs.triage.steps[0].run: variable `unused` is assigned but never used\n",
        platform_path(".github/workflows/self-hosted.yml")
    );
    cmd.assert().code(1).stdout(expected);
}

#[test]
fn check_concise_output_preserves_folded_workflow_line_gaps() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/folded.yml"),
        r#"on: push
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - run: >
          echo ok

          unused=1
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    let expected = format!(
        "{}:9:11: warning[C001] jobs.triage.steps[0].run: variable `unused` is assigned but never used\n",
        platform_path(".github/workflows/folded.yml")
    );
    cmd.assert().code(1).stdout(expected);
}

#[test]
fn check_concise_output_keeps_embedded_workflows_out_of_source_tracking() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join("main.sh"),
        "#!/bin/sh\n. ./.github/workflows/ci.yml\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/ci.yml"),
        r#"on: push
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - run: echo ok
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    let expected = format!(
        "{}:2:3: warning[C003] sourced file is not available to this analysis\n",
        platform_path("main.sh")
    );
    cmd.assert().code(1).stdout(expected);
}

#[test]
fn check_concise_output_remaps_folded_double_quoted_workflow_lines() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/quoted.yml"),
        r#"on: push
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - run: "echo ok
          ; unused=1"
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    let expected = format!(
        "{}:7:13: warning[C001] jobs.triage.steps[0].run: variable `unused` is assigned but never used\n",
        platform_path(".github/workflows/quoted.yml")
    );
    cmd.assert().code(1).stdout(expected);
}

#[test]
fn check_concise_output_remaps_double_quoted_workflow_line_continuations() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/continued.yml"),
        "on: push\njobs:\n  triage:\n    runs-on: ubuntu-latest\n    steps:\n      - run: \"echo a\\\n          ; unused=1\"\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    let expected = format!(
        "{}:7:13: warning[C001] jobs.triage.steps[0].run: variable `unused` is assigned but never used\n",
        platform_path(".github/workflows/continued.yml")
    );
    cmd.assert().code(1).stdout(expected);
}

#[test]
fn check_concise_output_remaps_single_quoted_workflow_runs() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/single-quoted.yml"),
        "on: push\njobs:\n  triage:\n    runs-on: ubuntu-latest\n    steps:\n      - run: 'echo ''ok''; unused=1'\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    let expected = format!(
        "{}:6:28: warning[C001] jobs.triage.steps[0].run: variable `unused` is assigned but never used\n",
        platform_path(".github/workflows/single-quoted.yml")
    );
    cmd.assert().code(1).stdout(expected);
}

#[test]
fn check_concise_output_remaps_plain_multiline_workflow_runs() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
    fs::write(
        tempdir.path().join(".github/workflows/plain.yml"),
        r#"on: push
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - run: echo ok
          ; unused=1
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    let expected = format!(
        "{}:7:13: warning[C001] jobs.triage.steps[0].run: variable `unused` is assigned but never used\n",
        platform_path(".github/workflows/plain.yml")
    );
    cmd.assert().code(1).stdout(expected);
}

#[test]
fn check_output_format_env_var_selects_json() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    let output = cmd
        .current_dir(tempdir.path())
        .env("SHUCK_OUTPUT_FORMAT", "json")
        .arg("check")
        .output()
        .unwrap();

    assert_eq!(output.status.code(), Some(1));
    let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(value[0]["filename"], "warn.sh");
    assert_eq!(value[0]["code"], "C001");
}

#[test]
fn check_cli_output_format_overrides_env_var() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .env("SHUCK_OUTPUT_FORMAT", "json")
        .args(["check", "--output-format", "concise"]);
    cmd.assert()
        .code(1)
        .stdout("warn.sh:2:1: warning[C001] variable `unused` is assigned but never used\n");
}

#[test]
fn check_ruff_output_format_env_var_is_ignored() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .env("RUFF_OUTPUT_FORMAT", "json")
        .arg("check");
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("warning[C001]:"))
        .stdout(predicate::str::contains("\"filename\"").not());
}

#[test]
fn check_json_output_is_valid_json() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let output = run_check_output(tempdir.path(), &["check", "--output-format", "json"]);
    assert_eq!(output.status.code(), Some(1));

    let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    let diagnostics = value.as_array().unwrap();
    assert_eq!(diagnostics.len(), 1);
    assert_eq!(diagnostics[0]["filename"], "warn.sh");
}

#[test]
fn check_json_lines_output_is_valid_json_lines() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let output = run_check_output(tempdir.path(), &["check", "--output-format", "json-lines"]);
    assert_eq!(output.status.code(), Some(1));

    let stdout = stdout_string(&output);
    let lines = stdout.lines().collect::<Vec<_>>();
    assert_eq!(lines.len(), 1);
    let value: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
    assert_eq!(value["filename"], "warn.sh");
}

#[test]
fn check_junit_output_is_valid_xml() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let output = run_check_output(tempdir.path(), &["check", "--output-format", "junit"]);
    assert_eq!(output.status.code(), Some(1));

    let stdout = stdout_string(&output);
    let document = roxmltree::Document::parse(&stdout).unwrap();
    assert_eq!(document.root_element().tag_name().name(), "testsuites");
    assert!(
        document
            .descendants()
            .any(|node| node.has_tag_name("testsuite"))
    );
    assert!(
        document
            .descendants()
            .any(|node| node.has_tag_name("testcase"))
    );
}

#[test]
fn check_gitlab_output_is_valid_json() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let output = run_check_output(tempdir.path(), &["check", "--output-format", "gitlab"]);
    assert_eq!(output.status.code(), Some(1));

    let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    let diagnostics = value.as_array().unwrap();
    assert_eq!(diagnostics[0]["location"]["path"], "warn.sh");
}

#[test]
fn check_rdjson_output_is_valid_json() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let output = run_check_output(tempdir.path(), &["check", "--output-format", "rdjson"]);
    assert_eq!(output.status.code(), Some(1));

    let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(value["source"]["name"], "shuck");
    assert_eq!(value["diagnostics"][0]["location"]["path"], "warn.sh");
}

#[test]
fn check_sarif_output_is_valid_json() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let output = run_check_output(tempdir.path(), &["check", "--output-format", "sarif"]);
    assert_eq!(output.status.code(), Some(1));

    let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(value["version"], "2.1.0");
    assert_eq!(value["runs"][0]["tool"]["driver"]["name"], "shuck");
}

#[test]
fn check_github_output_uses_actions_annotation_format() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let output = run_check_output(tempdir.path(), &["check", "--output-format", "github"]);
    assert_eq!(output.status.code(), Some(1));
    assert_eq!(
        stdout_string(&output),
        "::warning title=shuck (C001),file=warn.sh,line=2,col=1,endLine=2,endColumn=7::warn.sh:2:1: C001 variable `unused` is assigned but never used\n",
    );
}

#[test]
fn check_json_output_is_identical_on_cache_hit_for_fix_payloads() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nprintf '%s\\n' x &;\n",
    )
    .unwrap();

    let first = run_check_output(
        tempdir.path(),
        &["check", "--select", "S074", "--output-format", "json"],
    );
    assert_eq!(first.status.code(), Some(1));
    let first_stdout = stdout_string(&first);
    let first_value: serde_json::Value = serde_json::from_str(&first_stdout).unwrap();
    assert!(first_value[0]["fix"].is_object());

    let second = run_check_output(
        tempdir.path(),
        &["check", "--select", "S074", "--output-format", "json"],
    );
    assert_eq!(second.status.code(), Some(1));
    assert_eq!(stdout_string(&second), first_stdout);
}

#[test]
fn check_structured_parse_error_output_is_identical_on_cache_hit() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("broken.sh"),
        "#!/bin/bash\necho \"unterminated\n",
    )
    .unwrap();

    let first = run_check_output(tempdir.path(), &["check", "--output-format", "sarif"]);
    assert_eq!(first.status.code(), Some(1));
    let first_stdout = stdout_string(&first);
    let first_value: serde_json::Value = serde_json::from_str(&first_stdout).unwrap();
    assert_eq!(
        first_value["runs"][0]["results"][0]["ruleId"],
        "parse-error"
    );

    let second = run_check_output(tempdir.path(), &["check", "--output-format", "sarif"]);
    assert_eq!(second.status.code(), Some(1));
    assert_eq!(stdout_string(&second), first_stdout);
}

#[test]
fn check_add_ignore_writes_inline_shuck_ignore() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    fs::write(&script, "#!/bin/bash\necho $foo\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--add-ignore"]);
    cmd.assert()
        .success()
        .stdout("")
        .stderr(predicate::str::contains("Added 1 shuck ignore directive."));

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "#!/bin/bash\necho $foo  # shuck: ignore=C006\n"
    );
}

#[test]
fn check_rejects_add_noqa_alias() {
    let tempdir = tempdir().unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--add-noqa=legacy"]);
    cmd.assert()
        .code(2)
        .stderr(predicate::str::contains("unexpected argument '--add-noqa'"));
}

#[test]
fn check_add_ignore_merges_existing_ignore_and_preserves_reason() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    fs::write(
        &script,
        "#!/bin/bash\necho $foo  # shuck: ignore=S001 # legacy\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--add-ignore"]);
    cmd.assert()
        .success()
        .stdout("")
        .stderr(predicate::str::contains("Added 1 shuck ignore directive."));

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "#!/bin/bash\necho $foo  # shuck: ignore=C006, S001 # legacy\n"
    );
}

#[test]
fn check_add_ignore_reports_remaining_parse_errors() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("broken.sh");
    fs::write(&script, "#!/bin/bash\necho \"unterminated\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--add-ignore"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("error[parse-error]:"))
        .stderr(predicate::str::is_empty());

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "#!/bin/bash\necho \"unterminated\n"
    );
}

#[test]
fn check_add_ignore_leaves_uneditable_trailing_comment_lines_failing() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    let source = "#!/bin/bash\necho $foo # existing comment\n";
    fs::write(&script, source).unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--add-ignore", "--output-format", "concise"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("error[C006]"))
        .stderr(predicate::str::is_empty());

    assert_eq!(fs::read_to_string(script).unwrap(), source);
}

#[test]
fn check_add_ignore_respects_exit_zero_for_warning_leftovers() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    let source = "#!/bin/bash\nunused=1 # existing comment\necho ok\n";
    fs::write(&script, source).unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).args([
        "check",
        "--add-ignore",
        "--exit-zero",
        "--output-format",
        "concise",
    ]);
    cmd.assert()
        .success()
        .stdout(predicate::str::contains("warning[C001]"))
        .stderr(predicate::str::is_empty());

    assert_eq!(fs::read_to_string(script).unwrap(), source);
}

#[test]
fn check_add_ignore_leaves_continuation_lines_failing() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("warn.sh");
    let source = "#!/bin/bash\necho $foo \\\n&& echo ok\n";
    fs::write(&script, source).unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--add-ignore", "--output-format", "concise"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("error[C006]"))
        .stderr(predicate::str::is_empty());

    assert_eq!(fs::read_to_string(script).unwrap(), source);
}

#[test]
fn check_add_ignore_respects_force_exclude_for_explicit_files() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("ignored.sh");
    let source = "#!/bin/bash\necho $foo\n";
    fs::write(&script, source).unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).args([
        "check",
        "--add-ignore",
        "--exclude",
        "ignored.sh",
        "--force-exclude",
        "ignored.sh",
    ]);
    cmd.assert()
        .success()
        .stdout("")
        .stderr(predicate::str::is_empty());

    assert_eq!(fs::read_to_string(script).unwrap(), source);
}

#[test]
fn inline_shuck_ignore_suppresses_only_its_own_line() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\necho $foo  # shuck: ignore=C006\necho $bar\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--output-format", "concise"]);
    cmd.assert()
        .code(1)
        .stdout("warn.sh:3:6: error[C006] variable `bar` is referenced before assignment\n");
}

#[test]
fn check_cache_hits_keep_full_snippet_output() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("warn.sh"),
        "#!/bin/bash\nunused=1\necho ok\n",
    )
    .unwrap();

    let mut first = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut first, tempdir.path());
    first.current_dir(tempdir.path()).arg("check");
    first.assert().code(1);

    let mut second = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut second, tempdir.path());
    second.current_dir(tempdir.path()).arg("check");
    second
        .assert()
        .code(1)
        .stdout(predicate::str::contains("--> warn.sh:2:1"))
        .stdout(predicate::str::contains("2 | unused=1"))
        .stdout(predicate::str::contains("| ^"));
}

#[test]
fn check_skips_ignored_directories_when_defaulting_to_current_directory() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".git")).unwrap();
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();
    fs::write(
        tempdir.path().join(".git").join("broken.sh"),
        "#!/bin/bash\nif true\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("check");
    cmd.assert().success().stdout("");
}

#[test]
fn check_no_cache_does_not_write_cache_tree() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--no-cache"]);
    cmd.assert().success();

    assert!(!tempdir.path().join(".shuck_cache").exists());
    assert!(!cache_dir(tempdir.path()).exists());
}

#[test]
fn check_writes_versioned_bin_cache_file_via_env_override() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("check");
    cmd.assert().success();

    let version_dir = cache_dir(tempdir.path()).join(env!("CARGO_PKG_VERSION"));
    assert!(version_dir.is_dir());

    let entries = fs::read_dir(&version_dir)
        .unwrap()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();
    assert_eq!(entries.len(), 1);
    assert_eq!(
        entries[0].path().extension().and_then(|ext| ext.to_str()),
        Some("bin")
    );
}

#[test]
fn check_writes_versioned_bin_cache_file_via_cli_arg() {
    let tempdir = tempdir().unwrap();
    let cache_dir = tempdir.path().join("cli-cache");
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.current_dir(tempdir.path())
        .arg("--cache-dir")
        .arg(&cache_dir)
        .arg("check");
    cmd.assert().success();

    let version_dir = cache_dir.join(env!("CARGO_PKG_VERSION"));
    assert!(version_dir.is_dir());

    let entries = fs::read_dir(&version_dir)
        .unwrap()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();
    assert_eq!(entries.len(), 1);
    assert_eq!(
        entries[0].path().extension().and_then(|ext| ext.to_str()),
        Some("bin")
    );
}

#[test]
fn check_default_cache_uses_os_cache_dir_and_not_local_tree() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_default_cache_env(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("check");
    cmd.assert().success();

    assert!(!tempdir.path().join(".shuck_cache").exists());
    let cache_files = WalkDir::new(tempdir.path())
        .into_iter()
        .filter_map(Result::ok)
        .filter(|entry| entry.file_type().is_file())
        .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("bin"))
        .collect::<Vec<_>>();
    assert_eq!(cache_files.len(), 1);
    assert!(
        !cache_files[0]
            .path()
            .starts_with(tempdir.path().join(".shuck_cache"))
    );
}

#[test]
fn format_good_file_succeeds_and_preserves_contents() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("ok.sh");
    let source = "#!/bin/bash\necho ok\n";
    fs::write(&script, source).unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("format");
    cmd.assert().success().stdout("");

    assert_eq!(fs::read_to_string(script).unwrap(), source);
}

#[test]
fn format_check_and_diff_are_clean_for_valid_input() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

    let mut check = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut check, tempdir.path());
    check
        .current_dir(tempdir.path())
        .args(["format", "--check"]);
    check.assert().success().stdout("");

    let mut diff = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut diff, tempdir.path());
    diff.current_dir(tempdir.path()).args(["format", "--diff"]);
    diff.assert().success().stdout("");
}

#[test]
fn format_check_and_diff_report_noncanonical_input() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("fn.sh"), "foo(){\necho hi\n}\n").unwrap();

    let mut check = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut check, tempdir.path());
    check
        .current_dir(tempdir.path())
        .args(["format", "--check", "--function-next-line"]);
    check.assert().code(1).stdout("");

    let mut diff = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut diff, tempdir.path());
    diff.current_dir(tempdir.path())
        .args(["format", "--diff", "--function-next-line"]);
    diff.assert()
        .code(1)
        .stdout(predicate::str::contains("+foo()"));
}

#[test]
fn format_broken_file_reports_parse_error() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("broken.sh"), "#!/bin/bash\nif true\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("format");
    cmd.assert()
        .code(2)
        .stdout(predicate::str::contains("broken.sh:2:8: parse error"));
}

#[test]
fn format_stdin_round_trips_valid_input() {
    let source = "#!/bin/bash\necho ok\n";

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.args(["format", "-"]).write_stdin(source);
    cmd.assert().success().stdout(source);
}

#[test]
fn format_stdin_filename_reports_parse_errors() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.args(["format", "--stdin-filename", "foo.sh"])
        .write_stdin("#!/bin/bash\nif true\n");
    cmd.assert()
        .code(2)
        .stdout(predicate::str::contains("foo.sh:2:8: parse error"));
}

#[test]
fn format_stdin_uses_current_project_config() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "[format]\nfunction-next-line = true\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.current_dir(tempdir.path())
        .args(["format", "-"])
        .write_stdin("foo(){\necho hi\n}\n");
    cmd.assert().success().stdout("foo()\n{\n\techo hi\n}\n");
}

#[test]
fn format_stdin_filename_enforces_inferred_posix_dialect() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.args(["format", "--stdin-filename", "script.sh"])
        .write_stdin("[[ foo == bar ]]\n");
    cmd.assert()
        .code(2)
        .stdout(predicate::str::contains("script.sh:1:1: parse error"));
}

#[test]
fn format_stdin_filename_accepts_common_shell_extensions() {
    for path in ["script.bash", "script.mksh", "script.ksh", "script.dash"] {
        let mut cmd = Command::cargo_bin("shuck").unwrap();
        cmd.args(["format", "--stdin-filename", path])
            .write_stdin("echo ok\n");
        cmd.assert().success().stdout("echo ok\n");
    }
}

#[test]
fn format_stdin_filename_infers_zsh_dialect() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.args(["format", "--stdin-filename", "script.zsh"])
        .write_stdin("print ${(m)foo}\n");
    cmd.assert().success().stdout("print ${(m)foo}\n");
}

#[test]
fn format_stdin_rejects_configured_dialect() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "[format]\ndialect = \"zsh\"\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.current_dir(tempdir.path())
        .args(["format", "-"])
        .write_stdin("print ${(m)foo}\n");
    cmd.assert()
        .code(2)
        .stderr(predicate::str::contains("[format].dialect"))
        .stderr(predicate::str::contains("--dialect"));
}

#[test]
fn format_stdin_uses_cli_zsh_dialect_override() {
    let mut cmd = Command::cargo_bin("shuck").unwrap();
    cmd.args(["format", "--dialect", "zsh", "-"])
        .write_stdin("print ${(m)foo}\n");
    cmd.assert().success().stdout("print ${(m)foo}\n");
}

#[test]
fn check_zsh_extension_parses_with_inferred_zsh_dialect() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("ok.zsh"), "foo=bar\nprint ${(m)foo}\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("check");
    cmd.assert().success().stdout("");
}

#[test]
fn check_zsh_extension_parses_repeat_and_foreach_with_inferred_zsh_dialect() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("ok.zsh"),
        "repeat 3; do echo hi; done\nforeach x (a b c) { echo $x; }\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("check");
    cmd.assert().success().stdout("");
}

#[test]
fn check_zsh_shebang_parses_with_inferred_zsh_dialect() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("ok"),
        "#!/usr/bin/env zsh\nfoo=bar\nprint ${(m)foo}\n",
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).arg("check");
    cmd.assert().success().stdout("");
}

#[test]
fn check_analyzes_every_explicit_regular_file() {
    #[cfg(unix)]
    use std::os::unix::fs::PermissionsExt;

    let tempdir = tempdir().unwrap();
    #[cfg(unix)]
    let executable = tempdir.path().join("tool");
    for name in ["script", "PKGBUILD", ".bashrc", "notes.txt", "tool"] {
        fs::write(tempdir.path().join(name), "{\n").unwrap();
    }
    #[cfg(unix)]
    {
        let mut permissions = fs::metadata(&executable).unwrap().permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(&executable, permissions).unwrap();
    }

    for name in ["script", "PKGBUILD", ".bashrc", "notes.txt", "tool"] {
        let mut cmd = Command::cargo_bin("shuck").unwrap();
        configure_env_cache(&mut cmd, tempdir.path());
        cmd.current_dir(tempdir.path())
            .args(["check", "--no-cache", name]);
        cmd.assert()
            .code(1)
            .stdout(predicate::str::contains(format!("--> {name}:1:1")));
    }
}

#[test]
fn check_skips_explicit_plain_yaml_files() {
    let tempdir = tempdir().unwrap();
    for name in ["config.yml", "config.yaml"] {
        fs::write(tempdir.path().join(name), "- foo: 'bar'\n  bar: [foo]\n").unwrap();
    }

    for extra_args in [Vec::new(), vec!["--config", "check.embedded=false"]] {
        let mut cmd = Command::cargo_bin("shuck").unwrap();
        configure_env_cache(&mut cmd, tempdir.path());
        cmd.current_dir(tempdir.path())
            .args(["check", "--no-cache"])
            .args(extra_args)
            .args(["config.yml", "config.yaml"]);
        cmd.assert().success().stdout("");
    }
}

#[test]
fn check_extracts_explicit_workflow_yaml() {
    let tempdir = tempdir().unwrap();
    let workflows = tempdir.path().join(".github/workflows");
    fs::create_dir_all(&workflows).unwrap();
    fs::write(
        workflows.join("ci.yml"),
        r#"on: push
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: |
          unused=1
          echo ok
"#,
    )
    .unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--no-cache", ".github/workflows/ci.yml"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("jobs.test.steps[0].run"))
        .stdout(predicate::str::contains(format!(
            "--> {}:7:11",
            platform_path(".github/workflows/ci.yml")
        )));
}

#[test]
fn check_per_file_shell_reaches_explicit_extensionless_file() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("configured"), "{\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).args([
        "check",
        "--no-cache",
        "--per-file-shell",
        "configured:bash",
        "configured",
    ]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("--> configured:1:1"));
}

#[test]
fn check_directory_discovery_stays_limited_to_shell_candidates() {
    #[cfg(unix)]
    use std::os::unix::fs::PermissionsExt;

    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("script"), "#!/bin/sh\n{\n").unwrap();
    fs::write(tempdir.path().join("PKGBUILD"), "{\n").unwrap();
    fs::write(tempdir.path().join(".bashrc"), "{\n").unwrap();
    fs::write(tempdir.path().join(".profile"), "{\n").unwrap();
    fs::write(tempdir.path().join(".zshrc"), "{\n").unwrap();
    fs::write(tempdir.path().join("unknown"), "{\n").unwrap();
    fs::write(tempdir.path().join("notes.txt"), "{\n").unwrap();
    let executable = tempdir.path().join("tool");
    fs::write(&executable, "{\n").unwrap();
    #[cfg(unix)]
    {
        let mut permissions = fs::metadata(&executable).unwrap().permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(&executable, permissions).unwrap();
    }

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--no-cache"]);
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("--> script:2:1"))
        .stdout(predicate::str::contains("--> PKGBUILD:1:1"))
        .stdout(predicate::str::contains("--> .bashrc:1:1"))
        .stdout(predicate::str::contains("--> .profile:1:1"))
        .stdout(predicate::str::contains("--> .zshrc:1:1"))
        .stdout(predicate::str::contains("--> unknown:").not())
        .stdout(predicate::str::contains("--> notes.txt:").not())
        .stdout(predicate::str::contains("--> tool:").not());
}

#[test]
fn check_exclude_skips_walked_files_but_not_explicit_files_without_force_exclude() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();
    fs::write(tempdir.path().join("ignored.sh"), "#!/bin/bash\nif true\n").unwrap();

    let mut walked = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut walked, tempdir.path());
    walked
        .current_dir(tempdir.path())
        .args(["check", "--exclude", "ignored.sh"]);
    walked.assert().success().stdout("");

    let mut explicit = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut explicit, tempdir.path());
    explicit
        .current_dir(tempdir.path())
        .args(["check", "--exclude", "ignored.sh", "ignored.sh"]);
    explicit
        .assert()
        .code(1)
        .stdout(predicate::str::contains("--> ignored.sh:2:1"));

    let mut forced = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut forced, tempdir.path());
    forced.current_dir(tempdir.path()).args([
        "check",
        "--exclude",
        "ignored.sh",
        "--force-exclude",
        "ignored.sh",
    ]);
    forced.assert().success().stdout("");
}

#[test]
fn check_gitignore_and_force_exclude_flags_control_explicit_files() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join(".gitignore"), "ignored.sh\n").unwrap();
    fs::write(tempdir.path().join("ignored.sh"), "#!/bin/bash\nif true\n").unwrap();

    let mut default_walk = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut default_walk, tempdir.path());
    default_walk.current_dir(tempdir.path()).arg("check");
    default_walk.assert().success().stdout("");

    let mut no_respect = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut no_respect, tempdir.path());
    no_respect
        .current_dir(tempdir.path())
        .args(["check", "--no-respect-gitignore"]);
    no_respect
        .assert()
        .code(1)
        .stdout(predicate::str::contains("--> ignored.sh:2:1"));

    let mut explicit = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut explicit, tempdir.path());
    explicit
        .current_dir(tempdir.path())
        .args(["check", "ignored.sh"]);
    explicit
        .assert()
        .code(1)
        .stdout(predicate::str::contains("--> ignored.sh:2:1"));

    let mut forced = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut forced, tempdir.path());
    forced
        .current_dir(tempdir.path())
        .args(["check", "--force-exclude", "ignored.sh"]);
    forced.assert().success().stdout("");
}

#[test]
fn check_extend_exclude_adds_to_exclude_patterns() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("base.sh"), "#!/bin/bash\nif true\n").unwrap();
    fs::write(tempdir.path().join("extra.sh"), "#!/bin/bash\nif true\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path()).args([
        "check",
        "--exclude",
        "base.sh",
        "--extend-exclude",
        "extra.sh",
    ]);
    cmd.assert().success().stdout("");
}

#[test]
fn check_invalid_extend_exclude_pattern_reports_discovery_error() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["check", "--extend-exclude", "["]);
    cmd.assert()
        .code(2)
        .stderr(predicate::str::contains("invalid exclude pattern `[`"));
}

#[test]
fn check_watch_reruns_when_files_change() {
    let tempdir = tempdir().unwrap();
    let script = tempdir.path().join("watch.sh");
    fs::write(&script, "#!/bin/bash\nunused=1\necho ok\n").unwrap();

    let mut child = ProcessCommand::new(assert_cmd::cargo::cargo_bin("shuck"));
    child
        .env("SHUCK_CACHE_DIR", cache_dir(tempdir.path()))
        .current_dir(tempdir.path())
        .args(["check", "--watch", "--output-format", "concise"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    let mut child = child.spawn().unwrap();
    let stdout = child.stdout.take().unwrap();
    let stderr = child.stderr.take().unwrap();
    let (tx, rx) = mpsc::channel();
    let stdout_reader = spawn_output_reader(stdout, StreamKind::Stdout, tx.clone());
    let stderr_reader = spawn_output_reader(stderr, StreamKind::Stderr, tx);
    let mut captured = CapturedOutput::default();

    let initial_ready = wait_for_output(&rx, &mut captured, Duration::from_secs(10), |output| {
        output.stderr.contains("Starting linter in watch mode...")
            && output.stdout.contains("watch.sh:2:1: warning[C001]")
    });
    if !initial_ready {
        stop_child(&mut child);
        let _ = stdout_reader.join();
        let _ = stderr_reader.join();
        panic!(
            "watch mode did not emit its startup banner and initial report\nstdout:\n{}\nstderr:\n{}",
            captured.stdout, captured.stderr
        );
    }

    fs::write(&script, "#!/bin/bash\necho ok\nunused=1\n").unwrap();

    let rerun_ready = wait_for_output(&rx, &mut captured, Duration::from_secs(10), |output| {
        output.stderr.contains("File change detected...")
            && output.stdout.contains("watch.sh:3:1: warning[C001]")
    });

    stop_child(&mut child);
    let _ = stdout_reader.join();
    let _ = stderr_reader.join();

    assert!(
        rerun_ready,
        "watch mode did not rerun after file changes\nstdout:\n{}\nstderr:\n{}",
        captured.stdout, captured.stderr
    );
}

#[test]
fn format_exclude_skips_walked_files_but_not_explicit_files_without_force_exclude() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();
    let ignored = tempdir.path().join("ignored.sh");
    let unformatted = "#!/bin/bash\n echo ignored\n";
    let formatted = "#!/bin/bash\necho ignored\n";
    fs::write(&ignored, unformatted).unwrap();

    let mut walked = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut walked, tempdir.path());
    walked
        .current_dir(tempdir.path())
        .args(["format", "--exclude", "ignored.sh"]);
    walked.assert().success().stdout("");
    assert_eq!(fs::read_to_string(&ignored).unwrap(), unformatted);

    let mut explicit = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut explicit, tempdir.path());
    explicit
        .current_dir(tempdir.path())
        .args(["format", "--exclude", "ignored.sh", "ignored.sh"]);
    explicit.assert().success().stdout("");
    assert_eq!(fs::read_to_string(&ignored).unwrap(), formatted);

    fs::write(&ignored, unformatted).unwrap();

    let mut forced = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut forced, tempdir.path());
    forced.current_dir(tempdir.path()).args([
        "format",
        "--exclude",
        "ignored.sh",
        "--force-exclude",
        "ignored.sh",
    ]);
    forced.assert().success().stdout("");
    assert_eq!(fs::read_to_string(&ignored).unwrap(), unformatted);
}

#[test]
fn format_config_exclude_skips_walked_and_explicit_files_per_project() {
    let tempdir = tempdir().unwrap();
    let nested = tempdir.path().join("nested");
    fs::create_dir_all(&nested).unwrap();
    fs::write(tempdir.path().join("shuck.toml"), "[format]\n").unwrap();
    fs::write(
        nested.join("shuck.toml"),
        "[format]\nexclude = ['**/*p10k.zsh']\n",
    )
    .unwrap();

    let regular = nested.join("regular.zsh");
    let generated = nested.join(".p10k.zsh");
    let unformatted_regular = "if true; then\nprint ok\nfi\n";
    let formatted_regular = "if true; then\n\tprint ok\nfi\n";
    let unformatted_generated = "if true\n";
    fs::write(&regular, unformatted_regular).unwrap();
    fs::write(&generated, unformatted_generated).unwrap();

    let mut walked = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut walked, tempdir.path());
    walked.current_dir(tempdir.path()).arg("format");
    walked.assert().success().stdout("");

    assert_eq!(fs::read_to_string(&regular).unwrap(), formatted_regular);
    assert_eq!(
        fs::read_to_string(&generated).unwrap(),
        unformatted_generated
    );

    let mut explicit = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut explicit, tempdir.path());
    explicit
        .current_dir(tempdir.path())
        .args(["format", "nested/.p10k.zsh"]);
    explicit.assert().success().stdout("");
    assert_eq!(
        fs::read_to_string(&generated).unwrap(),
        unformatted_generated
    );
}

#[test]
fn format_gitignore_and_force_exclude_flags_control_explicit_files() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join(".gitignore"), "ignored.sh\n").unwrap();
    let ignored = tempdir.path().join("ignored.sh");
    let unformatted = "#!/bin/bash\n echo ignored\n";
    let formatted = "#!/bin/bash\necho ignored\n";
    fs::write(&ignored, unformatted).unwrap();

    let mut default_walk = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut default_walk, tempdir.path());
    default_walk.current_dir(tempdir.path()).arg("format");
    default_walk.assert().success().stdout("");
    assert_eq!(fs::read_to_string(&ignored).unwrap(), unformatted);

    let mut no_respect = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut no_respect, tempdir.path());
    no_respect
        .current_dir(tempdir.path())
        .args(["format", "--no-respect-gitignore"]);
    no_respect.assert().success().stdout("");
    assert_eq!(fs::read_to_string(&ignored).unwrap(), formatted);

    fs::write(&ignored, unformatted).unwrap();

    let mut explicit = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut explicit, tempdir.path());
    explicit
        .current_dir(tempdir.path())
        .args(["format", "ignored.sh"]);
    explicit.assert().success().stdout("");
    assert_eq!(fs::read_to_string(&ignored).unwrap(), formatted);

    fs::write(&ignored, unformatted).unwrap();

    let mut forced = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut forced, tempdir.path());
    forced
        .current_dir(tempdir.path())
        .args(["format", "--force-exclude", "ignored.sh"]);
    forced.assert().success().stdout("");
    assert_eq!(fs::read_to_string(&ignored).unwrap(), unformatted);
}

#[test]
fn format_honors_project_config_and_cli_overrides_it() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "[format]\nfunction-next-line = false\n",
    )
    .unwrap();
    let script = tempdir.path().join("fn.sh");
    fs::write(&script, "foo(){\necho hi\n}\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["format", "--function-next-line"]);
    cmd.assert().success().stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "foo()\n{\n\techo hi\n}\n"
    );
}

#[test]
fn format_honors_explicit_global_config_file() {
    let tempdir = tempdir().unwrap();
    let config_path = tempdir.path().join("override.toml");
    fs::write(&config_path, "[format]\nfunction-next-line = true\n").unwrap();
    let script = tempdir.path().join("fn.sh");
    fs::write(&script, "foo(){\necho hi\n}\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .arg("--config")
        .arg(&config_path)
        .arg("format");
    cmd.assert().success().stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "foo()\n{\n\techo hi\n}\n"
    );
}

#[test]
fn format_inline_global_config_override_beats_global_config_file() {
    let tempdir = tempdir().unwrap();
    let config_path = tempdir.path().join("override.toml");
    fs::write(&config_path, "[format]\nfunction-next-line = false\n").unwrap();
    let script = tempdir.path().join("fn.sh");
    fs::write(&script, "foo(){\necho hi\n}\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .arg("--config")
        .arg(&config_path)
        .arg("--config")
        .arg("format.function-next-line = true")
        .arg("format");
    cmd.assert().success().stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "foo()\n{\n\techo hi\n}\n"
    );
}

#[test]
fn format_isolated_ignores_discovered_project_config() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "[format]\nfunction-next-line = true\n",
    )
    .unwrap();
    let script = tempdir.path().join("fn.sh");
    fs::write(&script, "foo(){\necho hi\n}\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .arg("--isolated")
        .arg("format");
    cmd.assert().success().stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "foo() {\n\techo hi\n}\n"
    );
}

#[test]
fn format_prefers_nested_project_config_for_explicit_files() {
    let tempdir = tempdir().unwrap();
    let nested = tempdir.path().join("nested");
    fs::create_dir_all(&nested).unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "[format]\nfunction-next-line = false\n",
    )
    .unwrap();
    fs::write(
        nested.join("shuck.toml"),
        "[format]\nfunction-next-line = true\n",
    )
    .unwrap();
    let script = nested.join("fn.sh");
    fs::write(&script, "foo(){\necho hi\n}\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .args(["format", "nested/fn.sh"]);
    cmd.assert().success().stdout("");

    assert_eq!(
        fs::read_to_string(script).unwrap(),
        "foo()\n{\n\techo hi\n}\n"
    );
}

#[test]
fn format_cache_invalidates_when_formatter_options_change() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("fn.sh"), "foo(){\necho hi\n}\n").unwrap();

    let mut initial = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut initial, tempdir.path());
    initial.current_dir(tempdir.path()).arg("format");
    initial.assert().success().stdout("");

    let mut check = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut check, tempdir.path());
    check
        .current_dir(tempdir.path())
        .args(["format", "--check", "--function-next-line"]);
    check.assert().code(1).stdout("");
}

#[test]
fn clean_removes_existing_cache_tree() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

    let mut check = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut check, tempdir.path());
    check.current_dir(tempdir.path()).arg("check");
    check.assert().success();
    assert!(cache_dir(tempdir.path()).exists());

    let mut clean = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut clean, tempdir.path());
    clean.current_dir(tempdir.path()).arg("clean");
    clean
        .assert()
        .success()
        .stdout(predicate::str::contains("cache cleared"));

    assert!(!cache_dir(tempdir.path()).exists());
    assert!(!tempdir.path().join(".shuck_cache").exists());
}

#[test]
fn clean_succeeds_when_cache_tree_is_absent() {
    let tempdir = tempdir().unwrap();

    let mut clean = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut clean, tempdir.path());
    clean.current_dir(tempdir.path()).arg("clean");
    clean
        .assert()
        .success()
        .stdout(predicate::str::contains("cache cleared"));
}

#[test]
fn clean_removes_legacy_local_cache_directory_during_transition() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join(".shuck_cache").join("stale")).unwrap();

    let mut clean = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut clean, tempdir.path());
    clean.current_dir(tempdir.path()).arg("clean");
    clean.assert().success();

    assert!(!tempdir.path().join(".shuck_cache").exists());
}

#[test]
fn clean_only_removes_selected_project_entries_from_shared_cache() {
    let tempdir = tempdir().unwrap();
    let cache_dir = tempdir.path().join("shared-cache");
    let project_a = tempdir.path().join("project-a");
    let project_b = tempdir.path().join("project-b");
    fs::create_dir_all(&project_a).unwrap();
    fs::create_dir_all(&project_b).unwrap();
    fs::write(project_a.join("a.sh"), "#!/bin/bash\necho a\n").unwrap();
    fs::write(project_b.join("b.sh"), "#!/bin/bash\necho b\n").unwrap();

    let mut check_a = Command::cargo_bin("shuck").unwrap();
    check_a
        .current_dir(&project_a)
        .arg("--cache-dir")
        .arg(&cache_dir)
        .arg("check");
    check_a.assert().success();

    let mut check_b = Command::cargo_bin("shuck").unwrap();
    check_b
        .current_dir(&project_b)
        .arg("--cache-dir")
        .arg(&cache_dir)
        .arg("check");
    check_b.assert().success();

    let version_dir = cache_dir.join(env!("CARGO_PKG_VERSION"));
    let initial_entries = fs::read_dir(&version_dir)
        .unwrap()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();
    assert_eq!(initial_entries.len(), 2);

    let mut clean_a = Command::cargo_bin("shuck").unwrap();
    clean_a
        .current_dir(&project_a)
        .arg("--cache-dir")
        .arg(&cache_dir)
        .arg("clean");
    clean_a.assert().success();

    let remaining_entries = fs::read_dir(&version_dir)
        .unwrap()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();
    assert_eq!(remaining_entries.len(), 1);
}

#[test]
fn check_and_clean_share_config_root_mode_for_explicit_config_files() {
    let tempdir = tempdir().unwrap();
    let cache_dir = tempdir.path().join("shared-cache");
    let override_config = tempdir.path().join("override.toml");
    let nested = tempdir.path().join("nested");
    fs::create_dir_all(&nested).unwrap();
    fs::write(&override_config, "[format]\n").unwrap();
    fs::write(
        nested.join("shuck.toml"),
        "[format]\nfunction-next-line = true\n",
    )
    .unwrap();
    fs::write(nested.join("broken.sh"), "#!/bin/bash\nif true\n").unwrap();

    let mut check = Command::cargo_bin("shuck").unwrap();
    check
        .current_dir(tempdir.path())
        .arg("--cache-dir")
        .arg(&cache_dir)
        .arg("--config")
        .arg(&override_config)
        .arg("check");
    check.assert().code(1);
    assert!(cache_dir.exists());

    let mut clean = Command::cargo_bin("shuck").unwrap();
    clean
        .current_dir(tempdir.path())
        .arg("--cache-dir")
        .arg(&cache_dir)
        .arg("--config")
        .arg(&override_config)
        .arg("clean");
    clean.assert().success();

    assert!(!cache_dir.exists());
}

#[test]
fn check_color_always_forces_ansi_output() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("broken.sh"), "#!/bin/bash\nif true\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .arg("--color")
        .arg("always")
        .arg("check");
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("\u{1b}["));
}

#[test]
fn check_color_never_overrides_force_color_env() {
    let tempdir = tempdir().unwrap();
    fs::write(tempdir.path().join("broken.sh"), "#!/bin/bash\nif true\n").unwrap();

    let mut cmd = Command::cargo_bin("shuck").unwrap();
    configure_env_cache(&mut cmd, tempdir.path());
    cmd.current_dir(tempdir.path())
        .env("FORCE_COLOR", "1")
        .arg("--color")
        .arg("never")
        .arg("check");
    cmd.assert()
        .code(1)
        .stdout(predicate::str::contains("\u{1b}[").not());
}

#[test]
fn source_directive_silences_untracked_source_without_linting_target() {
    let tempdir = tempdir().unwrap();
    // The helper has an obvious unused-assignment (C001) that must NOT surface,
    // because a plain source= directive imports symbols without linting the target.
    fs::write(
        tempdir.path().join("helper.sh"),
        "greet() { echo hi; }\nunused_here=1\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("main.sh"),
        "#!/bin/bash\nDIR=$(dirname \"$0\")\n# shuck: source=helper.sh\nsource \"$DIR/helper.sh\"\ngreet\n",
    )
    .unwrap();

    let output = run_check_output(tempdir.path(), &["check", "--no-cache", "main.sh"]);
    let stdout = stdout_string(&output);
    assert!(
        !stdout.contains("C003"),
        "a source= directive should silence untracked-source: {stdout}"
    );
    assert!(
        !stdout.contains("helper.sh"),
        "a plain source= directive must not lint the target: {stdout}"
    );
}

#[test]
fn lint_true_directive_lints_the_target() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("helper.sh"),
        "greet() { echo hi; }\nunused_here=1\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("main.sh"),
        "#!/bin/bash\nDIR=$(dirname \"$0\")\n# shuck: source=helper.sh lint=true\nsource \"$DIR/helper.sh\"\ngreet\n",
    )
    .unwrap();

    let output = run_check_output(
        tempdir.path(),
        &[
            "check",
            "--no-cache",
            "--output-format",
            "concise",
            "main.sh",
        ],
    );
    let stdout = stdout_string(&output);
    assert!(
        stdout.contains("helper.sh") && stdout.contains("C001"),
        "lint=true should lint the target and report its C001: {stdout}"
    );
}

#[test]
fn lint_sources_config_false_downgrades_lint_true() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("helper.sh"),
        "greet() { echo hi; }\nunused_here=1\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("main.sh"),
        "#!/bin/bash\nDIR=$(dirname \"$0\")\n# shuck: source=helper.sh lint=true\nsource \"$DIR/helper.sh\"\ngreet\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "[lint]\nlint-sources = false\n",
    )
    .unwrap();

    let output = run_check_output(tempdir.path(), &["check", "--no-cache", "main.sh"]);
    let stdout = stdout_string(&output);
    assert!(
        !stdout.contains("helper.sh"),
        "lint-sources=false must not lint the target: {stdout}"
    );
    assert!(
        !stdout.contains("C003"),
        "the hint should still silence untracked-source: {stdout}"
    );
}

#[test]
fn source_paths_config_resolves_directive_targets_against_roots() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join("lib")).unwrap();
    fs::create_dir_all(tempdir.path().join("scripts")).unwrap();
    fs::write(
        tempdir.path().join("lib/util.sh"),
        "greet() { echo hi; }\nshared_unused=1\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("scripts/main.sh"),
        "#!/bin/bash\n# shuck: source=util.sh lint=true\nsource \"$SOMEDIR/util.sh\"\ngreet\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "[lint]\nsource-paths = [\"lib\"]\n",
    )
    .unwrap();

    let output = run_check_output(
        tempdir.path(),
        &[
            "check",
            "--no-cache",
            "--output-format",
            "concise",
            "scripts/main.sh",
        ],
    );
    let stdout = stdout_string(&output);
    assert!(
        !stdout.contains("C003"),
        "source-paths should resolve the hint: {stdout}"
    );
    assert!(
        stdout.contains("util.sh") && stdout.contains("C001"),
        "the followed target under lib/ should be linted: {stdout}"
    );
}

#[test]
fn directive_target_next_to_script_shadows_configured_root_match() {
    // The same target name exists both next to the annotating script and
    // under a configured source root; the local match must win, and only
    // that one file is linted.
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join("lib")).unwrap();
    fs::create_dir_all(tempdir.path().join("scripts")).unwrap();
    fs::write(
        tempdir.path().join("scripts/util.sh"),
        "greet() { echo hi; }\nlocal_unused=1\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("lib/util.sh"),
        "greet() { echo hi; }\nroot_unused=1\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("scripts/main.sh"),
        "#!/bin/bash\n# shuck: source=util.sh lint=true\nsource \"$SOMEDIR/util.sh\"\ngreet\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "[lint]\nsource-paths = [\"lib\"]\n",
    )
    .unwrap();

    let output = run_check_output(
        tempdir.path(),
        &[
            "check",
            "--no-cache",
            "--output-format",
            "concise",
            "scripts/main.sh",
        ],
    );
    let stdout = stdout_string(&output);
    assert!(
        stdout.contains(&format!("scripts{}util.sh", std::path::MAIN_SEPARATOR)),
        "the local target next to the script should be linted: {stdout}"
    );
    assert!(
        !stdout.contains(&format!("lib{}util.sh", std::path::MAIN_SEPARATOR)),
        "the configured-root shadow must not also be linted: {stdout}"
    );
}

#[test]
fn cached_directive_resolution_invalidates_when_nearer_target_appears() {
    let tempdir = tempdir().unwrap();
    fs::create_dir_all(tempdir.path().join("lib")).unwrap();
    fs::create_dir_all(tempdir.path().join("scripts")).unwrap();
    fs::write(tempdir.path().join("lib/util.sh"), "root_unused=1\n").unwrap();
    fs::write(
        tempdir.path().join("scripts/main.sh"),
        "#!/bin/bash\n# shuck: source=util.sh lint=true\nsource \"$SOMEDIR/util.sh\"\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("shuck.toml"),
        "[lint]\nsource-paths = [\"lib\"]\n",
    )
    .unwrap();

    let first = run_check_output(
        tempdir.path(),
        &["check", "--output-format", "concise", "scripts/main.sh"],
    );
    let first_stdout = stdout_string(&first);
    assert!(
        first_stdout.contains(&format!("lib{}util.sh", std::path::MAIN_SEPARATOR)),
        "the configured-root target should be selected initially: {first_stdout}"
    );

    fs::write(tempdir.path().join("scripts/util.sh"), "local_unused=1\n").unwrap();

    let second = run_check_output(
        tempdir.path(),
        &["check", "--output-format", "concise", "scripts/main.sh"],
    );
    let second_stdout = stdout_string(&second);
    assert!(
        second_stdout.contains(&format!("scripts{}util.sh", std::path::MAIN_SEPARATOR)),
        "creating the nearer target should invalidate the cached resolution: {second_stdout}"
    );
    assert!(
        !second_stdout.contains(&format!("lib{}util.sh", std::path::MAIN_SEPARATOR)),
        "the stale configured-root target must not survive the cache hit: {second_stdout}"
    );
}

#[test]
fn linted_source_targets_share_the_complete_analyzed_path_set() {
    let tempdir = tempdir().unwrap();
    fs::write(
        tempdir.path().join("main.sh"),
        "#!/bin/bash\n# shuck: source=a.sh lint=true\nsource \"$DIR/a.sh\"\n# shuck: source=b.sh lint=true\nsource \"$DIR/b.sh\"\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("a.sh"),
        "#!/bin/bash\n. ./b.sh\nshared_function\n",
    )
    .unwrap();
    fs::write(
        tempdir.path().join("b.sh"),
        "#!/bin/bash\nshared_function() { :; }\n",
    )
    .unwrap();

    let output = run_check_output(
        tempdir.path(),
        &[
            "check",
            "--no-cache",
            "--output-format",
            "concise",
            "main.sh",
        ],
    );
    let stdout = stdout_string(&output);
    assert!(
        !stdout.contains("C003"),
        "a followed file should recognize sibling followed targets as analyzed: {stdout}"
    );
}
