master
John Lauer 1.0.54: 1.0.54. Wiki #119: back-side footprints written by other tools are read the way KiCad reads them. A footprint o… 4f34c0c 1mo ago
//! Design rules by fab (wiki adom/kicad-bridge#73), ported from `handlers/design_rules.py`.
//!
//! The canonical per-vendor rules live on the wiki page adom/pcb-design-rules, which
//! renders one KiCad custom-rules file per (vendor, process, layer count) plus a
//! machine-readable index at `.../render/rules/index.json`. A rule change is a wiki
//! publish, not a bridge release: this module never carries rule values. It resolves
//! (fab, layers) to the page's file, stages it next to the board as
//! `<board stem>.kicad_dru` (the name KiCad loads automatically) and optionally
//! runs DRC so the caller sees the fab's limits applied.
//!
//! The 3rd party fab is the baseline and is listed first.

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

use serde_json::{json, Value};

pub const PAGE: &str = "https://wiki.adom.inc/adom/pcb-design-rules/render/";
pub const INDEX_URL: &str = "https://wiki.adom.inc/adom/pcb-design-rules/render/rules/index.json";
pub const HOUSE_SLUG: &str = "adom-instapcb";
const TIMEOUT: Duration = Duration::from_secs(20);
const USER_AGENT: &str = "adom-bridge-kicad design_rules";

/// GET a URL from the wiki (rustls with bundled roots: no OS CA store involved).
#[cfg(not(feature = "net"))]
pub fn fetch(_url: &str) -> Result<Vec<u8>, String> {
    Err("the net feature is off in this build: no HTTPS client".into())
}

#[cfg(feature = "net")]
pub fn fetch(url: &str) -> Result<Vec<u8>, String> {
    let config = ureq::Agent::config_builder().timeout_global(Some(TIMEOUT)).http_status_as_error(true).build();
    let agent = ureq::Agent::new_with_config(config);
    let mut resp = agent.get(url).header("User-Agent", USER_AGENT).call().map_err(|e| e.to_string())?;
    resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())
}

/// The page's vendors, baseline fab first, then the page's own order.
pub fn index() -> Result<Vec<Value>, String> {
    let bytes = fetch(INDEX_URL)?;
    let data: Value = serde_json::from_slice(&bytes).map_err(|e| format!("index is not JSON: {e}"))?;
    let mut vendors: Vec<Value> = data.get("vendors").and_then(Value::as_array).cloned().unwrap_or_default();
    vendors.sort_by_key(|v| if v.get("vendorSlug").and_then(Value::as_str) == Some(HOUSE_SLUG) { 0 } else { 1 });
    Ok(vendors)
}

/// Lowercase alphanumerics only, so "JLC PCB" and "jlcpcb" compare equal.
pub fn norm_key(s: &str) -> String {
    s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}

fn s(v: &Value, key: &str) -> String {
    v.get(key).and_then(Value::as_str).unwrap_or("").to_string()
}

/// Accept the slug, the display name, or a distinctive substring of either.
pub fn match_fab<'a>(vendors: &'a [Value], fab: &str) -> Vec<&'a Value> {
    let want = norm_key(fab);
    if want.is_empty() {
        return Vec::new();
    }
    let exact: Vec<&Value> = vendors.iter().filter(|v| norm_key(&s(v, "vendorSlug")) == want || norm_key(&s(v, "vendor")) == want).collect();
    if !exact.is_empty() {
        return exact;
    }
    vendors.iter().filter(|v| norm_key(&s(v, "vendorSlug")).contains(&want) || norm_key(&s(v, "vendor")).contains(&want)).collect()
}

/// One `rules` row of `kicad_list_design_rules`.
pub fn rule_row(v: &Value) -> Value {
    let files = v.get("files").cloned().unwrap_or(Value::Null);
    let file = |k: &str| files.get(k).and_then(Value::as_str).filter(|x| !x.is_empty()).map(|x| format!("{PAGE}{x}"));
    json!({
        "fab": v.get("vendor"),
        "slug": v.get("vendorSlug"),
        "process": v.get("process"),
        "layers": v.get("layerCount"),
        "kicadDruUrl": file("kicadDru"),
        "boardSetupUrl": file("kicadBoardSetup"),
        "house": v.get("vendorSlug").and_then(Value::as_str) == Some(HOUSE_SLUG),
    })
}

/// Python `handle_list_design_rules`.
pub fn list_design_rules(fab: Option<&str>) -> Value {
    let vendors = match index() {
        Ok(v) => v,
        Err(e) => return json!({"success": false, "error": format!("Could not read {INDEX_URL}: {e}"),
            "_hint": "The rules page is on wiki.adom.inc; check the desktop's network, then retry. Nothing is cached locally on purpose so a rule change is a wiki publish."}),
    };
    let picked: Vec<&Value> = match fab {
        Some(f) if !f.is_empty() => match_fab(&vendors, f),
        _ => vendors.iter().collect(),
    };
    let rows: Vec<Value> = picked.iter().map(|v| rule_row(v)).collect();
    json!({"success": true, "count": rows.len(), "rules": rows, "indexUrl": INDEX_URL,
           "_hint": "Pick a row and call kicad_set_design_rules {\"fab\": <slug>, \"layers\": <n>, \"boardPath\": <.kicad_pcb>}. Omit fab for the baseline fab (the 3rd party fab). Values come from the wiki page at call time, so do not copy them into code."})
}

/// What `set_design_rules` staged, before any DRC.
pub struct Staged {
    pub dru_path: PathBuf,
    pub backup: Option<PathBuf>,
    pub source: Value,
    pub rule_names: Vec<String>,
}

/// Resolve the rules file (local `druPath` or the wiki), validate it, back up and
/// replace `<board stem>.kicad_dru`. Returns the failure response on `Err`.
pub fn stage_rules(board_path: &Path, args: &Value) -> Result<Staged, Value> {
    let dru_target = board_path.with_extension("kicad_dru");
    let override_path = args.get("druPath").and_then(Value::as_str).map(str::trim).unwrap_or("");
    let (body, source): (Vec<u8>, Value) = if !override_path.is_empty() {
        let src = Path::new(override_path);
        if !src.exists() {
            return Err(json!({"success": false, "error": format!("druPath not found: {override_path}")}));
        }
        let body = std::fs::read(src).map_err(|e| json!({"success": false, "error": format!("cannot read druPath: {e}")}))?;
        (body, json!({"kind": "local", "path": crate::libraries::norm_path(src)}))
    } else {
        let vendors = index().map_err(|e| json!({"success": false, "error": format!("Could not read {INDEX_URL}: {e}"),
            "_hint": "Pass druPath with a local .kicad_dru to work offline, or retry when wiki.adom.inc is reachable."}))?;
        let fab = args.get("fab").and_then(Value::as_str).filter(|f| !f.is_empty()).unwrap_or(HOUSE_SLUG);
        let cands = match_fab(&vendors, fab);
        if cands.is_empty() {
            let mut known: Vec<String> = vendors.iter().map(|v| s(v, "vendorSlug")).collect();
            known.sort();
            known.dedup();
            return Err(json!({"success": false, "error": format!("No fab matches '{fab}'"), "errorCode": "fab_not_found",
                "knownFabs": known,
                "_hint": "Use one of knownFabs (kicad_list_design_rules shows names, processes and layer counts)."}));
        }
        let layers: i64 = match args.get("layers") {
            None | Some(Value::Null) => board_copper_layers(board_path).unwrap_or(2) as i64,
            Some(Value::Number(n)) => match n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)) {
                Some(i) => i,
                None => return Err(json!({"success": false, "error": format!("layers must be an integer, got {n}")})),
            },
            Some(Value::String(st)) => match st.trim().parse::<i64>() {
                Ok(i) => i,
                Err(_) => return Err(json!({"success": false, "error": format!("layers must be an integer, got '{st}'")})),
            },
            Some(other) => return Err(json!({"success": false, "error": format!("layers must be an integer, got {other}")})),
        };
        let process = args.get("process").and_then(Value::as_str).filter(|p| !p.is_empty());
        let pick: Vec<&Value> = cands
            .iter()
            .copied()
            .filter(|v| {
                let lc = v.get("layerCount").and_then(Value::as_i64).unwrap_or(0);
                lc == layers && process.map(|p| norm_key(&s(v, "process")) == norm_key(p)).unwrap_or(true)
            })
            .collect();
        let Some(v) = pick.first() else {
            let proc_txt = process.map(|p| format!(" {p}")).unwrap_or_default();
            return Err(json!({"success": false, "errorCode": "no_rules_for_layers",
                "error": format!("{} has no {layers}-layer{proc_txt} rules on the page", s(cands[0], "vendor")),
                "available": cands.iter().map(|c| json!({"process": c.get("process"), "layers": c.get("layerCount")})).collect::<Vec<_>>(),
                "_hint": "Pick one of `available`, or pass druPath with your own file."}));
        };
        let rel = v.get("files").and_then(|f| f.get("kicadDru")).and_then(Value::as_str).unwrap_or("");
        if rel.is_empty() {
            return Err(json!({"success": false, "error": format!("The page lists no KiCad file for {} {layers}L", s(v, "vendor"))}));
        }
        let url = format!("{PAGE}{rel}");
        let body = fetch(&url).map_err(|e| json!({"success": false, "error": format!("Could not download {url}: {e}")}))?;
        (body, json!({"kind": "wiki", "url": url, "fab": v.get("vendor"), "slug": v.get("vendorSlug"),
                      "process": v.get("process"), "layers": layers}))
    };

    let text = String::from_utf8_lossy(&body).to_string();
    let head: String = text.chars().take(200).collect();
    if !head.contains("(version") || !text.contains("(rule") {
        return Err(json!({"success": false, "errorCode": "not_a_kicad_dru",
            "error": "The rules file does not look like a KiCad custom-rules file ('(version N)' header and '(rule ...)' blocks)",
            "source": source, "head": text.chars().take(160).collect::<String>()}));
    }

    let mut backup = None;
    if dru_target.exists() {
        if args.get("keepExisting").map(truthy).unwrap_or(false) {
            return Err(json!({"success": false, "errorCode": "dru_exists",
                "error": format!("{} already exists and keepExisting is set", file_name(&dru_target)),
                "existing": crate::libraries::norm_path(&dru_target), "source": source,
                "_hint": "Drop keepExisting to replace it (the old file is backed up next to it)."}));
        }
        let bak = dru_target.with_file_name(format!("{}.bak-{}", file_name(&dru_target), timestamp()));
        std::fs::copy(&dru_target, &bak).map_err(|e| json!({"success": false, "error": format!("cannot back up {}: {e}", dru_target.display())}))?;
        backup = Some(bak);
    }
    std::fs::write(&dru_target, &body).map_err(|e| json!({"success": false, "error": format!("cannot write {}: {e}", dru_target.display())}))?;

    Ok(Staged { dru_path: dru_target, backup, source, rule_names: rule_names(&text) })
}

/// Names of the `(rule "NAME" ...)` blocks, in file order.
pub fn rule_names(text: &str) -> Vec<String> {
    text.lines()
        .filter_map(|ln| {
            let t = ln.trim();
            if t.starts_with("(rule ") && t.contains('"') {
                t.split('"').nth(1).map(str::to_string)
            } else {
                None
            }
        })
        .collect()
}

fn file_name(p: &Path) -> String {
    p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
}

fn truthy(v: &Value) -> bool {
    match v {
        Value::Bool(b) => *b,
        Value::Null => false,
        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
        Value::String(s) => !s.is_empty(),
        Value::Array(a) => !a.is_empty(),
        Value::Object(o) => !o.is_empty(),
    }
}

/// Count copper layers from the board's `(layers ...)` block, cheaply (first 20000
/// chars). `(0 "F.Cu" signal)` rows with a signal/power/mixed/jumper kind count.
pub fn board_copper_layers(board_path: &Path) -> Option<u32> {
    let bytes = std::fs::read(board_path).ok()?;
    let text = String::from_utf8_lossy(&bytes);
    let head: String = text.chars().take(20000).collect();
    copper_layers_in_text(&head)
}

pub fn copper_layers_in_text(head: &str) -> Option<u32> {
    let i = head.find("(layers")?;
    let mut depth = 0i32;
    let mut end = head.len();
    for (j, ch) in head[i..].char_indices() {
        match ch {
            '(' => depth += 1,
            ')' => {
                depth -= 1;
                if depth == 0 {
                    end = i + j + 1;
                    break;
                }
            }
            _ => {}
        }
    }
    let block = &head[i..end];
    let parsed = crate::libraries::parse_sexp(block).ok()?;
    let mut n = 0u32;
    for row in parsed.as_list()?.iter().skip(1) {
        let Some(l) = row.as_list() else { continue };
        let num_ok = l.first().and_then(|a| a.as_atom()).map(|a| !a.is_empty() && a.bytes().all(|b| b.is_ascii_digit())).unwrap_or(false);
        let cu = l.get(1).and_then(|a| a.as_atom()).map(|a| a.ends_with(".Cu")).unwrap_or(false);
        let kind = l.get(2).and_then(|a| a.as_atom()).map(|k| matches!(k, "signal" | "power" | "mixed" | "jumper")).unwrap_or(false);
        if num_ok && cu && kind {
            n += 1;
        }
    }
    if n == 0 { None } else { Some(n) }
}

/// `YYYYmmdd-HHMMSS` in UTC (the Python used local time; the port has no timezone
/// database and the suffix only needs to be unique and sortable).
pub fn timestamp() -> String {
    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
    let days = secs.div_euclid(86400);
    let rem = secs.rem_euclid(86400);
    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
    // Civil-from-days (Howard Hinnant).
    let z = days + 719468;
    let era = z.div_euclid(146097);
    let doe = z.rem_euclid(146097);
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if mo <= 2 { y + 1 } else { y };
    format!("{y:04}{mo:02}{d:02}-{h:02}{m:02}{s:02}")
}

/// The DRC the Python runs after staging (`handle_lint_board`): `pcb drc --format json
/// --severity-all --all-track-errors [--schematic-parity]`, shaped as
/// `{success, output, data:{violations, summary}}` so the caller's post-processing
/// matches. `cli` is kicad-cli; None means KiCad is not installed.
pub fn drc_with_rules(cli: Option<&Path>, board_path: &Path, schematic_parity: bool) -> Value {
    let Some(cli) = cli else {
        return json!({"success": false, "error": "KiCad not installed",
                      "_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."});
    };
    let report = std::env::temp_dir().join(format!("adom-kicad-drc-rules-{}.json", std::process::id()));
    let report_s = crate::libraries::norm_path(&report);
    let board_s = crate::libraries::norm_path(board_path);
    let mut argv: Vec<&str> = vec!["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors"];
    if schematic_parity {
        argv.push("--schematic-parity");
    }
    argv.extend(["--output", report_s.as_str(), board_s.as_str()]);
    let r = crate::cli::run(cli, &argv, Duration::from_secs(280));
    let parsed = std::fs::read_to_string(&report).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
    let _ = std::fs::remove_file(&report);
    match parsed {
        Some(rep) => {
            let violations = crate::cli::flatten_violations(&rep);
            let errors = violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("error")).count();
            let warnings = violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("warning")).count();
            let info = violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("info")).count();
            let total = violations.len();
            json!({
                "success": true,
                "output": format!("Lint complete: {errors} error(s), {warnings} warning(s), {total} total"),
                "data": {
                    "violations": violations,
                    "summary": {"total": total, "errors": errors, "warnings": warnings, "info": info},
                    "schematicParityChecked": schematic_parity,
                    "source_file": board_s,
                },
                "cliExitCode": r.exit_code,
                "cliElapsedMs": r.elapsed_ms,
            })
        }
        None => json!({
            "success": false,
            "error": if r.timed_out { "kicad-cli timed out".to_string() } else { format!("kicad-cli failed: {}", r.stderr.trim()) },
            "stdout": r.stdout,
            "stderr": r.stderr,
            "_hint": "If stderr mentions 'older format', the file needs upgrading (kicad-cli pcb upgrade, or open and save in KiCad). If schematic-parity failed because no matching .kicad_sch was found, retry with schematicParity:false.",
        }),
    }
}

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

    fn vendors() -> Vec<Value> {
        vec![
            json!({"vendor": "JLCPCB", "vendorSlug": "jlcpcb", "process": "standard", "layerCount": 2, "files": {"kicadDru": "kicad/jlcpcb-2L.kicad_dru", "kicadBoardSetup": "kicad/jlcpcb-2L.json"}}),
            json!({"vendor": "3rd party fab", "vendorSlug": HOUSE_SLUG, "process": "standard", "layerCount": 2, "files": {"kicadDru": format!("kicad/{HOUSE_SLUG}-2L.kicad_dru")}}),
            json!({"vendor": "JLCPCB", "vendorSlug": "jlcpcb", "process": "advanced", "layerCount": 4, "files": {}}),
        ]
    }

    #[test]
    fn fab_matching() {
        let v = vendors();
        assert_eq!(match_fab(&v, "JLC PCB").len(), 2);
        assert_eq!(match_fab(&v, "jlcpcb").len(), 2);
        assert_eq!(match_fab(&v, "3rd party").len(), 1);
        assert_eq!(match_fab(&v, "3rd party fab")[0]["vendorSlug"], json!(HOUSE_SLUG));
        assert!(match_fab(&v, "").is_empty());
        assert!(match_fab(&v, "oshpark").is_empty());
    }

    #[test]
    fn rows_carry_urls_and_house_flag() {
        let v = vendors();
        let r = rule_row(&v[0]);
        assert_eq!(r["kicadDruUrl"], json!("https://wiki.adom.inc/adom/pcb-design-rules/render/kicad/jlcpcb-2L.kicad_dru"));
        assert_eq!(r["boardSetupUrl"], json!("https://wiki.adom.inc/adom/pcb-design-rules/render/kicad/jlcpcb-2L.json"));
        assert_eq!(r["house"], json!(false));
        let r = rule_row(&v[1]);
        assert_eq!(r["boardSetupUrl"], Value::Null);
        assert_eq!(r["house"], json!(true));
        assert_eq!(rule_row(&v[2])["kicadDruUrl"], Value::Null);
    }

    #[test]
    fn copper_layer_count() {
        let board = r#"(kicad_pcb (version 20240108) (generator "pcbnew")
  (general (thickness 1.6))
  (layers
    (0 "F.Cu" signal)
    (1 "In1.Cu" power)
    (2 "In2.Cu" mixed)
    (31 "B.Cu" signal)
    (32 "B.Adhes" user "B.Adhesive")
    (36 "B.SilkS" user "B.Silkscreen")
  )
  (setup (pad_to_mask_clearance 0)))"#;
        assert_eq!(copper_layers_in_text(board), Some(4));
        assert_eq!(copper_layers_in_text("(kicad_pcb (general))"), None);
        assert_eq!(copper_layers_in_text("(kicad_pcb (layers (32 \"B.Adhes\" user)))"), None);
    }

    #[test]
    fn rule_names_and_dru_sniff() {
        let dru = "(version 1)\n(rule \"min-track-width\"\n  (constraint track_width (min 0.1mm)))\n(rule \"via-drill\" (constraint hole_size (min 0.2mm)))\n";
        assert_eq!(rule_names(dru), vec!["min-track-width".to_string(), "via-drill".to_string()]);
    }

    #[test]
    fn stage_rules_from_local_file_with_backup() {
        let dir = std::env::temp_dir().join(format!("adom-dru-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let board = dir.join("board.kicad_pcb");
        std::fs::write(&board, "(kicad_pcb (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)))").unwrap();
        let src = dir.join("rules.kicad_dru");
        std::fs::write(&src, "(version 1)\n(rule \"a\" (constraint track_width (min 0.1mm)))\n").unwrap();
        let existing = dir.join("board.kicad_dru");
        std::fs::write(&existing, "(version 1)\n(rule \"old\")\n").unwrap();
        let args = json!({"druPath": src.to_string_lossy(), "keepExisting": true});
        let e = stage_rules(&board, &args).err().unwrap();
        assert_eq!(e["errorCode"], json!("dru_exists"));
        let args = json!({"druPath": src.to_string_lossy()});
        let st = stage_rules(&board, &args).ok().unwrap();
        assert_eq!(st.dru_path, existing);
        assert!(st.backup.as_ref().unwrap().file_name().unwrap().to_string_lossy().starts_with("board.kicad_dru.bak-"));
        assert_eq!(st.rule_names, vec!["a".to_string()]);
        assert_eq!(st.source["kind"], json!("local"));
        assert_eq!(std::fs::read_to_string(&existing).unwrap(), std::fs::read_to_string(&src).unwrap());
        // Not a rules file.
        let bad = dir.join("bad.kicad_dru");
        std::fs::write(&bad, "hello").unwrap();
        let e = stage_rules(&board, &json!({"druPath": bad.to_string_lossy()})).err().unwrap();
        assert_eq!(e["errorCode"], json!("not_a_kicad_dru"));
        let e = stage_rules(&board, &json!({"druPath": dir.join("missing").to_string_lossy()})).err().unwrap();
        assert!(e["error"].as_str().unwrap().starts_with("druPath not found"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn timestamp_shape() {
        let t = timestamp();
        assert_eq!(t.len(), 15);
        assert_eq!(&t[8..9], "-");
        assert!(t[..8].bytes().all(|b| b.is_ascii_digit()));
        assert!(t.starts_with("20"));
    }
}