123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};

use crate::compare::{self, Verdict};
use crate::io::{err, hint, note, ok, set_json_only};
use crate::parsers::{self, Part};
use crate::result::{self, Hint, ResultDoc, Status};

#[derive(Parser)]
#[command(name = "adom-concur", version, about = "Cross-source consensus check for chip libraries")]
pub struct Cli {
    #[command(subcommand)]
    pub command: Cmd,
}

#[derive(Subcommand)]
pub enum Cmd {
    /// Compare ds2sf-extracted symbol+footprint against chip-fetcher's libraries.
    Check {
        chip_dir: PathBuf,

        /// Override auto-detected ds2sf symbol JSON.
        #[arg(long)]
        ds2sf_symbol: Option<PathBuf>,
        /// Override auto-detected ds2sf footprint JSON.
        #[arg(long)]
        ds2sf_footprint: Option<PathBuf>,
        /// Override auto-detected `<MPN>.kicad_sym`.
        #[arg(long)]
        kicad_sym: Option<PathBuf>,
        /// Override auto-detected `<MPN>.kicad_mod`.
        #[arg(long)]
        kicad_mod: Option<PathBuf>,
        /// Override auto-detected `<MPN>.lbr` (Fusion 360 EAGLE).
        #[arg(long)]
        eagle: Option<PathBuf>,
        /// Override auto-detected `<MPN>.PcbLib` (Altium binary footprint).
        #[arg(long)]
        altium: Option<PathBuf>,

        /// Override the MPN (defaults to chip-dir basename).
        #[arg(long)]
        mpn: Option<String>,
        /// Output directory for `<MPN>-concur.result.json`. Default: chip-dir.
        #[arg(long)]
        out_dir: Option<PathBuf>,

        /// Suppress progress; only `OK:`/`ERROR:` lines.
        #[arg(long)]
        json_only: bool,
        /// Bypass the cache and re-run.
        #[arg(long)]
        force: bool,
    },
    /// Print a one-screen summary of an existing concur.result.json.
    Inspect { chip_dir: PathBuf },
    /// Probe parser dependencies.
    Health,
    /// Drop SKILL.md to ~/.claude/skills/adom-concur/.
    Install,
}

pub fn run(cli: Cli) -> i32 {
    match cli.command {
        Cmd::Check {
            chip_dir,
            ds2sf_symbol, ds2sf_footprint,
            kicad_sym, kicad_mod, eagle, altium,
            mpn, out_dir,
            json_only, force,
        } => {
            set_json_only(json_only);
            run_check(
                chip_dir, ds2sf_symbol, ds2sf_footprint, kicad_sym, kicad_mod, eagle, altium,
                mpn, out_dir, force,
            )
        }
        Cmd::Inspect { chip_dir } => run_inspect(&chip_dir),
        Cmd::Health => run_health(),
        Cmd::Install => run_install(),
    }
}

#[allow(clippy::too_many_arguments)]
fn run_check(
    chip_dir: PathBuf,
    ds2sf_symbol: Option<PathBuf>, ds2sf_footprint: Option<PathBuf>,
    kicad_sym: Option<PathBuf>, kicad_mod: Option<PathBuf>, eagle: Option<PathBuf>,
    altium: Option<PathBuf>,
    mpn_override: Option<String>, out_dir_override: Option<PathBuf>,
    force: bool,
) -> i32 {
    let chip_dir = match chip_dir.canonicalize() {
        Ok(p) => p,
        Err(e) => { err(format!("chip dir: {e}")); return Status::Unrecoverable.exit_code(); }
    };
    let basename = chip_dir
        .file_name().and_then(|s| s.to_str()).unwrap_or("").to_string();
    let mpn = mpn_override.unwrap_or(basename.clone());
    let out_dir = out_dir_override.unwrap_or_else(|| chip_dir.clone());
    let result_path = out_dir.join(format!("{mpn}-concur.result.json"));

    if result_path.exists() && !force {
        // Cache-hit: return the cached exit_code.
        match std::fs::read_to_string(&result_path)
            .ok()
            .and_then(|raw| serde_json::from_str::<ResultDoc>(&raw).ok())
        {
            Some(d) => {
                ok(format!(
                    "{} — cached concur result at {} (use --force to re-run)",
                    d.mpn, result_path.display()
                ));
                return d.exit_code;
            }
            None => { /* fall through and re-run */ }
        }
    }

    // Auto-detect inputs.
    let ds_sym = ds2sf_symbol
        .or_else(|| pick(&chip_dir, &format!("{mpn}-symbol.extracted.json")));
    let ds_fp = ds2sf_footprint
        .or_else(|| pick(&chip_dir, &format!("{mpn}-footprint.extracted.json")));
    let ki_sym = kicad_sym
        .or_else(|| pick(&chip_dir, &format!("{mpn}.kicad_sym")));
    let ki_mod = kicad_mod
        .or_else(|| pick(&chip_dir, &format!("{mpn}.kicad_mod")));
    let lbr = eagle
        .or_else(|| pick(&chip_dir, &format!("{mpn}.lbr")));
    let altium_path = altium
        .or_else(|| pick(&chip_dir, &format!("{mpn}.PcbLib")))
        .or_else(|| pick(&chip_dir, &format!("{mpn}.IntLib")));
    // Altium symbol library (pins) — paired with the .PcbLib footprint above.
    let altium_schlib = pick(&chip_dir, &format!("{mpn}.SchLib"))
        .or_else(|| pick(&chip_dir, &format!("{mpn}.IntLib")));

    note(format!("concur check {mpn}: discovered sources →"));
    let mut sources_compared: Vec<String> = Vec::new();
    let mut sources_missing: Vec<String> = Vec::new();
    let mut hints: Vec<Hint> = Vec::new();
    let mut parts: Vec<Part> = Vec::new();

    // ds2sf
    if let Some(sym_p) = &ds_sym {
        match parsers::ds2sf::parse(sym_p, ds_fp.as_deref()) {
            Ok(p) => {
                note(format!("  ds2sf       — {} pins, {} pads, package {:?}",
                    p.pins.len(), p.pads.len(),
                    p.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?")));
                parts.push(p);
                sources_compared.push("ds2sf".into());
            }
            Err(e) => {
                hints.push(Hint::SourceParseFailed {
                    format: "ds2sf".into(), path: sym_p.clone(), error: e.to_string(),
                });
                sources_missing.push("ds2sf".into());
            }
        }
    } else {
        sources_missing.push("ds2sf".into());
    }
    // KiCad
    if ki_sym.is_some() || ki_mod.is_some() {
        match parsers::kicad::parse(ki_sym.as_deref(), ki_mod.as_deref()) {
            Ok(p) => {
                note(format!("  kicad       — {} pins, {} pads, footprint {:?}",
                    p.pins.len(), p.pads.len(),
                    p.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?")));
                parts.push(p);
                sources_compared.push("kicad".into());
            }
            Err(e) => {
                let fp = ki_sym.clone().or(ki_mod.clone()).unwrap();
                hints.push(Hint::SourceParseFailed { format: "kicad".into(), path: fp, error: e.to_string() });
                sources_missing.push("kicad".into());
            }
        }
    } else {
        sources_missing.push("kicad".into());
    }
    // EAGLE
    if let Some(p) = &lbr {
        match parsers::eagle::parse(p) {
            Ok(part) => {
                note(format!("  eagle (.lbr) — {} pins, {} pads, package {:?}",
                    part.pins.len(), part.pads.len(),
                    part.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?")));
                parts.push(part);
                sources_compared.push("eagle".into());
            }
            Err(e) => {
                hints.push(Hint::SourceParseFailed { format: "eagle".into(), path: p.clone(), error: e.to_string() });
                sources_missing.push("eagle".into());
            }
        }
    } else {
        sources_missing.push("eagle".into());
    }
    // Altium: .PcbLib (binary OLE2) for pad designators + .SchLib for symbol
    // pins (via altium-schlib), so Altium contributes to BOTH the footprint and
    // symbol axes — full parity with KiCad / EAGLE / ds2sf.
    //
    // Multi-variant selection: many vendor PcbLibs ship multiple variants
    // (e.g. DRV8316RRGFR's RGF0040E-IPC_A 41-pad standard + RGF0040E-MFG
    // 57-pad manufacturing). We compute the pad-count consensus from the
    // already-parsed sources (ds2sf / kicad / eagle) and pick the altium
    // variant closest to it. Without this, altium silently disagrees on
    // every chip with MFG variants because altium-pcblib picks the
    // longest variant by stream order.
    let consensus_pad_count = {
        let counts: Vec<usize> = parts
            .iter()
            .filter(|p| !p.pads.is_empty())
            .map(|p| p.pads.len())
            .collect();
        if counts.is_empty() {
            None
        } else {
            // Median = robust to one outlier (an under-reading ds2sf
            // doesn't drag the consensus). For 2 sources, the index
            // arithmetic still picks one of them.
            let mut sorted = counts.clone();
            sorted.sort();
            Some(sorted[sorted.len() / 2])
        }
    };

    if let Some(p) = &altium_path {
        match parsers::altium::parse_with_consensus_hint(p, consensus_pad_count, altium_schlib.as_deref()) {
            Ok(part) => {
                note(format!("  altium      — {} pins, {} pads, footprint {:?}{}",
                    part.pins.len(), part.pads.len(),
                    part.package.as_ref().map(|x| x.raw_name.as_str()).unwrap_or("?"),
                    consensus_pad_count.map(|c| format!(" (consensus_hint={c})")).unwrap_or_default()));
                parts.push(part);
                sources_compared.push("altium".into());
            }
            Err(e) => {
                hints.push(Hint::SourceParseFailed {
                    format: "altium".into(), path: p.clone(), error: e.to_string(),
                });
                sources_missing.push("altium".into());
            }
        }
    } else {
        sources_missing.push("altium".into());
    }

    if parts.len() < 2 {
        let summary = format!(
            "{} — only {} source(s) parseable; cross-source comparison needs ≥2",
            mpn, parts.len()
        );
        err(&summary);
        let doc = ResultDoc {
            schema_version: "1".into(),
            status: Status::Unrecoverable,
            exit_code: Status::Unrecoverable.exit_code(),
            mpn: mpn.clone(),
            summary,
            sources_compared,
            sources_missing,
            axes: vec![],
            variants: vec![],
            hints,
            extractor_version: env!("CARGO_PKG_VERSION").into(),
        };
        let _ = result::write(&result_path, &doc);
        return Status::Unrecoverable.exit_code();
    }

    let report = compare::compare(&parts);

    // Determine which axes are explained (covered) by a variant. Any
    // disagreement on a non-covered axis bumps status to Divergent.
    let covered: std::collections::HashSet<&str> = report
        .variants
        .iter()
        .flat_map(|v| v.covers().into_iter())
        .collect();

    // Lift uncovered disagreements into hints; stub-name disagreements (which
    // are already handled via the StubSymbolPinNames variant) get a friendlier
    // ul_symbol_has_stub_names hint *in addition* to the variant entry.
    for axis in &report.axes {
        if axis.verdict != Verdict::Disagree { continue; }
        if covered.contains(axis.axis.as_str()) {
            // Already explained by a variant — surface a stub-names hint when
            // applicable (downstream needs to know which source to prefer for
            // pin labels) but no Refetch hint.
            for c in &axis.conflicts {
                if c.contains("stub-only pin names") {
                    if let Some(src) = c.split_whitespace().next() {
                        let pin_count = parts.iter().find(|p| p.source == src)
                            .map(|p| p.pins.len() as u32).unwrap_or(0);
                        hints.push(Hint::UlSymbolHasStubNames {
                            source: src.to_string(),
                            pin_count,
                            suggestion: "Use ds2sf-extracted symbol JSON for downstream tools that need real pin labels.".into(),
                            chip_fetcher_action: "Do NOT refetch the .kicad_sym — UL stub-naming is a UL-bundle property, not a chip-fetcher choice. Instead, mark this chip dir's symbol source as 'ds2sf-preferred' so downstream sym_create authors a fresh .kicad_sym from ds2sf's JSON.".into(),
                        });
                    }
                }
            }
        } else {
            // Uncovered disagreement → real divergence. Build a rich Refetch
            // hint that tells chip-fetcher specifically what to do next.
            let format = hint_format_for_axis(&axis.axis);
            let suggested_sources = suggest_refetch_sources(&format, &mpn);
            let expected_resolution = format!(
                "Re-run `concur check` after refetch; axis '{}' should change from 'disagree' to 'agree' (or be covered by a benign variant).",
                axis.axis
            );
            // If ds2sf is the LONE disagreer on this axis (every other
            // parsed source agrees against it), the suspect is ds2sf's
            // extraction — not the chip-fetcher libs. Fire RerunDs2sfWith-
            // StrongerModel instead of asking chip-fetcher to refetch a
            // lib that's already correct. Real-world catch: DMG2305UX-7
            // had ds2sf-Haiku swap the GDS↔GSD pin order while kicad+eagle
            // both agreed on canonical GSD; v0.4 told chip-fetcher to
            // refetch the (correct) lib, which would have looped forever.
            let ds2sf_is_lone_disagreer =
                is_lone_ds2sf_disagreer(&axis.values)
                || is_lone_ds2sf_in_conflicts(&axis.conflicts);

            for c in &axis.conflicts {
                if ds2sf_is_lone_disagreer {
                    hints.push(Hint::RerunDs2sfWithStrongerModel {
                        reason: format!(
                            "On axis '{}', ds2sf is the lone disagreer — every other parsed source agrees against it. Most likely a Haiku misread of the datasheet (typo, swapped pins, or fine-pitch label collision). Re-running with Opus typically resolves these.",
                            axis.axis
                        ),
                        suspect_field: axis.axis.clone(),
                        suggested_model: "claude-opus-4-7".into(),
                    });
                } else if axis.axis == "body_dims" && c.to_lowercase().contains("ds2sf") {
                    hints.push(Hint::RerunDs2sfWithStrongerModel {
                        reason: "ds2sf reported a body dim that's likely the lead-tip span rather than the body D-dim — a known Haiku misread on small SOIC/VSSOP packages.".into(),
                        suspect_field: "bodyDimensions.x or bodyDimensions.y".into(),
                        suggested_model: "claude-opus-4-7".into(),
                    });
                } else {
                    hints.push(Hint::RefetchLibrarySource {
                        format: format.clone(),
                        suspect: axis.axis.clone(),
                        notes: c.clone(),
                        suggested_sources: suggested_sources.clone(),
                        expected_resolution: expected_resolution.clone(),
                    });
                }
            }
        }
    }

    let any_uncovered_disagreement = report.axes.iter().any(|a| {
        a.verdict == Verdict::Disagree && !covered.contains(a.axis.as_str())
    });
    let status = if any_uncovered_disagreement {
        Status::Divergent
    } else if !report.variants.is_empty() {
        Status::GoldenWithVariants
    } else if report.axes.iter().any(|a| a.verdict == Verdict::Agree) {
        Status::Golden
    } else {
        // No agree, no disagree → all source-missing. Treat as unrecoverable.
        Status::Unrecoverable
    };
    let summary = build_summary(&mpn, &report, &sources_compared, &sources_missing, status);
    if status.is_acceptable() { ok(&summary); } else { crate::io::warn(&summary); }

    let doc = ResultDoc {
        schema_version: "1".into(),
        status,
        exit_code: status.exit_code(),
        mpn,
        summary,
        sources_compared,
        sources_missing,
        axes: report.axes,
        variants: report.variants,
        hints,
        extractor_version: env!("CARGO_PKG_VERSION").into(),
    };
    if let Err(e) = result::write(&result_path, &doc) {
        err(format!("writing concur.result.json: {e}"));
    }
    status.exit_code()
}

/// True when ds2sf is the lone disagreer in a per-pin conflict list — every
/// conflict line has ds2sf on one side, and the OTHER sources don't conflict
/// with each other. Conflict format: `pin X: SRC1=V1 but SRC2=V2`.
///
/// Real-world catch (DMG2305UX-7, P-MOSFET): ds2sf-Haiku swapped pin 2/3
/// (D↔S) while kicad+eagle both agreed on canonical GSD ordering. The
/// per-source `values` map showed all sources reporting "3 named pins"
/// (summary count), so the values-based detector couldn't tell. This
/// function parses the conflict strings directly.
fn is_lone_ds2sf_in_conflicts(conflicts: &[String]) -> bool {
    use std::collections::HashSet;
    if conflicts.len() < 2 { return false; }

    // For each conflict, extract the two source names mentioned.
    // Pattern: "pin X: SRC1=V1 but SRC2=V2" — collect (SRC1, SRC2) pairs.
    let mut pairs: Vec<(String, String)> = Vec::new();
    for c in conflicts {
        if let Some((s1, s2)) = parse_conflict_sources(c) {
            pairs.push((s1, s2));
        }
    }
    if pairs.len() < 2 { return false; }

    // ds2sf must appear in every parsed pair.
    let ds2sf_in_all = pairs.iter().all(|(a, b)| a == "ds2sf" || b == "ds2sf");
    if !ds2sf_in_all { return false; }

    // The non-ds2sf source set must NOT include ds2sf in any other-side slot.
    // i.e., kicad and eagle never disagree with each other (otherwise ds2sf
    // wouldn't be the *lone* disagreer).
    let other_sources: HashSet<&str> = pairs
        .iter()
        .flat_map(|(a, b)| vec![a.as_str(), b.as_str()].into_iter())
        .filter(|s| *s != "ds2sf")
        .collect();
    // At least one non-ds2sf source must exist.
    !other_sources.is_empty()
}

/// Parse "pin X: SRC1=V1 but SRC2=V2" → Some(("SRC1", "SRC2")). The values
/// don't matter for lone-disagreer detection; only the source names.
fn parse_conflict_sources(c: &str) -> Option<(String, String)> {
    // Find the ": " that separates the pin label from the body.
    let body = c.split_once(": ").map(|(_, b)| b).unwrap_or(c);
    // Split on " but " to get the two halves.
    let (left, right) = body.split_once(" but ")?;
    let s1 = left.split_once('=')?.0.trim().to_string();
    let s2 = right.split_once('=')?.0.trim().to_string();
    Some((s1, s2))
}

/// True when the only source whose value differs from the others is "ds2sf".
/// Implementation: group the per-source values by stringified value; the
/// disagreer set has exactly 1 element AND that element is ds2sf.
fn is_lone_ds2sf_disagreer(values: &std::collections::BTreeMap<String, String>) -> bool {
    use std::collections::HashMap;
    if values.len() < 3 { return false; } // need ≥3 sources to identify a "lone" disagreer
    let mut buckets: HashMap<&String, Vec<&String>> = HashMap::new();
    for (src, val) in values {
        // Skip placeholder values like "(no symbol loaded)" — those aren't
        // real disagreements, they're missing-source markers.
        if val.starts_with('(') { continue; }
        buckets.entry(val).or_default().push(src);
    }
    // The "majority" bucket has ≥2 sources; the "minority" bucket has 1
    // source which must be ds2sf.
    let mut majority: Option<&Vec<&String>> = None;
    let mut minority: Option<&Vec<&String>> = None;
    for v in buckets.values() {
        if v.len() >= 2 { majority = Some(v); }
        else if v.len() == 1 { minority = Some(v); }
    }
    match (majority, minority) {
        (Some(_maj), Some(min)) => min.iter().any(|s| s.as_str() == "ds2sf"),
        _ => false,
    }
}

fn hint_format_for_axis(axis: &str) -> String {
    match axis {
        "package_family" | "pad_count" | "pad_numbers" | "body_dims" => "kicad_mod".into(),
        "symbol_pin_count" | "pin_names" | "pin_to_pad_map" => "kicad_sym".into(),
        _ => "unknown".into(),
    }
}

/// Concrete next-source recommendations chip-fetcher should walk through to
/// resolve a divergence. Order = priority (manufacturer-direct first).
fn suggest_refetch_sources(format: &str, mpn: &str) -> Vec<String> {
    let mut out = Vec::new();
    match format {
        "kicad_sym" | "kicad_mod" => {
            out.push(format!("Manufacturer site: {} datasheet+CAD page (search the part number on the manufacturer's website; many manufacturers offer a manufacturer-direct ZIP that bypasses UL).", mpn));
            out.push(format!("Ultra Librarian: re-fetch with a different package-suffix variant; the current UL bundle may be for a sibling part in the same family with a different pinout."));
            out.push(format!("SnapMagic / SnapEDA: alternate library source; usually carries a different upstream than UL."));
            out.push(format!("Mouser / DigiKey: some distributors host CAD downloads from a different upstream than UL."));
            out.push(format!("Component Search Engine (componentsearchengine.com): yet another upstream — useful as a tiebreaker."));
        }
        _ => {
            out.push("Inspect the chip-fetcher fetch log to identify which upstream source was used; refetch from a different one.".into());
        }
    }
    out
}

fn build_summary(
    mpn: &str,
    report: &compare::ComparisonReport,
    sources_compared: &[String],
    sources_missing: &[String],
    status: Status,
) -> String {
    let agreeing = report.axes.iter().filter(|a| a.verdict == Verdict::Agree).count();
    let disagreeing = report.axes.iter().filter(|a| a.verdict == Verdict::Disagree).count();
    let missing = report.axes.iter().filter(|a| a.verdict == Verdict::SourceMissing).count();
    let verdict = match status {
        Status::Golden => "GOLDEN",
        Status::GoldenWithVariants => "GOLDEN with variants",
        Status::Divergent => "DIVERGENT",
        Status::Unrecoverable => "UNRECOVERABLE",
    };
    let variants_str = if report.variants.is_empty() {
        String::new()
    } else {
        let kinds: Vec<&str> = report
            .variants
            .iter()
            .map(|v| match v {
                crate::result::Variant::ExposedPadVariant { .. } => "ep",
                crate::result::Variant::ModuleExtraPads { .. } => "module_extras",
                crate::result::Variant::LabelOnlyMismatch { .. } => "label_only",
                crate::result::Variant::StubSymbolPinNames { .. } => "stub_pin_names",
                crate::result::Variant::BodyDimMeasurementArtifact { .. } => "body_dim_artifact",
                crate::result::Variant::NamingConventionMismatch { .. } => "naming_convention",
                crate::result::Variant::KelvinSenseRepresentation { .. } => "kelvin_sense",
            })
            .collect();
        format!(" [variants: {}]", kinds.join(","))
    };
    format!(
        "{mpn} — {verdict}{variants_str}. Sources: [{}]{}. Axes: {} agree, {} disagree, {} source-missing.",
        sources_compared.join(","),
        if sources_missing.is_empty() { String::new() } else { format!(" (missing: {})", sources_missing.join(",")) },
        agreeing, disagreeing, missing,
    )
}

fn pick(dir: &Path, basename: &str) -> Option<PathBuf> {
    let p = dir.join(basename);
    if p.exists() { Some(p) } else { None }
}

fn run_inspect(chip_dir: &Path) -> i32 {
    let basename = chip_dir.file_name().and_then(|s| s.to_str()).unwrap_or("?");
    let p = chip_dir.join(format!("{basename}-concur.result.json"));
    if !p.exists() {
        err(format!("no concur result at {}", p.display()));
        hint(format!("run `concur check {}` first", chip_dir.display()));
        return 1;
    }
    let raw = match std::fs::read_to_string(&p) { Ok(r) => r, Err(e) => { err(e); return 1; } };
    let doc: ResultDoc = match serde_json::from_str(&raw) { Ok(d) => d, Err(e) => { err(e); return 1; } };
    note(format!("MPN: {} — status: {:?}, exit_code: {}", doc.mpn, doc.status, doc.exit_code));
    note(format!("Sources compared: {:?}", doc.sources_compared));
    if !doc.sources_missing.is_empty() {
        note(format!("Sources missing:  {:?}", doc.sources_missing));
    }
    note("Axes:");
    for a in &doc.axes {
        note(format!("  {:>16}  {:?}", a.axis, a.verdict));
        for c in &a.conflicts {
            note(format!("    ! {c}"));
        }
    }
    if !doc.hints.is_empty() {
        note("Hints:");
        for h in &doc.hints {
            note(format!("  - {}", serde_json::to_string(h).unwrap_or_default()));
        }
    }
    0
}

fn run_health() -> i32 {
    // No external deps for parsing — informational pass.
    ok(format!("concur {} ready (parsers: ds2sf, kicad, eagle, altium)", env!("CARGO_PKG_VERSION")));
    0
}

fn run_install() -> i32 {
    let home = match std::env::var("HOME") { Ok(h) => h, Err(_) => { err("HOME not set"); return 1; } };
    let dest_dir = std::path::PathBuf::from(&home).join(".claude/skills/adom-concur");
    if let Err(e) = std::fs::create_dir_all(&dest_dir) { err(format!("creating skill dir: {e}")); return 1; }
    let dest = dest_dir.join("SKILL.md");
    // SAFETY NET — do not rely on for release propagation.
    const SKILL_MD: &str = include_str!("../SKILL.md");
    if let Err(e) = std::fs::write(&dest, SKILL_MD) { err(format!("writing SKILL.md: {e}")); return 1; }
    ok(format!("installed SKILL.md → {}", dest.display()));
    0
}