master
John Lauer 1.0.33: NATIVE (Rust) build. 1.0.33 (wiki #109): model check and 3D show verbs report the 3D viewer's model-visibility… d3619d8 1mo ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108
//! kicad_model_check: read-only 3D model diagnostics for a footprint or a board.
//!
//! Port of handlers/model_check.py. A footprint editor that renders pads and no body
//! is a silent failure: the 3D viewer does not complain. This says, per footprint,
//! which `(model ...)` references exist, where KiCad would look for each file,
//! whether the file exists, whether it looks like a real STEP/VRML with geometry, and
//! what is still unverified (the render itself). Nothing here touches a window.
//!
//! KiCad's resolver (FILENAME_RESOLVER::ResolvePath): an absolute path is used as is;
//! `${VAR}` / `$(VAR)` expand from the environment and Configure Paths; anything else
//! is tried against the project directory (KIPRJMOD) and then each 3D search path
//! (KICAD<n>_3DMODEL_DIR, KICAD<n>_3RD_PARTY, system.extra_3d_search_dirs). It never
//! looks next to the .kicad_mod file, which is the trap in #88.
//!
//! This file also carries the tiny s-expression reader shared by the maintenance
//! group (footprint `(model ...)` clauses, board footprints, fp-lib-table rows) and
//! the `Ctx` path context (version, base dir, config dir, user dir, kicad_common.json).

use std::path::{Path, PathBuf};

use serde_json::{json, Map, Value};

use crate::detect::KicadInfo;

// ── Tiny s-expression reader ──────────────────────────────────────────────────

/// One node of a KiCad s-expression: a bare or quoted atom, or a parenthesised list.
#[derive(Clone, Debug, PartialEq)]
pub enum Sx {
    Atom(String),
    List(Vec<Sx>),
}

impl Sx {
    pub fn as_atom(&self) -> Option<&str> {
        match self {
            Sx::Atom(s) => Some(s),
            Sx::List(_) => None,
        }
    }
    pub fn as_list(&self) -> Option<&[Sx]> {
        match self {
            Sx::List(v) => Some(v),
            Sx::Atom(_) => None,
        }
    }
    /// The list's tag: its first element when that is an atom.
    pub fn tag(&self) -> Option<&str> {
        self.as_list().and_then(|v| v.first()).and_then(Sx::as_atom)
    }
    /// The i-th element (0 is the tag) as an atom.
    pub fn atom_at(&self, i: usize) -> Option<&str> {
        self.as_list().and_then(|v| v.get(i)).and_then(Sx::as_atom)
    }
    /// The first direct child list tagged `tag`.
    pub fn find(&self, tag: &str) -> Option<&Sx> {
        self.as_list()?.iter().find(|c| c.tag() == Some(tag))
    }
    /// Every direct child list tagged `tag`.
    pub fn find_all<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a Sx> + 'a {
        self.as_list().into_iter().flatten().filter(move |c| c.tag() == Some(tag))
    }
    /// `(tag "value" ...)` -> "value".
    pub fn value(&self) -> Option<&str> {
        self.atom_at(1)
    }
}

/// Tokens are "(", ")" or an atom (quotes stripped, `\"` and `\\` unescaped),
/// matching parsers/sexpr.py.
fn tokenize(text: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut chars = text.chars().peekable();
    while let Some(c) = chars.next() {
        match c {
            '(' => out.push("(".into()),
            ')' => out.push(")".into()),
            '"' => {
                let mut s = String::new();
                loop {
                    match chars.next() {
                        None => break,
                        Some('"') => break,
                        Some('\\') => match chars.next() {
                            Some('"') => s.push('"'),
                            Some('\\') => s.push('\\'),
                            Some(o) => {
                                s.push('\\');
                                s.push(o);
                            }
                            None => s.push('\\'),
                        },
                        Some(o) => s.push(o),
                    }
                }
                out.push(s);
            }
            c if c.is_whitespace() => {}
            c => {
                let mut s = String::new();
                s.push(c);
                while let Some(&n) = chars.peek() {
                    if n.is_whitespace() || n == '(' || n == ')' {
                        break;
                    }
                    s.push(n);
                    chars.next();
                }
                out.push(s);
            }
        }
    }
    out
}

fn parse_expr(tokens: &[String], pos: &mut usize) -> Sx {
    if tokens[*pos] == "(" {
        *pos += 1;
        let mut children = Vec::new();
        while *pos < tokens.len() && tokens[*pos] != ")" {
            children.push(parse_expr(tokens, pos));
        }
        if *pos < tokens.len() {
            *pos += 1;
        }
        Sx::List(children)
    } else {
        let a = Sx::Atom(tokens[*pos].clone());
        *pos += 1;
        a
    }
}

/// Parse the first top-level expression. None on empty input.
pub fn parse_sexpr(text: &str) -> Option<Sx> {
    let tokens = tokenize(text);
    if tokens.is_empty() {
        return None;
    }
    let mut pos = 0;
    Some(parse_expr(&tokens, &mut pos))
}

// ── Path context ──────────────────────────────────────────────────────────────

/// The KiCad facts the maintenance verbs need, resolved once per call. Tests build
/// one by hand; the verbs build it from detection with `Ctx::from_info`.
#[derive(Clone, Debug, Default)]
pub struct Ctx {
    /// Full version when known (kicad-cli X.Y.Z), else major.minor.
    pub version: String,
    /// KiCad install root (the parent of bin/), if installed.
    pub base_dir: Option<PathBuf>,
    /// Per-user settings dir (kicad_common.json, fp-lib-table, installed_packages.json).
    pub config_dir: Option<PathBuf>,
    /// Documents/KiCad/<major.minor>: footprints/, symbols/, 3rdparty/.
    pub user_dir: Option<PathBuf>,
    /// Parsed kicad_common.json, or Null when absent.
    pub common: Value,
}

impl Ctx {
    pub fn from_info(info: &KicadInfo) -> Ctx {
        let plat = kicad_platform::native();
        let inst = info.primary();
        let version = inst
            .map(|i| if i.cli_version.is_empty() { i.version.clone() } else { i.cli_version.clone() })
            .unwrap_or_default();
        let mm = kicad_platform::major_minor(&version);
        let config_dir = if mm.is_empty() { None } else { plat.config_dir(&mm) };
        let user_dir = if mm.is_empty() { None } else { plat.user_dir(&mm) };
        let common = config_dir
            .as_ref()
            .and_then(|c| std::fs::read_to_string(c.join("kicad_common.json")).ok())
            .and_then(|s| serde_json::from_str::<Value>(&s).ok())
            .unwrap_or(Value::Null);
        Ctx {
            version,
            base_dir: inst.map(|i| PathBuf::from(&i.base_dir)),
            config_dir,
            user_dir,
            common,
        }
    }
    pub fn major(&self) -> String {
        self.version.split('.').next().unwrap_or("").to_string()
    }
    /// kicad_common.json environment.vars (Configure Paths overrides).
    pub fn env_vars(&self) -> Map<String, Value> {
        self.common
            .get("environment")
            .and_then(|e| e.get("vars"))
            .and_then(Value::as_object)
            .cloned()
            .unwrap_or_default()
    }
    fn env_str(&self, key: &str) -> Option<String> {
        self.env_vars().get(key).and_then(Value::as_str).filter(|s| !s.is_empty()).map(|s| s.to_string())
    }
    /// The 3rdparty root KiCad uses, in KiCad's priority: kicad_common.json override
    /// (KICAD<n>_3RD_PARTY, or the older KICAD_3RD_PARTY spelling), else <user_dir>/3rdparty.
    /// Returns (path, how it was resolved).
    pub fn third_party_dir(&self) -> Option<(PathBuf, &'static str)> {
        let major = self.major();
        if !major.is_empty() {
            if let Some(v) = self.env_str(&format!("KICAD{major}_3RD_PARTY")) {
                return Some((PathBuf::from(v), "kicad_common.json override"));
            }
        }
        if let Some(v) = self.env_str("KICAD_3RD_PARTY") {
            return Some((PathBuf::from(v), "kicad_common.json override"));
        }
        self.user_dir.as_ref().map(|u| (u.join("3rdparty"), "platform user_dir (Documents/KiCad/<ver>/3rdparty)"))
    }
    /// Stock library roots and the variables KiCad defines for them.
    fn stock_subs(&self) -> Vec<(String, String)> {
        let major = self.major();
        let mut subs = Vec::new();
        if let Some(base) = &self.base_dir {
            let share = base.join("share").join("kicad");
            subs.push((format!("KICAD{major}_3DMODEL_DIR"), norm(&share.join("3dmodels"))));
            subs.push((format!("KICAD{major}_FOOTPRINT_DIR"), norm(&share.join("footprints"))));
            subs.push((format!("KICAD{major}_SYMBOL_DIR"), norm(&share.join("symbols"))));
            subs.push((format!("KICAD{major}_TEMPLATE_DIR"), norm(&share.join("template"))));
        }
        if let Some(u) = &self.user_dir {
            subs.push(("KICAD_USER_TEMPLATE_DIR".into(), norm(&u.join("template"))));
        }
        if let Some((third, _)) = self.third_party_dir() {
            subs.push((format!("KICAD{major}_3RD_PARTY"), norm(&third)));
            subs.push(("KICAD_3RD_PARTY".into(), norm(&third)));
        }
        for (k, v) in self.env_vars() {
            if let Some(s) = v.as_str() {
                subs.push((k, s.replace('\\', "/")));
            }
        }
        subs
    }
    /// The directories KiCad tries for a relative model path, in its order.
    pub fn search_dirs(&self, project_dir: Option<&str>) -> Vec<String> {
        let mut dirs = Vec::new();
        if let Some(p) = project_dir {
            dirs.push(p.to_string());
        }
        let major = self.major();
        let stock = self
            .env_str(&format!("KICAD{major}_3DMODEL_DIR"))
            .or_else(|| self.base_dir.as_ref().map(|b| norm(&b.join("share").join("kicad").join("3dmodels"))));
        if let Some(s) = stock {
            dirs.push(s);
        }
        if let Some((third, _)) = self.third_party_dir() {
            dirs.push(norm(&third));
        }
        if let Some(extra) = self.common.get("system").and_then(|s| s.get("extra_3d_search_dirs")).and_then(Value::as_array) {
            for d in extra {
                if let Some(s) = d.as_str() {
                    if !s.is_empty() {
                        dirs.push(s.to_string());
                    }
                }
            }
        }
        dirs.into_iter().map(|d| d.replace('\\', "/")).collect()
    }
    /// Expand `${VAR}` / `$(VAR)` the way KiCad does. Returns (expanded, unresolved vars).
    pub fn expand(&self, path: &str, project_dir: Option<&str>) -> (String, Vec<String>) {
        let mut p = path.replace('\\', "/");
        if p.contains("$(") {
            p = p.replace("$(", "${").replace(')', "}");
        }
        if let Some(pd) = project_dir {
            if p.contains("${KIPRJMOD}") {
                p = p.replace("${KIPRJMOD}", &pd.replace('\\', "/"));
            }
        }
        if !p.contains("${") {
            return (p, Vec::new());
        }
        for (k, v) in self.stock_subs() {
            p = p.replace(&format!("${{{k}}}"), &v);
        }
        (p.clone(), unresolved_vars(&p))
    }
    /// Expand an fp-lib-table / sym-lib-table uri. KIPRJMOD and unknown variables are
    /// refused (None) rather than guessed, like adom_library._expand_kicad_uri.
    pub fn expand_lib_uri(&self, uri: &str) -> Option<String> {
        let mut out = uri.replace('\\', "/");
        if out.contains("${KIPRJMOD}") {
            return None;
        }
        for (k, v) in self.stock_subs() {
            out = out.replace(&format!("${{{k}}}"), &v);
        }
        if out.contains("${") {
            return None;
        }
        Some(out)
    }
    /// The .pretty directory behind a footprint library nickname: the user's
    /// fp-lib-table first (URIs expanded), then unregistered <user_dir>/footprints/<lib>.pretty.
    pub fn footprint_library_dir(&self, library: &str) -> Option<PathBuf> {
        if let Some(cfg) = &self.config_dir {
            if let Ok(text) = std::fs::read_to_string(cfg.join("fp-lib-table")) {
                if let Some(tree) = parse_sexpr(&text) {
                    for row in tree.find_all("lib") {
                        let name = row.find("name").and_then(Sx::value).unwrap_or("");
                        if !name.eq_ignore_ascii_case(library) {
                            continue;
                        }
                        let uri = row.find("uri").and_then(Sx::value).unwrap_or("");
                        if let Some(resolved) = self.expand_lib_uri(uri) {
                            let p = PathBuf::from(resolved);
                            if p.is_dir() {
                                return Some(p);
                            }
                        }
                    }
                }
            }
        }
        if let Some(u) = &self.user_dir {
            let root = u.join("footprints");
            if let Ok(rd) = std::fs::read_dir(&root) {
                for e in rd.flatten() {
                    let p = e.path();
                    let stem = p.file_name().and_then(|s| s.to_str()).and_then(|s| s.strip_suffix(".pretty"));
                    if let Some(stem) = stem {
                        if stem.eq_ignore_ascii_case(library) && p.is_dir() {
                            return Some(p);
                        }
                    }
                }
            }
        }
        None
    }
}

fn unresolved_vars(p: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut rest = p;
    while let Some(i) = rest.find("${") {
        let after = &rest[i + 2..];
        match after.find('}') {
            Some(j) => {
                out.push(after[..j].to_string());
                rest = &after[j + 1..];
            }
            None => break,
        }
    }
    out
}

pub fn norm(p: &Path) -> String {
    p.to_string_lossy().replace('\\', "/")
}

fn is_absolute(p: &str) -> bool {
    // Accept both OS conventions regardless of the host: the check runs on the desktop
    // that owns the file, but a Windows path in a Linux test must still count.
    Path::new(p).is_absolute() || p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':' && (p.as_bytes()[2] == b'/' || p.as_bytes()[2] == b'\\'))
}

// ── Model rows ────────────────────────────────────────────────────────────────

const NO_MODEL_EXPECTED: &[&str] = &[
    "testpoint", "fiducial", "mountinghole", "solderjumper", "jumper", "net-tie", "nettie", "logo", "symbol", "marking", "hole_",
];

fn count_sub(hay: &[u8], needle: &[u8]) -> usize {
    if needle.is_empty() || hay.len() < needle.len() {
        return 0;
    }
    let mut n = 0;
    let mut i = 0;
    while i + needle.len() <= hay.len() {
        if &hay[i..i + needle.len()] == needle {
            n += 1;
            i += needle.len();
        } else {
            i += 1;
        }
    }
    n
}

/// Cheap format check: is this a STEP or VRML with something inside?
fn sniff(path: &Path) -> Map<String, Value> {
    let mut out = Map::new();
    out.insert("bytes".into(), Value::Null);
    out.insert("format".into(), Value::Null);
    out.insert("geometryHints".into(), json!(0));
    let (size, body) = match std::fs::metadata(path).and_then(|m| {
        use std::io::Read;
        let f = std::fs::File::open(path)?;
        let mut body = Vec::new();
        f.take(4 * 1024 * 1024).read_to_end(&mut body)?;
        Ok((m.len(), body))
    }) {
        Ok(v) => v,
        Err(e) => {
            let msg: String = e.to_string().chars().take(120).collect();
            out.insert("readError".into(), json!(msg));
            return out;
        }
    };
    out.insert("bytes".into(), json!(size));
    let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).unwrap_or_default();
    if body.starts_with(b"ISO-10303-21") {
        out.insert("format".into(), json!("step"));
        let hints = count_sub(&body, b"MANIFOLD_SOLID_BREP") + count_sub(&body, b"CLOSED_SHELL") + count_sub(&body, b"ADVANCED_FACE");
        out.insert("geometryHints".into(), json!(hints));
        let tail = &body[body.len().saturating_sub(4096)..];
        let terminated = count_sub(tail, b"END-ISO-10303-21") > 0 || size > body.len() as u64;
        out.insert("terminated".into(), json!(terminated));
    } else if body.starts_with(b"#VRML") {
        out.insert("format".into(), json!("vrml"));
        let hints = count_sub(&body, b"IndexedFaceSet") + count_sub(&body, b"Shape");
        out.insert("geometryHints".into(), json!(hints));
    } else if body.starts_with(&[0x1f, 0x8b]) && ext == "wrz" {
        out.insert("format".into(), json!("vrml-gzip"));
        out.insert("geometryHints".into(), Value::Null);
    } else {
        out.insert("format".into(), json!("unknown"));
    }
    out
}

fn xyz(node: &Sx, name: &str) -> Value {
    let Some(n) = node.find(name) else { return Value::Null };
    let Some(x) = n.find("xyz") else { return Value::Null };
    let Some(l) = x.as_list() else { return Value::Null };
    if l.len() < 4 {
        return Value::Null;
    }
    let mut v = Vec::new();
    for item in &l[1..4] {
        match item.as_atom().and_then(|s| s.parse::<f64>().ok()) {
            Some(f) => v.push(f),
            None => return Value::Null,
        }
    }
    json!(v)
}

fn warn(code: &str, hint: String) -> Value {
    json!({"code": code, "hint": hint})
}

fn model_rows(fp: &Sx, ctx: &Ctx, project_dir: Option<&str>) -> Vec<Value> {
    let mut rows = Vec::new();
    for m in fp.find_all("model") {
        let raw = m.atom_at(1).unwrap_or("").to_string();
        let mut hidden = false;
        for child in m.as_list().map(|l| l.iter().skip(2)).into_iter().flatten() {
            match child {
                Sx::Atom(a) if a == "hide" => hidden = true,
                Sx::List(_) if child.tag() == Some("hide") => {
                    let v = child.atom_at(1).unwrap_or("").to_ascii_lowercase();
                    hidden = matches!(v.as_str(), "yes" | "true" | "1");
                }
                _ => {}
            }
        }
        let mut row = Map::new();
        row.insert("reference".into(), json!(raw));
        row.insert("hidden".into(), json!(hidden));
        row.insert("offset".into(), xyz(m, "offset"));
        row.insert("scale".into(), xyz(m, "scale"));
        row.insert("rotate".into(), xyz(m, "rotate"));
        row.insert("resolved".into(), Value::Null);
        row.insert("exists".into(), json!(false));
        let mut warnings: Vec<Value> = Vec::new();
        let (expanded, unresolved) = ctx.expand(&raw, project_dir);
        if !unresolved.is_empty() {
            warnings.push(json!({"code": "unresolved_variable", "vars": unresolved,
                "hint": "KiCad cannot expand this variable on this box (Preferences > Configure Paths, or KIPRJMOD outside a project)."}));
        }
        let mut candidates: Vec<String> = Vec::new();
        let absolute = is_absolute(&expanded);
        if !unresolved.is_empty() {
        } else if absolute {
            candidates.push(expanded.clone());
        } else {
            let dirs = ctx.search_dirs(project_dir);
            candidates = dirs.iter().map(|d| format!("{}/{}", d.trim_end_matches('/'), expanded)).collect();
            row.insert("triedDirs".into(), json!(dirs));
        }
        let mut exists = false;
        for c in &candidates {
            if Path::new(c).is_file() {
                row.insert("resolved".into(), json!(c));
                row.insert("exists".into(), json!(true));
                exists = true;
                break;
            }
        }
        if !exists && unresolved.is_empty() {
            if !candidates.is_empty() && !absolute {
                warnings.push(warn("not_found_relative", format!(
                    "A bare relative model path is tried against the project directory and the 3D search paths, never next to the .kicad_mod. Use ${{KICAD{}_3DMODEL_DIR}}/<lib>.3dshapes/<file>.step, a Configure Paths variable, or an absolute path.",
                    ctx.major())));
            } else {
                warnings.push(warn("file_missing", format!("No file at {expanded}.")));
            }
        }
        if exists {
            let resolved = row.get("resolved").and_then(Value::as_str).unwrap_or("").to_string();
            let sn = sniff(Path::new(&resolved));
            let read_error = sn.get("readError").and_then(Value::as_str).map(|s| s.to_string());
            let format = sn.get("format").and_then(Value::as_str).map(|s| s.to_string());
            let hints = sn.get("geometryHints").cloned();
            for (k, v) in sn {
                row.insert(k, v);
            }
            if let Some(e) = read_error {
                warnings.push(warn("unreadable", e));
            } else if format.as_deref() == Some("unknown") {
                warnings.push(warn("not_step_or_vrml", "The file does not start like a STEP (ISO-10303-21) or VRML (#VRML) file; KiCad will draw nothing for it.".into()));
            } else if hints.as_ref().and_then(Value::as_u64) == Some(0) {
                warnings.push(warn("empty_geometry", "The file parses as STEP/VRML but declares no solids or faces.".into()));
            }
        }
        if let Some(sc) = row.get("scale").and_then(Value::as_array) {
            let vals: Vec<f64> = sc.iter().filter_map(Value::as_f64).collect();
            if vals.iter().any(|v| *v == 0.0) || vals.iter().any(|v| v.abs() > 100.0) {
                warnings.push(warn("implausible_scale", format!("scale {}: a zero or >100 factor makes the body invisible or huge.", fmt_list(&vals))));
            }
        }
        if hidden {
            warnings.push(warn("hidden", "This model is marked hidden in the footprint; the viewer will not draw it.".into()));
        }
        row.insert("warnings".into(), Value::Array(warnings));
        rows.push(Value::Object(row));
    }
    rows
}

/// Python-style list rendering for the scale hint: `[0.0, 1.0, 1.0]`.
fn fmt_list(v: &[f64]) -> String {
    let parts: Vec<String> = v.iter().map(|f| if f.fract() == 0.0 && f.abs() < 1e15 { format!("{f:.1}") } else { f.to_string() }).collect();
    format!("[{}]", parts.join(", "))
}

fn attrs(fp: &Sx) -> Vec<String> {
    fp.find("attr")
        .and_then(Sx::as_list)
        .map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(|s| s.to_string()).collect())
        .unwrap_or_default()
}

fn no_model_expected(fp_id: &str, attrs: &[String]) -> bool {
    let low = fp_id.to_ascii_lowercase();
    if NO_MODEL_EXPECTED.iter().any(|k| low.contains(k)) {
        return true;
    }
    let has = |a: &str| attrs.iter().any(|x| x == a);
    has("board_only") || (has("exclude_from_bom") && has("exclude_from_pos_files"))
}

fn tag_warnings(rows: &[Value]) -> Vec<Value> {
    let mut out = Vec::new();
    for r in rows {
        let model = r.get("reference").cloned().unwrap_or(Value::Null);
        for w in r.get("warnings").and_then(Value::as_array).into_iter().flatten() {
            let mut w = w.clone();
            if let Some(o) = w.as_object_mut() {
                o.insert("model".into(), model.clone());
            }
            out.push(w);
        }
    }
    out
}

/// Diagnose one footprint file (already located). `fp_id` is "Library:Name".
pub fn check_footprint_file(ctx: &Ctx, fp_id: &str, path: &Path, project_dir: Option<&str>) -> Result<Value, String> {
    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", norm(path)))?;
    let tree = parse_sexpr(&text).ok_or_else(|| "Empty S-expression".to_string())?;
    let fp_node = if tree.tag() == Some("footprint") { tree.clone() } else { tree.find("footprint").cloned().unwrap_or(Sx::List(vec![])) };
    let rows = model_rows(&fp_node, ctx, project_dir);
    let attrs = attrs(&fp_node);
    let mut warnings = tag_warnings(&rows);
    let note;
    if rows.is_empty() {
        if no_model_expected(fp_id, &attrs) {
            note = "no model reference, and none expected for this kind of footprint".to_string();
        } else {
            warnings.push(json!({"code": "no_model_reference", "model": null,
                "hint": "The footprint carries no (model ...) clause, so the 3D viewer shows pads only. Attach one (kicad_install_footprint with model, or edit the footprint)."}));
            note = "no model reference".to_string();
        }
    } else {
        let good = rows
            .iter()
            .filter(|r| r.get("exists").and_then(Value::as_bool).unwrap_or(false) && r.get("warnings").and_then(Value::as_array).map(|w| w.is_empty()).unwrap_or(true))
            .count();
        note = format!("{good} of {} model reference(s) resolve to a readable STEP/VRML with geometry", rows.len());
    }
    let (library, footprint) = fp_id.split_once(':').unwrap_or(("", fp_id));
    Ok(json!({
        "success": true,
        "footprint": fp_id,
        "file": norm(path),
        "attrs": attrs,
        "models": rows,
        "ok": warnings.is_empty(),
        "warnings": warnings,
        "renderVerified": false,
        "note": note,
        "nextCheck": format!("kicad_show_3d_chip {{\"library\":\"{library}\",\"footprintName\":\"{footprint}\"}} and read the frame it returns: a body over the pads is the only proof KiCad drew the model."),
    }))
}

/// kicad_model_check {library, footprintName}.
pub fn check_footprint(ctx: &Ctx, library: &str, footprint: &str, project_dir: Option<&str>) -> Value {
    let Some(pretty) = ctx.footprint_library_dir(library) else {
        return json!({"success": false, "errorCode": "library_not_found",
            "error": format!("No library named '{library}' in the fp-lib-table or user footprints dir")});
    };
    let path = pretty.join(format!("{footprint}.kicad_mod"));
    if !path.is_file() {
        return json!({"success": false, "errorCode": "footprint_not_found", "error": format!("{} does not exist", norm(&path))});
    }
    match check_footprint_file(ctx, &format!("{library}:{footprint}"), &path, project_dir) {
        Ok(v) => v,
        Err(e) => json!({"success": false, "errorCode": "model_check_failed", "error": e.chars().take(200).collect::<String>()}),
    }
}

/// The 3D viewer's model-visibility toggles (wiki #109): `<config>/3d_viewer.json`,
/// `render.show_footprints_*`. They hide whole CLASSES of footprint bodies in the viewer
/// AND in `kicad-cli pcb render`, so a board whose every model file resolves can still
/// render as pads only. `insert` is KiCad's name for SMD, `normal` for through-hole.
pub const VIEWER_FLAGS: &[(&str, &str, &str)] = &[
    ("smd", "show_footprints_insert", "SMD models"),
    ("throughHole", "show_footprints_normal", "through-hole models"),
    ("virtual", "show_footprints_virtual", "virtual (other) models"),
    ("notInPosFile", "show_footprints_not_in_posfile", "models not in the position file"),
    ("dnp", "show_footprints_dnp", "models marked DNP"),
];

pub fn viewer_settings_path(ctx: &Ctx) -> Option<std::path::PathBuf> {
    ctx.config_dir.as_ref().map(|d| d.join("3d_viewer.json"))
}

/// `{settingsFile, flags:{smd,throughHole,virtual,notInPosFile,dnp}, known}`. A missing
/// file or key reads as KiCad's default (true, except dnp which defaults false).
pub fn viewer_visibility(ctx: &Ctx) -> Value {
    let path = viewer_settings_path(ctx);
    let doc: Value = path.as_ref().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
    let mut flags = serde_json::Map::new();
    for (key, json_key, _) in VIEWER_FLAGS {
        let default = *key != "dnp";
        flags.insert((*key).to_string(), json!(doc["render"][*json_key].as_bool().unwrap_or(default)));
    }
    json!({"settingsFile": path.as_ref().map(|p| norm(p)), "known": !doc.is_null(), "flags": flags})
}

/// Which viewer class a footprint's body belongs to, from its `(attr ...)` tokens.
pub fn viewer_classes(attrs: &[String]) -> Vec<&'static str> {
    let has = |t: &str| attrs.iter().any(|a| a == t);
    let mut out = vec![if has("smd") { "smd" } else if has("through_hole") { "throughHole" } else { "virtual" }];
    if has("exclude_from_pos_files") { out.push("notInPosFile"); }
    if has("dnp") { out.push("dnp"); }
    out
}

/// Set the viewer's model-visibility toggles (wiki #109 ask 2). File logic only: the caller
/// refuses while KiCad runs, because KiCad rewrites 3d_viewer.json from memory when it exits.
/// Every other key in the file is preserved; the previous file is kept beside it as
/// `3d_viewer.json.adom.bak`; the write goes through a temp file and a rename.
pub fn set_viewer_visibility(ctx: &Ctx, args: &Value) -> Value {
    let Some(path) = viewer_settings_path(ctx) else {
        return json!({"success": false, "errorCode": "not_installed", "error": "No KiCad config directory detected, so there is no 3d_viewer.json to edit."});
    };
    let before = viewer_visibility(ctx);
    let mut wanted: Vec<(&str, &str, bool)> = Vec::new();
    for (key, json_key, _) in VIEWER_FLAGS {
        match args.get(*key) {
            None | Some(Value::Null) => {}
            Some(Value::Bool(b)) => wanted.push((key, json_key, *b)),
            Some(_) => return json!({"success": false, "errorCode": "bad_arg", "error": format!("{key} must be true or false"), "mutated": false}),
        }
    }
    if wanted.is_empty() {
        return json!({"success": true, "mutated": false, "viewer": before,
            "_hint": "Read only. Pass smd, throughHole, virtual, notInPosFile or dnp (true|false) to change a toggle; KiCad must be closed while the file is written, then reopen the viewer."});
    }
    let mut doc: Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| json!({}));
    if !doc.is_object() {
        doc = json!({});
    }
    if !doc["render"].is_object() {
        doc["render"] = json!({});
    }
    let mut changed = serde_json::Map::new();
    for (key, json_key, value) in &wanted {
        if doc["render"][*json_key].as_bool() != Some(*value) {
            changed.insert((*key).to_string(), json!(value));
        }
        doc["render"][*json_key] = json!(value);
    }
    if changed.is_empty() {
        return json!({"success": true, "mutated": false, "viewer": before, "_hint": "Every requested toggle already had that value; nothing was written."});
    }
    if path.is_file() {
        let _ = std::fs::copy(&path, path.with_extension("json.adom.bak"));
    } else if let Some(dir) = path.parent() {
        let _ = std::fs::create_dir_all(dir);
    }
    let tmp = path.with_extension("json.adom.tmp");
    let body = serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n";
    if let Err(e) = std::fs::write(&tmp, body).and_then(|_| std::fs::rename(&tmp, &path)) {
        let _ = std::fs::remove_file(&tmp);
        return json!({"success": false, "errorCode": "settings_write_failed", "error": format!("cannot write {}: {e}", path.display()), "mutated": false});
    }
    json!({"success": true, "mutated": true, "changed": changed, "before": before["flags"], "viewer": viewer_visibility(ctx),
        "backup": norm(&path.with_extension("json.adom.bak")),
        "_hint": "Written. The 3D viewer and kicad-cli pcb render read this file when they start: reopen the board and its viewer (kicad_show_3d_board) to see the bodies."})
}

/// The sentence for a reply when the viewer hides a class that exists on the board.
pub fn viewer_hint(visibility: &Value, counts: &serde_json::Map<String, Value>) -> Option<String> {
    let mut parts = Vec::new();
    for (key, _, label) in VIEWER_FLAGS {
        let n = counts.get(*key).and_then(Value::as_u64).unwrap_or(0);
        if n > 0 && visibility["flags"][*key] == json!(false) {
            parts.push(format!("{label} are hidden and the board has {n} such footprint(s)"));
        }
    }
    if parts.is_empty() {
        return None;
    }
    Some(format!("The 3D viewer settings hide bodies that exist: {}. They will not render in the viewer or in kicad-cli pcb render even though the model files resolve. kicad_3d_viewer_settings shows and sets these toggles (KiCad must be closed while it writes).", parts.join("; ")))
}


/// kicad_model_check {boardPath}.
pub fn check_board(ctx: &Ctx, board_path: &str, limit: usize) -> Value {
    let p = Path::new(board_path);
    if !p.is_file() {
        return json!({"success": false, "errorCode": "board_not_found", "error": format!("{board_path} does not exist")});
    }
    let text = match std::fs::read_to_string(p) {
        Ok(t) => t,
        Err(e) => return json!({"success": false, "errorCode": "model_check_failed", "error": e.to_string().chars().take(200).collect::<String>()}),
    };
    let Some(tree) = parse_sexpr(&text) else {
        return json!({"success": false, "errorCode": "model_check_failed", "error": "Empty S-expression"});
    };
    let project_dir = p.parent().map(norm).unwrap_or_default();
    let mut per = Vec::new();
    let mut bindings = Vec::new();
    let mut class_counts: serde_json::Map<String, Value> = serde_json::Map::new();
    let (mut footprints, mut with_model, mut no_model_expected_n, mut missing, mut unresolved, mut nwarn) = (0u64, 0u64, 0u64, 0u64, 0u64, 0u64);
    for fp in tree.find_all("footprint") {
        let fp_id = fp.atom_at(1).unwrap_or("?").to_string();
        let mut reference = String::new();
        for prop in fp.find_all("property") {
            if prop.atom_at(1) == Some("Reference") {
                if let Some(v) = prop.atom_at(2) {
                    reference = v.to_string();
                }
            }
        }
        let rows = model_rows(fp, ctx, Some(&project_dir));
        let a = attrs(fp);
        if !rows.is_empty() {
            for c in viewer_classes(&a) {
                let n = class_counts.get(c).and_then(Value::as_u64).unwrap_or(0) + 1;
                class_counts.insert(c.to_string(), json!(n));
            }
        }
        bindings.push(json!({
            "itemId": fp.find("uuid").and_then(|x| x.atom_at(1)),
            "reference": reference,
            "footprint": fp_id,
            "layer": fp.find("layer").and_then(|x| x.atom_at(1)),
            "models": rows,
            "noModelExpected": rows.is_empty() && no_model_expected(&fp_id, &a),
        }));
        footprints += 1;
        let mut w = tag_warnings(&rows);
        if !rows.is_empty() {
            with_model += 1;
        } else if no_model_expected(&fp_id, &a) {
            no_model_expected_n += 1;
        } else {
            missing += 1;
            w.push(json!({"code": "no_model_reference", "model": null, "hint": "no (model ...) clause"}));
        }
        if w.iter().any(|x| matches!(x.get("code").and_then(Value::as_str), Some("unresolved_variable" | "not_found_relative" | "file_missing"))) {
            unresolved += 1;
        }
        nwarn += w.len() as u64;
        if !w.is_empty() && per.len() < limit {
            let models: Vec<Value> = rows
                .iter()
                .map(|r| json!({"reference": r.get("reference"), "resolved": r.get("resolved"), "exists": r.get("exists")}))
                .collect();
            per.push(json!({"reference": reference, "footprint": fp_id, "warnings": w, "models": models}));
        }
    }
    let viewer = viewer_visibility(ctx);
    let viewer_note = viewer_hint(&viewer, &class_counts);
    json!({
        "success": true,
        "board": norm(p),
        "projectDir": project_dir,
        "searchDirs": ctx.search_dirs(Some(&project_dir)),
        "counts": {"footprints": footprints, "withModel": with_model, "noModelExpected": no_model_expected_n,
                   "missingModel": missing, "unresolvedModel": unresolved, "warnings": nwarn},
        "footprintsWithWarnings": per,
        "modelBindings": bindings,
        "viewer": viewer,
        "footprintsWithModelByViewerClass": class_counts,
        "_viewerHint": viewer_note,
        "bindingsComplete": true,
        "evidenceSource": "saved-board-file",
        "unsavedEditorStateIncluded": false,
        "ok": nwarn == 0,
        "renderVerified": false,
        "nextCheck": "kicad_show_3d_board and read the frame: bodies on every populated footprint are the proof.",
    })
}

pub const HINT: &str = "Read-only file diagnostics: resolution, existence, format and geometry per model reference. renderVerified is always false here; the viewer frame from nextCheck is the render proof. Warnings carry a code and a hint; ok:true means every reference resolved to a real STEP/VRML with geometry.";

/// kicad_model_check {library, footprintName} or {boardPath|filePath}: the verb body.
pub fn handle(ctx: &Ctx, args: &Value) -> Value {
    let s = |k: &str| args.get(k).and_then(Value::as_str).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
    let board = s("boardPath").or_else(|| s("filePath"));
    let mut out = match board {
        Some(b) if b.to_ascii_lowercase().ends_with(".kicad_pcb") => {
            let limit = args.get("limit").and_then(Value::as_u64).filter(|n| *n > 0).unwrap_or(50) as usize;
            check_board(ctx, &b, limit)
        }
        _ => {
            let lib = s("library").or_else(|| s("libraryName"));
            let fp = s("footprintName").or_else(|| s("footprint"));
            match (lib, fp) {
                (Some(lib), Some(fp)) => check_footprint(ctx, &lib, &fp, s("projectDir").as_deref()),
                _ => json!({"success": false, "errorCode": "missing_arg", "error": "Pass library + footprintName, or boardPath (.kicad_pcb)"}),
            }
        }
    };
    if out.get("success").and_then(Value::as_bool).unwrap_or(false) {
        out["_hint"] = json!(HINT);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    // Wiki #109: every model resolved and the chips were still invisible, because the
    // viewer's own "SMD Models" toggle was off.
    #[test]
    fn viewer_toggles_are_read_classed_hinted_and_set() {
        let dir = std::env::temp_dir().join(format!("kb109-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("3d_viewer.json"), r#"{"camera":{"keep":1},"render":{"show_footprints_insert":false,"show_zones":true}}"#).unwrap();
        let ctx = Ctx { config_dir: Some(dir.clone()), ..Default::default() };
        let v = viewer_visibility(&ctx);
        assert_eq!(v["flags"]["smd"], json!(false));
        assert_eq!(v["flags"]["throughHole"], json!(true), "a missing key reads as KiCad's default");
        assert_eq!(v["flags"]["dnp"], json!(false));
        assert_eq!(viewer_classes(&["smd".into(), "dnp".into()]), vec!["smd", "dnp"]);
        assert_eq!(viewer_classes(&["through_hole".into()]), vec!["throughHole"]);
        assert_eq!(viewer_classes(&[]), vec!["virtual"]);
        let mut counts = serde_json::Map::new();
        counts.insert("smd".into(), json!(3));
        counts.insert("throughHole".into(), json!(2));
        let hint = viewer_hint(&v, &counts).unwrap();
        assert!(hint.contains("SMD models are hidden") && hint.contains("3 such"), "{hint}");
        assert!(!hint.contains("through-hole"), "a visible class is not mentioned: {hint}");
        // Read-only call, then a real set that preserves every other key and leaves a backup.
        assert_eq!(set_viewer_visibility(&ctx, &json!({}))["mutated"], json!(false));
        assert_eq!(set_viewer_visibility(&ctx, &json!({"smd": "yes"}))["errorCode"], json!("bad_arg"));
        let r = set_viewer_visibility(&ctx, &json!({"smd": true}));
        assert_eq!(r["mutated"], json!(true), "{r}");
        assert_eq!(r["viewer"]["flags"]["smd"], json!(true));
        let doc: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("3d_viewer.json")).unwrap()).unwrap();
        assert_eq!(doc["camera"]["keep"], json!(1));
        assert_eq!(doc["render"]["show_zones"], json!(true));
        assert!(dir.join("3d_viewer.json.adom.bak").is_file());
        assert!(viewer_hint(&viewer_visibility(&ctx), &counts).is_none());
        assert_eq!(set_viewer_visibility(&ctx, &json!({"smd": true}))["mutated"], json!(false), "idempotent");
        let _ = std::fs::remove_dir_all(&dir);
    }

    const STEP: &[u8] = b"ISO-10303-21;\nHEADER;ENDSEC;\nDATA;\n#1=MANIFOLD_SOLID_BREP('x',#2);\n#2=CLOSED_SHELL('',());\nENDSEC;\nEND-ISO-10303-21;\n";

    struct Fixture {
        root: PathBuf,
        pretty: PathBuf,
        ctx: Ctx,
    }
    impl Drop for Fixture {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.root);
        }
    }
    fn fixture(name: &str) -> Fixture {
        let root = std::env::temp_dir().join(format!("kicad-core-model-check-{}-{name}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        let pretty = root.join("footprints").join("Adom.pretty");
        std::fs::create_dir_all(&pretty).unwrap();
        let base = root.join("kicad");
        let shapes = base.join("share/kicad/3dmodels/Adom.3dshapes");
        std::fs::create_dir_all(&shapes).unwrap();
        std::fs::write(shapes.join("GOOD.step"), STEP).unwrap();
        std::fs::write(pretty.join("BARE.step"), STEP).unwrap();
        std::fs::write(pretty.join("EMPTY.step"), b"ISO-10303-21;\nHEADER;ENDSEC;DATA;ENDSEC;END-ISO-10303-21;").unwrap();
        let ctx = Ctx { version: "10.0".into(), base_dir: Some(base), config_dir: None, user_dir: Some(root.clone()), common: Value::Null };
        Fixture { root, pretty, ctx }
    }
    fn fp_text(model: &str) -> String {
        format!("(footprint \"X\" (version 20240108) (generator \"t\") (layer \"F.Cu\") (attr smd) (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\")){model})")
    }
    fn write(f: &Fixture, name: &str, model: &str) {
        std::fs::write(f.pretty.join(format!("{name}.kicad_mod")), fp_text(model)).unwrap();
    }
    fn codes(r: &Value) -> Vec<String> {
        let mut c: Vec<String> = r["warnings"].as_array().unwrap().iter().map(|w| w["code"].as_str().unwrap().to_string()).collect();
        c.sort();
        c
    }

    #[test]
    fn tokenizer_handles_quotes_and_escapes() {
        let t = parse_sexpr(r#"(model "a \"b\" c\\d" (hide yes) hide)"#).unwrap();
        assert_eq!(t.atom_at(1), Some("a \"b\" c\\d"));
        assert_eq!(t.find("hide").and_then(Sx::value), Some("yes"));
        assert_eq!(t.as_list().unwrap().len(), 4);
        assert!(parse_sexpr("   ").is_none());
    }

    #[test]
    fn good_stock_reference() {
        let f = fixture("good");
        write(&f, "A", "(model \"${KICAD10_3DMODEL_DIR}/Adom.3dshapes/GOOD.step\" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))");
        let r = check_footprint(&f.ctx, "Adom", "A", None);
        assert_eq!(r["ok"], json!(true), "{r}");
        assert_eq!(r["models"][0]["format"], json!("step"));
        assert!(r["models"][0]["geometryHints"].as_u64().unwrap() > 0);
        assert_eq!(r["models"][0]["terminated"], json!(true));
        assert_eq!(r["models"][0]["scale"], json!([1.0, 1.0, 1.0]));
        assert_eq!(r["renderVerified"], json!(false));
        assert_eq!(r["footprint"], json!("Adom:A"));
    }

    #[test]
    fn bare_relative_next_to_footprint_is_not_found() {
        let f = fixture("bare");
        write(&f, "B", "(model \"BARE.step\" (offset (xyz 0 1.39 0.18)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 -90)))");
        let r = check_footprint(&f.ctx, "Adom", "B", None);
        assert_eq!(r["ok"], json!(false));
        assert_eq!(r["warnings"][0]["code"], json!("not_found_relative"));
        assert!(r["warnings"][0]["hint"].as_str().unwrap().contains("KICAD10_3DMODEL_DIR"));
        assert_eq!(r["warnings"][0]["model"], json!("BARE.step"));
        let tried = r["models"][0]["triedDirs"].as_array().unwrap();
        assert_eq!(tried.len(), 2, "stock 3dmodels then 3rdparty: {tried:?}");
        assert!(tried[1].as_str().unwrap().ends_with("/3rdparty"));
    }

    #[test]
    fn absolute_empty_geometry_hidden_and_scale() {
        let f = fixture("abs");
        let p = norm(&f.pretty.join("EMPTY.step"));
        write(&f, "C", &format!("(model \"{p}\" (hide yes) (offset (xyz 0 0 0)) (scale (xyz 0 1 1)) (rotate (xyz 0 0 0)))"));
        let r = check_footprint(&f.ctx, "Adom", "C", None);
        assert_eq!(codes(&r), vec!["empty_geometry", "hidden", "implausible_scale"]);
        assert_eq!(r["models"][0]["exists"], json!(true));
        let hint = r["warnings"].as_array().unwrap().iter().find(|w| w["code"] == "implausible_scale").unwrap()["hint"].as_str().unwrap().to_string();
        assert!(hint.starts_with("scale [0.0, 1.0, 1.0]"), "{hint}");
    }

    #[test]
    fn no_model_and_exempt() {
        let f = fixture("nomodel");
        write(&f, "D", "");
        let r = check_footprint(&f.ctx, "Adom", "D", None);
        assert_eq!(r["warnings"][0]["code"], json!("no_model_reference"));
        assert_eq!(r["warnings"][0]["model"], Value::Null);
        write(&f, "TestPoint_Pad", "");
        let r = check_footprint(&f.ctx, "Adom", "TestPoint_Pad", None);
        assert_eq!(r["ok"], json!(true));
        assert!(r["note"].as_str().unwrap().contains("none expected"));
    }

    #[test]
    fn unresolved_variable_and_missing_absolute() {
        let f = fixture("unresolved");
        write(&f, "E", "(model \"${NOPE}/x.step\")");
        let r = check_footprint(&f.ctx, "Adom", "E", None);
        assert_eq!(r["warnings"][0]["code"], json!("unresolved_variable"));
        assert_eq!(r["warnings"][0]["vars"], json!(["NOPE"]));
        let gone = norm(&f.root.join("nowhere.step"));
        write(&f, "F", &format!("(model \"{gone}\")"));
        let r = check_footprint(&f.ctx, "Adom", "F", None);
        assert_eq!(r["warnings"][0]["code"], json!("file_missing"));
    }

    #[test]
    fn not_step_or_vrml_and_paren_vars() {
        let f = fixture("junk");
        std::fs::write(f.pretty.join("JUNK.step"), b"hello").unwrap();
        // $(VAR) spelling and a Configure Paths override from kicad_common.json
        let mut ctx = f.ctx.clone();
        ctx.common = json!({"environment": {"vars": {"MYMODELS": norm(&f.pretty)}}});
        write(&f, "G", "(model \"$(MYMODELS)/JUNK.step\")");
        let r = check_footprint(&ctx, "Adom", "G", None);
        assert_eq!(codes(&r), vec!["not_step_or_vrml"]);
        assert_eq!(r["models"][0]["format"], json!("unknown"));
    }

    #[test]
    fn library_and_footprint_not_found() {
        let f = fixture("notfound");
        let r = check_footprint(&f.ctx, "Nope", "A", None);
        assert_eq!(r["errorCode"], json!("library_not_found"));
        let r = check_footprint(&f.ctx, "Adom", "Missing", None);
        assert_eq!(r["errorCode"], json!("footprint_not_found"));
    }

    #[test]
    fn fp_lib_table_row_resolves_library() {
        let f = fixture("libtable");
        let cfg = f.root.join("config");
        std::fs::create_dir_all(&cfg).unwrap();
        let other = f.root.join("elsewhere").join("Other.pretty");
        std::fs::create_dir_all(&other).unwrap();
        std::fs::write(other.join("Z.kicad_mod"), fp_text("")).unwrap();
        std::fs::write(cfg.join("fp-lib-table"), format!(
            "(fp_lib_table (version 7)\n  (lib (name \"Other\")(type \"KiCad\")(uri \"{}\")(options \"\")(descr \"\"))\n  (lib (name \"Proj\")(type \"KiCad\")(uri \"${{KIPRJMOD}}/x.pretty\")(options \"\")(descr \"\"))\n)",
            norm(&other))).unwrap();
        let mut ctx = f.ctx.clone();
        ctx.config_dir = Some(cfg);
        assert_eq!(ctx.footprint_library_dir("other"), Some(other));
        assert_eq!(ctx.footprint_library_dir("Proj"), None);
        let r = check_footprint(&ctx, "Other", "Z", None);
        assert_eq!(r["success"], json!(true));
        assert_eq!(r["warnings"][0]["code"], json!("no_model_reference"));
    }

    #[test]
    fn board() {
        let f = fixture("board");
        let board = f.root.join("b.kicad_pcb");
        std::fs::write(&board, "(kicad_pcb (version 20240108) (generator \"t\") (footprint \"Adom:A\" (layer \"F.Cu\") (property \"Reference\" \"U1\") (model \"${KICAD10_3DMODEL_DIR}/Adom.3dshapes/GOOD.step\")) (footprint \"Adom:B\" (layer \"F.Cu\") (property \"Reference\" \"U2\") (model \"BARE.step\")) (footprint \"TestPoint:TP\" (layer \"F.Cu\") (property \"Reference\" \"TP1\")) (footprint \"Adom:C\" (layer \"F.Cu\") (property \"Reference\" \"U3\")))").unwrap();
        let r = check_board(&f.ctx, &norm(&board), 50);
        let c = &r["counts"];
        assert_eq!((c["footprints"].as_u64(), c["withModel"].as_u64(), c["noModelExpected"].as_u64(), c["missingModel"].as_u64(), c["unresolvedModel"].as_u64()),
            (Some(4), Some(2), Some(1), Some(1), Some(1)));
        let refs: Vec<&str> = r["footprintsWithWarnings"].as_array().unwrap().iter().map(|x| x["reference"].as_str().unwrap()).collect();
        assert_eq!(refs, vec!["U2", "U3"]);
        assert_eq!(r["searchDirs"][0], json!(norm(&f.root)));
        assert_eq!(r["ok"], json!(false));
        // limit caps the per-footprint list, not the counts
        let r = check_board(&f.ctx, &norm(&board), 1);
        assert_eq!(r["footprintsWithWarnings"].as_array().unwrap().len(), 1);
        assert_eq!(r["counts"]["warnings"], json!(2));
        // Warning limit must never hide successful bindings or model-free items.
        let bindings = r["modelBindings"].as_array().unwrap();
        assert_eq!(bindings.len(), 4);
        assert_eq!(bindings[0]["reference"], "U1");
        assert_eq!(bindings[0]["layer"], "F.Cu");
        assert_eq!(bindings[0]["models"][0]["exists"], true);
        assert_eq!(bindings[2]["noModelExpected"], true);
        assert_eq!(bindings[3]["models"], json!([]));
        assert_eq!(r["bindingsComplete"], true);
        assert_eq!(r["unsavedEditorStateIncluded"], false);
    }

    #[test]
    fn handle_routes_args() {
        let f = fixture("handle");
        write(&f, "A", "");
        let r = handle(&f.ctx, &json!({"library": "Adom", "footprintName": "A"}));
        assert_eq!(r["success"], json!(true));
        assert_eq!(r["_hint"], json!(HINT));
        let r = handle(&f.ctx, &json!({"libraryName": "Adom", "footprint": "A"}));
        assert_eq!(r["success"], json!(true));
        let r = handle(&f.ctx, &json!({}));
        assert_eq!(r["errorCode"], json!("missing_arg"));
        let r = handle(&f.ctx, &json!({"boardPath": norm(&f.root.join("none.kicad_pcb"))}));
        assert_eq!(r["errorCode"], json!("board_not_found"));
    }

    #[test]
    fn third_party_dir_honours_common_override() {
        let mut ctx = Ctx { version: "10.0.6".into(), user_dir: Some(PathBuf::from("/u/Documents/KiCad/10.0")), ..Default::default() };
        assert_eq!(ctx.third_party_dir().unwrap().0, PathBuf::from("/u/Documents/KiCad/10.0/3rdparty"));
        ctx.common = json!({"environment": {"vars": {"KICAD10_3RD_PARTY": "/custom/3rd"}}});
        let (p, how) = ctx.third_party_dir().unwrap();
        assert_eq!(p, PathBuf::from("/custom/3rd"));
        assert_eq!(how, "kicad_common.json override");
        assert_eq!(ctx.search_dirs(None).last().map(String::as_str), Some("/custom/3rd"));
        assert_eq!(ctx.expand("${KICAD10_3RD_PARTY}/x.step", None).0, "/custom/3rd/x.step");
    }
}