app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Merge PR #366: kicad-lane: 1.0.95, KiCad on any drive (#137), cross_check from engine 0.24.0, eleven standard verbs by…
97fb57b
11d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010
//! Project-level rules on disk: the netclasses and board constraints KiCad keeps in the
//! `.kicad_pro` (JSON), and the custom rules in the `.kicad_dru` (s-expressions). Pure
//! reading and editing plus one atomic writer; the verbs in `kicad-bridge` decide when a
//! write is allowed (KiCad holds both files in memory while the project is open and
//! writes its own copy back on save, so an edit under an open KiCad is lost).
//!
//! Units are millimetres throughout, as KiCad stores them in both files.
use std::path::{Path, PathBuf};
use serde_json::{json, Map, Value};
use crate::pcb::RoutingError;
type R<T> = Result<T, RoutingError>;
// ---------------------------------------------------------------------------
// Locating and writing the files
// ---------------------------------------------------------------------------
/// The `.kicad_pro` a call addresses: `projectPath`, or the project beside `filePath`
/// (a board, schematic or rules file with the same stem).
pub fn project_path(args: &Value) -> R<PathBuf> {
let raw = ["projectPath", "filePath", "boardPath"].iter().find_map(|k| args.get(*k).and_then(Value::as_str)).map(str::trim).filter(|s| !s.is_empty());
let Some(raw) = raw else {
return Err(RoutingError::new("missing_arg", "Pass projectPath (the .kicad_pro) or filePath (the board beside it)"));
};
let p = PathBuf::from(raw);
let pro = if p.extension().map(|e| e == "kicad_pro").unwrap_or(false) { p } else { p.with_extension("kicad_pro") };
if !pro.is_file() {
return Err(RoutingError::new("project_not_found", format!("no project file at {}", pro.to_string_lossy().replace('\\', "/")))
.with("_hint", json!("Netclasses and board constraints live in the .kicad_pro beside the board (same stem). Open the board in KiCad once and save to create it.")));
}
Ok(pro)
}
/// KiCad's lock files for the project, its board and its schematic: present while a KiCad
/// editor has them open.
pub fn open_locks(pro: &Path) -> Vec<String> {
let mut out = Vec::new();
for ext in ["kicad_pro", "kicad_pcb", "kicad_sch"] {
let f = pro.with_extension(ext);
if let Some(name) = f.file_name() {
let lck = f.with_file_name(format!("~{}.lck", name.to_string_lossy()));
if lck.exists() {
out.push(lck.to_string_lossy().replace('\\', "/"));
}
}
}
out
}
pub fn read_json(path: &Path) -> R<Value> {
let text = std::fs::read_to_string(path).map_err(|e| RoutingError::new("project_unreadable", format!("cannot read {}: {e}", path.display())))?;
serde_json::from_str(&text).map_err(|e| RoutingError::new("project_unparsable", format!("{} is not valid JSON: {e}", path.display())))
}
/// KiCad's own layout: two-space indent, keys sorted (serde_json's map is sorted), newline at the end.
pub fn project_text(v: &Value) -> String {
let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
s.push('\n');
s
}
/// Replace `path` atomically (unique temp file, fsync, rename) after copying the old file
/// to `<path>.adom-bak`. Returns the backup path. Refuses when the file changed since it
/// was read (`expected`).
pub fn write_atomic(path: &Path, text: &str, expected: Option<&str>) -> R<String> {
if let Some(exp) = expected {
let now = std::fs::read_to_string(path).unwrap_or_default();
if now != exp {
return Err(RoutingError::new("file_changed", format!("{} changed while the edit was planned; read it again", path.display())).with("mutated", json!(false)));
}
}
let bak = format!("{}.adom-bak", path.to_string_lossy());
if path.exists() {
std::fs::copy(path, &bak).map_err(|e| RoutingError::new("write_failed", format!("cannot back up {}: {e}", path.display())).with("mutated", json!(false)))?;
}
let dir = path.parent().unwrap_or(Path::new("."));
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let tmp = dir.join(format!("{name}.{}.{nonce}.adom-tmp", std::process::id()));
let result = (|| -> Result<(), String> {
use std::io::Write;
let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
f.write_all(text.as_bytes()).map_err(|e| e.to_string())?;
f.sync_all().map_err(|e| e.to_string())?;
drop(f);
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
})();
let _ = std::fs::remove_file(&tmp);
result.map_err(|e| RoutingError::new("write_failed", format!("cannot replace {}: {e}", path.display())).with("mutated", json!(false)))?;
Ok(bak.replace('\\', "/"))
}
pub const RELOAD_NOTE: &str = "KiCad reads the project file when the project or board is opened and writes its own copy back when you save, so close KiCad (or the project) before this edit and reopen it afterwards; an editor that was open during the edit will overwrite it on its next save.";
// ---------------------------------------------------------------------------
// Netclasses
// ---------------------------------------------------------------------------
/// The netclass fields a caller may set: (camelCase argument, KiCad key).
pub const CLASS_FIELDS: &[(&str, &str)] = &[
("clearance", "clearance"),
("trackWidth", "track_width"),
("viaDiameter", "via_diameter"),
("viaDrill", "via_drill"),
("microviaDiameter", "microvia_diameter"),
("microviaDrill", "microvia_drill"),
("diffPairWidth", "diff_pair_width"),
("diffPairGap", "diff_pair_gap"),
("diffPairViaGap", "diff_pair_via_gap"),
("wireWidth", "wire_width"),
("busWidth", "bus_width"),
("priority", "priority"),
];
/// KiCad's Default netclass values (netclass.cpp defaults, KiCad 8 to 10).
pub fn default_class() -> Value {
json!({
"bus_width": 12, "clearance": 0.2, "diff_pair_gap": 0.25, "diff_pair_via_gap": 0.25, "diff_pair_width": 0.2,
"line_style": 0, "microvia_diameter": 0.3, "microvia_drill": 0.1, "name": "Default", "pcb_color": "rgba(0, 0, 0, 0.000)",
"priority": 2147483647, "schematic_color": "rgba(0, 0, 0, 0.000)", "track_width": 0.2, "via_diameter": 0.6, "via_drill": 0.3, "wire_width": 6
})
}
/// KiCad netclass pattern match: `*` any run, `?` one character, anchored at both ends.
/// KiCad also accepts a regular expression; a pattern with regex syntax is reported as
/// such (`regex: true`) and not evaluated here.
pub fn wildcard(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let s: Vec<char> = name.chars().collect();
let (mut pi, mut si, mut star, mut mark) = (0usize, 0usize, None::<usize>, 0usize);
while si < s.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == s[si]) {
pi += 1;
si += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
mark = si;
pi += 1;
} else if let Some(st) = star {
pi = st + 1;
mark += 1;
si = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
pub fn looks_regex(pattern: &str) -> bool {
pattern.chars().any(|c| matches!(c, '[' | ']' | '(' | ')' | '|' | '^' | '$' | '\\' | '{' | '}')) || pattern.contains(".*")
}
fn classes(pro: &Value) -> Vec<Value> {
pro.pointer("/net_settings/classes").and_then(Value::as_array).cloned().unwrap_or_default()
}
fn patterns(pro: &Value) -> Vec<(String, String)> {
pro.pointer("/net_settings/netclass_patterns")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|p| Some((p.get("netclass")?.as_str()?.to_string(), p.get("pattern")?.as_str()?.to_string()))).collect())
.unwrap_or_default()
}
/// Explicit `netclass_assignments` (net -> class or classes), when the project has any.
fn assignments(pro: &Value) -> Vec<(String, String)> {
let mut out = Vec::new();
if let Some(m) = pro.pointer("/net_settings/netclass_assignments").and_then(Value::as_object) {
for (net, c) in m {
match c {
Value::String(s) => out.push((net.clone(), s.clone())),
Value::Array(a) => out.extend(a.iter().filter_map(Value::as_str).map(|s| (net.clone(), s.to_string()))),
_ => {}
}
}
}
out
}
/// The class a net resolves to: an explicit assignment first, then the matching pattern
/// whose class has the best (lowest) priority, else Default.
pub fn class_for_net(pro: &Value, net: &str) -> Value {
let all = classes(pro);
let find = |name: &str| all.iter().find(|c| c.get("name").and_then(Value::as_str) == Some(name)).cloned();
if let Some((_, c)) = assignments(pro).into_iter().find(|(n, _)| n == net) {
if let Some(v) = find(&c) {
return v;
}
}
let mut hits: Vec<Value> = patterns(pro).into_iter().filter(|(_, p)| !looks_regex(p) && wildcard(p, net)).filter_map(|(c, _)| find(&c)).collect();
hits.sort_by_key(|c| c.get("priority").and_then(Value::as_i64).unwrap_or(i64::MAX));
hits.into_iter().next().or_else(|| find("Default")).unwrap_or_else(default_class)
}
fn camel_class(c: &Value) -> Value {
let mut out = json!({"name": c.get("name").cloned().unwrap_or(Value::Null)});
for (camel, key) in CLASS_FIELDS {
if let Some(v) = c.get(*key) {
out[*camel] = v.clone();
}
}
out
}
/// `kicad_netclasses` read: every class with its fields, its patterns and the board nets
/// those patterns match; nets no pattern matches fall to Default.
pub fn netclasses_report(pro: &Value, board_nets: &[String]) -> Value {
let pats = patterns(pro);
let assigns = assignments(pro);
let mut rows = Vec::new();
for c in classes(pro) {
let name = c.get("name").and_then(Value::as_str).unwrap_or("").to_string();
let mine: Vec<&(String, String)> = pats.iter().filter(|(cl, _)| *cl == name).collect();
let mut nets: Vec<String> = board_nets.iter().filter(|n| class_for_net(pro, n).get("name").and_then(Value::as_str) == Some(name.as_str())).cloned().collect();
nets.sort();
let mut row = camel_class(&c);
row["patterns"] = json!(mine.iter().map(|(_, p)| json!({"pattern": p, "regex": looks_regex(p), "matches": if looks_regex(p) { Value::Null } else { json!(board_nets.iter().filter(|n| wildcard(p, n)).collect::<Vec<_>>()) }})).collect::<Vec<_>>());
row["explicitNets"] = json!(assigns.iter().filter(|(_, cl)| *cl == name).map(|(n, _)| n).collect::<Vec<_>>());
row["nets"] = json!(nets);
row["netCount"] = json!(nets.len());
rows.push(row);
}
json!({"classes": rows, "classCount": rows.len(), "patternCount": pats.len(), "boardNetCount": board_nets.len(), "raw": pro.get("net_settings").cloned().unwrap_or(Value::Null)})
}
fn num(v: &Value, label: &str) -> R<f64> {
let f = v.as_f64().ok_or_else(|| RoutingError::new("invalid_value", format!("{label} must be a number in mm")))?;
if !f.is_finite() || f < 0.0 {
return Err(RoutingError::new("invalid_value", format!("{label} must be a finite, non-negative number in mm")));
}
Ok(f)
}
fn ensure_net_settings(pro: &mut Value) -> &mut Map<String, Value> {
if !pro.get("net_settings").map(Value::is_object).unwrap_or(false) {
pro["net_settings"] = json!({"classes": [], "meta": {"version": 5}, "netclass_patterns": []});
}
let ns = pro["net_settings"].as_object_mut().unwrap();
if !ns.get("classes").map(Value::is_array).unwrap_or(false) {
ns.insert("classes".into(), json!([]));
}
if !ns.get("netclass_patterns").map(Value::is_array).unwrap_or(false) {
ns.insert("netclass_patterns".into(), json!([]));
}
ns
}
/// Apply a `kicad_netclasses` edit to the parsed project. `set: {name, clearance?, ...}`
/// creates or updates a class (a new class starts from the Default class's values),
/// `assign: {netclass, patterns: [...]}` adds patterns (an exact net name is a valid
/// pattern), `unassign: {patterns: [...]}` removes them, `remove: "Name"` drops a class
/// and its patterns. Returns the list of changes; an empty list means nothing changed.
pub fn edit_netclasses(pro: &mut Value, args: &Value) -> R<Vec<Value>> {
let mut changes = Vec::new();
if let Some(set) = args.get("set") {
let sets: Vec<Value> = match set {
Value::Array(a) => a.clone(),
Value::Object(_) => vec![set.clone()],
_ => return Err(RoutingError::new("invalid_set", "set is {name, clearance?, trackWidth?, viaDiameter?, viaDrill?, diffPairWidth?, diffPairGap?, ...} or a list of them")),
};
for s in sets {
let name = s.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| RoutingError::new("invalid_set", "every class in set needs a name"))?.to_string();
let mut fields: Vec<(&str, Value)> = Vec::new();
for (camel, key) in CLASS_FIELDS {
let v = s.get(*camel).or_else(|| s.get(*key));
if let Some(v) = v.filter(|v| !v.is_null()) {
let f = num(v, camel)?;
fields.push((key, if *key == "priority" || *key == "wire_width" || *key == "bus_width" { json!(f as i64) } else { json!(f) }));
}
}
if let (Some(d), Some(w)) = (fields.iter().find(|f| f.0 == "via_drill").and_then(|f| f.1.as_f64()), fields.iter().find(|f| f.0 == "via_diameter").and_then(|f| f.1.as_f64())) {
if d >= w {
return Err(RoutingError::new("invalid_value", format!("class {name}: viaDrill {d} must be smaller than viaDiameter {w}")));
}
}
let base = classes(pro).into_iter().find(|c| c.get("name").and_then(Value::as_str) == Some("Default")).unwrap_or_else(default_class);
let ns = ensure_net_settings(pro);
let list = ns.get_mut("classes").and_then(Value::as_array_mut).unwrap();
match list.iter_mut().find(|c| c.get("name").and_then(Value::as_str) == Some(name.as_str())) {
Some(c) => {
for (k, v) in &fields {
if c.get(*k) != Some(v) {
changes.push(json!({"class": name, "field": k, "from": c.get(*k).cloned().unwrap_or(Value::Null), "to": v}));
c[*k] = v.clone();
}
}
}
None => {
let mut c = base.clone();
c["name"] = json!(name);
if name != "Default" {
c["priority"] = json!(0);
}
for (k, v) in &fields {
c[*k] = v.clone();
}
changes.push(json!({"class": name, "created": true, "values": camel_class(&c)}));
list.push(c);
}
}
}
}
if let Some(a) = args.get("assign") {
let class = a.get("netclass").or_else(|| a.get("class")).and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_assign", "assign is {netclass, patterns:[\"USB_*\", \"GND\"]}"))?.to_string();
let pats: Vec<String> = a.get("patterns").or_else(|| a.get("nets")).and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if pats.is_empty() {
return Err(RoutingError::new("invalid_assign", "assign.patterns lists at least one pattern or net name"));
}
if !classes(pro).iter().any(|c| c.get("name").and_then(Value::as_str) == Some(class.as_str())) {
return Err(RoutingError::new("unknown_netclass", format!("no netclass named {class}; create it with set first (both may go in one call)")));
}
let ns = ensure_net_settings(pro);
let list = ns.get_mut("netclass_patterns").and_then(Value::as_array_mut).unwrap();
for p in pats {
let exists = list.iter().any(|e| e.get("pattern").and_then(Value::as_str) == Some(p.as_str()) && e.get("netclass").and_then(Value::as_str) == Some(class.as_str()));
if !exists {
list.push(json!({"netclass": class, "pattern": p}));
changes.push(json!({"assigned": p, "netclass": class}));
}
}
}
if let Some(u) = args.get("unassign") {
let pats: Vec<String> = u.get("patterns").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
let class = u.get("netclass").and_then(Value::as_str).map(str::to_string);
let ns = ensure_net_settings(pro);
let list = ns.get_mut("netclass_patterns").and_then(Value::as_array_mut).unwrap();
list.retain(|e| {
let p = e.get("pattern").and_then(Value::as_str).unwrap_or("");
let c = e.get("netclass").and_then(Value::as_str).unwrap_or("");
let hit = pats.iter().any(|x| x == p) && class.as_deref().map(|k| k == c).unwrap_or(true);
if hit {
changes.push(json!({"unassigned": p, "netclass": c}));
}
!hit
});
}
if let Some(r) = args.get("remove").and_then(Value::as_str) {
if r == "Default" {
return Err(RoutingError::new("invalid_remove", "the Default netclass cannot be removed"));
}
let ns = ensure_net_settings(pro);
let list = ns.get_mut("classes").and_then(Value::as_array_mut).unwrap();
let before = list.len();
list.retain(|c| c.get("name").and_then(Value::as_str) != Some(r));
if list.len() == before {
return Err(RoutingError::new("unknown_netclass", format!("no netclass named {r}")));
}
changes.push(json!({"removedClass": r}));
if let Some(p) = ns.get_mut("netclass_patterns").and_then(Value::as_array_mut) {
p.retain(|e| e.get("netclass").and_then(Value::as_str) != Some(r));
}
}
Ok(changes)
}
// ---------------------------------------------------------------------------
// Board constraints and predefined sizes
// ---------------------------------------------------------------------------
/// The board constraints (Board Setup > Design Rules > Constraints): camelCase argument
/// and KiCad's key under `board.design_settings.rules`.
pub const RULE_FIELDS: &[(&str, &str)] = &[
("minClearance", "min_clearance"),
("minTrackWidth", "min_track_width"),
("minConnection", "min_connection"),
("minViaDiameter", "min_via_diameter"),
("minViaAnnularWidth", "min_via_annular_width"),
("minThroughHoleDiameter", "min_through_hole_diameter"),
("minHoleClearance", "min_hole_clearance"),
("minHoleToHole", "min_hole_to_hole"),
("minCopperEdgeClearance", "min_copper_edge_clearance"),
("minMicroviaDiameter", "min_microvia_diameter"),
("minMicroviaDrill", "min_microvia_drill"),
("minSilkClearance", "min_silk_clearance"),
("minTextHeight", "min_text_height"),
("minTextThickness", "min_text_thickness"),
("minGrooveWidth", "min_groove_width"),
("solderMaskToCopperClearance", "solder_mask_to_copper_clearance"),
];
/// KiCad stores each predefined-size list with a leading zero row (the "use netclass"
/// entry of the Board Setup table); the reply hides it and a write puts it back.
fn strip_lead<T: Clone>(v: &[T], is_zero: impl Fn(&T) -> bool) -> Vec<T> {
match v.first() {
Some(f) if is_zero(f) => v[1..].to_vec(),
_ => v.to_vec(),
}
}
pub fn board_rules_report(pro: &Value) -> Value {
let ds = pro.pointer("/board/design_settings").cloned().unwrap_or(Value::Null);
let rules = ds.get("rules").cloned().unwrap_or(json!({}));
let mut constraints = json!({});
for (camel, key) in RULE_FIELDS {
constraints[*camel] = rules.get(*key).cloned().unwrap_or(Value::Null);
}
let zero = |v: &Value| v.as_f64() == Some(0.0);
let tw: Vec<Value> = strip_lead(ds.get("track_widths").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]), zero);
let vz = |v: &Value| v.get("diameter").and_then(Value::as_f64) == Some(0.0);
let vias: Vec<Value> = strip_lead(ds.get("via_dimensions").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]), vz);
let dz = |v: &Value| v.get("width").and_then(Value::as_f64) == Some(0.0);
let dps: Vec<Value> = strip_lead(ds.get("diff_pair_dimensions").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]), dz);
json!({
"constraints": constraints,
"trackWidths": tw,
"viaSizes": vias.iter().map(|v| json!({"diameter": v.get("diameter"), "drill": v.get("drill")})).collect::<Vec<_>>(),
"diffPairs": dps.iter().map(|v| json!({"width": v.get("width"), "gap": v.get("gap"), "viaGap": v.get("via_gap")})).collect::<Vec<_>>(),
"rawRules": rules,
})
}
/// Apply `rules: {minClearance: 0.15, ...}` (camelCase or KiCad's snake keys),
/// `trackWidths: [0.2, 0.3]`, `viaSizes: [{diameter, drill}]`, `diffPairs: [{width, gap, viaGap?}]`.
pub fn edit_board_rules(pro: &mut Value, args: &Value) -> R<Vec<Value>> {
let mut changes = Vec::new();
if !pro.pointer("/board/design_settings").map(Value::is_object).unwrap_or(false) {
return Err(RoutingError::new("project_without_design_settings", "the project file has no board.design_settings; open the board in KiCad once and save so KiCad writes them"));
}
if let Some(r) = args.get("rules") {
let obj = r.as_object().ok_or_else(|| RoutingError::new("invalid_rules", "rules is an object like {\"minClearance\": 0.15}"))?;
let ds = pro.pointer_mut("/board/design_settings").unwrap();
if !ds.get("rules").map(Value::is_object).unwrap_or(false) {
ds["rules"] = json!({});
}
for (k, v) in obj {
let key = RULE_FIELDS.iter().find(|(c, s)| c == k || s == k).map(|(_, s)| *s).ok_or_else(|| {
RoutingError::new("unknown_rule", format!("unknown board constraint {k}")).with("known", json!(RULE_FIELDS.iter().map(|(c, _)| c).collect::<Vec<_>>()))
})?;
let f = num(v, k)?;
if ds["rules"].get(key).and_then(Value::as_f64) != Some(f) {
changes.push(json!({"constraint": key, "from": ds["rules"].get(key).cloned().unwrap_or(Value::Null), "to": f}));
ds["rules"][key] = json!(f);
}
}
}
let ds = pro.pointer_mut("/board/design_settings").unwrap();
if let Some(tw) = args.get("trackWidths") {
let list = tw.as_array().ok_or_else(|| RoutingError::new("invalid_track_widths", "trackWidths is a list of widths in mm"))?;
let mut vals = vec![json!(0.0)];
for v in list {
let f = num(v, "trackWidths[]")?;
if f <= 0.0 {
return Err(RoutingError::new("invalid_value", "every track width must be positive"));
}
vals.push(json!(f));
}
let new = Value::Array(vals);
if ds.get("track_widths") != Some(&new) {
changes.push(json!({"list": "track_widths", "to": tw}));
ds["track_widths"] = new;
}
}
if let Some(vs) = args.get("viaSizes") {
let list = vs.as_array().ok_or_else(|| RoutingError::new("invalid_via_sizes", "viaSizes is a list of {diameter, drill} in mm"))?;
let mut vals = vec![json!({"diameter": 0.0, "drill": 0.0})];
for v in list {
let d = num(v.get("diameter").unwrap_or(&Value::Null), "viaSizes[].diameter")?;
let h = num(v.get("drill").unwrap_or(&Value::Null), "viaSizes[].drill")?;
if h <= 0.0 || h >= d {
return Err(RoutingError::new("invalid_value", format!("via {d}/{h}: drill must be positive and smaller than the diameter")));
}
vals.push(json!({"diameter": d, "drill": h}));
}
let new = Value::Array(vals);
if ds.get("via_dimensions") != Some(&new) {
changes.push(json!({"list": "via_dimensions", "to": vs}));
ds["via_dimensions"] = new;
}
}
if let Some(dp) = args.get("diffPairs") {
let list = dp.as_array().ok_or_else(|| RoutingError::new("invalid_diff_pairs", "diffPairs is a list of {width, gap, viaGap?} in mm"))?;
let mut vals = vec![json!({"gap": 0.0, "via_gap": 0.0, "width": 0.0})];
for v in list {
let w = num(v.get("width").unwrap_or(&Value::Null), "diffPairs[].width")?;
let g = num(v.get("gap").unwrap_or(&Value::Null), "diffPairs[].gap")?;
let vg = match v.get("viaGap") {
Some(x) => num(x, "diffPairs[].viaGap")?,
None => g,
};
vals.push(json!({"gap": g, "via_gap": vg, "width": w}));
}
let new = Value::Array(vals);
if ds.get("diff_pair_dimensions") != Some(&new) {
changes.push(json!({"list": "diff_pair_dimensions", "to": dp}));
ds["diff_pair_dimensions"] = new;
}
}
Ok(changes)
}
// ---------------------------------------------------------------------------
// Custom rules (.kicad_dru)
// ---------------------------------------------------------------------------
/// Constraint types KiCad 8 to 10 accept in a `(constraint TYPE ...)`.
pub const CONSTRAINT_TYPES: &[&str] = &[
"annular_width", "assertion", "bridged_mask", "clearance", "connection_width", "courtyard_clearance", "creepage", "diff_pair_gap", "diff_pair_uncoupled",
"disallow", "edge_clearance", "hole_clearance", "hole_size", "hole_to_hole", "length", "min_resolved_spokes", "physical_clearance", "physical_hole_clearance",
"silk_clearance", "skew", "solder_mask_expansion", "solder_paste_abs_margin", "solder_paste_rel_margin", "text_height", "text_thickness", "thermal_relief_gap",
"thermal_spoke_width", "track_angle", "track_segment_length", "track_width", "via_count", "via_dangling", "via_diameter", "zone_connection",
];
/// Layer selectors a rule may name besides a layer name.
pub const LAYER_KEYWORDS: &[&str] = &["outer", "inner"];
/// One `(rule ...)` of a .kicad_dru.
#[derive(Clone, Debug, PartialEq)]
pub struct DruRule {
pub name: String,
pub layer: Option<String>,
pub condition: Option<String>,
pub severity: Option<String>,
pub constraints: Vec<Value>,
pub text: String,
pub span: (usize, usize),
}
/// A quote-aware tokenizer that keeps whether an atom was quoted: `(`, `)`, or an atom.
#[derive(Clone, Debug, PartialEq)]
enum Tok {
Open(usize),
Close(usize),
Atom(String, bool, usize),
}
fn tokens(text: &str) -> Result<Vec<Tok>, String> {
let mut out = Vec::new();
let b: Vec<(usize, char)> = text.char_indices().collect();
let mut i = 0;
while i < b.len() {
let (pos, c) = b[i];
match c {
'(' => out.push(Tok::Open(pos)),
')' => out.push(Tok::Close(pos)),
'"' => {
let mut s = String::new();
i += 1;
let mut closed = false;
while i < b.len() {
let c = b[i].1;
if c == '\\' && i + 1 < b.len() {
s.push(b[i + 1].1);
i += 2;
continue;
}
if c == '"' {
closed = true;
break;
}
s.push(c);
i += 1;
}
if !closed {
return Err(format!("unterminated string starting at byte {pos}"));
}
out.push(Tok::Atom(s, true, pos));
}
'#' if out.is_empty() || matches!(out.last(), Some(Tok::Close(_))) => {
// a comment line between rules
while i < b.len() && b[i].1 != '\n' {
i += 1;
}
}
c if c.is_whitespace() => {}
_ => {
let mut s = String::new();
while i < b.len() && !b[i].1.is_whitespace() && b[i].1 != '(' && b[i].1 != ')' && b[i].1 != '"' {
s.push(b[i].1);
i += 1;
}
out.push(Tok::Atom(s, false, pos));
continue;
}
}
i += 1;
}
Ok(out)
}
#[derive(Clone, Debug)]
enum Node {
Atom(String, bool),
List(Vec<Node>, usize, usize),
}
fn tree(toks: &[Tok], pos: &mut usize, text_len: usize) -> Result<Node, String> {
match &toks[*pos] {
Tok::Open(start) => {
let start = *start;
*pos += 1;
let mut kids = Vec::new();
loop {
match toks.get(*pos) {
None => return Err(format!("unbalanced parentheses: the list opened at byte {start} is never closed")),
Some(Tok::Close(end)) => {
let end = *end;
*pos += 1;
return Ok(Node::List(kids, start, (end + 1).min(text_len)));
}
Some(_) => kids.push(tree(toks, pos, text_len)?),
}
}
}
Tok::Close(at) => Err(format!("unexpected ')' at byte {at}")),
Tok::Atom(s, q, _) => {
*pos += 1;
Ok(Node::Atom(s.clone(), *q))
}
}
}
fn head(n: &Node) -> Option<&str> {
match n {
Node::List(k, _, _) => match k.first() {
Some(Node::Atom(s, false)) => Some(s),
_ => None,
},
_ => None,
}
}
/// A KiCad rules value: a number with an optional unit (mm, mil, in, um, deg).
pub fn parse_quantity(s: &str) -> Option<f64> {
let s = s.trim();
for (unit, scale) in [("mm", 1.0), ("mil", 0.0254), ("um", 0.001), ("in", 25.4), ("deg", 1.0)] {
if let Some(n) = s.strip_suffix(unit) {
return n.trim().parse::<f64>().ok().map(|v| v * scale);
}
}
s.parse::<f64>().ok()
}
/// Parse a .kicad_dru: its `(version N)` and every rule. Errors are syntax problems that
/// would make KiCad refuse the file.
pub fn parse_dru(text: &str) -> Result<(Option<i64>, Vec<DruRule>), Vec<String>> {
let toks = tokens(text).map_err(|e| vec![e])?;
let mut pos = 0;
let mut nodes = Vec::new();
while pos < toks.len() {
match tree(&toks, &mut pos, text.len()) {
Ok(n) => nodes.push(n),
Err(e) => return Err(vec![e]),
}
}
let mut version = None;
let mut rules = Vec::new();
let mut problems = Vec::new();
for n in &nodes {
match (head(n), n) {
(Some("version"), Node::List(k, _, _)) => {
version = match k.get(1) {
Some(Node::Atom(s, _)) => s.parse::<i64>().ok(),
_ => None,
};
if version.is_none() {
problems.push("(version N) needs an integer".to_string());
}
}
(Some("rule"), Node::List(k, start, end)) => {
let name = match k.get(1) {
Some(Node::Atom(s, _)) => s.clone(),
_ => {
problems.push(format!("a rule at byte {start} has no name"));
continue;
}
};
let mut rule = DruRule { name: name.clone(), layer: None, condition: None, severity: None, constraints: Vec::new(), text: text[*start..*end].to_string(), span: (*start, *end) };
for c in &k[2..] {
let Node::List(kk, _, _) = c else {
problems.push(format!("rule {name}: stray atom"));
continue;
};
let atom = |i: usize| match kk.get(i) {
Some(Node::Atom(s, _)) => Some(s.clone()),
_ => None,
};
match head(c) {
Some("layer") => rule.layer = atom(1),
Some("condition") => match kk.get(1) {
Some(Node::Atom(s, true)) => rule.condition = Some(s.clone()),
_ => problems.push(format!("rule {name}: condition must be a quoted expression")),
},
Some("severity") => rule.severity = atom(1),
Some("constraint") => {
let ty = atom(1).unwrap_or_default();
if !CONSTRAINT_TYPES.contains(&ty.as_str()) {
problems.push(format!("rule {name}: unknown constraint type '{ty}'"));
}
let mut cv = json!({"type": ty});
for arg in &kk[2..] {
match arg {
Node::List(a, _, _) => {
let key = match a.first() {
Some(Node::Atom(s, _)) => s.clone(),
_ => continue,
};
let val = match a.get(1) {
Some(Node::Atom(s, _)) => s.clone(),
_ => String::new(),
};
if matches!(key.as_str(), "min" | "max" | "opt") && parse_quantity(&val).is_none() {
problems.push(format!("rule {name}: ({key} {val}) is not a number with a unit"));
}
cv[key] = json!(val);
}
Node::Atom(s, _) => {
let list = cv.get("args").and_then(Value::as_array).cloned().unwrap_or_default();
let mut l = list;
l.push(json!(s));
cv["args"] = json!(l);
}
}
}
rule.constraints.push(cv);
}
Some(other) => problems.push(format!("rule {name}: unknown clause ({other} ...)")),
None => problems.push(format!("rule {name}: a clause without a keyword")),
}
}
if rule.constraints.is_empty() {
problems.push(format!("rule {name}: no (constraint ...)"));
}
rules.push(rule);
}
(Some(other), _) => problems.push(format!("unknown top-level ({other} ...)")),
_ => problems.push("a top-level atom outside any list".to_string()),
}
}
if version.is_none() && !rules.is_empty() {
problems.push("missing (version 1) at the top".to_string());
}
let mut seen = std::collections::HashSet::new();
for r in &rules {
if !seen.insert(r.name.clone()) {
problems.push(format!("rule name '{}' appears twice", r.name));
}
}
if problems.is_empty() {
Ok((version, rules))
} else {
Err(problems)
}
}
/// The kind a KiCad constraint belongs to, named as the standard's rules_by_kind names them (Clearance, Width,
/// RoutingVias, ...).
fn kind_of(constraint: &str) -> String {
match constraint {
"clearance" | "min_clearance" | "minClearance" => "Clearance".into(),
"track_width" | "min_track_width" | "minTrackWidth" | "trackWidth" | "connection_width" | "minConnection" => "Width".into(),
"via_diameter" | "annular_width" | "minViaDiameter" | "minViaAnnularWidth" | "viaDiameter" | "minMicroviaDiameter" | "minMicroviaDrill" => "RoutingVias".into(),
"hole_size" | "minThroughHoleDiameter" | "viaDrill" => "HoleSize".into(),
"hole_clearance" | "minHoleClearance" => "HoleClearance".into(),
"hole_to_hole" | "minHoleToHole" => "HoleToHoleClearance".into(),
"edge_clearance" | "minCopperEdgeClearance" => "BoardOutlineClearance".into(),
"silk_clearance" | "minSilkClearance" => "SilkToSilkClearance".into(),
"text_height" | "text_thickness" | "minTextHeight" | "minTextThickness" => "SilkscreenText".into(),
"diff_pair_gap" | "diff_pair_uncoupled" | "diffPairWidth" | "diffPairGap" => "DiffPairsRouting".into(),
"length" => "Length".into(),
"skew" => "MatchedLengths".into(),
"courtyard_clearance" => "ComponentClearance".into(),
"physical_clearance" | "physical_hole_clearance" => "PhysicalClearance".into(),
"disallow" => "RoutingDisallow".into(),
"solderMaskToCopperClearance" | "solder_mask_expansion" => "SolderMaskExpansion".into(),
"solder_paste_abs_margin" | "solder_paste_rel_margin" => "PasteMaskExpansion".into(),
"zone_connection" | "thermal_relief_gap" | "thermal_spoke_width" | "min_resolved_spokes" => "PolygonConnect".into(),
other => other.split('_').map(|w| { let mut c = w.chars(); c.next().map(|f| f.to_ascii_uppercase().to_string() + c.as_str()).unwrap_or_default() }).collect(),
}
}
/// The standard's rules_by_kind from a KiCad project: its custom rules (.kicad_dru), its net classes and its Board
/// Setup minimums, grouped by kind, each kind sorted by priority (1 wins). KiCad's own precedence sets the order: a
/// later custom rule overrides an earlier one, custom rules override net classes (whose own priority orders them),
/// and both override Board Setup's minimums. KiCad rules are always enabled.
pub fn rules_by_kind(pro: &Value, dru: Option<&str>) -> Value {
let mut rows: Vec<Value> = Vec::new();
let mut problems: Vec<String> = Vec::new();
// custom rules, the last in the file first
if let Some(text) = dru {
match parse_dru(text) {
Ok((_, rules)) => {
for r in rules.iter().rev() {
for c in &r.constraints {
let ty = c.get("type").and_then(Value::as_str).unwrap_or("");
rows.push(json!({"name": r.name, "kind": kind_of(ty), "constraint": ty, "enabled": true, "source": "custom",
"scope1": r.condition.clone().unwrap_or_else(|| "All".into()), "scope2": "All", "layer": r.layer, "severity": r.severity,
"min": c.get("min"), "max": c.get("max"), "opt": c.get("opt"), "args": c.get("args"), "comment": "KiCad custom rule (.kicad_dru)"}));
}
}
}
Err(p) => problems = p,
}
}
// net classes, by their own priority (lower first), Default last
let mut cls = classes(pro);
cls.sort_by_key(|c| if c.get("name").and_then(Value::as_str) == Some("Default") { i64::MAX } else { c.get("priority").and_then(Value::as_i64).unwrap_or(i64::MAX - 1) });
for c in &cls {
let name = c.get("name").and_then(Value::as_str).unwrap_or("");
let scope = if name == "Default" { "All".to_string() } else { format!("NetClass == '{name}'") };
for (field, key) in [("clearance", "clearance"), ("trackWidth", "track_width"), ("viaDiameter", "via_diameter"), ("viaDrill", "via_drill"), ("diffPairWidth", "diff_pair_width"), ("diffPairGap", "diff_pair_gap")] {
if let Some(v) = c.get(key).filter(|v| v.is_number()) {
rows.push(json!({"name": format!("{name} netclass {field}"), "kind": kind_of(field), "constraint": key, "enabled": true, "source": "netclass",
"scope1": scope, "scope2": "All", "opt": v, "comment": format!("KiCad net class {name}")}));
}
}
}
// Board Setup minimums, every net
let report = board_rules_report(pro);
for (camel, key) in RULE_FIELDS {
if let Some(v) = report["constraints"].get(*camel).filter(|v| v.is_number()) {
rows.push(json!({"name": format!("Board Setup {camel}"), "kind": kind_of(camel), "constraint": key, "enabled": true, "source": "board",
"scope1": "All", "scope2": "All", "min": v, "comment": "KiCad Board Setup > Constraints"}));
}
}
let mut by_kind: std::collections::BTreeMap<String, Vec<Value>> = std::collections::BTreeMap::new();
for r in rows {
by_kind.entry(r["kind"].as_str().unwrap_or("").to_string()).or_default().push(r);
}
let mut count = 0;
for list in by_kind.values_mut() {
for (i, r) in list.iter_mut().enumerate() {
r["priority"] = json!(i + 1);
}
count += list.len();
}
json!({"rulesByKind": by_kind, "count": count, "problems": problems})
}
pub fn rule_json(r: &DruRule) -> Value {
json!({"name": r.name, "layer": r.layer, "condition": r.condition, "severity": r.severity, "constraints": r.constraints, "text": r.text})
}
fn q(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
fn mm(v: &Value, label: &str) -> R<String> {
match v {
Value::Number(n) => Ok(format!("{}mm", crate::pcb::num(n.as_f64().unwrap_or(0.0)))),
Value::String(s) if parse_quantity(s).is_some() => Ok(s.trim().to_string()),
_ => Err(RoutingError::new("invalid_rule", format!("{label} must be a number in mm or a string with a unit like \"8mil\""))),
}
}
/// The text of one rule from `{name, layer?, condition?, netclassPair?: [A, B], severity?,
/// constraints: [{type, min?, max?, opt?, args?}]}`, or the caller's own `text`.
pub fn rule_text(spec: &Value) -> R<String> {
if let Some(t) = spec.get("text").and_then(Value::as_str) {
return Ok(t.trim().to_string());
}
let name = spec.get("name").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).ok_or_else(|| RoutingError::new("invalid_rule", "a rule needs a name"))?;
let mut s = format!("(rule {}", q(name));
if let Some(l) = spec.get("layer").and_then(Value::as_str) {
let quoted = if LAYER_KEYWORDS.contains(&l) { l.to_string() } else { q(l) };
s.push_str(&format!("\n\t(layer {quoted})"));
}
let mut cond = spec.get("condition").and_then(Value::as_str).map(str::to_string);
if let Some(pair) = spec.get("netclassPair").and_then(Value::as_array) {
let a = pair.first().and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_rule", "netclassPair is [classA, classB]"))?;
let b = pair.get(1).and_then(Value::as_str).unwrap_or(a);
let pc = if a == b { format!("A.NetClass == '{a}' && B.NetClass == '{b}'") } else { format!("(A.NetClass == '{a}' && B.NetClass == '{b}') || (A.NetClass == '{b}' && B.NetClass == '{a}')") };
cond = Some(match cond {
Some(c) => format!("({c}) && ({pc})"),
None => pc,
});
}
if let Some(c) = cond {
s.push_str(&format!("\n\t(condition {})", q(&c)));
}
if let Some(sv) = spec.get("severity").and_then(Value::as_str) {
if !["error", "warning", "ignore", "exclusion"].contains(&sv) {
return Err(RoutingError::new("invalid_rule", "severity is error, warning, ignore or exclusion"));
}
s.push_str(&format!("\n\t(severity {sv})"));
}
let cons = spec.get("constraints").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| RoutingError::new("invalid_rule", "constraints lists at least one {type, min?, max?, opt?}"))?;
for c in cons {
let ty = c.get("type").and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_rule", "each constraint needs a type"))?;
if !CONSTRAINT_TYPES.contains(&ty) {
return Err(RoutingError::new("invalid_rule", format!("unknown constraint type {ty}")).with("known", json!(CONSTRAINT_TYPES)));
}
let mut line = format!("\n\t(constraint {ty}");
for a in c.get("args").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) {
line.push(' ');
line.push_str(a);
}
for k in ["min", "opt", "max"] {
if let Some(v) = c.get(k) {
let unitless = ty == "via_count" || ty == "min_resolved_spokes";
let txt = if unitless { v.as_f64().map(|f| crate::pcb::num(f)).unwrap_or_else(|| v.as_str().unwrap_or("").to_string()) } else { mm(v, k)? };
line.push_str(&format!(" ({k} {txt})"));
}
}
line.push(')');
s.push_str(&line);
}
s.push(')');
Ok(s)
}
/// Add (or with `replace` swap in) a rule. The whole result must parse.
pub fn add_rule(text: &str, rule: &str, replace: bool) -> R<String> {
let (_, one) = parse_dru(&format!("(version 1)\n{rule}\n")).map_err(|p| RoutingError::new("invalid_rule", "the rule does not parse").with("problems", json!(p)))?;
let new_name = one.first().map(|r| r.name.clone()).unwrap_or_default();
let base = if text.trim().is_empty() { "(version 1)\n".to_string() } else { text.to_string() };
let (_, rules) = parse_dru(&base).map_err(|p| RoutingError::new("dru_invalid", "the existing .kicad_dru does not parse; fix or replace it first").with("problems", json!(p)))?;
let out = match rules.iter().find(|r| r.name == new_name) {
Some(old) if replace => format!("{}{}{}", &base[..old.span.0], rule, &base[old.span.1..]),
Some(_) => return Err(RoutingError::new("rule_exists", format!("a rule named '{new_name}' already exists")).with("_hint", json!("Pass replace:true to swap it, or remove it first."))),
None => format!("{}\n{}\n", base.trim_end(), rule),
};
parse_dru(&out).map_err(|p| RoutingError::new("invalid_rule", "the file would not parse with this rule").with("problems", json!(p)))?;
Ok(out)
}
/// Remove rules by name; returns the new text and the names actually removed.
pub fn remove_rules(text: &str, names: &[String]) -> R<(String, Vec<String>)> {
let (_, rules) = parse_dru(text).map_err(|p| RoutingError::new("dru_invalid", "the existing .kicad_dru does not parse").with("problems", json!(p)))?;
let mut out = String::with_capacity(text.len());
let mut last = 0;
let mut removed = Vec::new();
for r in &rules {
if names.contains(&r.name) {
out.push_str(&text[last..r.span.0]);
last = r.span.1;
// swallow the line break after the rule
if text[last..].starts_with("\r\n") {
last += 2;
} else if text[last..].starts_with('\n') {
last += 1;
}
removed.push(r.name.clone());
}
}
out.push_str(&text[last..]);
Ok((out, removed))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wildcards_match_like_kicad_patterns() {
assert!(wildcard("USB_*", "USB_D+"));
assert!(wildcard("*CLK?", "/SYS_CLK1"));
assert!(!wildcard("USB_*", "/USB_D+"));
assert!(wildcard("GND", "GND"));
assert!(!wildcard("GND", "GNDA"));
assert!(looks_regex("USB_D[PN]"));
assert!(!looks_regex("USB_*"));
}
#[test]
fn quantities_take_units() {
assert_eq!(parse_quantity("0.2mm"), Some(0.2));
assert!((parse_quantity("8mil").unwrap() - 0.2032).abs() < 1e-9);
assert_eq!(parse_quantity("0.5"), Some(0.5));
assert_eq!(parse_quantity("abc"), None);
}
#[test]
fn a_rule_round_trips_through_text() {
let t = rule_text(&json!({"name": "HV to LV", "netclassPair": ["HV", "LV"], "layer": "outer", "constraints": [{"type": "clearance", "min": 1.0}]})).unwrap();
assert!(t.contains("(layer outer)"));
assert!(t.contains("(constraint clearance (min 1mm))"));
let file = add_rule("", &t, false).unwrap();
let (v, rules) = parse_dru(&file).unwrap();
assert_eq!(v, Some(1));
assert_eq!(rules[0].name, "HV to LV");
assert_eq!(rules[0].layer.as_deref(), Some("outer"));
assert!(rules[0].condition.as_deref().unwrap().contains("A.NetClass == 'HV'"));
assert!(add_rule(&file, &t, false).is_err());
let replaced = add_rule(&file, &t.replace("1mm", "2mm"), true).unwrap();
assert!(replaced.contains("2mm") && !replaced.contains("(min 1mm)"));
let (gone, removed) = remove_rules(&replaced, &["HV to LV".into()]).unwrap();
assert_eq!(removed, vec!["HV to LV".to_string()]);
assert_eq!(parse_dru(&gone).unwrap().1.len(), 0);
}
#[test]
fn bad_rules_are_named() {
let p = parse_dru("(version 1)\n(rule \"a\" (constraint wiggle (min 1mm)))\n(rule \"b\" (condition A.NetClass) (constraint clearance (min x)))").unwrap_err();
assert!(p.iter().any(|e| e.contains("unknown constraint type 'wiggle'")), "{p:?}");
assert!(p.iter().any(|e| e.contains("condition must be a quoted")), "{p:?}");
assert!(p.iter().any(|e| e.contains("(min x)")), "{p:?}");
assert!(parse_dru("(version 1)\n(rule \"a\" (constraint clearance (min 1mm))").is_err());
assert!(rule_text(&json!({"name": "x", "constraints": [{"type": "bogus"}]})).is_err());
}
#[test]
fn rules_by_kind_orders_custom_rules_then_classes_then_board_setup() {
let pro = json!({"board": {"design_settings": {"rules": {"min_clearance": 0.15, "min_track_width": 0.127, "min_hole_to_hole": 0.25}}},
"net_settings": {"classes": [{"name": "Default", "clearance": 0.2, "track_width": 0.2, "priority": 2147483647}, {"name": "Power", "clearance": 0.3, "track_width": 0.6, "priority": 0}]}});
let dru = "(version 1)\n(rule \"HV\" (condition \"A.NetClass == 'HV'\") (constraint clearance (min 1mm)))\n(rule \"USB\" (condition \"A.NetClass == 'USB'\") (constraint clearance (min 0.15mm)) (constraint track_width (opt 0.3mm)))\n";
let v = super::rules_by_kind(&pro, Some(dru));
let clr = v["rulesByKind"]["Clearance"].as_array().unwrap();
let names: Vec<&str> = clr.iter().map(|r| r["name"].as_str().unwrap()).collect();
assert_eq!(names, vec!["USB", "HV", "Power netclass clearance", "Default netclass clearance", "Board Setup minClearance"], "{v}");
assert_eq!((clr[0]["priority"].as_u64(), clr[0]["scope1"].as_str(), clr[2]["scope1"].as_str(), clr[4]["scope1"].as_str()), (Some(1), Some("A.NetClass == 'USB'"), Some("NetClass == 'Power'"), Some("All")));
assert_eq!(v["rulesByKind"]["Width"].as_array().unwrap().len(), 4, "USB, Power, Default, Board Setup");
assert_eq!(v["rulesByKind"]["HoleToHoleClearance"][0]["min"], 0.25);
}
}