master
Colby Knox Merge PR #415: Report a library Fusion refuses instead of calling it opened 73b4f61 7d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263
//! The `.lbr` engine. Phase 2 ports `library_bundle.py` and the binding injection from
//! server.py here, behind golden-file tests: the same input library must produce the same
//! answer the Python bridge gives today (`tests/golden/*.json` are generated by the Python
//! functions themselves, on the fixtures in `tests/fixtures/`).
//!
//! AI hint: a golden that disagrees with Python is a bug HERE until proven otherwise; the
//! Python bridge is the production oracle for the whole port.

pub mod authoring;
pub mod import;
use std::collections::BTreeMap;

#[derive(Debug, thiserror::Error)]
pub enum LbrError {
    #[error("not an EAGLE library: {0}")]
    NotALibrary(String),
    /// Phase 2 lands the engine; until then callers get an error, never a panic.
    #[error("not ported to the Rust engine yet: {0}")]
    NotPorted(&'static str),
}

/// Names that exist in a library (the equivalent of `_library_artifact_names`).
///
/// A deviceset's symbol and footprint are NOT required to share its name (the wiki's
/// ABM8-16MHZ sits on symbol+package "Crystal_GND24"), so `dev_sym` / `dev_pac` map each
/// deviceset to the gate symbols and device packages it actually references: a prove step
/// that EDITs `<deviceset>.sym` must follow the gate, not guess the name.
#[derive(Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ArtifactNames {
    pub sym: Vec<String>,
    pub pac: Vec<String>,
    pub dev: Vec<String>,
    pub p3d: Vec<String>,
    pub dev_sym: BTreeMap<String, Vec<String>>,
    pub dev_pac: BTreeMap<String, Vec<String>>,
}

pub fn artifact_names(xml: &str) -> Result<ArtifactNames, LbrError> {
    // EAGLE files open with <!DOCTYPE eagle SYSTEM "eagle.dtd">; roxmltree refuses DTDs unless told.
    let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
    let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;
    let root = doc.root_element();
    if root.tag_name().name() != "eagle" {
        return Err(LbrError::NotALibrary(format!("root element is <{}>, not <eagle>", root.tag_name().name())));
    }
    let names = |tag: &str| -> Vec<String> {
        root.descendants().filter(|n| n.is_element() && n.tag_name().name() == tag)
            .filter_map(|n| n.attribute("name").map(str::to_string)).collect()
    };
    let mut out = ArtifactNames { sym: names("symbol"), pac: names("package"), dev: names("deviceset"), p3d: names("package3d"), ..Default::default() };
    for ds in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "deviceset") {
        let Some(dn) = ds.attribute("name") else { continue };
        let mut syms: Vec<String> = ds.descendants().filter(|n| n.is_element() && n.tag_name().name() == "gate")
            .filter_map(|g| g.attribute("symbol").map(str::to_string)).collect();
        syms.sort(); syms.dedup();
        let mut pacs: Vec<String> = ds.descendants().filter(|n| n.is_element() && n.tag_name().name() == "device")
            .filter_map(|d| d.attribute("package").map(str::to_string)).collect();
        pacs.sort(); pacs.dedup();
        out.dev_sym.insert(dn.to_string(), syms);
        out.dev_pac.insert(dn.to_string(), pacs);
    }
    Ok(out)
}

/// One package's 3D binding: the urn Fusion resolves for `<package name=…>`.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Binding {
    pub package: String,
    pub wip_urn: String,
}

/// Inject EAGLE `<packages3d>` + per-device `<package3dinstances>` into an .lbr, MERGE-AWARE
/// and IDEMPOTENT (safe to re-run with any subset of parts). Bindings already in the file
/// are read first and new ones win on conflict; every prior 3D block is stripped and the
/// full merged set re-emitted, so a re-run with only the parts that failed last time
/// ACCUMULATES instead of wiping the parts that already succeeded. Byte-for-byte the
/// Python bridge's `_inject_package3d_bindings` (golden-tested), including its one quirk:
/// each re-run leaves one more blank line before `<symbols>` (harmless to Fusion).
///
/// AI hint: this is text surgery, not XML, on purpose: Fusion writes this markup in exactly
/// this shape and position, and re-serialising the whole document through an XML library
/// would move attributes and whitespace Fusion is sensitive to.
pub fn inject_package3d_bindings(lbr_text: &str, bindings: &[Binding]) -> String {
    use regex::Regex;
    // insertion order matters (the emitted block order is the merged map's order)
    let mut merged: Vec<(String, String)> = Vec::new();
    let mut upsert = |pkg: &str, urn: &str| {
        if let Some(e) = merged.iter_mut().find(|(p, _)| p == pkg) { e.1 = urn.to_string(); }
        else { merged.push((pkg.to_string(), urn.to_string())); }
    };
    let existing = Regex::new(r"(?s)<packages3d>(.*?)</packages3d>").unwrap();
    let one = Regex::new(r#"<package3d name="([^"]+)"[^>]*wip_urn="([^"]+)""#).unwrap();
    if let Some(em) = existing.captures(lbr_text) {
        for pm in one.captures_iter(em.get(1).map(|m| m.as_str()).unwrap_or("")) { upsert(&pm[1], &pm[2]); }
    }
    for b in bindings { upsert(&b.package, &b.wip_urn); }

    let mut text = Regex::new(r"(?s)\s*<packages3d>.*?</packages3d>").unwrap().replace_all(lbr_text, "").to_string();
    text = Regex::new(r"(?s)\s*<package3dinstances>.*?</package3dinstances>").unwrap().replace_all(&text, "").to_string();

    let blocks: Vec<String> = merged.iter().map(|(pkg, urn)| format!(
        r#"<package3d name="{pkg}" urn="" wip_urn="{urn}" locally_modified="yes" type="model"><description>{pkg}</description><packageinstances><packageinstance name="{pkg}"/></packageinstances></package3d>"#)).collect();
    let pkgs3d = format!("<packages3d>\n{}\n</packages3d>\n", blocks.join("\n"));
    text = text.replacen("</packages>", &format!("</packages>\n{pkgs3d}"), 1);

    let dev_re = Regex::new(r"(?s)<device\b[^>]*>.*?</device>").unwrap();
    let pkg_attr = Regex::new(r#"package="([^"]+)""#).unwrap();
    dev_re.replace_all(&text, |m: &regex::Captures| {
        let dev = m.get(0).unwrap().as_str();
        if let Some(pm) = pkg_attr.captures(dev) {
            if let Some((_, urn)) = merged.iter().find(|(p, _)| p == &pm[1]) {
                if dev.contains("</connects>") {
                    let inst = format!(r#"<package3dinstances><package3dinstance package3d_urn="{urn}"/></package3dinstances>"#);
                    return dev.replacen("</connects>", &format!("</connects>\n{inst}"), 1);
                }
            }
        }
        dev.to_string()
    }).to_string()
}

// ───────────────────────── bundle pairing (library_bundle.py) ─────────────────────────

/// One orderable variant of a deviceset (a `<device>` × `<technology>`), with its attributes.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Variant {
    #[serde(default)] pub device: String,
    #[serde(default)] pub technology: String,
    #[serde(default, rename = "fullName")] pub full_name: String,
    #[serde(default)] pub package: String,
    #[serde(default)] pub attributes: BTreeMap<String, String>,
    #[serde(default)] pub missing: Vec<String>,
}

#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Deviceset {
    #[serde(default)] pub name: String,
    #[serde(default)] pub description: String,
    #[serde(default)] pub variants: Vec<Variant>,
}

#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Totals {
    pub devicesets: usize,
    pub variants: usize,
    #[serde(rename = "withMpn")] pub with_mpn: usize,
    #[serde(rename = "withDistributor")] pub with_distributor: usize,
}

/// `_library_part_metadata`: every orderable variant in a .lbr with its attributes.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LibraryMetadata {
    #[serde(default)] pub ok: bool,
    #[serde(default)] pub devicesets: Vec<Deviceset>,
    #[serde(default)] pub totals: Totals,
}

pub const DISTRIBUTOR_ATTRS: [&str; 3] = ["MOUSER_PN", "DIGIKEY_PN", "LCSC_PN"];

/// Offline, no Fusion: what an EE needs to ORDER each variant, and what is missing.
pub fn library_part_metadata(xml: &str) -> Result<LibraryMetadata, LbrError> {
    let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
    let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;
    let el = |n: roxmltree::Node, tag: &str| n.is_element() && n.tag_name().name() == tag;
    let mut out = Vec::new();
    for ds in doc.root_element().descendants().filter(|n| el(*n, "deviceset")) {
        let ds_name = ds.attribute("name").unwrap_or("").to_string();
        let ds_desc = ds.children().find(|c| el(*c, "description")).and_then(|d| d.text()).unwrap_or("").trim().to_string();
        let mut variants = Vec::new();
        for dev in ds.descendants().filter(|n| el(*n, "device")) {
            let dev_name = dev.attribute("name").unwrap_or("").to_string();
            // Fusion-authored libraries do not use EAGLE's <technology><attribute>. They layer
            // <att_values> on the deviceset, again on the device, and then per named <att_set>
            // (the "DEFAULT" column of Fusion's Variant Attributes dialog is the set named "").
            // A later non-empty value wins. Both dialects are read, so a converted library that
            // carries both still reports one truthful set per variant.
            let layer = |parent: roxmltree::Node, into: &mut BTreeMap<String, String>| {
                for holder in parent.children().filter(|c| el(*c, "att_values")) {
                    for a in holder.children().filter(|c| el(*c, "att_value")) {
                        let n = a.attribute("name").unwrap_or("").to_uppercase();
                        let v = a.attribute("value").unwrap_or("").trim().to_string();
                        if !n.is_empty() && !v.is_empty() { into.insert(n, v); }
                    }
                }
            };
            let mut dev_base = BTreeMap::new();
            layer(ds, &mut dev_base);
            layer(dev, &mut dev_base);
            let att_sets: Vec<roxmltree::Node> = dev.children().filter(|c| el(*c, "att_sets")).flat_map(|h| h.children().filter(|c| el(*c, "att_set")).collect::<Vec<_>>()).collect();
            let set_values = |set: roxmltree::Node, into: &mut BTreeMap<String, String>| {
                for a in set.children().filter(|c| el(*c, "att_value")) {
                    let n = a.attribute("name").unwrap_or("").to_uppercase();
                    let v = a.attribute("value").unwrap_or("").trim().to_string();
                    if !n.is_empty() && !v.is_empty() { into.insert(n, v); }
                }
            };
            let tech_nodes: Vec<roxmltree::Node> = dev.descendants().filter(|n| el(*n, "technology")).collect();
            // one variant per technology; with none, one per attribute set; with neither, one bare variant
            let names: Vec<String> = if !tech_nodes.is_empty() { tech_nodes.iter().map(|t| t.attribute("name").unwrap_or("").to_string()).collect() }
                else if !att_sets.is_empty() { att_sets.iter().map(|t| t.attribute("name").unwrap_or("").to_string()).collect() }
                else { vec![String::new()] };
            for tname in names {
                let mut attrs = dev_base.clone();
                if let Some(set) = att_sets.iter().find(|t| t.attribute("name").unwrap_or("") == tname) { set_values(*set, &mut attrs); }
                if let Some(t) = tech_nodes.iter().find(|t| t.attribute("name").unwrap_or("") == tname) {
                    for a in t.descendants().filter(|n| el(*n, "attribute")) {
                        let n = a.attribute("name").unwrap_or("").to_uppercase();
                        let v = a.attribute("value").unwrap_or("").trim().to_string();
                        if !n.is_empty() && !v.is_empty() { attrs.insert(n, v); }
                    }
                }
                let mut missing: Vec<String> = ["MPN", "MANUFACTURER"].iter().filter(|k| attrs.get(**k).map(|v| v.is_empty()).unwrap_or(true)).map(|k| k.to_string()).collect();
                if !DISTRIBUTOR_ATTRS.iter().any(|d| attrs.get(*d).map(|v| !v.is_empty()).unwrap_or(false)) {
                    missing.push("a distributor PN (MOUSER_PN/DIGIKEY_PN/LCSC_PN)".into());
                }
                variants.push(Variant { full_name: format!("{ds_name}{dev_name}{tname}"), device: dev_name.clone(), technology: tname,
                                        package: dev.attribute("package").unwrap_or("").to_string(), attributes: attrs, missing });
            }
        }
        out.push(Deviceset { name: ds_name, description: ds_desc, variants });
    }
    let totals = Totals {
        devicesets: out.len(),
        variants: out.iter().map(|d| d.variants.len()).sum(),
        with_mpn: out.iter().flat_map(|d| &d.variants).filter(|v| v.attributes.get("MPN").map(|s| !s.is_empty()).unwrap_or(false)).count(),
        with_distributor: out.iter().flat_map(|d| &d.variants).filter(|v| DISTRIBUTOR_ATTRS.iter().any(|d| v.attributes.get(*d).map(|s| !s.is_empty()).unwrap_or(false))).count(),
    };
    Ok(LibraryMetadata { ok: true, devicesets: out, totals })
}

/// alnum-only, case-folded: how two names are compared when their spellings differ.
pub fn norm(s: &str) -> String { s.to_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect() }

/// A library folder name Fusion and the wiki both accept.
pub fn library_name_ok(name: &str) -> bool {
    let mut it = name.chars();
    match it.next() { Some(c) if c.is_ascii_alphanumeric() => {}, _ => return false }
    name.len() <= 64 && it.all(|c| c.is_ascii_alphanumeric() || " ._-".contains(c))
}

fn stem(p: &str) -> String {
    let base = p.rsplit(['/', '\\']).next().unwrap_or(p);
    match base.rfind('.') { Some(i) if i > 0 => base[..i].to_string(), _ => base.to_string() }
}

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PairedPart {
    pub deviceset: String,
    pub device: String,
    pub package: String,
    #[serde(rename = "lbrPath")] pub lbr_path: String,
    #[serde(rename = "modelPath")] pub model_path: String,
    #[serde(rename = "matchedBy")] pub matched_by: String,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Unbound { pub package: String, pub deviceset: String, pub device: String, pub why: String }
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Shared { pub deviceset: String, pub package: String, pub via: String }
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Pairing {
    pub parts: Vec<PairedPart>,
    pub unbound: Vec<Unbound>,
    pub shared: Vec<Shared>,
    pub models: Vec<String>,
    #[serde(rename = "modelsUnused")] pub models_unused: Vec<String>,
}

/// Pair every (deviceset, PACKAGE) in the .lbr with a STEP by name. ONE PART PER PACKAGE
/// VARIANT, not per deviceset (LAN7800 "", "-HV", "-SB" each bind their own STEP so Change
/// Package swaps footprint and 3D together); devicesets sharing an already-paired package are
/// `shared`, never unbound. Match order per unit: the package name, then deviceset+device (or
/// the bare deviceset), exact → normalised → normalised prefix (≥4 chars). A one-deviceset
/// library with one model pairs regardless of name. Same answers as `library_bundle.pair_parts`.
pub fn pair_parts(lbr_path: &str, models: &[String], metadata: &LibraryMetadata) -> Pairing {
    let mut units: Vec<(String, String, String)> = Vec::new();
    let mut devicesets = Vec::new();
    for d in &metadata.devicesets {
        devicesets.push(d.name.clone());
        let mut seen: Vec<String> = Vec::new();
        for v in &d.variants {
            if v.package.is_empty() || seen.contains(&v.package) { continue; }
            seen.push(v.package.clone());
            units.push((d.name.clone(), v.device.clone(), v.package.clone()));
        }
        if seen.is_empty() { units.push((d.name.clone(), String::new(), String::new())); }
    }
    let primary_pkg = |ds: &str| units.iter().find(|(d, _, pk)| d == ds && !pk.is_empty()).map(|u| u.2.clone()).unwrap_or_default();
    // later models overwrite earlier ones on a duplicate key, exactly like the dict comprehensions
    let mut by_stem: BTreeMap<String, String> = BTreeMap::new();
    let mut by_norm: Vec<(String, String)> = Vec::new(); // insertion-ordered like a dict
    for m in models {
        by_stem.insert(stem(m), m.clone());
        let k = norm(&stem(m));
        if let Some(e) = by_norm.iter_mut().find(|(kk, _)| *kk == k) { e.1 = m.clone(); } else { by_norm.push((k, m.clone())); }
    }
    let mut parts = Vec::new(); let mut unbound = Vec::new(); let mut shared = Vec::new();
    let mut used: Vec<String> = Vec::new();
    let mut bound_pkgs: BTreeMap<String, String> = BTreeMap::new();
    for (ds, dev, pk) in &units {
        if !pk.is_empty() && bound_pkgs.contains_key(pk) {
            shared.push(Shared { deviceset: ds.clone(), package: pk.clone(), via: bound_pkgs[pk].clone() });
            continue;
        }
        let mut cands: Vec<(String, &str)> = Vec::new();
        if !pk.is_empty() { cands.push((pk.clone(), "package")); }
        if !dev.is_empty() { cands.push((format!("{ds}{dev}"), "deviceset+device")); } else { cands.push((ds.clone(), "deviceset")); }
        let mut hit: Option<(String, String)> = None;
        for (name, how) in &cands {
            if let Some(m) = by_stem.get(name) { if !used.contains(m) { hit = Some((m.clone(), how.to_string())); break; } }
        }
        if hit.is_none() {
            for (name, how) in &cands {
                let n = norm(name);
                if let Some((_, m)) = by_norm.iter().find(|(k, _)| !n.is_empty() && *k == n) {
                    if !used.contains(m) { hit = Some((m.clone(), format!("{how}-normalised"))); break; }
                }
            }
        }
        if hit.is_none() {
            'outer: for (name, how) in &cands {
                let n = norm(name);
                for (k, m) in &by_norm {
                    if !n.is_empty() && !used.contains(m) && (k.starts_with(&n) || n.starts_with(k.as_str())) && n.len() >= 4 {
                        hit = Some((m.clone(), format!("{how}-prefix"))); break 'outer;
                    }
                }
            }
        }
        match hit {
            Some((m, how)) => {
                used.push(m.clone());
                let package = if !pk.is_empty() { pk.clone() } else { let p = primary_pkg(ds); if p.is_empty() { ds.clone() } else { p } };
                parts.push(PairedPart { deviceset: ds.clone(), device: dev.clone(), package, lbr_path: lbr_path.to_string(), model_path: m, matched_by: how });
                if !pk.is_empty() { bound_pkgs.insert(pk.clone(), ds.clone()); }
            }
            None => unbound.push(Unbound { package: if pk.is_empty() { ds.clone() } else { pk.clone() }, deviceset: ds.clone(), device: dev.clone(),
                                          why: format!("no STEP in the bundle named like the package {} or the device {}", py_repr(pk), py_repr(&format!("{ds}{dev}"))) }),
        }
    }
    if parts.is_empty() && units.len() == 1 && models.len() == 1 {
        unbound.clear();
        let (ds, dev, pk) = &units[0];
        used.push(models[0].clone());
        let package = if !pk.is_empty() { pk.clone() } else { let p = primary_pkg(ds); if p.is_empty() { ds.clone() } else { p } };
        parts.push(PairedPart { deviceset: ds.clone(), device: dev.clone(), package, lbr_path: lbr_path.to_string(), model_path: models[0].clone(), matched_by: "only-one-of-each".into() });
    }
    let models_unused = models.iter().filter(|m| !used.contains(m)).cloned().collect();
    Pairing { parts, unbound, shared, models: models.to_vec(), models_unused }
}

/// Python's `%r` for a str: single-quoted unless the text has a single quote and no double.
/// Python `repr(str)`: single quotes unless the text has a single quote and no double quote;
/// backslash, the quote in use, and control characters escaped the way CPython escapes them.
fn py_repr(s: &str) -> String {
    let dq = s.contains('\'') && !s.contains('"');
    let q = if dq { '"' } else { '\'' };
    let mut out = String::with_capacity(s.len() + 2);
    out.push(q);
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"),
            c if c == q => { out.push('\\'); out.push(c); }
            c if (c as u32) < 0x20 || c as u32 == 0x7f => out.push_str(&format!("\\x{:02x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push(q);
    out
}

// ───────────────────────── per-part split (library_bundle.split_per_part) ─────────────────────────

/// An owned, editable element tree that serialises exactly like Python's ElementTree
/// (`ET.ElementTree(root).write(fp, encoding="utf-8", xml_declaration=True)`): attributes in
/// document order, `<x />` for an element with no text and no children, `&`/`<`/`>` escaped
/// in text, plus `"` and the three control characters in attributes, no DOCTYPE, no root
/// tail. Comments and processing instructions are dropped, as ET's default parser drops them.
#[derive(Debug, Clone, PartialEq)]
pub struct Element {
    pub tag: String,
    pub attrs: Vec<(String, String)>,
    /// Text before the first child (ET `.text`).
    pub text: String,
    pub children: Vec<Element>,
    /// Text after this element's close, inside the parent (ET `.tail`).
    pub tail: String,
}

impl Element {
    pub fn new(tag: &str) -> Element { Element { tag: tag.into(), attrs: vec![], text: String::new(), children: vec![], tail: String::new() } }
    pub fn attr(&self, k: &str) -> Option<&str> { self.attrs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.as_str()) }
    pub fn find_child(&self, tag: &str) -> Option<&Element> { self.children.iter().find(|c| c.tag == tag) }
    pub fn find_child_mut(&mut self, tag: &str) -> Option<&mut Element> { self.children.iter_mut().find(|c| c.tag == tag) }
    /// Depth-first search (ET `.//tag`).
    pub fn find_deep_mut(&mut self, tag: &str) -> Option<&mut Element> {
        if self.tag == tag { return Some(self); }
        for c in self.children.iter_mut() { if let Some(f) = c.find_deep_mut(tag) { return Some(f); } }
        None
    }
    fn from_node(n: roxmltree::Node) -> Element {
        let mut e = Element::new(n.tag_name().name());
        e.attrs = n.attributes().map(|a| (a.name().to_string(), a.value().to_string())).collect();
        let mut last_child: Option<usize> = None;
        for c in n.children() {
            if c.is_element() {
                e.children.push(Element::from_node(c));
                last_child = Some(e.children.len() - 1);
            } else if c.is_text() {
                match last_child { Some(i) => e.children[i].tail.push_str(c.text().unwrap_or("")), None => e.text.push_str(c.text().unwrap_or("")) }
            }
        }
        e
    }
    pub fn parse(xml: &str) -> Result<Element, LbrError> {
        let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
        let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;
        Ok(Element::from_node(doc.root_element()))
    }
    fn write(&self, out: &mut String, is_root: bool) {
        out.push('<'); out.push_str(&self.tag);
        for (k, v) in &self.attrs { out.push(' '); out.push_str(k); out.push_str("=\""); out.push_str(&escape_attrib(v)); out.push('"'); }
        if self.text.is_empty() && self.children.is_empty() { out.push_str(" />"); }
        else {
            out.push('>'); out.push_str(&escape_cdata(&self.text));
            for c in &self.children { c.write(out, false); }
            out.push_str("</"); out.push_str(&self.tag); out.push('>');
        }
        if !is_root { out.push_str(&escape_cdata(&self.tail)); }
    }
    /// The whole document as Python's ElementTree writes it.
    pub fn to_xml_document(&self) -> String {
        let mut s = String::from("<?xml version='1.0' encoding='utf-8'?>\n");
        self.write(&mut s, true);
        s
    }
}

fn escape_cdata(s: &str) -> String { s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;") }
fn escape_attrib(s: &str) -> String {
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
        .replace('\r', "&#13;").replace('\n', "&#10;").replace('\t', "&#09;")
}

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SplitEntry {
    #[serde(rename = "lbrPath")] pub lbr_path: String,
    pub package: String,
    pub symbols: Vec<String>,
    pub packages: Vec<String>,
}

fn safe_name(s: &str) -> String {
    let mut out = String::new(); let mut in_run = false;
    for c in s.chars() {
        if c.is_ascii_alphanumeric() || "._-".contains(c) { out.push(c); in_run = false; }
        else if !in_run { out.push('_'); in_run = true; }
    }
    out
}

/// One library per deviceset AND one per (deviceset, package variant), so Fusion's Package3D
/// generator (which loads the FIRST package of the open library whatever path it is given)
/// mints each chip on its own footprint. Each file keeps the merged library's header and
/// layers and carries exactly the packages that deviceset's devices use (primary first), the
/// symbols its gates use, and the deviceset itself, with any prior `<package3dinstances>`
/// stripped. Keys: `<deviceset>` (primary package file) and `<deviceset>::<package>`.
/// Byte-for-byte what `library_bundle.split_per_part` writes (golden-tested).
pub fn split_per_part(xml: &str, out_dir: &std::path::Path) -> Result<BTreeMap<String, SplitEntry>, LbrError> {
    let root = Element::parse(xml)?;
    std::fs::create_dir_all(out_dir).map_err(|e| LbrError::NotALibrary(format!("cannot create {}: {e}", out_dir.display())))?;
    let mut probe = root.clone();
    let lib = probe.find_deep_mut("library").ok_or_else(|| LbrError::NotALibrary("no <library>".into()))?.clone();
    let pk_all: Vec<Element> = lib.find_child("packages").map(|p| p.children.iter().filter(|c| c.tag == "package").cloned().collect()).unwrap_or_default();
    let sy_all: Vec<Element> = lib.find_child("symbols").map(|p| p.children.iter().filter(|c| c.tag == "symbol").cloned().collect()).unwrap_or_default();
    let devicesets: Vec<Element> = lib.find_child("devicesets").map(|p| p.children.iter().filter(|c| c.tag == "deviceset").cloned().collect()).unwrap_or_default();
    let mut out = BTreeMap::new();
    for ds in &devicesets {
        let name = ds.attr("name").unwrap_or("").to_string();
        let mut pkgs: Vec<String> = Vec::new();
        if let Some(devs) = ds.find_child("devices") {
            for d in devs.children.iter().filter(|c| c.tag == "device") {
                if let Some(pk) = d.attr("package") { if !pk.is_empty() && !pkgs.iter().any(|p| p == pk) { pkgs.push(pk.to_string()); } }
            }
        }
        let mut syms: Vec<String> = Vec::new();
        if let Some(gates) = ds.find_child("gates") {
            for g in gates.children.iter().filter(|c| c.tag == "gate") {
                if let Some(sy) = g.attr("symbol") { if !sy.is_empty() && !syms.iter().any(|s| s == sy) { syms.push(sy.to_string()); } }
            }
        }
        let mut variants: Vec<Option<String>> = vec![None];
        variants.extend(pkgs.iter().cloned().map(Some));
        for only_pk in variants {
            let mut new_root = root.clone();
            let new_lib = new_root.find_deep_mut("library").unwrap();
            // ET.remove drops the element AND its tail
            new_lib.children.retain(|c| !matches!(c.tag.as_str(), "packages" | "symbols" | "devicesets" | "packages3d"));
            let use_pkgs: Vec<String> = match &only_pk { Some(p) => vec![p.clone()], None => pkgs.clone() };
            let mut pe = Element::new("packages");
            for pk in &use_pkgs { if let Some(p) = pk_all.iter().find(|p| p.attr("name") == Some(pk.as_str())) { pe.children.push(p.clone()); } }
            let mut se = Element::new("symbols");
            for sy in &syms { if let Some(s) = sy_all.iter().find(|s| s.attr("name") == Some(sy.as_str())) { se.children.push(s.clone()); } }
            let mut de = Element::new("devicesets");
            let mut ds2 = ds.clone();
            if let Some(devs) = ds2.find_child_mut("devices") {
                for dev in devs.children.iter_mut().filter(|c| c.tag == "device") {
                    dev.children.retain(|c| c.tag != "package3dinstances");
                }
                if let Some(pk) = &only_pk {
                    devs.children.retain(|c| c.tag != "device" || c.attr("package") == Some(pk.as_str()));
                }
            }
            de.children.push(ds2);
            // EAGLE order inside <library>: description?, packages, symbols, devicesets
            let head: Vec<Element> = new_lib.children.iter().filter(|c| !matches!(c.tag.as_str(), "packages" | "symbols" | "devicesets")).cloned().collect();
            new_lib.children = head; new_lib.children.push(pe); new_lib.children.push(se); new_lib.children.push(de);
            let mut safe = safe_name(&name); if safe.is_empty() { safe = "part".into(); }
            if let Some(pk) = &only_pk { let mut sp = safe_name(pk); if sp.is_empty() { sp = "pkg".into(); } safe = format!("{safe}__{sp}"); }
            let fp = out_dir.join(format!("{safe}.lbr"));
            std::fs::write(&fp, new_root.to_xml_document()).map_err(|e| LbrError::NotALibrary(format!("cannot write {}: {e}", fp.display())))?;
            let entry = SplitEntry { lbr_path: fp.to_string_lossy().replace('\\', "/"), package: only_pk.clone().unwrap_or_else(|| pkgs.first().cloned().unwrap_or_default()), symbols: syms.clone(), packages: use_pkgs };
            match &only_pk { Some(pk) => { out.insert(format!("{name}::{pk}"), entry); } None => { out.insert(name.clone(), entry); } }
        }
    }
    Ok(out)
}

// ───────────────────────── bundle staging (library_bundle.py) ─────────────────────────

pub const MAX_ZIP_BYTES: u64 = 256 * 1024 * 1024;
pub const MAX_UNPACKED_BYTES: u64 = 512 * 1024 * 1024;
const MODEL_EXT: [&str; 2] = [".step", ".stp"];

#[derive(Debug, thiserror::Error)]
pub enum BundleError {
    #[error("unsafe zip entry: {0}")]
    UnsafeEntry(String),
    #[error("bundle unpacks past the {0} MB cap")]
    TooBig(u64),
    #[error("zip error: {0}")]
    Zip(String),
    #[error("io error: {0}")]
    Io(String),
}

/// Extract with a zip-slip guard and an unpacked-size cap. Returns files extracted.
/// Same refusals as `library_bundle.safe_extract`: an absolute entry, a drive-letter entry,
/// or any `..` segment is refused before a byte is written.
pub fn safe_extract(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<usize, BundleError> {
    let f = std::fs::File::open(zip_path).map_err(|e| BundleError::Io(e.to_string()))?;
    let mut zf = zip::ZipArchive::new(f).map_err(|e| BundleError::Zip(e.to_string()))?;
    std::fs::create_dir_all(dest).map_err(|e| BundleError::Io(e.to_string()))?;
    let dest = dest.canonicalize().map_err(|e| BundleError::Io(e.to_string()))?;
    let (mut total, mut count) = (0u64, 0usize);
    for i in 0..zf.len() {
        let mut entry = zf.by_index(i).map_err(|e| BundleError::Zip(e.to_string()))?;
        let name = entry.name().to_string();
        if name.is_empty() || name.ends_with('/') { continue; }
        let norm = name.replace('\\', "/");
        let drive = norm.len() >= 2 && norm.as_bytes()[0].is_ascii_alphabetic() && norm.as_bytes()[1] == b':';
        if norm.starts_with('/') || drive || norm.split('/').any(|s| s == "..") { return Err(BundleError::UnsafeEntry(name)); }
        total += entry.size();
        if total > MAX_UNPACKED_BYTES { return Err(BundleError::TooBig(MAX_UNPACKED_BYTES >> 20)); }
        let target = dest.join(&norm);
        if !target.starts_with(&dest) { return Err(BundleError::UnsafeEntry(name)); }
        if let Some(p) = target.parent() { std::fs::create_dir_all(p).map_err(|e| BundleError::Io(e.to_string()))?; }
        let mut out = std::fs::File::create(&target).map_err(|e| BundleError::Io(e.to_string()))?;
        std::io::copy(&mut entry, &mut out).map_err(|e| BundleError::Io(e.to_string()))?;
        count += 1;
    }
    Ok(count)
}

#[derive(Debug, Clone, Default, PartialEq)]
pub struct BundleFiles {
    /// The shallowest .lbr (ties broken by path), or None.
    pub lbr: Option<String>,
    /// Every .step/.stp anywhere in the bundle, sorted.
    pub models: Vec<String>,
    pub all_lbrs: Vec<String>,
}

fn walk(dir: &std::path::Path, out: &mut Vec<String>) {
    if let Ok(rd) = std::fs::read_dir(dir) {
        for e in rd.flatten() {
            let p = e.path();
            if p.is_dir() { walk(&p, out); } else { out.push(p.to_string_lossy().to_string()); }
        }
    }
}

/// The shallowest .lbr wins; models are every .step/.stp anywhere in the bundle.
pub fn find_bundle_files(root: &std::path::Path) -> BundleFiles {
    let mut files = Vec::new(); walk(root, &mut files);
    let sep = std::path::MAIN_SEPARATOR;
    let mut lbrs: Vec<String> = files.iter().filter(|f| f.to_lowercase().ends_with(".lbr")).cloned().collect();
    let mut models: Vec<String> = files.iter().filter(|f| { let l = f.to_lowercase(); MODEL_EXT.iter().any(|x| l.ends_with(x)) }).cloned().collect();
    lbrs.sort_by(|a, b| (a.matches(sep).count(), a.as_str()).cmp(&(b.matches(sep).count(), b.as_str())));
    models.sort();
    BundleFiles { lbr: lbrs.first().cloned(), models, all_lbrs: lbrs }
}

/// Copy the .lbr and its models to the stable install folder; returns the new .lbr path and
/// the old→new model map, forward slashes throughout (what the chain and the wiki expect).
pub fn place_library(lbr_path: &std::path::Path, models: &[String], dest_dir: &std::path::Path) -> Result<(String, BTreeMap<String, String>), BundleError> {
    std::fs::create_dir_all(dest_dir).map_err(|e| BundleError::Io(e.to_string()))?;
    let new_lbr = dest_dir.join(lbr_path.file_name().unwrap_or_default());
    std::fs::copy(lbr_path, &new_lbr).map_err(|e| BundleError::Io(e.to_string()))?;
    let mut map = BTreeMap::new();
    for m in models {
        let src = std::path::Path::new(m);
        let t = dest_dir.join(src.file_name().unwrap_or_default());
        let same = match (src.canonicalize(), t.canonicalize()) { (Ok(a), Ok(b)) => a == b, _ => false };
        if !same { std::fs::copy(src, &t).map_err(|e| BundleError::Io(e.to_string()))?; }
        map.insert(m.clone(), t.to_string_lossy().replace('\\', "/"));
    }
    Ok((new_lbr.to_string_lossy().replace('\\', "/"), map))
}

// ───────────────────────── preflight + lints (server.py) ─────────────────────────

/// Python's `repr(float)` for the values these lints print (shortest round-trip, with a
/// trailing `.0` on integral values), so warning texts match the Python bridge exactly.
pub fn py_float(v: f64) -> String {
    if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e16 { format!("{:.1}", v) } else { format!("{}", v) }
}

/// Python's `repr(str)`: single quotes unless the text has a single quote and no double.
pub fn py_repr_str(s: &str) -> String { py_repr(s) }

fn round3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Check { pub check: String, pub ok: bool, pub detail: String }

#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ConnectCheck {
    pub ok: bool,
    #[serde(default)] pub connects: usize,
    #[serde(default, rename = "badPinRefs")] pub bad_pin_refs: Vec<(Option<String>, Option<String>)>,
    #[serde(default, rename = "badPadRefs")] pub bad_pad_refs: Vec<(Option<String>, Option<String>)>,
    #[serde(default, rename = "unconnectedPads")] pub unconnected_pads: Vec<String>,
    pub why: Option<String>,
}

/// Referential integrity of the pin→pad mapping (the fifth artifact). A connect's `pad` is a
/// SPACE-SEPARATED list (QFN thermal vias, BGA balls), so every name in it is checked.
pub fn check_connects(xml: &str) -> ConnectCheck {
    let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
    let Ok(doc) = roxmltree::Document::parse_with_options(xml, opts) else {
        return ConnectCheck { ok: false, why: Some("library unreadable".into()), ..Default::default() };
    };
    let el = |n: roxmltree::Node, t: &str| n.is_element() && n.tag_name().name() == t;
    let root = doc.root_element();
    let named = |t: &str| -> std::collections::BTreeSet<String> { root.descendants().filter(|n| el(*n, t)).filter_map(|n| n.attribute("name").map(str::to_string)).collect() };
    let pins = named("pin");
    let mut pads = named("smd"); pads.extend(named("pad"));
    let connects: Vec<(Option<String>, Option<String>)> = root.descendants().filter(|n| el(*n, "connect"))
        .map(|c| (c.attribute("pin").map(str::to_string), c.attribute("pad").map(str::to_string))).collect();
    let bad_pin: Vec<_> = connects.iter().filter(|c| !c.0.as_ref().map(|p| pins.contains(p)).unwrap_or(false)).cloned().collect();
    let bad_pad: Vec<_> = connects.iter().filter(|c| {
        let names: Vec<&str> = c.1.as_deref().unwrap_or("").split_whitespace().collect();
        names.is_empty() || names.iter().any(|p| !pads.contains(*p))
    }).cloned().collect();
    let connected: std::collections::BTreeSet<String> = connects.iter().flat_map(|c| c.1.as_deref().unwrap_or("").split_whitespace().map(str::to_string).collect::<Vec<_>>()).collect();
    let unconnected: Vec<String> = pads.iter().filter(|p| !connected.contains(*p)).cloned().collect();
    let ok = !connects.is_empty() && bad_pin.is_empty() && bad_pad.is_empty();
    let why = if ok { None } else if connects.is_empty() { Some("no connects at all".into()) }
              else { Some(format!("connects reference missing {}", if !bad_pin.is_empty() { "pins" } else { "pads" })) };
    ConnectCheck { ok, connects: connects.len(), bad_pin_refs: bad_pin.into_iter().take(8).collect(),
                   bad_pad_refs: bad_pad.into_iter().take(8).collect(), unconnected_pads: unconnected.into_iter().take(12).collect(), why }
}

/// One EE-grade warning about a library (metadata or geometry); the same keys Python emits.
pub type Warning = serde_json::Map<String, serde_json::Value>;

fn warn(pairs: &[(&str, serde_json::Value)]) -> Warning {
    let mut m = Warning::new();
    for (k, v) in pairs { m.insert(k.to_string(), v.clone()); }
    m
}

/// Shape-aware geometry checks: stacked pins, pad copper overlap or < 0.2 mm clearance,
/// generic class names / descriptions. Same warnings as `_lint_library_geometry`.
pub fn lint_library_geometry(xml: &str) -> Vec<Warning> {
    use serde_json::json;
    let mut out = Vec::new();
    let Ok(root) = Element::parse(xml) else { return out };
    let mut probe = root.clone();
    let Some(lib) = probe.find_deep_mut("library") else { return out };
    let lib = lib.clone();
    let f = |e: &Element, k: &str| -> f64 { e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0) };
    // 1. symbol pins at identical coordinates
    if let Some(symbols) = lib.find_child("symbols") {
        for sym in symbols.children.iter().filter(|c| c.tag == "symbol") {
            let mut seen: Vec<((f64, f64), String)> = Vec::new();
            for pin in sym.children.iter().filter(|c| c.tag == "pin") {
                let key = (round3(f(pin, "x")), round3(f(pin, "y")));
                let name = pin.attr("name").unwrap_or("").to_string();
                if let Some((_, prev)) = seen.iter().find(|(k, _)| *k == key) {
                    out.push(warn(&[("code", json!("symbol_pins_stacked")), ("symbol", json!(sym.attr("name"))),
                        ("detail", json!(format!("pins {} and {} sit at the same point ({}, {}); one hides the other", py_repr(prev), py_repr(&name), py_float(key.0), py_float(key.1))))]));
                }
                seen.retain(|(k, _)| *k != key); seen.push((key, name));
            }
            let has = |t: &str| sym.children.iter().any(|c| c.tag == t);
            let pins = sym.children.iter().filter(|c| c.tag == "pin").count();
            if !has("wire") && !has("rectangle") && !has("circle") && !has("polygon") && pins <= 4 {
                out.push(warn(&[("code", json!("symbol_no_body")), ("symbol", json!(sym.attr("name"))),
                    ("detail", json!("symbol has pins but no body graphics (the converter dropped them)"))]));
            }
        }
    }
    // 2. pad copper overlap / clearance
    let bbox = |e: &Element| -> (f64, f64, f64, f64) {
        let (x, y) = (f(e, "x"), f(e, "y"));
        let (mut dx, mut dy) = if e.tag == "smd" { (f(e, "dx"), f(e, "dy")) } else {
            let mut dia = f(e, "diameter"); if dia == 0.0 { dia = f(e, "drill") * 1.5; }
            let shape = e.attr("shape").unwrap_or("round").to_lowercase();
            if shape == "long" { (dia * 2.0, dia) } else { (dia, dia) }
        };
        let rot = e.attr("rot").unwrap_or("");
        if rot.contains("R90") || rot.contains("R270") { std::mem::swap(&mut dx, &mut dy); }
        (x - dx / 2.0, y - dy / 2.0, x + dx / 2.0, y + dy / 2.0)
    };
    if let Some(packages) = lib.find_child("packages") {
        for pkg in packages.children.iter().filter(|c| c.tag == "package") {
            let pads: Vec<&Element> = pkg.children.iter().filter(|c| c.tag == "smd").chain(pkg.children.iter().filter(|c| c.tag == "pad")).collect();
            let boxes: Vec<(String, (f64, f64, f64, f64))> = pads.iter().map(|p| (p.attr("name").unwrap_or("").to_string(), bbox(p))).collect();
            // pads that share a base name are one net by construction (a heatsink via under an
            // exposed pad is written as B4@2, B4@3 ... and connected to B4's pin): copper that
            // meets between them is the thermal connection, never a short (page issue #89)
            let base = |n: &str| n.split('@').next().unwrap_or(n).trim().to_ascii_uppercase();
            let mut thermal: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
            for i in 0..boxes.len() {
                for j in i + 1..boxes.len() {
                    let (na, a) = &boxes[i]; let (nb, b) = &boxes[j];
                    let gap_x = (a.0 - b.2).max(b.0 - a.2);
                    let gap_y = (a.1 - b.3).max(b.1 - a.3);
                    let gap = gap_x.max(gap_y);
                    if base(na) == base(nb) && !na.is_empty() {
                        if gap < 0.2 { *thermal.entry(base(na)).or_insert(0) += 1; }
                        continue;
                    }
                    if gap < 0.0 {
                        out.push(warn(&[("code", json!("pad_overlap")), ("package", json!(pkg.attr("name"))),
                            ("detail", json!(format!("pads {} and {} overlap (copper intersects): a short on the board", py_repr(na), py_repr(nb))))]));
                    } else if gap < 0.2 {
                        out.push(warn(&[("code", json!("pad_clearance")), ("package", json!(pkg.attr("name"))),
                            ("detail", json!(format!("pads {} and {} are only {:.2} mm apart", py_repr(na), py_repr(nb), gap)))]));
                    }
                }
            }
            for (b, n) in thermal {
                out.push(warn(&[("code", json!("heatsink_vias")), ("level", json!("info")), ("package", json!(pkg.attr("name"))),
                    ("detail", json!(format!("{n} same-net pad join(s) under {}: heatsink vias (B4@n) on the exposed pad's own net, the intended thermal connection", py_repr(&b))))]));
            }
        }
    }
    // 3. generic names / descriptions
    let gen_name = regex::Regex::new(r"(?i)^(L|R|C|D|LED|Q|Y|X|F|FB|J|Conn_\d+x\d+.*|Crystal(_\w+)?|Resistor|Capacitor|Inductor)$").unwrap();
    let gen_desc = regex::Regex::new(r"(?i)^(inductor|resistor|capacitor|unpolarized capacitor|polarized capacitor|light emitting diode|led|diode|generic connector.*|four pin crystal.*|crystal)$").unwrap();
    if let Some(devicesets) = lib.find_child("devicesets") {
        for ds in devicesets.children.iter().filter(|c| c.tag == "deviceset") {
            let n = ds.attr("name").unwrap_or("").to_string();
            let d = ds.find_child("description").map(|x| x.text.trim().to_string()).unwrap_or_default();
            if gen_name.is_match(&n) {
                out.push(warn(&[("code", json!("generic_deviceset_name")), ("deviceset", json!(n)),
                    ("detail", json!(format!("deviceset is named {}, a class name, not a part; the MPN belongs here", py_repr(&n))))]));
            }
            if !d.is_empty() && gen_desc.is_match(&d) {
                out.push(warn(&[("code", json!("generic_description")), ("deviceset", json!(n)),
                    ("detail", json!(format!("description {} says the class, not the part (value, package, maker)", py_repr(&d))))]));
            }
        }
    }
    out
}

#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MetadataLint {
    pub ok: bool,
    #[serde(default)] pub warnings: Vec<Warning>,
    #[serde(default, skip_serializing_if = "Option::is_none")] pub totals: Option<Totals>,
    #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option<String>,
}

/// EE-grade warnings about part metadata (never blocks opening), then geometry. `ok` means
/// "fit to ORDER from", a different question from "will open". Same as `_lint_part_metadata`.
pub fn lint_part_metadata(xml: &str) -> MetadataLint {
    use serde_json::json;
    let md = match library_part_metadata(xml) { Ok(m) => m, Err(e) => return MetadataLint { ok: false, warnings: vec![], totals: None, error: Some(e.to_string()) } };
    let mut warns = Vec::new();
    for ds in &md.devicesets {
        if ds.description.is_empty() {
            warns.push(warn(&[("code", json!("deviceset_no_description")), ("deviceset", json!(ds.name)),
                ("detail", json!("No <description>. Fusion's Content Manager shows this to whoever places the part; without it they are picking on a symbol outline alone."))]));
        }
        for v in &ds.variants {
            if !v.missing.is_empty() {
                warns.push(warn(&[("code", json!("variant_missing_part_numbers")), ("deviceset", json!(ds.name)), ("variant", json!(v.full_name)),
                    ("missing", json!(v.missing)),
                    ("detail", json!(format!("Missing {}. This bridge's own BOM export reads these exact attribute names, so the BOM comes out blank for this part.", v.missing.join(", "))))]));
            }
        }
        if ds.variants.len() > 1 {
            let mpns: Vec<Option<&String>> = ds.variants.iter().map(|v| v.attributes.get("MPN").filter(|s| !s.is_empty())).collect();
            let named: Vec<&String> = mpns.iter().flatten().cloned().collect();
            if named.len() < ds.variants.len() {
                warns.push(warn(&[("code", json!("variants_indistinguishable")), ("deviceset", json!(ds.name)),
                    ("detail", json!(format!("{} variants but only {} carry an MPN. Variants that share a symbol, footprint and 3D body are distinguishable ONLY by metadata - an automotive grade part and a baseline one look identical here.", ds.variants.len(), named.len())))]));
            } else {
                let mut uniq = named.clone(); uniq.sort(); uniq.dedup();
                if uniq.len() < named.len() {
                    let mut dups: Vec<&String> = named.iter().filter(|m| named.iter().filter(|n| n == m).count() > 1).cloned().collect();
                    dups.sort(); dups.dedup();
                    let list: Vec<String> = dups.into_iter().take(3).cloned().collect();
                    warns.push(warn(&[("code", json!("duplicate_variant_mpn")), ("deviceset", json!(ds.name)),
                        ("detail", json!(format!("Two or more variants share the same MPN ({}), so they are not actually different parts - or one MPN is wrong.", list.join(", "))))]));
                }
            }
        }
    }
    warns.extend(lint_library_geometry(xml));
    MetadataLint { ok: warns.is_empty(), warnings: warns, totals: Some(md.totals), error: None }
}

#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Preflight {
    pub ok: bool,
    pub checks: Vec<Check>,
    pub errors: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "metadataOk")] pub metadata_ok: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "metadataWarnings")] pub metadata_warnings: Option<Vec<Warning>>,
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "partTotals")] pub part_totals: Option<Totals>,
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "_metadataHint")] pub metadata_hint: Option<String>,
}

const EAGLE_ALIGNS: [&str; 9] = ["bottom-left", "bottom-center", "bottom-right", "center-left", "center", "center-right", "top-left", "top-center", "top-right"];

/// Will Fusion open this .lbr? Every rejection Fusion has actually produced, as a named
/// offline check: truncated file, empty packages, dead connects, absent devicesets, duplicate
/// pad names. `ok` keeps meaning "will open"; ordering fitness rides separately.
pub fn preflight_lbr(path: &std::path::Path) -> Preflight {
    let mut checks: Vec<Check> = Vec::new();
    let mut c = |name: &str, ok: bool, detail: String| -> bool { checks.push(Check { check: name.into(), ok, detail }); ok };
    let sz = match std::fs::metadata(path) {
        Ok(m) if m.is_file() => m.len(),
        Ok(_) => { c("file-readable", false, format!("[Errno 21] Is a directory: {}", py_repr(&path.to_string_lossy()))); return finish(checks, None); }
        Err(e) => { c("file-readable", false, format!("[Errno {}] {}: {}", e.raw_os_error().unwrap_or(2), if e.kind() == std::io::ErrorKind::NotFound { "No such file or directory" } else { "cannot read" }, py_repr(&path.to_string_lossy()))); return finish(checks, None); }
    };
    c("file-readable", true, format!("{sz} bytes"));
    c("size-sane", sz >= 200, format!("{sz} bytes - a real EAGLE library is never this small (truncated transfer?)"));
    let mut xml: Option<String> = None;
    if sz >= 200 {
        let text = std::fs::read_to_string(path).unwrap_or_default();
        let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
        match roxmltree::Document::parse_with_options(&text, opts) {
            Ok(_) => { c("xml-parses", true, String::new()); xml = Some(text); }
            Err(e) => { c("xml-parses", false, e.to_string().chars().take(160).collect()); }
        }
    }
    if let Some(text) = &xml {
        let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
        let doc = roxmltree::Document::parse_with_options(text, opts).unwrap();
        let root = doc.root_element();
        let el = |n: roxmltree::Node, t: &str| n.is_element() && n.tag_name().name() == t;
        c("root-is-eagle", root.tag_name().name() == "eagle", format!("root is <{}>", root.tag_name().name()));
        let count = |t: &str| root.descendants().filter(|n| el(*n, t)).count();
        let (pkgs, syms, devs) = (count("package"), count("symbol"), count("deviceset"));
        c("packages-nonempty", pkgs > 0, if pkgs > 0 { format!("{pkgs} package(s)") } else { "EMPTY <packages> - Fusion opens this to a silent 'No Components' library".into() });
        c("symbols-present", syms > 0, format!("{syms} symbol(s)"));
        let bad_align: Vec<String> = root.descendants().filter(|n| el(*n, "text")).filter_map(|t| t.attribute("align")).filter(|a| !EAGLE_ALIGNS.contains(a)).map(str::to_string).collect();
        let mut uniq = bad_align.clone(); uniq.sort(); uniq.dedup();
        c("text-align-valid", true, if bad_align.is_empty() { "all <text> align values are EAGLE values".into() } else {
            format!("{} <text> element(s) carry a non-EAGLE align value ([{}]); Fusion will list a warning for each on open. Fix upstream (adom-lbr group captions).", bad_align.len(), uniq.iter().map(|s| py_repr(s)).collect::<Vec<_>>().join(", ")) });
        c("devicesets-present", devs > 0, if devs > 0 { format!("{devs} deviceset(s)") } else { "no <deviceset> - EDIT <name>.dev will raise Fusion's Create-new prompt".into() });
        if pkgs > 0 && syms > 0 {
            let conn = check_connects(text);
            c("connects-resolve", conn.ok, if conn.ok { format!("{} connect(s), all resolving", conn.connects) } else { conn.why.clone().unwrap_or_else(|| "invalid".into()).chars().take(160).collect() });
        }
        let mut dups: Vec<String> = Vec::new();
        for pkg in root.descendants().filter(|n| el(*n, "package")) {
            let mut seen: Vec<(String, usize)> = Vec::new();
            let names = pkg.descendants().filter(|n| el(*n, "smd")).chain(pkg.descendants().filter(|n| el(*n, "pad"))).filter_map(|p| p.attribute("name").map(str::to_string));
            for nm in names { match seen.iter_mut().find(|(k, _)| *k == nm) { Some(e) => e.1 += 1, None => seen.push((nm, 1)) } }
            dups.extend(seen.into_iter().filter(|(_, n)| *n > 1).map(|(n, cnt)| format!("{}/{} x{}", pkg.attribute("name").unwrap_or(""), n, cnt)));
        }
        c("pads-unique", dups.is_empty(), if dups.is_empty() { "all pad names unique per package".into() } else {
            format!("duplicate pad names - Fusion's importer refuses the ENTIRE library over these: {}. Rename to e.g. 17, 17@2, 17@3...", dups.iter().take(6).cloned().collect::<Vec<_>>().join(", ")) });
        // A 3D package naming a package the library does not have makes Fusion refuse the ENTIRE
        // library: an Error modal and an empty design. Only libraries with bindings get this check.
        if root.descendants().any(|n| el(n, "packageinstance")) {
            let names: Vec<&str> = root.descendants().filter(|n| el(*n, "package")).filter_map(|p| p.attribute("name")).collect();
            let dangling: Vec<&str> = root.descendants().filter(|n| el(*n, "packageinstance")).filter_map(|p| p.attribute("name")).filter(|n| !names.iter().any(|k| k.eq_ignore_ascii_case(n))).collect();
            c("package3d-refs-resolve", dangling.is_empty(), if dangling.is_empty() { "every 3D binding names a package in this library".into() } else {
                format!("3D binding(s) name packages this library does not have, so Fusion refuses the ENTIRE library: {}. Bind to the <package> name, not the deviceset's.", dangling.iter().take(6).copied().collect::<Vec<_>>().join(", ")) });
        }
    }
    finish(checks, xml.as_deref())
}

fn finish(checks: Vec<Check>, xml: Option<&str>) -> Preflight {
    let ok = checks.iter().all(|c| c.ok);
    let errors = checks.iter().filter(|c| !c.ok).map(|c| format!("{}: {}", c.check, c.detail)).collect();
    let mut res = Preflight { ok, checks, errors, ..Default::default() };
    if let Some(text) = xml {
        let lint = lint_part_metadata(text);
        if lint.error.is_none() {
            res.metadata_ok = Some(lint.ok);
            if !lint.ok {
                res.metadata_hint = Some(format!("The library will open, but {} part-metadata warning(s) mean an EE cannot order from it: MPN / MANUFACTURER / a distributor PN (MOUSER_PN, DIGIKEY_PN, LCSC_PN) are what this bridge's own BOM export reads, and variants that share geometry are told apart ONLY by these. See fusion_library_parts for the per-variant breakdown.", lint.warnings.len()));
            }
            res.metadata_warnings = Some(lint.warnings);
            res.part_totals = lint.totals;
        }
    }
    res
}

#[cfg(test)]
mod heatsink_tests {
    #[test]
    fn preflight_refuses_a_3d_binding_to_a_missing_package() {
        let lib = |instance: &str| format!(r#"<?xml version="1.0"?><eagle><drawing><library><packages><package name="SOIC-8"><smd name="1" x="0" y="0" dx="1" dy="1" layer="1"/></package></packages><packages3d><package3d name="{instance}" wip_urn="urn:x"><packageinstances><packageinstance name="{instance}"/></packageinstances></package3d></packages3d><symbols><symbol name="S"/></symbols><devicesets><deviceset name="NY8A054ES8"><devices><device name="" package="SOIC-8"><connects/></device></devices></deviceset></devicesets></library></drawing></eagle>"#);
        let dir = std::env::temp_dir().join(format!("fusion-lbr-refs-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let check = |instance: &str| {
            let path = dir.join(format!("{instance}.lbr"));
            std::fs::write(&path, lib(instance)).unwrap();
            crate::preflight_lbr(&path).checks.into_iter().find(|c| c.check == "package3d-refs-resolve").expect("the check runs on a library with bindings")
        };
        let bad = check("NY8A054ES8");
        assert!(!bad.ok, "a binding to the deviceset's name is refused");
        assert!(bad.detail.contains("NY8A054ES8"), "and names it: {}", bad.detail);
        assert!(check("SOIC-8").ok, "a binding to the real package passes");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn heatsink_vias_are_not_shorts() {
        let lbr = r#"<eagle version="9.6.2"><drawing><library><packages><package name="QFN-EP">
<smd name="B4" x="0" y="0" dx="3" dy="3" layer="1"/>
<pad name="B4@2" x="0.7" y="0.7" drill="0.3" diameter="0.6"/>
<pad name="B4@3" x="-0.7" y="0.7" drill="0.3" diameter="0.6"/>
<smd name="1" x="0" y="0.2" dx="1" dy="1" layer="1"/>
</package></packages></library></drawing></eagle>"#;
        let w = super::lint_library_geometry(lbr);
        let codes: Vec<String> = w.iter().map(|x| x["code"].as_str().unwrap_or("").to_string()).collect();
        let overlaps: Vec<&serde_json::Map<String, serde_json::Value>> = w.iter().filter(|x| x["code"] == "pad_overlap").collect();
        assert!(overlaps.iter().all(|o| o["detail"].as_str().unwrap_or("").contains("'1'")), "only the genuinely different pad '1' may be flagged: {codes:?}");
        assert!(w.iter().any(|x| x["code"] == "heatsink_vias" && x["level"] == "info"), "same-net joins are reported as info: {codes:?}");
    }
}

#[cfg(test)]
mod fusion_att_tests {
    use super::*;
    #[test]
    fn fusion_att_values_are_read_in_layers() {
        let xml = r#"<?xml version="1.0"?><eagle><drawing><library><devicesets>
<deviceset name="VL53L5CX" prefix="U"><description>ToF</description>
<att_values><att_value name="MANUFACTURER" value="STMicroelectronics"/><att_value name="DATASHEET" value="https://x/ds.pdf"/></att_values>
<devices><device name="" package="VL53L5CX">
<att_values><att_value name="MPN" value=""/><att_value name="MOUSER_PN" value=""/></att_values>
<att_sets><att_set name=""><att_value name="MPN" value="VL53L5CXV0GC/1"/><att_value name="MOUSER_PN" value="511-VL53L5CXV0GC/1"/></att_set></att_sets>
</device></devices></deviceset></devicesets></library></drawing></eagle>"#;
        let md = library_part_metadata(xml).unwrap();
        let v = &md.devicesets[0].variants[0];
        assert_eq!(v.attributes.get("MPN").map(String::as_str), Some("VL53L5CXV0GC/1"));
        assert_eq!(v.attributes.get("MANUFACTURER").map(String::as_str), Some("STMicroelectronics"));
        assert_eq!(v.attributes.get("MOUSER_PN").map(String::as_str), Some("511-VL53L5CXV0GC/1"));
        assert!(v.missing.is_empty(), "{:?}", v.missing);
        assert_eq!(md.totals.with_mpn, 1);
    }
}

/// The standard's read_footprint: every detail of one package, from a .lbr or from a library a
/// board embeds (`library` names it; a .lbr has one). Pads with their number, type, shape,
/// position, size, drill and layers; the layers used; the courtyard box (EAGLE's component
/// exclude layers 39/40, else the pads with the placement outline); graphics per layer; the
/// description; and the 3D packages that instance it.
pub fn read_footprint(xml: &str, library: Option<&str>, name: &str) -> Result<serde_json::Value, LbrError> {
    use serde_json::json;
    let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
    let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;
    let el = |n: &roxmltree::Node, tag: &str| n.is_element() && n.tag_name().name() == tag;
    let layer_names: BTreeMap<String, String> = doc.root_element().descendants().filter(|n| el(n, "layer"))
        .map(|l| (l.attribute("number").unwrap_or("").to_string(), l.attribute("name").unwrap_or("").to_string())).collect();
    let lname = |n: &str| layer_names.get(n).cloned().unwrap_or_else(|| n.to_string());
    let libs: Vec<roxmltree::Node> = doc.root_element().descendants().filter(|n| el(n, "library"))
        .filter(|l| library.map(|want| l.attribute("name").map(|n| n.eq_ignore_ascii_case(want)).unwrap_or(true)).unwrap_or(true)).collect();
    if libs.is_empty() { return Err(LbrError::NotALibrary(format!("no library {}", library.unwrap_or("")))); }
    let pkgs: Vec<roxmltree::Node> = libs.iter().flat_map(|l| l.descendants().filter(|n| el(n, "package") && n.parent().map(|p| el(&p, "packages")).unwrap_or(false)).collect::<Vec<_>>()).collect();
    let Some(pkg) = pkgs.iter().find(|p| p.attribute("name") == Some(name)).or_else(|| pkgs.iter().find(|p| p.attribute("name").map(|n| n.eq_ignore_ascii_case(name)).unwrap_or(false))) else {
        let names: Vec<&str> = pkgs.iter().filter_map(|p| p.attribute("name")).collect();
        return Err(LbrError::NotALibrary(format!("no footprint {name}; the library has {}", names.join(", "))));
    };
    let f = |n: &roxmltree::Node, a: &str| n.attribute(a).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
    let rot = |n: &roxmltree::Node| n.attribute("rot").unwrap_or("R0").trim_start_matches('S').trim_start_matches('M').trim_start_matches('R').parse::<f64>().unwrap_or(0.0);
    let mut pads = Vec::new();
    let mut used: std::collections::BTreeSet<String> = Default::default();
    let mut graphics: BTreeMap<String, usize> = BTreeMap::new();
    let (mut bx, mut cy) = (Vec::<(f64, f64)>::new(), Vec::<(f64, f64)>::new());
    for g in pkg.children().filter(|c| c.is_element()) {
        match g.tag_name().name() {
            "smd" => {
                let layer = g.attribute("layer").unwrap_or("1");
                let mut layers: Vec<String> = vec![lname(layer)];
                if g.attribute("stop") != Some("no") { layers.push(lname(if layer == "16" { "30" } else { "29" })); }
                if g.attribute("cream") != Some("no") { layers.push(lname(if layer == "16" { "32" } else { "31" })); }
                let (x, y, dx, dy) = (f(&g, "x"), f(&g, "y"), f(&g, "dx"), f(&g, "dy"));
                let rr = f(&g, "roundness");
                bx.extend([(x - dx / 2.0, y - dy / 2.0), (x + dx / 2.0, y + dy / 2.0)]);
                used.extend(layers.iter().cloned());
                pads.push(json!({"number": g.attribute("name").unwrap_or(""), "type": "smd", "shape": if rr >= 100.0 { "round" } else if rr > 0.0 { "roundrect" } else { "rect" },
                                 "at": [x, y], "sizeMm": [dx, dy], "drillMm": null, "layers": layers, "rotation": rot(&g), "roundness": rr}));
            }
            "pad" => {
                let (x, y, d) = (f(&g, "x"), f(&g, "y"), f(&g, "drill"));
                let dia = if f(&g, "diameter") > 0.0 { f(&g, "diameter") } else { d * 1.6 };
                let shape = g.attribute("shape").unwrap_or("round");
                let size = if shape == "long" || shape == "offset" { [dia * 2.0, dia] } else { [dia, dia] };
                bx.extend([(x - size[0] / 2.0, y - size[1] / 2.0), (x + size[0] / 2.0, y + size[1] / 2.0)]);
                let mut layers = vec![lname("1"), lname("16"), lname("17")];
                if g.attribute("stop") != Some("no") { layers.extend([lname("29"), lname("30")]); }
                used.extend(layers.iter().cloned());
                pads.push(json!({"number": g.attribute("name").unwrap_or(""), "type": "thru", "shape": shape, "at": [x, y], "sizeMm": size, "drillMm": d,
                                 "layers": layers, "rotation": rot(&g), "plated": true}));
            }
            "hole" => {
                let (x, y, d) = (f(&g, "x"), f(&g, "y"), f(&g, "drill"));
                used.insert(lname("45"));
                pads.push(json!({"number": "", "type": "npth", "shape": "round", "at": [x, y], "sizeMm": [d, d], "drillMm": d, "layers": [lname("45")], "rotation": 0.0, "plated": false}));
            }
            "description" => {}
            tag => {
                if let Some(l) = g.attribute("layer") {
                    *graphics.entry(lname(l)).or_default() += 1;
                    used.insert(lname(l));
                    let mut pts: Vec<(f64, f64)> = match tag {
                        "wire" | "rectangle" => vec![(f(&g, "x1"), f(&g, "y1")), (f(&g, "x2"), f(&g, "y2"))],
                        "circle" => { let (x, y, r) = (f(&g, "x"), f(&g, "y"), f(&g, "radius")); vec![(x - r, y - r), (x + r, y + r)] }
                        _ => g.descendants().filter(|v| el(v, "vertex")).map(|v| (f(&v, "x"), f(&v, "y"))).collect(),
                    };
                    if l == "39" || l == "40" { cy.append(&mut pts); } else if l == "21" || l == "22" || l == "51" || l == "52" { bx.append(&mut pts); }
                }
            }
        }
    }
    let boxed = |p: &[(f64, f64)]| -> serde_json::Value {
        if p.is_empty() { return serde_json::Value::Null; }
        let r = |v: f64| (v * 10000.0).round() / 10000.0;
        json!([r(p.iter().map(|q| q.0).fold(f64::MAX, f64::min)), r(p.iter().map(|q| q.1).fold(f64::MAX, f64::min)),
               r(p.iter().map(|q| q.0).fold(f64::MIN, f64::max)), r(p.iter().map(|q| q.1).fold(f64::MIN, f64::max))])
    };
    let (courtyard, measured) = if !cy.is_empty() { (boxed(&cy), "component exclude layer (39/40)") } else { (boxed(&bx), "pads and placement outline (the package has no exclude layer)") };
    let lib_name = pkg.ancestors().find(|a| el(a, "library")).and_then(|l| l.attribute("name")).unwrap_or("");
    let models: Vec<serde_json::Value> = libs.iter().flat_map(|l| l.descendants().filter(|n| el(n, "package3d")).collect::<Vec<_>>())
        .filter(|p3| p3.descendants().any(|i| el(&i, "packageinstance") && i.attribute("name") == pkg.attribute("name")))
        .map(|p3| json!({"name": p3.attribute("name").unwrap_or(""), "urn": p3.attribute("urn").filter(|u| !u.is_empty()).or(p3.attribute("wip_urn")).unwrap_or(""),
                         "type": p3.attribute("type").unwrap_or(""), "path": null, "offset": null, "scale": null, "rotate": null})).collect();
    let smd = pads.iter().filter(|p| p["type"] == "smd").count();
    let tht = pads.iter().filter(|p| p["type"] == "thru").count();
    Ok(json!({
        "name": pkg.attribute("name").unwrap_or(""), "library": lib_name,
        "description": pkg.children().find(|c| el(c, "description")).and_then(|d| d.text()).unwrap_or("").trim(),
        "pads": pads, "layers": used.into_iter().collect::<Vec<_>>(), "courtyardMm": courtyard, "courtyardFrom": measured, "graphics": graphics,
        "attributes": {"mount": if tht > 0 && smd > 0 { "mixed" } else if tht > 0 { "tht" } else if smd > 0 { "smd" } else { "none" }, "excludeFromBom": null, "excludeFromPos": null, "dnp": null},
        "properties": {"locallyModified": pkg.attribute("locally_modified").unwrap_or("no")},
        "models": models,
    }))
}

/// The standard's edit_footprint on one package of a .lbr (or a board-embedded library's text):
/// pads changed, deleted, added or renumbered (a device's connects follow a renamed pad, and a
/// deleted pad's connects are dropped and reported); graphics appended to, replaced on or deleted
/// from a layer; the description set. 3D models are Fusion cloud links (package3d URNs), so a
/// models edit is refused. Returns the new document text and what changed.
pub fn edit_footprint(xml: &str, name: &str, edits: &serde_json::Value) -> Result<(String, serde_json::Value), String> {
    use serde_json::{json, Value};
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let layer_names: Vec<(String, String)> = {
        let mut v = Vec::new();
        fn walk(e: &Element, v: &mut Vec<(String, String)>) { if e.tag == "layer" { v.push((e.attr("number").unwrap_or("").into(), e.attr("name").unwrap_or("").into())); } for c in &e.children { walk(c, v); } }
        walk(&root, &mut v); v
    };
    let layer_of = |l: &Value| -> Result<String, String> {
        if let Some(n) = l.as_u64() { return Ok(n.to_string()); }
        let s = l.as_str().unwrap_or("");
        let std_ = [("F.Cu", "1"), ("B.Cu", "16"), ("F.SilkS", "21"), ("B.SilkS", "22"), ("F.Fab", "51"), ("B.Fab", "52"), ("F.CrtYd", "39"), ("B.CrtYd", "40"),
                    ("F.Mask", "29"), ("B.Mask", "30"), ("F.Paste", "31"), ("B.Paste", "32"), ("Edge.Cuts", "20"), ("Dwgs.User", "48"), ("Cmts.User", "48")];
        if let Some((_, n)) = std_.iter().find(|(k, _)| k.eq_ignore_ascii_case(s)) { return Ok(n.to_string()); }
        if s.chars().all(|c| c.is_ascii_digit()) && !s.is_empty() { return Ok(s.to_string()); }
        layer_names.iter().find(|(_, nm)| nm.eq_ignore_ascii_case(s)).map(|(n, _)| n.clone()).ok_or_else(|| format!("unknown layer {s}"))
    };
    if !edits["models"].is_null() { return Err("models: Fusion links a package to its 3D model by cloud URN (package3d); edit it in Fusion's 3D package editor (fusion_show_3d_package)".into()); }
    let f = |v: f64| { let s = format!("{:.4}", v); s.trim_end_matches('0').trim_end_matches('.').to_string() };
    let mut changed: Vec<Value> = Vec::new();
    let mut renames: Vec<(String, String)> = Vec::new();
    let mut deleted: Vec<String> = Vec::new();
    {
        let lib = root.find_deep_mut("library").ok_or("no <library>")?;
        let pkgs = lib.find_child_mut("packages").ok_or("no <packages>")?;
        let pkg = pkgs.children.iter_mut().find(|p| p.tag == "package" && p.attr("name") == Some(name)).ok_or_else(|| format!("no footprint {name}"))?;
        let set_attr = |e: &mut Element, k: &str, v: String| { match e.attrs.iter_mut().find(|(kk, _)| kk == k) { Some(a) => a.1 = v, None => e.attrs.push((k.into(), v)) } };
        let is_pad = |e: &Element| e.tag == "smd" || e.tag == "pad";
        let make_pad = |spec: &Value, number: &str| -> Result<Element, String> {
            let thru = matches!(spec["type"].as_str(), Some("thru") | Some("tht") | Some("pad"));
            let mut e = Element::new(if thru { "pad" } else { "smd" });
            let at = spec["at"].as_array().ok_or("a new pad needs at [x, y]")?;
            e.attrs = vec![("name".into(), number.into()), ("x".into(), f(at[0].as_f64().unwrap_or(0.0))), ("y".into(), f(at[1].as_f64().unwrap_or(0.0)))];
            let size = spec["sizeMm"].as_array();
            if thru {
                let d = spec["drillMm"].as_f64().ok_or("a through pad needs drillMm")?;
                e.attrs.push(("drill".into(), f(d)));
                if let Some(s) = size { e.attrs.push(("diameter".into(), f(s[0].as_f64().unwrap_or(0.0)))); }
                if let Some(sh) = spec["shape"].as_str() { e.attrs.push(("shape".into(), match sh { "rect" | "square" => "square", "oval" | "long" => "long", "octagon" => "octagon", _ => "round" }.into())); }
            } else {
                let s = size.ok_or("an SMD pad needs sizeMm [w, h]")?;
                e.attrs.push(("dx".into(), f(s[0].as_f64().unwrap_or(0.0)))); e.attrs.push(("dy".into(), f(s[1].as_f64().unwrap_or(0.0))));
                let layer = spec["layers"].as_array().and_then(|l| l.first()).map(|l| if l.as_str().map(|s| s.starts_with("B.")).unwrap_or(false) { "16" } else { "1" }).unwrap_or("1");
                e.attrs.push(("layer".into(), layer.into()));
                if let Some(sh) = spec["shape"].as_str() { if sh != "rect" { e.attrs.push(("roundness".into(), if sh == "roundrect" { "25" } else { "100" }.into())); } }
            }
            if let Some(r) = spec["rotation"].as_f64() { if r != 0.0 { e.attrs.push(("rot".into(), format!("R{}", f(r)))); } }
            Ok(e)
        };
        // pad edits
        for pe in edits["pads"].as_array().cloned().unwrap_or_default() {
            let num = pe["number"].as_str().map(str::to_string).or(pe["number"].as_u64().map(|n| n.to_string())).ok_or("each pad edit needs number")?;
            let idx = pkg.children.iter().position(|c| is_pad(c) && c.attr("name") == Some(num.as_str())).ok_or_else(|| format!("no pad {num} in {name}"))?;
            if pe["delete"].as_bool() == Some(true) { pkg.children.remove(idx); deleted.push(num.clone()); changed.push(json!({"pad": num, "what": "deleted"})); continue; }
            let set = &pe["set"];
            if let Some(t) = set["type"].as_str() {
                let want_thru = matches!(t, "thru" | "tht" | "pad");
                if want_thru != (pkg.children[idx].tag == "pad") {
                    let old = &pkg.children[idx];
                    let mut spec = set.clone();
                    if spec["at"].is_null() { spec["at"] = json!([old.attr("x").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0), old.attr("y").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0)]); }
                    let e = make_pad(&spec, &num)?;
                    pkg.children[idx] = Element { tail: pkg.children[idx].tail.clone(), ..e };
                    changed.push(json!({"pad": num, "what": format!("type {t}")}));
                }
            }
            let e = &mut pkg.children[idx];
            if let Some(at) = set["at"].as_array() { set_attr(e, "x", f(at[0].as_f64().unwrap_or(0.0))); set_attr(e, "y", f(at[1].as_f64().unwrap_or(0.0))); changed.push(json!({"pad": num, "what": "at"})); }
            if let Some(s) = set["sizeMm"].as_array() {
                if e.tag == "smd" { set_attr(e, "dx", f(s[0].as_f64().unwrap_or(0.0))); set_attr(e, "dy", f(s[1].as_f64().unwrap_or(0.0))); } else { set_attr(e, "diameter", f(s[0].as_f64().unwrap_or(0.0))); }
                changed.push(json!({"pad": num, "what": "size"}));
            }
            if let Some(d) = set["drillMm"].as_f64() { if e.tag != "pad" { return Err(format!("pad {num} is SMD: drillMm needs type thru")); } set_attr(e, "drill", f(d)); changed.push(json!({"pad": num, "what": "drill"})); }
            if let Some(r) = set["rotation"].as_f64() { set_attr(e, "rot", format!("R{}", f(r))); changed.push(json!({"pad": num, "what": "rotation"})); }
            if let Some(sh) = set["shape"].as_str() {
                if e.tag == "smd" { set_attr(e, "roundness", match sh { "rect" => "0", "roundrect" => "25", _ => "100" }.into()); }
                else { set_attr(e, "shape", match sh { "rect" | "square" => "square", "oval" | "long" => "long", "octagon" => "octagon", _ => "round" }.into()); }
                changed.push(json!({"pad": num, "what": "shape"}));
            }
            if let Some(ls) = set["layers"].as_array() { if e.tag == "smd" { let l = if ls.iter().any(|l| l.as_str().map(|s| s.starts_with("B.")).unwrap_or(false)) { "16" } else { "1" }; set_attr(e, "layer", l.into()); changed.push(json!({"pad": num, "what": "layer"})); } }
            if let Some(nn) = set["newNumber"].as_str().map(str::to_string).or(set["newNumber"].as_u64().map(|n| n.to_string())) { set_attr(e, "name", nn.clone()); renames.push((num.clone(), nn.clone())); changed.push(json!({"pad": num, "what": format!("renumbered {nn}")})); }
        }
        // new pads
        for spec in edits["addPads"].as_array().cloned().unwrap_or_default() {
            let num = spec["number"].as_str().map(str::to_string).or(spec["number"].as_u64().map(|n| n.to_string())).ok_or("a new pad needs number")?;
            if pkg.children.iter().any(|c| is_pad(c) && c.attr("name") == Some(num.as_str())) { return Err(format!("pad {num} already exists in {name}")); }
            let e = make_pad(&spec, &num)?;
            let at = pkg.children.iter().rposition(|c| is_pad(c)).map(|i| i + 1).unwrap_or(pkg.children.len());
            pkg.children.insert(at, e);
            changed.push(json!({"pad": num, "what": "added"}));
        }
        // renumbering
        let rn = &edits["renumber"];
        let mut map: Vec<(String, String)> = Vec::new();
        if let Some(m) = rn["map"].as_object() { map = m.iter().map(|(k, v)| (k.clone(), v.as_str().map(str::to_string).unwrap_or(v.to_string()))).collect(); }
        else if rn["sequential"].as_bool() == Some(true) {
            let start = rn["start"].as_u64().unwrap_or(1);
            map = pkg.children.iter().filter(|c| is_pad(c)).enumerate().map(|(i, c)| (c.attr("name").unwrap_or("").to_string(), (start + i as u64).to_string())).collect();
        }
        if !map.is_empty() {
            let olds: Vec<String> = pkg.children.iter().filter(|c| is_pad(c)).map(|c| c.attr("name").unwrap_or("").to_string()).collect();
            for (from, _) in &map { if !olds.contains(from) { return Err(format!("renumber: no pad {from}")); } }
            let finals: Vec<String> = olds.iter().map(|o| map.iter().find(|(f_, _)| f_ == o).map(|(_, t)| t.clone()).unwrap_or(o.clone())).collect();
            let mut seen = std::collections::HashSet::new();
            if let Some(d) = finals.iter().find(|n| !seen.insert(n.as_str())) { return Err(format!("renumber gives two pads the number {d}")); }
            for c in pkg.children.iter_mut().filter(|c| is_pad(c)) {
                let old = c.attr("name").unwrap_or("").to_string();
                if let Some((_, to)) = map.iter().find(|(f_, _)| *f_ == old) { if *to != old { set_attr(c, "name", to.clone()); renames.push((old, to.clone())); } }
            }
            changed.push(json!({"what": format!("renumbered {} pads", map.len())}));
        }
        // graphics
        for g in edits["graphics"].as_array().cloned().unwrap_or_default() {
            let layer = layer_of(&g["layer"])?;
            let mode = g["mode"].as_str().unwrap_or("append");
            if mode == "replace" || mode == "delete" {
                let before = pkg.children.len();
                pkg.children.retain(|c| is_pad(c) || c.tag == "description" || c.tag == "hole" || c.attr("layer") != Some(layer.as_str()));
                changed.push(json!({"layer": layer, "what": format!("{} graphics removed", before - pkg.children.len())}));
                if mode == "delete" { continue; }
            }
            let w = f(g["widthMm"].as_f64().unwrap_or(0.12));
            let mut n = 0;
            for s in g["shapes"].as_array().cloned().unwrap_or_default() {
                let pt = |v: &Value| -> (String, String) { (f(v[0].as_f64().unwrap_or(0.0)), f(v[1].as_f64().unwrap_or(0.0))) };
                let mut e: Element;
                match s["type"].as_str().unwrap_or("") {
                    "line" | "arc" => {
                        let (a, b) = (pt(&s["start"]), pt(&s["end"]));
                        e = Element::new("wire");
                        e.attrs = vec![("x1".into(), a.0), ("y1".into(), a.1), ("x2".into(), b.0), ("y2".into(), b.1), ("width".into(), w.clone()), ("layer".into(), layer.clone())];
                        if let Some(c) = s["angle"].as_f64() { e.attrs.push(("curve".into(), f(c))); }
                    }
                    "rect" => {
                        let (a, b) = (pt(&s["start"]), pt(&s["end"]));
                        e = Element::new("rectangle");
                        e.attrs = vec![("x1".into(), a.0), ("y1".into(), a.1), ("x2".into(), b.0), ("y2".into(), b.1), ("layer".into(), layer.clone())];
                    }
                    "circle" => {
                        let c = pt(&s["center"]);
                        e = Element::new("circle");
                        e.attrs = vec![("x".into(), c.0), ("y".into(), c.1), ("radius".into(), f(s["radius"].as_f64().unwrap_or(0.5))), ("width".into(), w.clone()), ("layer".into(), layer.clone())];
                    }
                    "poly" => {
                        e = Element::new("polygon");
                        e.attrs = vec![("width".into(), w.clone()), ("layer".into(), layer.clone())];
                        for p in s["points"].as_array().cloned().unwrap_or_default() { let q = pt(&p); let mut v = Element::new("vertex"); v.attrs = vec![("x".into(), q.0), ("y".into(), q.1)]; e.children.push(v); }
                        if e.children.len() < 3 { return Err("a poly needs three points".into()); }
                    }
                    other => return Err(format!("unknown shape type {other}")),
                }
                pkg.children.push(e); n += 1;
            }
            if n > 0 { changed.push(json!({"layer": layer, "what": format!("{n} graphics added")})); }
        }
        // metadata
        if let Some(d) = edits["metadata"]["description"].as_str() {
            match pkg.children.iter_mut().find(|c| c.tag == "description") { Some(e) => e.text = d.to_string(), None => { let mut e = Element::new("description"); e.text = d.to_string(); pkg.children.insert(0, e); } }
            changed.push(json!({"what": "description"}));
        }
        set_attr(pkg, "locally_modified", "yes".into());
    }
    // devices that use the package follow renamed and deleted pads
    let mut dropped: Vec<Value> = Vec::new();
    fn fix(e: &mut Element, name: &str, renames: &[(String, String)], deleted: &[String], dropped: &mut Vec<Value>) {
        if e.tag == "device" && e.attr("package") == Some(name) {
            if let Some(cs) = e.find_child_mut("connects") {
                cs.children.retain(|c| { let keep = !deleted.iter().any(|d| c.attr("pad").map(|p| p.split(' ').any(|q| q == d)).unwrap_or(false)); if !keep { dropped.push(serde_json::json!({"pin": c.attr("pin"), "pad": c.attr("pad")})); } keep });
                for c in cs.children.iter_mut() {
                    if let Some(pad) = c.attr("pad").map(str::to_string) {
                        let np: Vec<String> = pad.split(' ').map(|q| renames.iter().find(|(f_, _)| f_ == q).map(|(_, t)| t.clone()).unwrap_or(q.to_string())).collect();
                        let v = np.join(" ");
                        if v != pad { if let Some(a) = c.attrs.iter_mut().find(|(k, _)| k == "pad") { a.1 = v; } }
                    }
                }
            }
        }
        for c in e.children.iter_mut() { fix(c, name, renames, deleted, dropped); }
    }
    fix(&mut root, name, &renames, &deleted, &mut dropped);
    let text = root.to_xml_document();
    Ok((text, json!({"changed": changed, "connectsDropped": dropped, "renamed": renames.iter().map(|(a, b)| json!({"from": a, "to": b})).collect::<Vec<_>>()})))
}