Skip to main content

kiutils_kicad/
dru.rs

1use std::fs;
2use std::path::Path;
3
4use kiutils_sexpr::{parse_rootless, CstDocument, Node};
5
6use crate::diagnostic::{Diagnostic, Severity};
7use crate::sexpr_edit::{
8    atom_quoted, atom_symbol, child_index, list_node, mutate_nodes_and_refresh_rootless,
9    upsert_scalar,
10};
11use crate::sexpr_utils::{atom_as_string, head_of, second_atom_i32, second_atom_string};
12use crate::{Error, UnknownNode, WriteMode};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16pub struct DesignRuleSummary {
17    pub name: Option<String>,
18    pub constraint_count: usize,
19    pub condition: Option<String>,
20    pub layer: Option<String>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub struct DesignRulesAst {
26    pub version: Option<i32>,
27    pub rules: Vec<DesignRuleSummary>,
28    pub rule_count: usize,
29    pub total_constraint_count: usize,
30    pub rules_with_condition_count: usize,
31    pub rules_with_layer_count: usize,
32    pub unknown_nodes: Vec<UnknownNode>,
33}
34
35#[derive(Debug, Clone)]
36pub struct DesignRulesDocument {
37    ast: DesignRulesAst,
38    cst: CstDocument,
39    diagnostics: Vec<Diagnostic>,
40    ast_dirty: bool,
41}
42
43impl DesignRulesDocument {
44    pub fn ast(&self) -> &DesignRulesAst {
45        &self.ast
46    }
47
48    pub fn ast_mut(&mut self) -> &mut DesignRulesAst {
49        self.ast_dirty = true;
50        &mut self.ast
51    }
52
53    pub fn cst(&self) -> &CstDocument {
54        &self.cst
55    }
56
57    pub fn diagnostics(&self) -> &[Diagnostic] {
58        &self.diagnostics
59    }
60
61    pub fn set_version(&mut self, version: i32) -> &mut Self {
62        let version_node = list_node(vec![
63            atom_symbol("version".to_string()),
64            atom_symbol(version.to_string()),
65        ]);
66        self.mutate_nodes(|nodes| {
67            if let Some(idx) = child_index(nodes, "version", 0) {
68                if nodes[idx] == version_node {
69                    false
70                } else {
71                    nodes[idx] = version_node;
72                    true
73                }
74            } else {
75                nodes.insert(0, version_node);
76                true
77            }
78        })
79    }
80
81    pub fn add_rule<S: Into<String>>(&mut self, name: S) -> &mut Self {
82        let name = name.into();
83        let node = list_node(vec![atom_symbol("rule".to_string()), atom_quoted(name)]);
84        self.mutate_nodes(|nodes| {
85            nodes.push(node);
86            true
87        })
88    }
89
90    pub fn rename_rule<S: Into<String>>(&mut self, from: &str, to: S) -> &mut Self {
91        let from = from.to_string();
92        let to = to.into();
93        self.mutate_nodes(|nodes| {
94            let Some(idx) = find_rule_index(nodes, &from) else {
95                return false;
96            };
97            let Some(Node::List { items, .. }) = nodes.get_mut(idx) else {
98                return false;
99            };
100            if items.len() < 2 {
101                return false;
102            }
103            let next = atom_quoted(to);
104            if items[1] == next {
105                false
106            } else {
107                items[1] = next;
108                true
109            }
110        })
111    }
112
113    pub fn rename_first_rule<S: Into<String>>(&mut self, to: S) -> &mut Self {
114        let to = to.into();
115        self.mutate_nodes(|nodes| {
116            let Some(idx) = nodes
117                .iter()
118                .enumerate()
119                .find(|(_, node)| head_of(node) == Some("rule"))
120                .map(|(idx, _)| idx)
121            else {
122                return false;
123            };
124            let Some(Node::List { items, .. }) = nodes.get_mut(idx) else {
125                return false;
126            };
127            if items.len() < 2 {
128                return false;
129            }
130            let next = atom_quoted(to);
131            if items[1] == next {
132                false
133            } else {
134                items[1] = next;
135                true
136            }
137        })
138    }
139
140    pub fn upsert_rule_condition<S: Into<String>>(
141        &mut self,
142        rule_name: &str,
143        condition: S,
144    ) -> &mut Self {
145        let rule_name = rule_name.to_string();
146        let condition = condition.into();
147        self.mutate_nodes(|nodes| {
148            let Some(idx) = find_rule_index(nodes, &rule_name) else {
149                return false;
150            };
151            let Some(Node::List { items, .. }) = nodes.get_mut(idx) else {
152                return false;
153            };
154            upsert_scalar(items, "condition", atom_quoted(condition), 2)
155        })
156    }
157
158    pub fn remove_rule_condition(&mut self, rule_name: &str) -> &mut Self {
159        let rule_name = rule_name.to_string();
160        self.mutate_nodes(|nodes| {
161            let Some(idx) = find_rule_index(nodes, &rule_name) else {
162                return false;
163            };
164            let Some(Node::List { items, .. }) = nodes.get_mut(idx) else {
165                return false;
166            };
167            if let Some(cond_idx) = child_index(items, "condition", 2) {
168                items.remove(cond_idx);
169                true
170            } else {
171                false
172            }
173        })
174    }
175
176    pub fn upsert_rule_layer<S: Into<String>>(&mut self, rule_name: &str, layer: S) -> &mut Self {
177        let rule_name = rule_name.to_string();
178        let layer = layer.into();
179        self.mutate_nodes(|nodes| {
180            let Some(idx) = find_rule_index(nodes, &rule_name) else {
181                return false;
182            };
183            let Some(Node::List { items, .. }) = nodes.get_mut(idx) else {
184                return false;
185            };
186            upsert_scalar(items, "layer", atom_symbol(layer), 2)
187        })
188    }
189
190    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
191        self.write_mode(path, WriteMode::Lossless)
192    }
193
194    pub fn write_mode<P: AsRef<Path>>(&self, path: P, mode: WriteMode) -> Result<(), Error> {
195        if self.ast_dirty {
196            return Err(Error::Validation(
197                "ast_mut changes are not serializable; use document setter APIs".to_string(),
198            ));
199        }
200        match mode {
201            WriteMode::Lossless => fs::write(path, self.cst.to_lossless_string())?,
202            WriteMode::Canonical => fs::write(path, self.cst.to_canonical_string())?,
203        }
204        Ok(())
205    }
206
207    fn mutate_nodes<F>(&mut self, mutate: F) -> &mut Self
208    where
209        F: FnOnce(&mut Vec<Node>) -> bool,
210    {
211        mutate_nodes_and_refresh_rootless(
212            &mut self.cst,
213            &mut self.ast,
214            &mut self.diagnostics,
215            mutate,
216            parse_ast,
217            |_cst, ast| collect_diagnostics(ast.version),
218        );
219        self.ast_dirty = false;
220        self
221    }
222}
223
224pub struct DesignRulesFile;
225
226impl DesignRulesFile {
227    pub fn read<P: AsRef<Path>>(path: P) -> Result<DesignRulesDocument, Error> {
228        let raw = fs::read_to_string(path)?;
229        let cst = parse_rootless(&raw)?;
230        let ast = parse_ast(&cst);
231        let diagnostics = collect_diagnostics(ast.version);
232
233        Ok(DesignRulesDocument {
234            ast,
235            cst,
236            diagnostics,
237            ast_dirty: false,
238        })
239    }
240}
241
242fn parse_ast(cst: &CstDocument) -> DesignRulesAst {
243    let mut version = None;
244    let mut rules = Vec::new();
245    let mut unknown_nodes = Vec::new();
246
247    for node in &cst.nodes {
248        match head_of(node) {
249            Some("version") => version = second_atom_i32(node),
250            Some("rule") => rules.push(parse_rule_summary(node)),
251            _ => {
252                if let Some(unknown) = UnknownNode::from_node(node) {
253                    unknown_nodes.push(unknown);
254                }
255            }
256        }
257    }
258
259    let rule_count = rules.len();
260    let total_constraint_count = rules.iter().map(|r| r.constraint_count).sum();
261    let rules_with_condition_count = rules.iter().filter(|r| r.condition.is_some()).count();
262    let rules_with_layer_count = rules.iter().filter(|r| r.layer.is_some()).count();
263
264    DesignRulesAst {
265        version,
266        rules,
267        rule_count,
268        total_constraint_count,
269        rules_with_condition_count,
270        rules_with_layer_count,
271        unknown_nodes,
272    }
273}
274
275fn parse_rule_summary(node: &Node) -> DesignRuleSummary {
276    let Node::List { items, .. } = node else {
277        return DesignRuleSummary {
278            name: None,
279            constraint_count: 0,
280            condition: None,
281            layer: None,
282        };
283    };
284
285    let name = items.get(1).and_then(atom_as_string);
286    let mut constraint_count = 0usize;
287    let mut condition = None;
288    let mut layer = None;
289
290    for child in items.iter().skip(2) {
291        match head_of(child) {
292            Some("constraint") => constraint_count += 1,
293            Some("condition") => condition = second_atom_string(child),
294            Some("layer") => layer = second_atom_string(child),
295            _ => {}
296        }
297    }
298
299    DesignRuleSummary {
300        name,
301        constraint_count,
302        condition,
303        layer,
304    }
305}
306
307fn find_rule_index(nodes: &[Node], name: &str) -> Option<usize> {
308    nodes
309        .iter()
310        .enumerate()
311        .find(|(_, node)| {
312            if head_of(node) != Some("rule") {
313                return false;
314            }
315            match node {
316                Node::List { items, .. } => {
317                    items.get(1).and_then(atom_as_string).as_deref() == Some(name)
318                }
319                _ => false,
320            }
321        })
322        .map(|(idx, _)| idx)
323}
324
325fn collect_diagnostics(version: Option<i32>) -> Vec<Diagnostic> {
326    match version {
327        Some(1) => Vec::new(),
328        Some(other) => vec![Diagnostic {
329            severity: Severity::Warning,
330            code: "unsupported_version",
331            message: format!(
332                "unsupported design-rules version `{other}`; parsing in compatibility mode"
333            ),
334            span: None,
335            hint: Some("expected `(version 1)` for KiCad v9/v10".to_string()),
336        }],
337        None => vec![Diagnostic {
338            severity: Severity::Warning,
339            code: "missing_version",
340            message: "missing design-rules version token".to_string(),
341            span: None,
342            hint: Some("add top-level `(version 1)`".to_string()),
343        }],
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use std::path::PathBuf;
350    use std::time::{SystemTime, UNIX_EPOCH};
351
352    use super::*;
353
354    fn tmp_file(name: &str) -> PathBuf {
355        let nanos = SystemTime::now()
356            .duration_since(UNIX_EPOCH)
357            .expect("clock")
358            .as_nanos();
359        std::env::temp_dir().join(format!("{name}_{nanos}.kicad_dru"))
360    }
361
362    #[test]
363    fn read_rootless_dru() {
364        let path = tmp_file("dru_ok");
365        let src =
366            "(version 1)\n(rule \"x\" (constraint clearance (min \"0.1mm\")) (condition \"A\"))\n";
367        fs::write(&path, src).expect("write fixture");
368
369        let doc = DesignRulesFile::read(&path).expect("read");
370        assert_eq!(doc.ast().version, Some(1));
371        assert_eq!(doc.ast().rule_count, 1);
372        assert_eq!(doc.ast().total_constraint_count, 1);
373        assert_eq!(doc.ast().rules_with_condition_count, 1);
374        assert!(doc.ast().unknown_nodes.is_empty());
375        assert!(doc.diagnostics().is_empty());
376        assert_eq!(doc.cst().to_lossless_string(), src);
377
378        let _ = fs::remove_file(path);
379    }
380
381    #[test]
382    fn read_rootless_dru_captures_unknown_rule_item() {
383        let path = tmp_file("dru_unknown");
384        let src = "(version 1)\n(mystery xyz)\n(rule \"x\" (constraint clearance (min \"0.1mm\")) (condition \"A\"))\n";
385        fs::write(&path, src).expect("write fixture");
386
387        let doc = DesignRulesFile::read(&path).expect("read");
388        assert_eq!(doc.ast().unknown_nodes.len(), 1);
389        assert_eq!(doc.ast().unknown_nodes[0].head.as_deref(), Some("mystery"));
390
391        let _ = fs::remove_file(path);
392    }
393
394    #[test]
395    fn edit_roundtrip_updates_rule_metadata() {
396        let path = tmp_file("dru_edit");
397        let src =
398            "(version 1)\n(rule \"old\" (constraint clearance (min 0.1mm)) (condition \"A\"))\n";
399        fs::write(&path, src).expect("write fixture");
400
401        let mut doc = DesignRulesFile::read(&path).expect("read");
402        doc.set_version(1)
403            .rename_rule("old", "new")
404            .upsert_rule_condition("new", "A.NetClass == 'DDR4'")
405            .upsert_rule_layer("new", "outer");
406
407        let out = tmp_file("dru_edit_out");
408        doc.write(&out).expect("write");
409        let reread = DesignRulesFile::read(&out).expect("reread");
410
411        assert_eq!(reread.ast().version, Some(1));
412        assert_eq!(reread.ast().rule_count, 1);
413        assert_eq!(
414            reread.ast().rules.first().and_then(|r| r.name.clone()),
415            Some("new".to_string())
416        );
417        assert_eq!(
418            reread.ast().rules.first().and_then(|r| r.layer.clone()),
419            Some("outer".to_string())
420        );
421        assert_eq!(
422            reread.ast().rules.first().and_then(|r| r.condition.clone()),
423            Some("A.NetClass == 'DDR4'".to_string())
424        );
425
426        let _ = fs::remove_file(path);
427        let _ = fs::remove_file(out);
428    }
429
430    #[test]
431    fn warns_when_version_missing_or_unsupported() {
432        let path_missing = tmp_file("dru_missing");
433        fs::write(&path_missing, "(rule \"x\")\n").expect("write fixture");
434        let missing = DesignRulesFile::read(&path_missing).expect("read");
435        assert_eq!(missing.diagnostics().len(), 1);
436        assert_eq!(missing.diagnostics()[0].code, "missing_version");
437
438        let path_bad = tmp_file("dru_bad");
439        fs::write(&path_bad, "(version 2)\n(rule \"x\")\n").expect("write fixture");
440        let bad = DesignRulesFile::read(&path_bad).expect("read");
441        assert_eq!(bad.diagnostics().len(), 1);
442        assert_eq!(bad.diagnostics()[0].code, "unsupported_version");
443
444        let _ = fs::remove_file(path_missing);
445        let _ = fs::remove_file(path_bad);
446    }
447}