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}