//! Presentation layer: pure formatters that turn domain values
//! (`Outcome` / `Diagnostic` / `Found`) into strings.
//!
//! Nothing in here writes to stdout. Callers (in `run.rs`) print
//! the returned string. Keeping the formatters pure makes them
//! unit-testable without an `assert_cmd`-style integration harness.

use crate::Attribution;
use crate::config::Relation;
use crate::doctor::{Diagnostic, Kind, Severity};
use crate::lint::Outcome;
use crate::sort::{SortNote, SortPlan};
use crate::trace::{Found, Provenance, TraceOutcome, UninstallHint};

/// Presentation-layer toggles common to every human renderer.
/// 0.0.18 introduced this so `--no-glyphs` finally takes effect on
/// `doctor` / `trace` / `sort` outputs (pre-0.0.18 the flag only
/// toggled the `[OK]` / `[NG]` glyphs in `report.rs::status_tag`,
/// while everything else in `format.rs` kept emitting `—` and `→`
/// unconditionally).
///
/// Callers pass `Style::default()` for the legacy "show glyphs"
/// shape (also what every existing unit test uses) and
/// `Style { no_glyphs: true }` to get an ASCII-only rendering.
#[derive(Debug, Clone, Copy, Default)]
pub struct Style {
    pub no_glyphs: bool,
}

/// Substitute a unicode glyph for an ASCII fallback when the
/// caller asked for `--no-glyphs`. 0.0.18 introduced this so every
/// renderer in `format.rs` can route attacker-safe substitutions
/// through one place.
fn glyph(style: Style, unicode: &'static str, ascii: &'static str) -> &'static str {
    if style.no_glyphs { ascii } else { unicode }
}

/// Render a single doctor diagnostic into a multi-line block
/// (header line + indented detail). The trailing newline is
/// omitted; the caller decides whether to add one.
pub fn doctor_line(d: &Diagnostic, entries: &[Attribution], style: Style) -> String {
    if let Kind::Conflict { diagnostic, groups } = &d.kind {
        return doctor_conflict(entries, diagnostic, groups);
    }

    let tag = match d.severity {
        Severity::Error => "[ERR] ",
        Severity::Warn => "[warn]",
    };
    // Every PATH-derived string (entry text, suggestion, reason,
    // canonical pointer for case variants) goes through
    // strip_control_chars so a hostile PATH cannot inject ANSI
    // escapes into doctor's human output (Z16-L1).
    let detail = match &d.kind {
        Kind::Duplicate { first_index } => {
            // Display the form the user authored — registry `%VAR%`
            // form on Windows process target (provenance overlay),
            // observed raw elsewhere.
            let first_path = entries
                .get(*first_index)
                .map(|e| e.effective_raw_for_user_intent().to_string())
                .unwrap_or_default();
            format!(
                "duplicate of entry #{first} ({first_path})",
                first = first_index,
                first_path = strip_control_chars(&first_path),
            )
        }
        Kind::Missing => "directory does not exist".into(),
        Kind::Shortenable { suggestion } => {
            format!("could be written as {}", strip_control_chars(suggestion))
        }
        Kind::TrailingSlash => "trailing slash; some shells handle this oddly".into(),
        Kind::CaseVariant { canonical } => format!(
            "case / slash variant of {}; OS treats them as one directory",
            strip_control_chars(canonical)
        ),
        Kind::ShortName => "Windows 8.3 short name in PATH; long-name form is more portable".into(),
        Kind::Malformed { reason } => format!("malformed entry: {}", strip_control_chars(reason)),
        Kind::Conflict { .. } => unreachable!("handled by early return above"),
        Kind::PerSourceMissingRequired { source } => format!(
            "declared `[source.{}]` path does not exist on this host",
            strip_control_chars(source)
        ),
        Kind::DuplicateButShadowed {
            command,
            shadowed_indexes,
        } => {
            let list = shadowed_indexes
                .iter()
                .map(|i| format!("#{i}"))
                .collect::<Vec<_>>()
                .join(", ");
            format!(
                "`{}` shadows {}; this dir wins",
                strip_control_chars(command),
                list,
            )
        }
        Kind::RelativePathEntry => format!(
            "relative PATH entry {} resolved against cwd at command time",
            glyph(style, "—", "-")
        ),
        Kind::WriteablePathDir => {
            "directory is writable by other users; binary substitution risk".into()
        }
    };
    // Per-source diagnostics use usize::MAX as the "no PATH index"
    // sentinel; render that as "(catalog)" instead of a number.
    let idx_render = if d.index == usize::MAX {
        "(catalog)".to_string()
    } else {
        format!("#{idx:>3}", idx = d.index)
    };
    format!(
        "{tag} {idx_render} {entry}\n      {detail}",
        entry = strip_control_chars(&d.entry)
    )
}

/// Conflict diagnostics get a multi-line layout that enumerates the
/// PATH entries in each conflicting source group below the header.
/// The header line uses a diagnostic-specific phrasing for the two
/// built-in conflicts (`mise_activate_both`); everything else falls
/// back to a generic "two sources collide" message that still names
/// the diagnostic so the user can grep for it.
fn doctor_conflict(entries: &[Attribution], diagnostic: &str, groups: &[Vec<usize>]) -> String {
    // diagnostic is user-supplied via [[relation]] when not a
    // built-in name. Strip + match against the built-in literal
    // first so hostile diagnostics still land in the generic
    // header (and get stripped on the way out).
    let safe_diag = strip_control_chars(diagnostic);
    let header = if safe_diag == "mise_activate_both" {
        "[warn] mise activate exposes both shim and install layers (PATH order matters)".to_string()
    } else {
        format!("[warn] {safe_diag}: multiple conflicting sources are active in PATH")
    };
    let mut buf = format!("{header}\n");
    for (idx, group) in groups.iter().enumerate() {
        buf.push_str(&format!("      group #{idx}:\n"));
        for &i in group {
            let entry = entries
                .get(i)
                .map(|e| e.effective_raw_for_user_intent().to_string())
                .unwrap_or_default();
            buf.push_str(&format!(
                "        #{i:>3} {}\n",
                strip_control_chars(&entry)
            ));
        }
    }
    buf.pop(); // strip the trailing newline so the caller adds its own
    buf
}

/// Render a `Found` outcome from `pathlint trace` as a multi-line
/// human block. Order: command header, resolved path, sources,
/// optional provenance, uninstall hint. No trailing newline.
///
/// Every attacker-controllable string (command name, resolved
/// path, matched source names, plugin segment) is run through
/// `strip_control_chars` so a hostile PATH or config can't paint
/// the terminal with ANSI escapes. The uninstall hint comes from
/// `derive_uninstall` already shell-quoted, so it does not need
/// stripping again.
pub fn where_human(found: &Found, style: Style) -> String {
    let dash = glyph(style, "—", "-");
    let mut buf = String::new();
    buf.push_str(&strip_control_chars(&found.command));
    buf.push('\n');
    buf.push_str(&format!(
        "  resolved: {}\n",
        strip_control_chars(&found.resolved.to_string_lossy())
    ));
    if found.matched_sources.is_empty() {
        buf.push_str("  sources:  (no source matched)\n");
    } else {
        let cleaned: Vec<String> = found
            .matched_sources
            .iter()
            .map(|s| strip_control_chars(s).into_owned())
            .collect();
        buf.push_str(&format!("  sources:  {}\n", cleaned.join(", ")));
    }
    if let Some(Provenance::WrapperInstaller {
        installer,
        plugin_segment,
    }) = &found.provenance
    {
        let installer = strip_control_chars(installer);
        let plugin_segment = strip_control_chars(plugin_segment);
        buf.push_str(&format!(
            "  provenance: {installer} (via mise plugin `{plugin_segment}`)\n"
        ));
    }
    match &found.uninstall {
        UninstallHint::Command { command } => {
            buf.push_str(&format!("  hint:     {command}"));
        }
        UninstallHint::NoTemplate { source } => {
            buf.push_str(&format!(
                "  hint:     (no uninstall template for source `{}`)",
                strip_control_chars(source)
            ));
        }
        UninstallHint::NoSource => {
            buf.push_str(&format!(
                "  hint:     (no source matched {dash} pathlint cannot guess)"
            ));
        }
    }
    buf
}

/// Render a NotFound `where` outcome — single line, no trailing
/// newline. The command name is stripped of control characters so
/// a hostile CLI argument cannot inject ANSI escapes here either.
pub fn where_not_found(command: &str, style: Style) -> String {
    let dash = glyph(style, "—", "-");
    format!("{} {dash} not found on PATH", strip_control_chars(command))
}

/// Render a `SortPlan` as a multi-line human-readable block. The
/// layout shows the *original* PATH on the left numbered column,
/// the *proposed* PATH on the right column, and a list of
/// "moved entries" with the reason each one was promoted. Notes
/// (e.g. unsatisfiable `prefer`) follow as fyi lines.
///
/// No trailing newline; callers add their own. Pure.
pub fn sort_human(plan: &SortPlan, style: Style) -> String {
    let mut buf = String::new();
    if plan.is_noop() {
        buf.push_str("pathlint sort: PATH is already in a satisfying order.\n");
        for note in &plan.notes {
            push_sort_note(&mut buf, note, style);
        }
        // strip trailing newline so behaviour matches the other
        // human formatters.
        if buf.ends_with('\n') {
            buf.pop();
        }
        return buf;
    }

    buf.push_str("pathlint sort: proposed PATH order (--dry-run; not applied).\n\n");
    let width = plan.original.len().max(plan.sorted.len()).to_string().len();
    let original_header = "before".to_string();
    let sorted_header = "after".to_string();
    let col_w = plan
        .original
        .iter()
        .chain(plan.sorted.iter())
        .map(|s| s.len())
        .max()
        .unwrap_or(0)
        .max(original_header.len());

    buf.push_str(&format!(
        "  {:>w$}  {:<col$}    {:>w$}  {:<col$}\n",
        "#",
        original_header,
        "#",
        sorted_header,
        w = width,
        col = col_w,
    ));
    let rows = plan.original.len().max(plan.sorted.len());
    for i in 0..rows {
        let lhs = plan.original.get(i).cloned().unwrap_or_default();
        let rhs = plan.sorted.get(i).cloned().unwrap_or_default();
        buf.push_str(&format!(
            "  {:>w$}  {:<col$}    {:>w$}  {:<col$}\n",
            i,
            lhs,
            i,
            rhs,
            w = width,
            col = col_w,
        ));
    }

    if !plan.moves.is_empty() {
        buf.push_str("\nmoved:\n");
        for m in &plan.moves {
            buf.push_str(&format!(
                "  #{from} -> #{to}: {entry}\n      reason: {reason}\n",
                from = m.from,
                to = m.to,
                entry = m.entry,
                reason = m.reason,
            ));
        }
    }

    for note in &plan.notes {
        push_sort_note(&mut buf, note, style);
    }

    if buf.ends_with('\n') {
        buf.pop();
    }
    buf
}

fn push_sort_note(buf: &mut String, note: &SortNote, style: Style) {
    let dash = glyph(style, "—", "-");
    match note {
        SortNote::UnsatisfiablePrefer { command, prefer } => {
            buf.push_str(&format!(
                "\nnote: `{command}` cannot be satisfied by reordering {dash} no PATH entry matches `prefer = [{}]`. Install via one of those sources, or relax the rule.\n",
                prefer.join(", "),
            ));
        }
    }
}

/// Render a `SortPlan` as pretty-printed JSON. Schema is the
/// `SortPlan` value verbatim; `notes` carry a `kind` discriminator
/// so machine consumers can pattern-match. Stable through `0.0.x`.
pub fn sort_json(plan: &SortPlan) -> Result<String, serde_json::Error> {
    serde_json::to_string_pretty(plan)
}

/// Render doctor diagnostics as a pretty-printed JSON array — the
/// machine-readable counterpart of `pathlint doctor`'s human view.
/// Each element carries `index`, `entry`, `severity`, the
/// discriminator `kind`, and any per-variant payload fields
/// (e.g. `suggestion` for shortenable, `canonical` for
/// case_variant, `diagnostic` + `groups` for conflict — the
/// 0.0.11 generalisation that retired the old `shim_indices` /
/// `install_indices` shape).
///
/// The schema parallels `check --json`: top-level array, every
/// failure carries enough structured detail that CI consumers
/// don't need to parse human strings. Stable through `0.0.x`.
/// Render the relation list as a human-readable block. One relation
/// per stanza, grouped only by declaration order. No trailing
/// newline.
pub fn relations_human(relations: &[Relation], style: Style) -> String {
    let arrow = glyph(style, "→", "->");
    if relations.is_empty() {
        return "no relations declared".to_string();
    }
    // Every user-supplied identifier (source name, diagnostic
    // label, glob pattern) goes through strip_control_chars so a
    // hostile pathlint.toml cannot inject ANSI escapes into
    // `pathlint catalog relations` output.
    let s = |v: &str| strip_control_chars(v).into_owned();
    let mut buf = String::new();
    for (i, rel) in relations.iter().enumerate() {
        if i > 0 {
            buf.push('\n');
        }
        match rel {
            Relation::AliasOf { parent, children } => {
                let kids: Vec<String> = children.iter().map(|c| s(c)).collect();
                buf.push_str(&format!(
                    "alias_of: `{}` {arrow} [{}]",
                    s(parent),
                    kids.join(", ")
                ));
            }
            Relation::ConflictsWhenBothInPath {
                sources,
                diagnostic,
            } => {
                let names: Vec<String> = sources.iter().map(|n| s(n)).collect();
                buf.push_str(&format!(
                    "conflicts_when_both_in_path: [{}] (diagnostic: `{}`)",
                    names.join(", "),
                    s(diagnostic),
                ));
            }
            Relation::ServedByVia {
                host,
                guest_pattern,
                guest_provider,
                installer_token,
            } => {
                let via = match installer_token {
                    Some(tok) if tok != guest_provider => {
                        format!(" (installer token `{}`)", s(tok))
                    }
                    _ => String::new(),
                };
                buf.push_str(&format!(
                    "served_by_via: `{}` serves `{}` from `{}`{via}",
                    s(host),
                    s(guest_pattern),
                    s(guest_provider),
                ));
            }
            Relation::DependsOn { source, target } => {
                buf.push_str(&format!(
                    "depends_on: `{}` {arrow} `{}`",
                    s(source),
                    s(target),
                ));
            }
            Relation::PreferOrderOver { earlier, later } => {
                buf.push_str(&format!(
                    "prefer_order_over: `{}` should appear before `{}`",
                    s(earlier),
                    s(later),
                ));
            }
        }
    }
    buf
}

/// Pretty-print the relations as a JSON array. Each element carries
/// the `kind` discriminator the TOML schema uses.
pub fn relations_json(relations: &[Relation]) -> Result<String, serde_json::Error> {
    serde_json::to_string_pretty(relations)
}

pub fn doctor_json(diags: &[&Diagnostic]) -> Result<String, serde_json::Error> {
    serde_json::to_string_pretty(diags)
}

/// Render `check` outcomes as a pretty-printed JSON array — the
/// machine-readable counterpart of `--explain`. Each element
/// carries the per-expectation outcome kind, resolved path (when
/// known), the matched / prefer / avoid sets, and a tagged
/// `diagnosis` object derived from `lint::diagnose`.
///
/// Schema (0.0.15+): the top-level discriminator is `kind` —
/// `"ok"` / `"ng_wrong_source"` / `"ng_not_found"` /
/// `"ng_not_executable"` / `"skip"`. This matches the `kind`
/// discriminator already used by doctor / trace / sort / catalog
/// relations JSON. The pre-0.0.15 `status` field name is gone.
///
/// The `diagnosis` field uses its own `kind` discriminator
/// (`"wrong_source"` / `"unknown_source"` / `"not_found"` /
/// `"not_executable"` / `"config"`) so consumers can
/// pattern-match instead of string-searching.
///
/// Pure: callers do the printing and exit-code mapping.
pub fn check_json(outcomes: &[Outcome]) -> Result<String, serde_json::Error> {
    let view: Vec<crate::lint::CheckOutcomeView> = outcomes
        .iter()
        .map(crate::lint::CheckOutcomeView::from)
        .collect();
    serde_json::to_string_pretty(&view)
}

/// Render the trace outcome as pretty-printed JSON. The schema
/// is documented in PRD §7.7 and stable for `0.0.x`. Used by
/// `pathlint trace --json`.
///
/// `command` is needed because `TraceOutcome::NotFound` carries no
/// data. As of 0.0.14 the JSON shape uses a top-level `kind`
/// discriminator (`"found"` or `"not_found"`) so future variants
/// (e.g. an "ambiguous" outcome covering wrapper layers) can land
/// without breaking consumers that pattern-matched on
/// `found: true|false`. The previous `found: bool` field is gone.
pub fn where_json(command: &str, outcome: &TraceOutcome) -> Result<String, serde_json::Error> {
    let payload = crate::trace::TraceJsonOutput::from_outcome(command, outcome);
    serde_json::to_string_pretty(&payload)
}

/// Strip ANSI / control characters from `s`, replacing each one
/// with `?`. `\t` and `\n` are preserved because human-mode
/// formatters rely on them. Pure; returns the input unchanged
/// (`Cow::Borrowed`) when nothing needed replacement.
///
/// Used by `pathlint trace`'s human renderer so a hostile PATH
/// segment containing `\x1b[31m...` cannot recolor or rewrite the
/// terminal output.
pub fn strip_control_chars(s: &str) -> std::borrow::Cow<'_, str> {
    if s.bytes().all(|b| !is_disallowed_byte(b)) {
        return std::borrow::Cow::Borrowed(s);
    }
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if c == '\t' || c == '\n' {
            out.push(c);
        } else if (c as u32) < 0x20 || c as u32 == 0x7F {
            out.push('?');
        } else {
            out.push(c);
        }
    }
    std::borrow::Cow::Owned(out)
}

fn is_disallowed_byte(b: u8) -> bool {
    matches!(b, 0..=0x08 | 0x0B..=0x1F | 0x7F)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::lint::Status;
    use crate::path_entry::PathEntry;
    use std::path::PathBuf;

    fn entries(strs: &[&str]) -> Vec<Attribution> {
        strs.iter()
            .map(|s| Attribution::new(PathEntry::from_raw(*s, |_| -> Option<String> { None })))
            .collect()
    }

    fn found_minimal() -> Found {
        Found {
            command: "rustc".into(),
            resolved: PathBuf::from("/home/u/.cargo/bin/rustc"),
            matched_sources: vec!["cargo".into()],
            uninstall: UninstallHint::Command {
                command: "cargo uninstall rustc".into(),
            },
            provenance: None,
        }
    }

    #[test]
    fn warn_diagnostic_has_warn_tag_and_indented_detail() {
        let d = Diagnostic {
            index: 3,
            entry: "/usr/bin".into(),
            severity: Severity::Warn,
            kind: Kind::Missing,
        };
        let out = doctor_line(&d, &entries(&[]), Style::default());
        assert!(out.starts_with("[warn]"));
        assert!(out.contains("#  3 /usr/bin"));
        assert!(out.contains("      directory does not exist"));
    }

    #[test]
    fn error_diagnostic_uses_err_tag() {
        let d = Diagnostic {
            index: 0,
            entry: "C:\\foo|bar".into(),
            severity: Severity::Error,
            kind: Kind::Malformed {
                reason: "illegal character '|' in path".into(),
            },
        };
        let out = doctor_line(&d, &entries(&[]), Style::default());
        assert!(out.starts_with("[ERR]"));
        assert!(out.contains("malformed entry: illegal character"));
    }

    #[test]
    fn duplicate_renders_first_index_with_back_reference() {
        let entries = entries(&["/usr/bin", "/foo/bar", "/usr/bin"]);
        let d = Diagnostic {
            index: 2,
            entry: "/usr/bin".into(),
            severity: Severity::Warn,
            kind: Kind::Duplicate { first_index: 0 },
        };
        let out = doctor_line(&d, &entries, Style::default());
        assert!(
            out.contains("duplicate of entry #0 (/usr/bin)"),
            "out: {out}"
        );
    }

    #[test]
    fn shortenable_renders_suggestion_string() {
        let d = Diagnostic {
            index: 5,
            entry: "C:\\Users\\who\\.cargo\\bin".into(),
            severity: Severity::Warn,
            kind: Kind::Shortenable {
                suggestion: "%UserProfile%\\.cargo\\bin".into(),
            },
        };
        let out = doctor_line(&d, &entries(&[]), Style::default());
        assert!(out.contains("could be written as %UserProfile%\\.cargo\\bin"));
    }

    #[test]
    fn where_human_minimal_has_command_resolved_sources_hint_in_order() {
        let out = where_human(&found_minimal(), Style::default());
        let lines: Vec<&str> = out.lines().collect();
        assert_eq!(lines[0], "rustc");
        assert!(lines[1].starts_with("  resolved: "));
        assert!(lines[2].starts_with("  sources:  cargo"));
        assert_eq!(lines[3], "  hint:     cargo uninstall rustc");
        // No trailing newline (caller adds one when printing).
        assert!(!out.ends_with('\n'));
    }

    #[test]
    fn where_human_includes_provenance_line_when_set() {
        let mut f = found_minimal();
        f.matched_sources = vec!["mise_installs".into(), "mise".into()];
        f.provenance = Some(Provenance::WrapperInstaller {
            installer: "cargo".to_string(),
            plugin_segment: "cargo-foo".into(),
        });
        f.uninstall = UninstallHint::Command {
            command: "mise uninstall cargo:foo".into(),
        };
        let out = where_human(&f, Style::default());
        assert!(out.contains("provenance: cargo (via mise plugin `cargo-foo`)"));
    }

    #[test]
    fn where_human_uninstall_no_template_names_the_source() {
        let mut f = found_minimal();
        f.uninstall = UninstallHint::NoTemplate {
            source: "aqua".into(),
        };
        let out = where_human(&f, Style::default());
        assert!(
            out.contains("(no uninstall template for source `aqua`)"),
            "out: {out}"
        );
    }

    #[test]
    fn where_human_uninstall_no_source_says_pathlint_cannot_guess() {
        let mut f = found_minimal();
        f.matched_sources = Vec::new();
        f.uninstall = UninstallHint::NoSource;
        let out = where_human(&f, Style::default());
        assert!(out.contains("sources:  (no source matched)"), "out: {out}");
        assert!(
            out.contains("(no source matched — pathlint cannot guess)"),
            "out: {out}"
        );
    }

    #[test]
    fn where_not_found_is_single_line_with_em_dash() {
        let out = where_not_found("ghost", Style::default());
        assert_eq!(out, "ghost — not found on PATH");
        assert!(!out.ends_with('\n'));
    }

    #[test]
    fn where_not_found_strips_control_chars_from_command() {
        // 0.0.13: a hostile command argument such as `rg\x1b[31m`
        // must not leak the raw escape into stdout.
        let out = where_not_found("rg\x1b[31m", Style::default());
        assert!(!out.contains('\x1b'), "raw escape leaked: {out:?}");
        assert!(out.contains("rg?[31m"), "expected stripped form: {out:?}");
    }

    #[test]
    fn where_human_no_template_strips_control_chars_from_source() {
        // 0.0.13: a hostile [source.evil\x1b[31m] declared in
        // pathlint.toml must not paint the terminal when its
        // uninstall hint reaches the NoTemplate branch.
        let mut f = found_minimal();
        f.uninstall = UninstallHint::NoTemplate {
            source: "aqua\x1b[31m".into(),
        };
        let out = where_human(&f, Style::default());
        assert!(!out.contains('\x1b'), "raw escape leaked: {out:?}");
        assert!(out.contains("aqua?[31m"), "expected stripped form: {out:?}");
    }

    #[test]
    fn where_json_found_carries_kind_discriminators() {
        let out = where_json("rustc", &TraceOutcome::Found(found_minimal())).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        // 0.0.14: top-level kind discriminator instead of found:bool.
        assert_eq!(v["kind"], "found");
        assert_eq!(v["command"], "rustc");
        assert_eq!(v["uninstall"]["kind"], "command");
        assert_eq!(v["uninstall"]["command"], "cargo uninstall rustc");
        assert!(v["provenance"].is_null());
    }

    #[test]
    fn where_json_not_found_is_compact() {
        let out = where_json("ghost", &TraceOutcome::NotFound).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        // 0.0.14: top-level kind = "not_found" replaces found:false.
        assert_eq!(v["kind"], "not_found");
        assert_eq!(v["command"], "ghost");
        assert!(v.get("resolved").is_none());
    }

    #[test]
    fn where_json_provenance_emits_kind_and_segment() {
        let mut f = found_minimal();
        f.provenance = Some(Provenance::WrapperInstaller {
            installer: "cargo".to_string(),
            plugin_segment: "cargo-foo".into(),
        });
        let out = where_json("foo", &TraceOutcome::Found(f)).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        // 0.0.14: variant renamed from MiseInstallerPlugin →
        // WrapperInstaller; serde tag = "wrapper_installer".
        assert_eq!(v["provenance"]["kind"], "wrapper_installer");
        assert_eq!(v["provenance"]["installer"], "cargo");
        assert_eq!(v["provenance"]["plugin_segment"], "cargo-foo");
    }

    fn check_outcome_ok() -> Outcome {
        Outcome {
            command: "rg".into(),
            status: Status::Ok,
            resolved: Some(PathBuf::from("/home/u/.cargo/bin/rg")),
            matched_sources: vec!["cargo".into()],
            prefer: vec!["cargo".into()],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: None,
        }
    }

    fn check_outcome_wrong_source() -> Outcome {
        Outcome {
            command: "rg".into(),
            status: Status::NgWrongSource,
            resolved: Some(PathBuf::from("/usr/local/bin/rg")),
            matched_sources: vec!["scoop".into()],
            prefer: vec!["cargo".into()],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: None,
        }
    }

    fn sort_plan_noop() -> SortPlan {
        SortPlan {
            original: vec!["/usr/bin".into(), "/home/u/.cargo/bin".into()],
            sorted: vec!["/usr/bin".into(), "/home/u/.cargo/bin".into()],
            moves: vec![],
            notes: vec![],
        }
    }

    fn sort_plan_swap() -> SortPlan {
        SortPlan {
            original: vec!["/usr/bin".into(), "/home/u/.cargo/bin".into()],
            sorted: vec!["/home/u/.cargo/bin".into(), "/usr/bin".into()],
            moves: vec![
                crate::sort::EntryMove {
                    entry: "/home/u/.cargo/bin".into(),
                    from: 1,
                    to: 0,
                    reason: "preferred source for `rg`".into(),
                },
                crate::sort::EntryMove {
                    entry: "/usr/bin".into(),
                    from: 0,
                    to: 1,
                    reason: "displaced by a preferred entry".into(),
                },
            ],
            notes: vec![],
        }
    }

    #[test]
    fn sort_human_noop_says_already_sorted() {
        let out = sort_human(&sort_plan_noop(), Style::default());
        assert!(out.contains("already in a satisfying order"), "out: {out}");
        assert!(!out.ends_with('\n'), "no trailing newline");
    }

    #[test]
    fn sort_human_swap_renders_both_columns_and_moved_section() {
        let out = sort_human(&sort_plan_swap(), Style::default());
        assert!(out.contains("before"), "out: {out}");
        assert!(out.contains("after"), "out: {out}");
        assert!(out.contains("--dry-run"), "must mention dry-run: {out}");
        assert!(out.contains("moved:"), "must list moves: {out}");
        assert!(out.contains("preferred source for `rg`"));
    }

    #[test]
    fn sort_human_unsatisfiable_note_appears_after_diff() {
        let mut plan = sort_plan_noop();
        plan.notes.push(SortNote::UnsatisfiablePrefer {
            command: "rg".into(),
            prefer: vec!["cargo".into()],
        });
        let out = sort_human(&plan, Style::default());
        assert!(out.contains("`rg` cannot be satisfied"));
        assert!(out.contains("`prefer = [cargo]`"));
    }

    #[test]
    fn sort_json_serializes_plan_verbatim() {
        let out = sort_json(&sort_plan_swap()).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v["original"][0], "/usr/bin");
        assert_eq!(v["sorted"][0], "/home/u/.cargo/bin");
        assert_eq!(v["moves"][0]["from"], 1);
        assert_eq!(v["moves"][0]["to"], 0);
        assert_eq!(v["moves"][0]["entry"], "/home/u/.cargo/bin");
    }

    #[test]
    fn sort_json_note_carries_kind_discriminator() {
        let mut plan = sort_plan_noop();
        plan.notes.push(SortNote::UnsatisfiablePrefer {
            command: "rg".into(),
            prefer: vec!["cargo".into()],
        });
        let out = sort_json(&plan).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v["notes"][0]["kind"], "unsatisfiable_prefer");
        assert_eq!(v["notes"][0]["command"], "rg");
    }

    #[test]
    fn doctor_json_emits_top_level_array_with_kind_discriminator() {
        let d_missing = Diagnostic {
            index: 3,
            entry: "/usr/bin".into(),
            severity: Severity::Warn,
            kind: Kind::Missing,
        };
        let d_short = Diagnostic {
            index: 5,
            entry: "C:\\Users\\who\\.cargo\\bin".into(),
            severity: Severity::Warn,
            kind: Kind::Shortenable {
                suggestion: "%UserProfile%\\.cargo\\bin".into(),
            },
        };
        let out = doctor_json(&[&d_missing, &d_short]).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert!(v.is_array());
        assert_eq!(v[0]["index"], 3);
        assert_eq!(v[0]["entry"], "/usr/bin");
        assert_eq!(v[0]["severity"], "warn");
        assert_eq!(v[0]["kind"], "missing");
        // Missing has no payload — no extra fields beyond the four above.
        assert!(v[0].get("suggestion").is_none());
        assert_eq!(v[1]["kind"], "shortenable");
        assert_eq!(v[1]["suggestion"], "%UserProfile%\\.cargo\\bin");
    }

    #[test]
    fn doctor_json_malformed_carries_error_severity_and_reason() {
        let d = Diagnostic {
            index: 0,
            entry: "C:\\foo|bar".into(),
            severity: Severity::Error,
            kind: Kind::Malformed {
                reason: "illegal character '|' in path".into(),
            },
        };
        let out = doctor_json(&[&d]).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["severity"], "error");
        assert_eq!(v[0]["kind"], "malformed");
        assert!(v[0]["reason"].as_str().unwrap().contains("illegal"));
    }

    #[test]
    fn doctor_json_duplicate_carries_first_index() {
        let d = Diagnostic {
            index: 2,
            entry: "/usr/bin".into(),
            severity: Severity::Warn,
            kind: Kind::Duplicate { first_index: 0 },
        };
        let out = doctor_json(&[&d]).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["kind"], "duplicate");
        assert_eq!(v[0]["first_index"], 0);
    }

    #[test]
    fn doctor_json_conflict_carries_diagnostic_and_groups() {
        let d = Diagnostic {
            index: 0,
            entry: "/home/u/.local/share/mise/shims".into(),
            severity: Severity::Warn,
            kind: Kind::Conflict {
                diagnostic: "mise_activate_both".into(),
                groups: vec![vec![0], vec![1, 2]],
            },
        };
        let out = doctor_json(&[&d]).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["kind"], "conflict");
        assert_eq!(v[0]["diagnostic"], "mise_activate_both");
        assert_eq!(v[0]["groups"][0][0], 0);
        assert_eq!(v[0]["groups"][1][0], 1);
        assert_eq!(v[0]["groups"][1][1], 2);
    }

    #[test]
    fn doctor_conflict_strips_hostile_diagnostic() {
        // The diagnostic label travels from a user-supplied
        // [[relation]] in pathlint.toml. A hostile config could
        // smuggle ANSI escapes or carriage returns hoping to
        // repaint the doctor terminal. doctor_conflict must run
        // every user-controlled string through strip_control_chars
        // before formatting — including the header line that
        // names the diagnostic and the per-entry path under each
        // group.
        let entries = entries(&["/foo/a\x1b[31m_evil", "/foo/b"]);
        let d = Diagnostic {
            index: 0,
            entry: entries[0].observed.raw.clone(),
            severity: Severity::Warn,
            kind: Kind::Conflict {
                diagnostic: "evil\x1b[31mlabel\rFAKE".into(),
                groups: vec![vec![0], vec![1]],
            },
        };
        let out = doctor_line(&d, &entries, Style::default());
        assert!(!out.contains('\x1b'), "ANSI escape leaked through: {out:?}");
        assert!(
            !out.contains('\r'),
            "carriage return leaked through: {out:?}"
        );
        // strip_control_chars replaces each control byte with '?'
        // (it deliberately preserves text length so column-based
        // formatting stays sane). The label and entry must still
        // be present, just with the escapes neutered.
        assert!(
            out.contains("evil?[31mlabel?FAKE"),
            "stripped diagnostic must still be visible with replacements: {out:?}"
        );
        assert!(
            out.contains("/foo/a?[31m_evil"),
            "stripped entry must still be enumerated: {out:?}"
        );
    }

    #[test]
    fn doctor_json_empty_diagnostics_yields_empty_array() {
        let out = doctor_json(&[]).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert!(v.is_array());
        assert_eq!(v.as_array().unwrap().len(), 0);
    }

    #[test]
    fn check_json_emits_array_with_kind_resolved_and_diagnosis() {
        // 0.0.15: top-level discriminator is `kind`, not `status`.
        // The pre-0.0.15 `status` field name is gone.
        let out = check_json(&[check_outcome_ok(), check_outcome_wrong_source()]).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["command"], "rg");
        assert_eq!(v[0]["kind"], "ok");
        assert!(
            v[0].get("status").is_none(),
            "stale `status` field leaked: {out}"
        );
        assert_eq!(v[0]["resolved"], "/home/u/.cargo/bin/rg");
        assert!(
            v[0].get("diagnosis").is_none(),
            "ok must not carry diagnosis"
        );
        assert_eq!(v[1]["kind"], "ng_wrong_source");
        assert_eq!(v[1]["diagnosis"]["kind"], "wrong_source");
        assert_eq!(v[1]["diagnosis"]["matched"][0], "scoop");
        assert_eq!(v[1]["diagnosis"]["prefer_missed"][0], "cargo");
    }

    #[test]
    fn check_json_omits_empty_prefer_and_avoid_for_ok() {
        let out = check_json(&[check_outcome_ok()]).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        // prefer is non-empty for this outcome
        assert!(v[0].get("prefer").is_some());
        // avoid is empty -> skipped
        assert!(v[0].get("avoid").is_none(), "empty avoid leaked");
    }

    #[test]
    fn check_json_resolved_field_absent_when_outcome_has_no_path() {
        let not_found = Outcome {
            command: "ghost".into(),
            status: Status::NgNotFound,
            resolved: None,
            matched_sources: vec![],
            prefer: vec!["cargo".into()],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: None,
        };
        let out = check_json(&[not_found]).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert!(v[0].get("resolved").is_none(), "resolved leaked: {out}");
        assert_eq!(v[0]["diagnosis"]["kind"], "not_found");
    }

    #[test]
    fn check_json_kind_field_is_always_a_string() {
        // 0.0.17 BLOCKER fix: kind must be a string discriminator
        // for every Status variant. Pre-0.0.17 the externally-tagged
        // NgNotExecutable(String) and ConfigError(String) variants
        // emitted {"kind": {"ng_not_executable": "reason"}} which
        // broke the contract every other JSON-emitting subcommand
        // honours.
        let cases: Vec<(Outcome, &str)> = vec![
            (
                Outcome {
                    command: "x".into(),
                    status: Status::Ok,
                    ..Default::default()
                },
                "ok",
            ),
            (
                Outcome {
                    command: "x".into(),
                    status: Status::NgNotExecutable,
                    reason: Some("not a regular file".into()),
                    ..Default::default()
                },
                "ng_not_executable",
            ),
            (
                Outcome {
                    command: "x".into(),
                    status: Status::ConfigError,
                    reason: Some("undefined source: x".into()),
                    ..Default::default()
                },
                "config_error",
            ),
            (
                Outcome {
                    command: "x".into(),
                    status: Status::Skip,
                    ..Default::default()
                },
                "skip",
            ),
        ];
        for (outcome, expected_kind) in cases {
            let out = check_json(std::slice::from_ref(&outcome)).unwrap();
            let v: serde_json::Value = serde_json::from_str(&out).unwrap();
            assert!(
                v[0]["kind"].is_string(),
                "kind must be a string for {:?}: {out}",
                outcome.status
            );
            assert_eq!(v[0]["kind"], expected_kind);
        }
    }

    #[test]
    fn check_json_skip_outcome_has_no_diagnosis() {
        let skip = Outcome {
            command: "tooly".into(),
            status: Status::Skip,
            resolved: None,
            matched_sources: vec![],
            prefer: vec![],
            avoid: vec![],
            severity: crate::config::Severity::Error,
            reason: None,
        };
        let out = check_json(&[skip]).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["kind"], "skip");
        assert!(v[0].get("diagnosis").is_none());
    }

    #[test]
    fn mise_activate_both_lists_each_layer_separately() {
        let entries = entries(&[
            "/home/u/.local/share/mise/shims",
            "/home/u/.local/share/mise/installs/python/3.14/bin",
            "/home/u/.local/share/mise/installs/node/25.9.0/bin",
        ]);
        let d = Diagnostic {
            index: 0,
            entry: entries[0].observed.raw.clone(),
            severity: Severity::Warn,
            kind: Kind::Conflict {
                diagnostic: "mise_activate_both".into(),
                groups: vec![vec![0], vec![1, 2]],
            },
        };
        let out = doctor_line(&d, &entries, Style::default());
        assert!(out.starts_with(
            "[warn] mise activate exposes both shim and install layers (PATH order matters)"
        ));
        // group #0 (shims) lists entry 0
        assert!(out.contains("group #0:\n        #  0 /home/u/.local/share/mise/shims"));
        // group #1 (installs) lists both entries 1 and 2
        assert!(out.contains("group #1:\n        #  1"));
        assert!(out.contains("\n        #  2 /home/u/.local/share/mise/installs/node/25.9.0/bin"));
        // No trailing newline (caller adds its own)
        assert!(!out.ends_with('\n'));
    }

    #[test]
    fn doctor_line_strips_control_chars_from_entry() {
        let d = Diagnostic {
            index: 0,
            entry: "/foo\x1b[31m/bar".into(),
            severity: Severity::Warn,
            kind: Kind::Missing,
        };
        let out = doctor_line(&d, &[], Style::default());
        assert!(!out.contains('\x1b'), "raw escape: {out:?}");
        assert!(out.contains("/foo?[31m/bar"));
    }

    #[test]
    fn relations_human_strips_control_chars_from_diagnostic() {
        let rels = vec![Relation::ConflictsWhenBothInPath {
            sources: vec!["a".into(), "b".into()],
            diagnostic: "evil\x1b[31m".into(),
        }];
        let out = relations_human(&rels, Style::default());
        assert!(!out.contains('\x1b'));
        assert!(out.contains("evil?[31m"));
    }

    #[test]
    fn user_conflict_uses_generic_header() {
        // Non-built-in diagnostic strings get the generic header
        // that names the diagnostic so the user can grep for it.
        let entries = entries(&["/foo/a", "/foo/b"]);
        let d = Diagnostic {
            index: 0,
            entry: entries[0].observed.raw.clone(),
            severity: Severity::Warn,
            kind: Kind::Conflict {
                diagnostic: "foo_overlap".into(),
                groups: vec![vec![0], vec![1]],
            },
        };
        let out = doctor_line(&d, &entries, Style::default());
        assert!(
            out.starts_with("[warn] foo_overlap: multiple conflicting"),
            "out: {out}"
        );
        assert!(out.contains("group #0:"));
        assert!(out.contains("group #1:"));
    }

    // ---- Relation formatters ----------------------------------------

    fn alias_of(parent: &str, children: &[&str]) -> Relation {
        Relation::AliasOf {
            parent: parent.into(),
            children: children.iter().map(|s| s.to_string()).collect(),
        }
    }

    fn conflicts(sources: &[&str], diagnostic: &str) -> Relation {
        Relation::ConflictsWhenBothInPath {
            sources: sources.iter().map(|s| s.to_string()).collect(),
            diagnostic: diagnostic.into(),
        }
    }

    fn served(host: &str, pattern: &str, provider: &str) -> Relation {
        Relation::ServedByVia {
            host: host.into(),
            guest_pattern: pattern.into(),
            guest_provider: provider.into(),
            installer_token: None,
        }
    }

    fn depends(source: &str, target: &str) -> Relation {
        Relation::DependsOn {
            source: source.into(),
            target: target.into(),
        }
    }

    #[test]
    fn relations_human_empty_says_no_relations() {
        let out = relations_human(&[], Style::default());
        assert_eq!(out, "no relations declared");
    }

    #[test]
    fn relations_human_renders_alias_of_with_arrow_and_children() {
        let rels = vec![alias_of("mise", &["mise_shims", "mise_installs"])];
        let out = relations_human(&rels, Style::default());
        assert!(out.starts_with("alias_of:"), "out: {out}");
        assert!(out.contains("`mise`"), "out: {out}");
        assert!(out.contains("mise_shims, mise_installs"), "out: {out}");
    }

    #[test]
    fn relations_human_renders_conflicts_with_diagnostic_name() {
        let rels = vec![conflicts(
            &["mise_shims", "mise_installs"],
            "mise_activate_both",
        )];
        let out = relations_human(&rels, Style::default());
        assert!(out.starts_with("conflicts_when_both_in_path:"));
        assert!(out.contains("mise_shims, mise_installs"));
        assert!(out.contains("`mise_activate_both`"));
    }

    #[test]
    fn relations_human_renders_served_by_via_with_pattern_and_provider() {
        let rels = vec![served("mise_installs", "cargo-*", "cargo")];
        let out = relations_human(&rels, Style::default());
        assert!(out.starts_with("served_by_via:"));
        assert!(out.contains("`mise_installs`"));
        assert!(out.contains("`cargo-*`"));
        assert!(out.contains("`cargo`"));
    }

    #[test]
    fn relations_human_renders_depends_on_with_source_and_target() {
        let rels = vec![depends("paru", "pacman")];
        let out = relations_human(&rels, Style::default());
        assert!(out.starts_with("depends_on:"));
        assert!(out.contains("`paru`"));
        assert!(out.contains("`pacman`"));
    }

    #[test]
    fn relations_human_separates_multiple_relations_by_newline() {
        // Each relation occupies one line; the joiner is a single
        // newline. No trailing newline (caller adds it).
        let rels = vec![alias_of("mise", &["mise_shims"]), depends("paru", "pacman")];
        let out = relations_human(&rels, Style::default());
        assert_eq!(out.lines().count(), 2, "out:\n{out}");
        assert!(!out.ends_with('\n'));
    }

    #[test]
    fn relations_json_is_an_array_with_kind_discriminator() {
        let rels = vec![
            alias_of("mise", &["mise_shims"]),
            conflicts(&["a", "b"], "x"),
            served("h", "p-*", "g"),
            depends("a", "b"),
        ];
        let out = relations_json(&rels).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        let arr = v.as_array().unwrap();
        assert_eq!(arr.len(), 4);
        assert_eq!(arr[0]["kind"], "alias_of");
        assert_eq!(arr[1]["kind"], "conflicts_when_both_in_path");
        assert_eq!(arr[2]["kind"], "served_by_via");
        assert_eq!(arr[3]["kind"], "depends_on");
    }

    #[test]
    fn relations_json_alias_of_carries_parent_and_children_at_top_level() {
        // The TOML schema and the JSON output mirror each other
        // (no nesting under `payload` etc.) — this is the contract
        // CI consumers depend on.
        let rels = vec![alias_of("mise", &["mise_shims", "mise_installs"])];
        let out = relations_json(&rels).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["parent"], "mise");
        assert_eq!(v[0]["children"][0], "mise_shims");
        assert_eq!(v[0]["children"][1], "mise_installs");
    }

    #[test]
    fn relations_json_served_by_via_keeps_pattern_field() {
        let rels = vec![served("mise_installs", "cargo-*", "cargo")];
        let out = relations_json(&rels).unwrap();
        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v[0]["host"], "mise_installs");
        assert_eq!(v[0]["guest_pattern"], "cargo-*");
        assert_eq!(v[0]["guest_provider"], "cargo");
    }

    // ---- shell quoting moved to src/shell_quote.rs in 0.0.17 ----

    // ---- control char stripping (0.0.10) ----

    #[test]
    fn strip_control_chars_keeps_plain_text() {
        let s = "Hello, world!";
        let out = strip_control_chars(s);
        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
        assert_eq!(out, s);
    }

    #[test]
    fn strip_control_chars_replaces_ansi_escape() {
        let out = strip_control_chars("rg\x1b[31m");
        assert_eq!(out, "rg?[31m");
    }

    #[test]
    fn strip_control_chars_preserves_tab_and_newline() {
        let out = strip_control_chars("a\tb\nc");
        assert_eq!(out, "a\tb\nc");
    }

    #[test]
    fn strip_control_chars_replaces_del() {
        let out = strip_control_chars("x\x7Fy");
        assert_eq!(out, "x?y");
    }

    #[test]
    fn where_human_strips_ansi_escape_in_command() {
        let f = Found {
            command: "rg\x1b[31m".into(),
            resolved: PathBuf::from("/usr/bin/rg"),
            matched_sources: vec!["apt".into()],
            uninstall: UninstallHint::NoTemplate {
                source: "apt".into(),
            },
            provenance: None,
        };
        let out = where_human(&f, Style::default());
        assert!(!out.contains('\x1b'), "raw escape leaked: {out:?}");
        assert!(out.contains("rg?[31m"));
    }
}
