main
Colby Knox 0.2.9: the dedicated freeze-fix release (#6, #7, #8) - workspace-union teardown/status, bootstrap refuses legacy fallba… 9f4e1af 1mo ago
//! Reading the board: one primitive, many typed views.
//!
//! The point of the Rust bridge: the DelphiScript agent stops shaping JSON per verb and emits a few
//! one generic read; the bridge derives every view from it. That moves the fragile part (parsing,
//! tallies, filters, sorting) out of a language where a typo halts the engine behind a modal on the
//! user's screen, into one that is typed, unit-tested, and changeable without restarting Altium.
//!
//! `read_board` is ONE agent round trip for components + nets + layers + copper + rules. Five verbs
//! that used to cost five round trips (~50 ms each, plus Altium's own work) now cost one, and the
//! views below are pure functions over the result — which is why they can be tested against a
//! recorded fixture with no Altium anywhere (see `fixtures/`).
use serde_json::{json, Value};

/// A recorded or live `read_board` payload (the agent's `data` object).
#[derive(Clone, Debug)]
pub struct BoardData(pub Value);

impl BoardData {
    pub fn new(data: Value) -> Self {
        BoardData(data)
    }

    /// The agent composes sub-envelopes (`{ok, ...}`) per section; unwrap one by name.
    fn section(&self, name: &str) -> Option<&Value> {
        self.0.get(name).filter(|s| s.get("ok").and_then(|b| b.as_bool()).unwrap_or(false))
    }

    pub fn board_path(&self) -> Option<&str> {
        self.0.get("board").and_then(|v| v.as_str())
    }

    fn array(&self, section: &str, key: &str) -> Vec<Value> {
        self.section(section)
            .and_then(|s| s.get(key))
            .and_then(|a| a.as_array())
            .cloned()
            .unwrap_or_default()
    }

    // ---- views ---------------------------------------------------------------

    pub fn components(&self) -> Vec<Value> {
        self.array("components", "components")
    }

    /// Designators, sorted naturally (U2 before U10 — plain string order gets this wrong, and a
    /// BOM or a review list read in the wrong order is the kind of thing nobody notices).
    pub fn designators(&self) -> Vec<String> {
        let mut d: Vec<String> = self
            .components()
            .iter()
            .filter_map(|c| c.get("designator").or_else(|| c.get("name")).and_then(|v| v.as_str()).map(str::to_string))
            .collect();
        d.sort_by(|a, b| natural_cmp(a, b));
        d
    }

    pub fn nets(&self) -> Vec<Value> {
        self.array("nets", "nets")
    }

    pub fn layers(&self) -> Vec<Value> {
        self.array("layers", "layers")
    }

    /// Copper layers only, in stack order, ignoring the ones Altium reports as not present.
    pub fn copper_layers(&self) -> Vec<Value> {
        self.array("copper", "layers")
            .into_iter()
            .filter(|l| l.get("used").and_then(|b| b.as_bool()).unwrap_or(true))
            .collect()
    }

    pub fn rules(&self) -> Vec<Value> {
        self.array("rules", "rules")
    }

    /// Enabled rules grouped by kind NAME, each group sorted by priority — the shape a design
    /// review wants. The agent emits Altium's numeric `TRuleKind` ordinal; the mapping below was
    /// read off a live default rule set (each default rule is named after its own kind), so it is
    /// derived from data rather than guessed. A user-RENAMED rule keeps its ordinal, so the group
    /// label stays correct even when the rule name no longer matches.
    pub fn rules_by_kind(&self) -> Value {
        let mut kinds: std::collections::BTreeMap<String, Vec<Value>> = Default::default();
        for r in self.rules() {
            if r.get("enabled").and_then(|b| b.as_bool()) == Some(false) {
                continue;
            }
            let label = r
                .get("kind")
                .and_then(|k| k.as_i64())
                .map(rule_kind_name)
                .unwrap_or_else(|| "Unknown".to_string());
            kinds.entry(label).or_default().push(r);
        }
        for v in kinds.values_mut() {
            v.sort_by_key(|r| r.get("priority").and_then(|p| p.as_i64()).unwrap_or(i64::MAX));
        }
        json!(kinds)
    }

    /// Every layer carrying a pad/track/arc, densest first — copper AND silkscreen AND mechanical.
    /// The agent tallies primitives by layer in one pass; this is the raw occupancy.
    pub fn layers_with_primitives(&self) -> Vec<Value> {
        let mut v = self.array("occupancy", "occupancy");
        v.sort_by_key(|l| std::cmp::Reverse(l.get("primitives").and_then(|p| p.as_i64()).unwrap_or(0)));
        v
    }

    /// The layer ids Altium reports as COPPER (top + mid + internal planes + bottom). Taken from
    /// the dump's own `copper` section rather than assumed from id ranges — the ranges are an
    /// implementation detail of the enum and not something to hardcode.
    fn copper_layer_ids(&self) -> std::collections::BTreeSet<i64> {
        self.copper_layers().iter().filter_map(|l| l.get("id").and_then(|i| i.as_i64())).collect()
    }

    /// COPPER layers that actually carry copper, densest first — the honest answer to "how many
    /// layers is this board".
    ///
    /// Two caveats the caller must not lose: an inner plane with no primitives is invisible here
    /// (so this is a floor, not the stack), and the first cut of this view forgot to filter by
    /// copper at all and cheerfully reported silkscreen and mechanical layers as board layers.
    pub fn copper_layers_in_use(&self) -> Vec<Value> {
        let copper = self.copper_layer_ids();
        self.layers_with_primitives()
            .into_iter()
            .filter(|l| l.get("layerId").and_then(|i| i.as_i64()).map(|i| copper.contains(&i)).unwrap_or(false))
            .collect()
    }

    /// One-screen summary: what is on this board.
    pub fn summary(&self) -> Value {
        let comps = self.components();
        let (mut top, mut bottom) = (0usize, 0usize);
        for c in &comps {
            if c.get("bottom").and_then(|b| b.as_bool()).unwrap_or(false) { bottom += 1 } else { top += 1 }
        }
        let rules = self.rules();
        json!({
            "board": self.board_path(),
            "components": comps.len(),
            "componentsTop": top,
            "componentsBottom": bottom,
            "nets": self.nets().len(),
            // NOTE: the agent enumerates every copper layer Altium CAN address (top + 30 mid +
            // 16 planes + bottom), not the stack actually in use — there is no "in stack" flag in
            // the read yet, so this is a capability count, not a layer count. Do not report it to
            // a user as "this board has N layers".
            // layers actually carrying copper (a floor — an empty inner plane is invisible here);
            // `copperLayersAddressable` is what Altium CAN address, which is not a board property
            "copperLayersInUse": self.copper_layers_in_use().len(),
            "copperLayersInUseNames": self.copper_layers_in_use().iter()
                .filter_map(|l| l.get("name").and_then(|n| n.as_str()).map(str::to_string)).collect::<Vec<_>>(),
            "layersWithPrimitives": self.layers_with_primitives().len(),
            "copperLayersAddressable": self.copper_layers().len(),
            "rules": rules.len(),
            "rulesEnabled": rules.iter().filter(|r| r.get("enabled").and_then(|b| b.as_bool()) != Some(false)).count(),
            "ruleKinds": self.rules_by_kind().as_object().map(|o| o.len()).unwrap_or(0),
        })
    }
}

/// A recorded or live `read_schematic` payload (the agent's `data` object): the CURRENT sheet's
/// components with pins and params. Same envelope rules as `BoardData` — the agent nests the
/// section reader's own `{ok, count, components}` under the section name.
#[derive(Clone, Debug)]
pub struct SchematicData(pub Value);

impl SchematicData {
    pub fn new(data: Value) -> Self {
        SchematicData(data)
    }

    pub fn document(&self) -> Option<&str> {
        self.0.get("document").and_then(|v| v.as_str())
    }

    pub fn components(&self) -> Vec<Value> {
        self.0
            .get("components")
            .filter(|s| s.get("ok").and_then(|b| b.as_bool()).unwrap_or(false))
            .and_then(|s| s.get("components"))
            .and_then(|a| a.as_array())
            .cloned()
            .unwrap_or_default()
    }

    /// Designators, naturally sorted and de-duplicated (a multi-part symbol appears once per part).
    pub fn designators(&self) -> Vec<String> {
        let mut d: Vec<String> = self
            .components()
            .iter()
            .filter_map(|c| c.get("designator").and_then(|v| v.as_str()).map(str::to_string))
            .collect();
        d.sort_by(|a, b| natural_cmp(a, b));
        d.dedup();
        d
    }
}

/// Windows folder of a document path, lowercased — `sameProject` compares folders because the
/// SchServer and PCBServer each hold their own "current document" and the two can genuinely
/// belong to different projects.
fn folder_of(p: Option<&str>) -> Option<String> {
    let q = p?.replace('/', "\\");
    q.rsplit_once('\\').map(|(dir, _)| dir.to_lowercase())
}

/// Schematic sheet vs board: what is drawn but not placed, and placed but not drawn — which
/// neither read answers alone. Pure function over the two reads (the verb does the activation
/// dance, because Altium cannot hold both documents current at once). Two caveats travel WITH
/// the answer because both make it wrong to act on silently: the schematic side is ONE sheet,
/// and the two currents can belong to different projects (`sameProject: false`).
pub fn cross_check(sheet: &SchematicData, board: &BoardData) -> Value {
    let sch: std::collections::BTreeSet<String> = sheet.designators().into_iter().collect();
    let pcb: std::collections::BTreeSet<String> = board.designators().into_iter().collect();
    let sort = |s: Vec<String>| {
        let mut v = s;
        v.sort_by(|a, b| natural_cmp(a, b));
        v
    };
    let on_sheet_only = sort(sch.difference(&pcb).cloned().collect());
    let on_board_only = sort(pcb.difference(&sch).cloned().collect());
    let same = match (folder_of(sheet.document()), folder_of(board.board_path())) {
        (Some(a), Some(b)) => json!(a == b),
        _ => Value::Null,
    };
    let mut out = json!({
        "sheet": sheet.document(),
        "board": board.board_path(),
        "sameProject": same,
        "matched": sch.intersection(&pcb).count(),
        "onSheetNotOnBoard": on_sheet_only,
        "onBoardNotOnSheet": on_board_only,
        "schematicComponents": sheet.components().len(),
        "boardComponents": board.components().len(),
        "_scope": "The schematic side is ONE sheet. On a hierarchical design, run this per leaf sheet before calling anything 'missing from the schematic'.",
    });
    if out.get("sameProject") == Some(&json!(false)) {
        out["_warning"] = json!(
            "The sheet and the board are in DIFFERENT folders, so they are probably not the same project — which makes every difference above meaningless."
        );
    }
    out
}

/// Altium `TRuleKind` ordinals, read off a live AD26 default rule set (2026-09-01): every default
/// rule is named after its own kind, which makes the mapping observable rather than guessed.
/// Unknown ordinals are rendered as `kind <n>` — never invented.
pub fn rule_kind_name(k: i64) -> String {
    let name = match k {
        0 => "Clearance", 2 => "Width", 6 => "PlaneConnect", 7 => "RoutingTopology",
        8 => "RoutingPriority", 9 => "RoutingLayers", 10 => "RoutingCorners", 11 => "RoutingVias",
        12 => "PlaneClearance", 13 => "SolderMaskExpansion", 14 => "PasteMaskExpansion",
        15 => "ShortCircuit", 16 => "UnRoutedNet", 20 => "PolygonConnect",
        24 => "ComponentClearance", 42 => "HoleSize", 43 => "FabricationTestpoint",
        44 => "FabricationTestPointUsage", 48 => "LayerPairs", 49 => "Fanout", 50 => "Height",
        51 => "DiffPairsRouting", 52 => "HoleToHoleClearance", 53 => "MinimumSolderMaskSliver",
        54 => "SilkToSolderMaskClearance", 55 => "SilkToSilkClearance", 56 => "NetAntennae",
        57 => "AssemblyTestpoint", 58 => "AssemblyTestPointUsage",
        59 => "SilkToBoardRegionClearance", 62 => "UnpouredPolygon",
        _ => "",
    };
    if name.is_empty() { format!("kind {k}") } else { name.to_string() }
}

/// U2 before U10, R1 before R2 before R10 — compare digit runs numerically.
pub fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
    let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable());
    loop {
        match (x.peek().copied(), y.peek().copied()) {
            (None, None) => return std::cmp::Ordering::Equal,
            (None, _) => return std::cmp::Ordering::Less,
            (_, None) => return std::cmp::Ordering::Greater,
            (Some(ca), Some(cb)) => {
                if ca.is_ascii_digit() && cb.is_ascii_digit() {
                    let na: String = std::iter::from_fn(|| x.next_if(|c| c.is_ascii_digit())).collect();
                    let nb: String = std::iter::from_fn(|| y.next_if(|c| c.is_ascii_digit())).collect();
                    let (va, vb) = (na.parse::<u64>().unwrap_or(0), nb.parse::<u64>().unwrap_or(0));
                    if va != vb {
                        return va.cmp(&vb);
                    }
                } else {
                    if ca != cb {
                        return ca.cmp(&cb);
                    }
                    x.next();
                    y.next();
                }
            }
        }
    }
}

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

    fn fixture() -> BoardData {
        // Recorded from a live board; see fixtures/README.md for how to re-record.
        let raw = include_str!("../fixtures/read_board.json");
        let v: Value = serde_json::from_str(raw).expect("fixture parses");
        BoardData::new(v.get("data").cloned().unwrap_or(v))
    }

    #[test]
    fn natural_order_beats_string_order() {
        let mut v = vec!["U10", "U2", "R1", "C10", "C9"];
        v.sort_by(|a, b| natural_cmp(a, b));
        assert_eq!(v, vec!["C9", "C10", "R1", "U2", "U10"]);
    }

    #[test]
    fn views_read_the_recorded_board() {
        let d = fixture();
        assert!(d.board_path().unwrap().ends_with(".PcbDoc"));
        let s = d.summary();
        assert_eq!(s.get("rules").and_then(|v| v.as_u64()), Some(d.rules().len() as u64));
        assert_eq!(
            s.get("components").and_then(|v| v.as_u64()).unwrap(),
            s.get("componentsTop").and_then(|v| v.as_u64()).unwrap()
                + s.get("componentsBottom").and_then(|v| v.as_u64()).unwrap(),
            "every component lands on exactly one side"
        );
    }

    #[test]
    fn every_rule_in_the_fixture_maps_to_a_named_kind() {
        // 35 rules / 31 ordinals on a live AD26 default rule set — if Altium adds a kind we do not
        // know, this fails loudly instead of silently labelling it "Unknown".
        let d = fixture();
        let by = d.rules_by_kind();
        let unnamed: Vec<&String> = by.as_object().unwrap().keys().filter(|k| k.starts_with("kind ")).collect();
        assert!(unnamed.is_empty(), "unmapped TRuleKind ordinals: {unnamed:?}");
        // the five Fanout defaults (BGA/LCC/SOIC/Small/Default) all carry ordinal 49 — the one
        // many-to-one group in the default set, and the reason grouping by ordinal beats by name
        assert_eq!(by.get("Fanout").map(|f| f.as_array().unwrap().len()), Some(5));
        assert!(by.get("Clearance").is_some());
    }

    #[test]
    fn copper_layers_in_use_excludes_silkscreen_and_mechanical() {
        // measured on a live board: the raw tally is dominated by a MECHANICAL layer
        // (Adom.SolderBalls, 296 prims) — reporting that as a board layer would be wrong
        let d = BoardData::new(json!({
            "copper": {"ok": true, "layers": [{"id": 1, "name": "Top Layer"}, {"id": 32, "name": "Bottom Layer"}]},
            "occupancy": {"ok": true, "occupancy": [
                {"layerId": 65, "name": "Adom.SolderBalls", "primitives": 296},
                {"layerId": 1,  "name": "Top Layer",        "primitives": 51},
                {"layerId": 33, "name": "Top Overlay",      "primitives": 9},
                {"layerId": 32, "name": "Bottom Layer",     "primitives": 4}
            ]}
        }));
        assert_eq!(d.layers_with_primitives().len(), 4, "raw tally keeps everything");
        let cu = d.copper_layers_in_use();
        assert_eq!(cu.len(), 2, "only copper survives");
        assert_eq!(cu[0].get("name").unwrap(), "Top Layer", "densest copper first");
        let s = d.summary();
        assert_eq!(s.get("copperLayersInUse").and_then(|v| v.as_u64()), Some(2));
        assert_eq!(s.get("layersWithPrimitives").and_then(|v| v.as_u64()), Some(4));
    }

    #[test]
    fn an_unknown_ordinal_is_labelled_not_invented() {
        assert_eq!(rule_kind_name(0), "Clearance");
        assert_eq!(rule_kind_name(999), "kind 999");
    }

    #[test]
    fn rules_by_kind_drops_disabled_and_sorts_by_priority() {
        let d = BoardData::new(json!({
            "rules": {"ok": true, "rules": [
                {"name": "b", "kind": 0, "priority": 2, "enabled": true},
                {"name": "a", "kind": 0, "priority": 1, "enabled": true},
                {"name": "off", "kind": 0, "priority": 0, "enabled": false},
                {"name": "w", "kind": 2, "priority": 1, "enabled": true}
            ]}
        }));
        let by = d.rules_by_kind();
        let cl = by.get("Clearance").unwrap().as_array().unwrap();
        assert_eq!(cl.len(), 2, "disabled rule dropped");
        assert_eq!(cl[0].get("name").unwrap(), "a", "sorted by priority");
        assert!(by.get("Width").is_some());
    }

    #[test]
    fn a_failed_section_yields_an_empty_view_not_a_panic() {
        // the agent reports {ok:false, error} per section when e.g. no board is current
        let d = BoardData::new(json!({"components": {"ok": false, "error": "no current PCB board"}}));
        assert!(d.designators().is_empty());
        assert_eq!(d.summary().get("components").and_then(|v| v.as_u64()), Some(0));
    }

    fn sheet(des: &[&str], doc: &str) -> SchematicData {
        SchematicData::new(json!({
            "ok": true, "document": doc,
            "components": {"ok": true,
                "components": des.iter().map(|d| json!({"designator": d})).collect::<Vec<_>>()}
        }))
    }

    fn small_board(des: &[&str], path: &str) -> BoardData {
        BoardData::new(json!({
            "board": path,
            "components": {"ok": true,
                "components": des.iter().map(|d| json!({"designator": d})).collect::<Vec<_>>()}
        }))
    }

    #[test]
    fn cross_check_reports_both_directions_naturally_sorted() {
        let s = sheet(&["U1", "U10", "U2", "R1"], "C:\\proj\\main.SchDoc");
        let b = small_board(&["U1", "U2", "C5"], "C:\\proj\\main.PcbDoc");
        let out = cross_check(&s, &b);
        assert_eq!(out["matched"], json!(2));
        assert_eq!(out["onSheetNotOnBoard"], json!(["R1", "U10"]));
        assert_eq!(out["onBoardNotOnSheet"], json!(["C5"]));
        assert_eq!(out["sameProject"], json!(true));
        assert!(out.get("_warning").is_none());
    }

    #[test]
    fn cross_check_warns_on_different_folders() {
        let s = sheet(&["U1"], "C:\\projA\\main.SchDoc");
        let b = small_board(&["U1"], "C:\\projB\\main.PcbDoc");
        let out = cross_check(&s, &b);
        assert_eq!(out["sameProject"], json!(false));
        assert!(out.get("_warning").is_some(), "different folders must warn loudly");
    }

    #[test]
    fn schematic_failed_section_is_empty_not_a_panic() {
        let s = SchematicData::new(json!({"components": {"ok": false, "error": "no current schematic"}}));
        assert!(s.components().is_empty());
        assert!(s.designators().is_empty());
        // and sameProject stays null when either path is missing
        let out = cross_check(&s, &small_board(&["U1"], "C:\\p\\b.PcbDoc"));
        assert_eq!(out["sameProject"], Value::Null);
    }
}