main
Colby Knox 0.2.56 source (not yet released, needs signing): altium_bom + altium_sheet_params + the altium-bom CLI 22a91c1 12h ago
//! The project BOM Altium itself does not give an AI: every sheet's placed parts with their
//! parameters, the board's placement, variants, grouped by part number, checked for what an order
//! needs, and written as the files distributors and assemblers take (generic, JLCPCB BOM + CPL,
//! Mouser and DigiKey BOM upload). `sheet_params` writes part numbers back onto the sheets.
use crate::support::{self, s, timeout_of};
use crate::agent;
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::time::Duration;

/// Parameter names that mean the same thing across libraries: Altium's own templates, the Adom
/// part import (`MPN`, `Mouser Part #`...), the wiki's Cloud Library export (`Manufacturer Part
/// Number`), Altium 365 supplier links (`Supplier Part Number 1`) and Fusion-style attributes.
const MPN: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer Part Number 1", "Mfr Part #", "Mfr. Part Number", "MFR PN", "MFR_PN", "MANUFACTURER_PART_NUMBER", "Part Number", "PartNumber", "Part_Number"];
const MFR: &[&str] = &["Manufacturer", "Manufacturer 1", "Manufacturer Name", "Mfr", "MANUFACTURER"];
const MOUSER: &[&str] = &["Mouser Part #", "Mouser Part Number", "Mouser PN", "MOUSER_PN"];
const DIGIKEY: &[&str] = &["DigiKey Part #", "Digi-Key Part Number", "DigiKey Part Number", "Digikey PN", "DIGIKEY_PN"];
const LCSC: &[&str] = &["LCSC Part #", "LCSC", "LCSC Part", "LCSC_PN", "JLCPCB Part #", "JLC Part #"];
const VALUE: &[&str] = &["Value", "VALUE"];
const PACKAGE: &[&str] = &["Package", "Case/Package", "PACKAGE"];
const DNP: &[&str] = &["DNP", "Do Not Populate", "DNF", "Do Not Fit"];
const DESCRIPTION: &[&str] = &["Description", "DESCRIPTION"];

/// ISch_Component.ComponentKind values that stay out of a BOM: Graphical, Net Tie (no BOM),
/// Standard (no BOM). Mechanical (1), Net Tie (in BOM, 3) and Jumper (6) are bought parts.
const NO_BOM_KINDS: &[i64] = &[2, 4, 5];

fn param(params: &[(String, String)], names: &[&str]) -> String {
    for n in names {
        if let Some((_, v)) = params.iter().find(|(k, v)| k.eq_ignore_ascii_case(n) && !v.trim().is_empty() && !v.trim().starts_with('=')) {
            return v.trim().to_string();
        }
    }
    String::new()
}

fn truthy(v: &str) -> bool {
    matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "y" | "dnp" | "x")
}

/// R1 < R2 < R10 < R10A: the letters, then the number, then the rest.
pub fn natural_key(d: &str) -> (String, u64, String) {
    let pre: String = d.chars().take_while(|c| !c.is_ascii_digit()).collect();
    let rest = &d[pre.len()..];
    let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
    (pre.to_ascii_uppercase(), num.parse().unwrap_or(0), rest[num.len()..].to_string())
}

/// The project file: its schematic sheets and board (absolute), and its variants.
#[derive(Debug, Default)]
pub struct Project {
    pub sheets: Vec<String>,
    pub boards: Vec<String>,
    /// variant description -> designator -> (kind, alternate part)
    pub variants: Vec<(String, HashMap<String, (i64, String)>)>,
}

pub fn parse_project(text: &str, dir: &Path) -> Project {
    let mut p = Project::default();
    let mut section = String::new();
    let mut current: Option<(String, HashMap<String, (i64, String)>)> = None;
    for raw in text.lines() {
        let line = raw.trim();
        if line.starts_with('[') && line.ends_with(']') {
            if let Some(v) = current.take() { p.variants.push(v); }
            section = line[1..line.len() - 1].to_string();
            if section.starts_with("ProjectVariant") { current = Some((String::new(), HashMap::new())); }
            continue;
        }
        let Some((k, v)) = line.split_once('=') else { continue };
        // [DocumentN], and [DeviceSheetN]: a managed device sheet the project pulled from its workspace
        if (section.starts_with("Document") || section.starts_with("DeviceSheet")) && k == "DocumentPath" {
            let path = if Path::new(v).is_absolute() || v.contains(':') { PathBuf::from(v) } else { dir.join(v.replace('\\', "/")) };
            let full = path.to_string_lossy().replace('/', "\\");
            let low = v.to_ascii_lowercase();
            if low.ends_with(".schdoc") { p.sheets.push(full); } else if low.ends_with(".pcbdoc") { p.boards.push(full); }
        } else if let Some((_, m)) = current.as_mut().filter(|_| k.starts_with("Variation") && k[9..].chars().all(|c| c.is_ascii_digit()) && k.len() > 9) {
            // Variation1=Designator=R1|UniqueId=\ABCD|Kind=1|AlternatePart=
            let f: HashMap<&str, &str> = v.split('|').filter_map(|x| x.split_once('=')).collect();
            if let Some(d) = f.get("Designator") {
                m.insert(d.to_ascii_uppercase(), (f.get("Kind").and_then(|x| x.parse().ok()).unwrap_or(0), f.get("AlternatePart").unwrap_or(&"").to_string()));
            }
        } else if let Some((name, _)) = current.as_mut().filter(|_| k == "Description") {
            *name = v.to_string();
        }
    }
    if let Some(v) = current.take() { p.variants.push(v); }
    p
}

/// One placed part, merged from its sheet (parameters) and the board (footprint, placement).
#[derive(Debug, Clone, Default)]
pub struct Part {
    pub designator: String,
    pub comment: String,
    pub description: String,
    pub lib_ref: String,
    pub sheet: String,
    pub kind: i64,
    pub params: Vec<(String, String)>,
    pub footprint: String,
    pub placed: Option<(f64, f64, f64, String)>,
    pub dnp: bool,
    pub alternate: String,
    /// a workspace-managed part (Altium 365 / a content vault): its item GUID and source library
    pub item_guid: String,
    pub source_lib: String,
}

impl Part {
    fn get(&self, names: &[&str]) -> String { param(&self.params, names) }
    pub fn mpn(&self) -> String { self.get(MPN) }
    fn value(&self) -> String { let v = self.get(VALUE); if v.is_empty() { self.comment.clone() } else { v } }
}

const FIELDS: &[(&str, &[&str])] = &[("mpn", MPN), ("manufacturer", MFR), ("mouser", MOUSER), ("digikey", DIGIKEY), ("lcsc", LCSC)];

/// Group the fitted parts: one line per manufacturer part number, else per comment + footprint.
/// Returns (lines, checks).
pub fn group(parts: &[Part]) -> (Vec<Value>, Value) {
    let mut groups: BTreeMap<String, Vec<&Part>> = BTreeMap::new();
    for p in parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind)) {
        let m = p.mpn();
        let key = if m.is_empty() { format!("~{}|{}", p.value().to_ascii_uppercase(), p.footprint.to_ascii_uppercase()) } else { format!("{}|{}", m.to_ascii_uppercase(), p.get(MFR).to_ascii_uppercase()) };
        groups.entry(key).or_default().push(p);
    }
    let mut lines = Vec::new();
    let mut inconsistent = Vec::new();
    for (_, mut g) in groups {
        g.sort_by_key(|p| natural_key(&p.designator));
        let refs: Vec<String> = g.iter().map(|p| p.designator.clone()).collect();
        let mut line = Map::new();
        line.insert("quantity".into(), json!(g.len()));
        line.insert("designators".into(), json!(refs));
        for (name, names) in FIELDS {
            let vals: Vec<String> = g.iter().map(|p| p.get(names)).collect();
            let mut distinct: Vec<&String> = vals.iter().filter(|v| !v.is_empty()).collect();
            distinct.sort(); distinct.dedup();
            if distinct.len() > 1 { inconsistent.push(json!({"field": name, "designators": refs, "values": distinct})); }
            line.insert((*name).into(), json!(distinct.first().map(|s| s.as_str()).unwrap_or("")));
        }
        let first = g[0];
        let mut fps: Vec<&str> = g.iter().map(|p| p.footprint.as_str()).filter(|f| !f.is_empty()).collect();
        fps.sort(); fps.dedup();
        if fps.len() > 1 { inconsistent.push(json!({"field": "footprint", "designators": refs, "values": fps})); }
        line.insert("value".into(), json!(first.value()));
        line.insert("comment".into(), json!(first.comment));
        line.insert("description".into(), json!(if first.description.is_empty() { first.get(DESCRIPTION) } else { first.description.clone() }));
        line.insert("sourceLibrary".into(), json!(first.source_lib));
        line.insert("managed".into(), json!(!first.item_guid.is_empty()));
        line.insert("footprint".into(), json!(fps.first().copied().unwrap_or("")));
        line.insert("package".into(), json!(first.get(PACKAGE)));
        line.insert("libReference".into(), json!(first.lib_ref));
        lines.push(Value::Object(line));
    }
    lines.sort_by_key(|l| natural_key(l["designators"][0].as_str().unwrap_or("")));
    let fitted: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind)).collect();
    let missing = |names: &[&str]| -> Vec<String> {
        let mut v: Vec<String> = fitted.iter().filter(|p| p.get(names).is_empty()).map(|p| p.designator.clone()).collect();
        v.sort_by_key(|d| natural_key(d)); v
    };
    let mut dnp: Vec<String> = parts.iter().filter(|p| p.dnp).map(|p| p.designator.clone()).collect();
    dnp.sort_by_key(|d| natural_key(d));
    let mut excluded: Vec<String> = parts.iter().filter(|p| !p.dnp && NO_BOM_KINDS.contains(&p.kind)).map(|p| p.designator.clone()).collect();
    excluded.sort_by_key(|d| natural_key(d));
    let checks = json!({
        "missingMpn": missing(MPN), "missingManufacturer": missing(MFR),
        "missingPartNumbers": {"mouser": missing(MOUSER), "digikey": missing(DIGIKEY), "lcsc": missing(LCSC)},
        "inconsistent": inconsistent, "dnp": dnp, "excludedKinds": excluded,
    });
    (lines, checks)
}

fn csv_cell(s: &str) -> String {
    if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
}

fn csv(rows: &[Vec<String>]) -> String {
    rows.iter().map(|r| r.iter().map(|c| csv_cell(c)).collect::<Vec<_>>().join(",")).collect::<Vec<_>>().join("\r\n") + "\r\n"
}

fn st(v: &Value) -> String { v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()) }

/// The files, as (name, text). `boards` multiplies the quantities of the ordering files.
pub fn files(lines: &[Value], parts: &[Part], boards: u64) -> Vec<(String, String)> {
    let refs = |l: &Value| l["designators"].as_array().map(|a| a.iter().map(st).collect::<Vec<_>>().join(",")).unwrap_or_default();
    let mut out = Vec::new();
    let mut rows = vec![["Line", "Quantity", "Designators", "Value", "Comment", "Description", "Footprint", "Package", "MPN", "Manufacturer", "Mouser Part #", "DigiKey Part #", "LCSC Part #"].map(String::from).to_vec()];
    for (i, l) in lines.iter().enumerate() {
        rows.push(vec![(i + 1).to_string(), st(&l["quantity"]), refs(l), st(&l["value"]), st(&l["comment"]), st(&l["description"]), st(&l["footprint"]), st(&l["package"]), st(&l["mpn"]), st(&l["manufacturer"]), st(&l["mouser"]), st(&l["digikey"]), st(&l["lcsc"])]);
    }
    out.push(("bom.csv".to_string(), csv(&rows)));
    // JLCPCB assembly: their BOM and CPL column names, mm, Top/Bottom
    let mut j = vec![["Comment", "Designator", "Footprint", "LCSC Part #"].map(String::from).to_vec()];
    for l in lines { j.push(vec![st(&l["value"]), refs(l), st(&l["footprint"]), st(&l["lcsc"])]); }
    out.push(("bom-jlcpcb.csv".to_string(), csv(&j)));
    let mut c = vec![["Designator", "Mid X", "Mid Y", "Layer", "Rotation"].map(String::from).to_vec()];
    let mut placed: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind) && p.placed.is_some()).collect();
    placed.sort_by_key(|p| natural_key(&p.designator));
    for p in placed {
        let (x, y, r, side) = p.placed.clone().unwrap();
        c.push(vec![p.designator.clone(), format!("{x:.4}mm"), format!("{y:.4}mm"), if side == "bottom" { "Bottom".into() } else { "Top".into() }, format!("{}", (r.rem_euclid(360.0) * 1000.0).round() / 1000.0)]);
    }
    out.push(("cpl-jlcpcb.csv".to_string(), csv(&c)));
    // distributor BOM tools (Mouser BOM Tool, DigiKey myLists / BOM Manager) map these headers directly
    for (name, field, header) in [("bom-mouser.csv", "mouser", "Mouser Part Number"), ("bom-digikey.csv", "digikey", "Digi-Key Part Number")] {
        let mut m = vec![vec![header.to_string(), "Manufacturer Part Number".into(), "Manufacturer".into(), "Quantity".into(), "Customer Reference".into()]];
        for l in lines.iter().filter(|l| !st(&l[field]).is_empty() || !st(&l["mpn"]).is_empty()) {
            m.push(vec![st(&l[field]), st(&l["mpn"]), st(&l["manufacturer"]), (l["quantity"].as_u64().unwrap_or(0) * boards.max(1)).to_string(), refs(l)]);
        }
        out.push((name.to_string(), csv(&m)));
    }
    out
}

fn sheet_parts(sheet: &str, timeout: Duration) -> Result<(String, Vec<Value>), Value> {
    if let Some(e) = support::must_open(sheet, 60) { return Err(e); }
    // only the values a BOM reads: other parameters can hold anything (an example IC's preview-image blobs
    // took minutes to read), so their values are never fetched
    let mut names: Vec<&str> = [MPN, MFR, MOUSER, DIGIKEY, LCSC, VALUE, PACKAGE, DNP, DESCRIPTION, &["Fitted"]].concat();
    names.sort(); names.dedup();
    let filter = format!("|{}|", names.iter().map(|n| n.to_ascii_uppercase()).collect::<Vec<_>>().join("|"));
    let r = agent::call("schematic_data", &[("pins", "0".into()), ("params", "1".into()), ("limit", "5000".into()), ("paramFilter", filter)], None, timeout);
    match r.get("data") {
        Some(d) if d["ok"].as_bool() == Some(true) => Ok((st(&d["document"]), d["components"].as_array().cloned().unwrap_or_default())),
        _ => Err(r),
    }
}

/// altium_bom {projectPath, variant?, outputDir?, boards?, write?}
pub fn bom(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() { return g; }
    let t0 = support::now();
    let Some(prj) = s(args, "projectPath") else {
        return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) is required", "_hint": "Pass the project file; the BOM reads every schematic sheet it lists and its board."});
    };
    let text = match std::fs::read(&prj) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "errorCode": "project_not_found", "error": format!("{prj}: {e}")}) };
    let dir = Path::new(&prj).parent().map(Path::to_path_buf).unwrap_or_default();
    let project = parse_project(&text, &dir);
    if project.sheets.is_empty() { return json!({"success": false, "errorCode": "no_sheets", "error": "the project lists no .SchDoc", "projectPath": prj}); }
    let timeout = timeout_of(args, 120);
    // the variant: by its description, or none
    let want_variant = s(args, "variant");
    let variant = match &want_variant {
        Some(v) => match project.variants.iter().find(|(n, _)| n.eq_ignore_ascii_case(v)) {
            Some(x) => Some(x),
            None => return json!({"success": false, "errorCode": "variant_not_found", "error": format!("no variant named {v}"), "variants": project.variants.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>()}),
        },
        None => None,
    };
    let mut parts: BTreeMap<String, Part> = BTreeMap::new();
    let mut sheets_read = Vec::new();
    // altium_progress shows which sheet a long read is on (a large project reads dozens)
    crate::progress::begin("bom", "warm", "reading the schematic sheets", "bridge");
    let n_sheets = project.sheets.len();
    for (i, sheet) in project.sheets.iter().enumerate() {
        let name = Path::new(&sheet.replace('\\', "/")).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
        crate::progress::step("bom", &format!("sheet {} of {n_sheets}: {name}", i + 1), Some((i * 90 / n_sheets.max(1)) as i64), None, "bridge");
        let (doc, comps) = match sheet_parts(sheet, timeout) { Ok(x) => x, Err(e) => { crate::progress::end("bom", "bridge"); return json!({"success": false, "errorCode": "sheet_read_failed", "sheet": sheet, "reply": e}) } };
        sheets_read.push(json!({"sheet": doc, "components": comps.len()}));
        for c in comps {
            let d = st(&c["designator"]);
            if d.is_empty() || d.ends_with('?') { continue; }
            let params: Vec<(String, String)> = c["params"].as_array().cloned().unwrap_or_default().iter().filter(|p| p.get("value").is_some()).map(|p| (st(&p["name"]), st(&p["value"]))).collect();
            // a multi-part component appears once per part: keep the first, add any parameter it lacks
            let e = parts.entry(d.to_ascii_uppercase()).or_insert_with(|| Part {
                designator: d.clone(), comment: st(&c["comment"]), description: st(&c["description"]), lib_ref: st(&c["libReference"]),
                sheet: doc.clone(), kind: c["kind"].as_i64().unwrap_or(0), item_guid: st(&c["itemGuid"]), source_lib: st(&c["sourceLibrary"]), ..Default::default() });
            for (k, v) in params { if !e.params.iter().any(|(k2, _)| k2.eq_ignore_ascii_case(&k)) { e.params.push((k, v)); } }
        }
    }
    // the board: footprint and placement of each part
    let mut board_only = Vec::new();
    let board = s(args, "pcbPath").or_else(|| project.boards.first().cloned());
    let mut board_read = Value::Null;
    if let Some(b) = &board {
        crate::progress::step("bom", "reading the board's placement", Some(92), None, "bridge");
        if let Some(e) = support::must_open(b, 60) { crate::progress::end("bom", "bridge"); return json!({"success": false, "errorCode": "board_open_failed", "board": b, "reply": e}); }
        // the lean placement read: a full routing_state of a large board (MiniPC) ran past 10 minutes
        let r = agent::call("bom_placement", &[], None, timeout);
        let Some(comps) = r.get("data").filter(|d| d["ok"].as_bool() == Some(true)).and_then(|d| d["comps"].as_array()).cloned() else {
            crate::progress::end("bom", "bridge");
            return json!({"success": false, "errorCode": "board_read_failed", "board": b, "reply": r});
        };
        board_read = json!({"board": b, "components": comps.len()});
        for c in comps {
            // [designator, pattern, x, y (raw from the origin, 10000 per mil), rotation (millidegrees), bottom]
            let d = st(&c[0]);
            let mm = |v: &Value| (v.as_f64().unwrap_or(0.0) / 10000.0 * 0.0254 * 10000.0).round() / 10000.0;
            let placed = Some((mm(&c[2]), mm(&c[3]), c[4].as_f64().unwrap_or(0.0) / 1000.0, if c[5].as_bool() == Some(true) { "bottom".to_string() } else { "top".to_string() }));
            match parts.get_mut(&d.to_ascii_uppercase()) {
                Some(p) => { p.footprint = st(&c[1]); p.placed = placed; }
                None => board_only.push(d),
            }
        }
    }
    let mut not_on_board: Vec<String> = if board.is_some() { parts.values().filter(|p| p.placed.is_none() && !NO_BOM_KINDS.contains(&p.kind)).map(|p| p.designator.clone()).collect() } else { vec![] };
    not_on_board.sort_by_key(|d| natural_key(d));
    board_only.sort_by_key(|d| natural_key(d));
    for p in parts.values_mut() {
        if truthy(&p.get(DNP)) { p.dnp = true; }
        if p.get(&["Fitted"]).eq_ignore_ascii_case("false") { p.dnp = true; }
        if let Some((_, m)) = variant {
            if let Some((kind, alt)) = m.get(&p.designator.to_ascii_uppercase()) {
                if *kind == 1 { p.dnp = true; } else if !alt.is_empty() { p.alternate = alt.clone(); }
            }
        }
    }
    crate::progress::end("bom", "bridge");
    let list: Vec<Part> = parts.values().cloned().collect();
    let (lines, mut checks) = group(&list);
    checks["sheetNotOnBoard"] = json!(not_on_board);
    checks["boardNotOnSheet"] = json!(board_only);
    let alternates: Vec<Value> = list.iter().filter(|p| !p.alternate.is_empty()).map(|p| json!({"designator": p.designator, "alternatePart": p.alternate})).collect();
    let fitted = list.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind)).count();
    let boards = args.get("boards").and_then(|v| v.as_u64()).unwrap_or(1);
    let mut written = Vec::new();
    if args.get("write").and_then(|b| b.as_bool()) != Some(false) {
        let out_dir = s(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| dir.join("BOM"));
        if let Err(e) = std::fs::create_dir_all(&out_dir) { return json!({"success": false, "errorCode": "output_dir", "error": format!("{}: {e}", out_dir.display())}); }
        for (name, body) in files(&lines, &list, boards) {
            let p = out_dir.join(&name);
            if let Err(e) = std::fs::write(&p, body) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{}: {e}", p.display())}); }
            written.push(p.to_string_lossy().to_string());
        }
        let j = out_dir.join("bom.json");
        let _ = std::fs::write(&j, serde_json::to_string_pretty(&json!({"project": prj, "variant": want_variant, "lines": lines, "checks": checks})).unwrap_or_default());
        written.push(j.to_string_lossy().to_string());
    }
    let missing = checks["missingMpn"].as_array().map(|a| a.len()).unwrap_or(0);
    let gaps: Vec<String> = ["mouser", "digikey", "lcsc"].iter().filter_map(|k| { let n = checks["missingPartNumbers"][k].as_array().map(|a| a.len()).unwrap_or(0); (n > 0).then(|| format!("{n} without a {k} number")) }).collect();
    let mut hint = format!("{} lines, {fitted} fitted parts{}. ", lines.len(), if checks["dnp"].as_array().map_or(true, |a| a.is_empty()) { String::new() } else { format!(", {} DNP", checks["dnp"].as_array().unwrap().len()) });
    if missing > 0 { hint.push_str(&format!("{missing} part(s) have no MPN: find them (adom-parts-search search <value> <package>) and write them back with altium_sheet_params. ")); }
    if !gaps.is_empty() { hint.push_str(&format!("Distributor numbers: {}; look them up by MPN (adom-parts-search search <MPN>) and write them back with altium_sheet_params. ", gaps.join(", "))); }
    if checks["inconsistent"].as_array().map_or(false, |a| !a.is_empty()) { hint.push_str("Some lines mix values for one part number (checks.inconsistent): fix the sheets so every part of a line agrees. "); }
    hint.push_str("Price and stock the lines with adom-parts-search / adom-mouser part <MPN>; bom-mouser.csv and bom-digikey.csv upload to those sites' BOM tools; bom-jlcpcb.csv + cpl-jlcpcb.csv go to JLCPCB assembly (check rotations on their preview).");
    json!({"success": true, "projectPath": prj, "variant": want_variant, "variants": project.variants.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
        "sheets": sheets_read, "board": board_read, "lines": lines, "lineCount": lines.len(), "fittedParts": fitted, "checks": checks,
        "alternates": alternates, "boards": boards, "files": written, "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background", "_hint": hint})
}

/// altium_sheet_params {sheet?, designators, params, save?}: write parameters onto placed parts.
/// Without a sheet, every sheet of projectPath is tried and each designator lands where it lives.
pub fn sheet_params(args: &Value) -> Value {
    if let Some(g) = support::need_licensed() { return g; }
    let designators: Vec<String> = match args.get("designators") {
        Some(Value::Array(a)) => a.iter().map(st).collect(),
        Some(Value::String(t)) => t.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),
        _ => vec![],
    };
    let params: Vec<String> = match args.get("params") {
        Some(Value::Object(o)) => o.iter().map(|(k, v)| format!("{}={}", k.trim(), st(v).replace(';', ","))).collect(),
        Some(Value::String(t)) => t.split(';').map(str::to_string).filter(|x| x.contains('=')).collect(),
        _ => vec![],
    };
    if designators.is_empty() || params.is_empty() {
        return json!({"success": false, "errorCode": "bad_args", "error": "designators and params are required", "_hint": "designators: [\"R1\",\"R2\"]; params: {\"MPN\": \"...\", \"Manufacturer\": \"...\", \"Mouser Part #\": \"...\"}"});
    }
    if params.iter().any(|p| p.contains('\n') || p.contains('\r')) { return json!({"success": false, "errorCode": "bad_args", "error": "parameter values may not contain line breaks"}); }
    let sheets: Vec<String> = match (s(args, "sheet"), s(args, "projectPath")) {
        (Some(sh), _) => vec![sh],
        (None, Some(prj)) => {
            let text = std::fs::read(&prj).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();
            parse_project(&text, Path::new(&prj).parent().unwrap_or(Path::new("."))).sheets
        }
        _ => return json!({"success": false, "errorCode": "bad_args", "error": "sheet (.SchDoc) or projectPath (.PrjPcb) is required"}),
    };
    let mut done = Vec::new();
    let mut left: Vec<String> = designators.clone();
    let mut per_sheet = Vec::new();
    for sheet in &sheets {
        if left.is_empty() { break; }
        if let Some(e) = support::must_open(sheet, 60) { return json!({"success": false, "errorCode": "sheet_open_failed", "sheet": sheet, "reply": e}); }
        let r = agent::call("sheet_params", &[("designators", left.join(",")), ("params", params.join(";"))], None, timeout_of(args, 90));
        let d = r.get("data").cloned().unwrap_or(Value::Null);
        if d["ok"].as_bool() != Some(true) { return json!({"success": false, "errorCode": d["errorCode"].as_str().unwrap_or("sheet_params_failed"), "error": d["error"], "sheet": sheet, "reply": r}); }
        let here: Vec<String> = d["designators"].as_array().cloned().unwrap_or_default().iter().map(st).collect();
        if !here.is_empty() {
            if args.get("save").and_then(|b| b.as_bool()) != Some(false) {
                let sv = crate::surfaces::save(&json!({"filePath": sheet}));
                if sv["success"].as_bool() == Some(false) { return json!({"success": false, "errorCode": "save_failed", "sheet": sheet, "reply": sv}); }
            }
            per_sheet.push(json!({"sheet": sheet, "designators": here, "paramsSet": d["paramsSet"]}));
            left.retain(|x| !here.iter().any(|h| h.eq_ignore_ascii_case(x)));
            done.extend(here);
        }
    }
    json!({"success": left.is_empty(), "errorCode": if left.is_empty() { Value::Null } else { json!("designator_not_found") }, "updated": done, "notFound": left, "sheets": per_sheet,
        "_hint": if left.is_empty() { "Written on the sheets (saved unless save:false). The board's components pick the parameters up with Design > Update PCB; the BOM reads the sheets, so altium_bom shows them now." } else { "Some designators are on no sheet of the project: check the names (altium_bom lists every designator)." }})
}

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

    const PRJ: &str = "[Design]\r\nVersion=1.0\r\n\r\n[Document1]\r\nDocumentPath=Power.SchDoc\r\n\r\n[Document2]\r\nDocumentPath=Sub\\IO.SchDoc\r\n\r\n[Document3]\r\nDocumentPath=Board.PcbDoc\r\n\r\n[DeviceSheet1]\r\nDocumentPath=Managed\\Sheets\\AB\\Osc.SchDoc\r\n\r\n[DeviceSheet2]\r\nDocumentPath=Managed\\Sheets\\AB\\Osc.Harness\r\n\r\n[ProjectVariant1]\r\nDescription=Lite\r\nVariationCount=2\r\nVariation1=Designator=R2|UniqueId=\\ABC|Kind=1|AlternatePart=\r\nVariation2=Designator=U1|UniqueId=\\DEF|Kind=2|AlternatePart=TPS54302\r\n";

    fn part(d: &str, params: &[(&str, &str)], fp: &str) -> Part {
        Part { designator: d.into(), comment: "c".into(), footprint: fp.into(), params: params.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(), placed: Some((1.0, 2.0, 90.0, "top".into())), ..Default::default() }
    }

    #[test]
    fn the_project_file_gives_sheets_board_and_variants() {
        let p = parse_project(PRJ, Path::new("C:/proj"));
        assert_eq!(p.sheets.len(), 3, "two sheets and a device sheet (its harness is not a sheet)");
        assert!(p.sheets[1].ends_with("Sub\\IO.SchDoc"), "{:?}", p.sheets);
        assert!(p.sheets[2].ends_with("Managed\\Sheets\\AB\\Osc.SchDoc"), "{:?}", p.sheets);
        assert_eq!(p.boards.len(), 1);
        assert_eq!(p.variants.len(), 1);
        assert_eq!(p.variants[0].0, "Lite");
        assert_eq!(p.variants[0].1["R2"].0, 1);
        assert_eq!(p.variants[0].1["U1"], (2, "TPS54302".to_string()));
    }

    #[test]
    fn lines_group_by_mpn_across_parameter_names() {
        let parts = vec![
            part("R10", &[("MPN", "RC0402-10K"), ("Manufacturer", "Yageo"), ("Mouser Part #", "603-RC0402")], "R0402"),
            part("R2", &[("Manufacturer Part Number", "RC0402-10K"), ("Manufacturer", "Yageo")], "R0402"),
            part("C1", &[("Value", "100n")], "C0402"),
            part("C2", &[("Value", "100n")], "C0402"),
            part("TP1", &[("DNP", "yes")], "TP"),
        ];
        let mut parts = parts;
        parts[4].dnp = true;
        let (lines, checks) = group(&parts);
        assert_eq!(lines.len(), 2);
        assert_eq!(lines[0]["designators"], json!(["C1", "C2"]));
        assert_eq!(lines[1]["designators"], json!(["R2", "R10"]), "natural order");
        assert_eq!(lines[1]["mpn"], json!("RC0402-10K"));
        assert_eq!(lines[1]["mouser"], json!("603-RC0402"));
        assert_eq!(checks["missingMpn"], json!(["C1", "C2"]));
        assert_eq!(checks["missingPartNumbers"]["mouser"], json!(["C1", "C2", "R2"]));
        assert_eq!(checks["dnp"], json!(["TP1"]));
        assert!(checks["inconsistent"].as_array().unwrap().is_empty());
    }

    #[test]
    fn a_line_with_two_footprints_is_inconsistent() {
        let parts = vec![part("R1", &[("MPN", "X")], "R0402"), part("R2", &[("MPN", "X")], "R0603")];
        let (_, checks) = group(&parts);
        assert_eq!(checks["inconsistent"][0]["field"], json!("footprint"));
    }

    #[test]
    fn no_bom_kinds_stay_out() {
        let mut logo = part("LOGO1", &[], "");
        logo.kind = 2;
        let (lines, checks) = group(&[part("R1", &[("MPN", "X")], "R0402"), logo]);
        assert_eq!(lines.len(), 1);
        assert_eq!(checks["excludedKinds"], json!(["LOGO1"]));
    }

    #[test]
    fn files_carry_the_columns_each_site_takes() {
        let parts = vec![part("U1", &[("MPN", "TPS54202DDCR"), ("Manufacturer", "TI"), ("LCSC Part #", "C191884"), ("Mouser Part #", "595-TPS54202DDCR")], "SOT-23-6")];
        let (lines, _) = group(&parts);
        let f: HashMap<String, String> = files(&lines, &parts, 5).into_iter().collect();
        assert!(f["bom-jlcpcb.csv"].starts_with("Comment,Designator,Footprint,LCSC Part #\r\n"));
        assert!(f["bom-jlcpcb.csv"].contains("U1,SOT-23-6,C191884"));
        assert!(f["cpl-jlcpcb.csv"].contains("U1,1.0000mm,2.0000mm,Top,90"));
        assert!(f["bom-mouser.csv"].contains("595-TPS54202DDCR,TPS54202DDCR,TI,5,U1"), "{}", f["bom-mouser.csv"]);
        assert!(f["bom.csv"].lines().nth(1).unwrap().starts_with("1,1,U1,"));
    }

    #[test]
    fn csv_quotes_what_needs_it() {
        assert_eq!(csv_cell("R1,R2"), "\"R1,R2\"");
        assert_eq!(csv_cell("10\" cable"), "\"10\"\" cable\"");
        assert_eq!(csv_cell("plain"), "plain");
    }
}