main
John Lauer v0.1.15: dump_board primitive, rust cold bootstrap proven, dump views with recorded fixtures 955dfe9 1mo ago
//! Dump primitives → typed views.
//!
//! The point of the Rust bridge: the DelphiScript agent stops shaping JSON per verb and emits a few
//! generic dumps; the bridge derives every view from them. 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.
//!
//! `dump_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 `dump_board` payload (the agent's `data` object).
#[derive(Clone, Debug)]
pub struct BoardDump(pub Value);

impl BoardDump {
    pub fn new(data: Value) -> Self {
        BoardDump(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)
    }

    /// 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 dump yet, so this is a capability count, not a layer count. Do not report it to
            // a user as "this board has N layers".
            "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),
        })
    }
}

/// 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() -> BoardDump {
        // Recorded from a live board; see fixtures/README.md for how to re-record.
        let raw = include_str!("../fixtures/dump_board.json");
        let v: Value = serde_json::from_str(raw).expect("fixture parses");
        BoardDump::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_dump() {
        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 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 = BoardDump::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 = BoardDump::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));
    }
}