1use std::fs;
2use std::path::Path;
3
4use kiutils_sexpr::{parse_one, CstDocument, Node};
5
6use crate::diagnostic::{Diagnostic, Severity};
7use crate::sexpr_edit::{
8 atom_quoted, atom_symbol, ensure_root_head_any, mutate_root_and_refresh,
9 remove_property as remove_property_node, root_head, upsert_property_preserve_tail,
10 upsert_scalar,
11};
12use crate::sexpr_utils::{
13 atom_as_string, head_of, list_child_head_count, second_atom_i32, second_atom_string,
14};
15use crate::version_diag::collect_version_diagnostics;
16use crate::{Error, UnknownNode, WriteMode};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub struct FootprintAst {
21 pub lib_id: Option<String>,
22 pub version: Option<i32>,
23 pub tedit: Option<String>,
24 pub generator: Option<String>,
25 pub generator_version: Option<String>,
26 pub layer: Option<String>,
27 pub descr: Option<String>,
28 pub tags: Option<String>,
29 pub property_count: usize,
30 pub attr_present: bool,
31 pub locked_present: bool,
32 pub private_layers_present: bool,
33 pub net_tie_pad_groups_present: bool,
34 pub embedded_fonts_present: bool,
35 pub has_embedded_files: bool,
36 pub embedded_file_count: usize,
37 pub clearance: Option<String>,
38 pub solder_mask_margin: Option<String>,
39 pub solder_paste_margin: Option<String>,
40 pub solder_paste_margin_ratio: Option<String>,
41 pub duplicate_pad_numbers_are_jumpers: Option<bool>,
42 pub pad_count: usize,
43 pub model_count: usize,
44 pub zone_count: usize,
45 pub group_count: usize,
46 pub fp_line_count: usize,
47 pub fp_rect_count: usize,
48 pub fp_circle_count: usize,
49 pub fp_arc_count: usize,
50 pub fp_poly_count: usize,
51 pub fp_curve_count: usize,
52 pub fp_text_count: usize,
53 pub fp_text_box_count: usize,
54 pub dimension_count: usize,
55 pub graphic_count: usize,
56 pub unknown_nodes: Vec<UnknownNode>,
57}
58
59#[derive(Debug, Clone)]
60pub struct FootprintDocument {
61 ast: FootprintAst,
62 cst: CstDocument,
63 diagnostics: Vec<Diagnostic>,
64 ast_dirty: bool,
65}
66
67impl FootprintDocument {
68 pub fn ast(&self) -> &FootprintAst {
69 &self.ast
70 }
71
72 pub fn ast_mut(&mut self) -> &mut FootprintAst {
73 self.ast_dirty = true;
74 &mut self.ast
75 }
76
77 pub fn set_lib_id<S: Into<String>>(&mut self, lib_id: S) -> &mut Self {
78 let lib_id = lib_id.into();
79 self.mutate_root_items(|items| {
80 let value = atom_quoted(lib_id);
81 if let Some(current) = items.get(1) {
82 if *current == value {
83 false
84 } else {
85 items[1] = value;
86 true
87 }
88 } else {
89 items.push(value);
90 true
91 }
92 })
93 }
94
95 pub fn set_version(&mut self, version: i32) -> &mut Self {
96 self.mutate_root_items(|items| {
97 upsert_scalar(items, "version", atom_symbol(version.to_string()), 2)
98 })
99 }
100
101 pub fn set_generator<S: Into<String>>(&mut self, generator: S) -> &mut Self {
102 self.mutate_root_items(|items| {
103 upsert_scalar(items, "generator", atom_symbol(generator.into()), 2)
104 })
105 }
106
107 pub fn set_generator_version<S: Into<String>>(&mut self, generator_version: S) -> &mut Self {
108 self.mutate_root_items(|items| {
109 upsert_scalar(
110 items,
111 "generator_version",
112 atom_quoted(generator_version.into()),
113 2,
114 )
115 })
116 }
117
118 pub fn set_layer<S: Into<String>>(&mut self, layer: S) -> &mut Self {
119 self.mutate_root_items(|items| upsert_scalar(items, "layer", atom_quoted(layer.into()), 2))
120 }
121
122 pub fn set_descr<S: Into<String>>(&mut self, descr: S) -> &mut Self {
123 self.mutate_root_items(|items| upsert_scalar(items, "descr", atom_quoted(descr.into()), 2))
124 }
125
126 pub fn set_tags<S: Into<String>>(&mut self, tags: S) -> &mut Self {
127 self.mutate_root_items(|items| upsert_scalar(items, "tags", atom_quoted(tags.into()), 2))
128 }
129
130 pub fn set_reference<S: Into<String>>(&mut self, value: S) -> &mut Self {
131 self.upsert_property("Reference", value)
132 }
133
134 pub fn set_value<S: Into<String>>(&mut self, value: S) -> &mut Self {
135 self.upsert_property("Value", value)
136 }
137
138 pub fn upsert_property<K: Into<String>, V: Into<String>>(
139 &mut self,
140 key: K,
141 value: V,
142 ) -> &mut Self {
143 let key = key.into();
144 let value = value.into();
145 self.mutate_root_items(|items| upsert_property_preserve_tail(items, &key, &value, 2))
146 }
147
148 pub fn remove_property(&mut self, key: &str) -> &mut Self {
149 let key = key.to_string();
150 self.mutate_root_items(|items| remove_property_node(items, &key, 2))
151 }
152
153 pub fn cst(&self) -> &CstDocument {
154 &self.cst
155 }
156
157 pub fn diagnostics(&self) -> &[Diagnostic] {
158 &self.diagnostics
159 }
160
161 pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
162 self.write_mode(path, WriteMode::Lossless)
163 }
164
165 pub fn write_mode<P: AsRef<Path>>(&self, path: P, mode: WriteMode) -> Result<(), Error> {
166 if self.ast_dirty {
167 return Err(Error::Validation(
168 "ast_mut changes are not serializable; use document setter APIs".to_string(),
169 ));
170 }
171 match mode {
172 WriteMode::Lossless => fs::write(path, self.cst.to_lossless_string())?,
173 WriteMode::Canonical => fs::write(path, self.cst.to_canonical_string())?,
174 }
175 Ok(())
176 }
177
178 fn mutate_root_items<F>(&mut self, mutate: F) -> &mut Self
179 where
180 F: FnOnce(&mut Vec<Node>) -> bool,
181 {
182 mutate_root_and_refresh(
183 &mut self.cst,
184 &mut self.ast,
185 &mut self.diagnostics,
186 mutate,
187 parse_ast,
188 |cst, ast| collect_diagnostics(cst, ast.version),
189 );
190 self.ast_dirty = false;
191 self
192 }
193}
194
195pub struct FootprintFile;
196
197impl FootprintFile {
198 pub fn read<P: AsRef<Path>>(path: P) -> Result<FootprintDocument, Error> {
199 let raw = fs::read_to_string(path)?;
200 let cst = parse_one(&raw)?;
201 ensure_root_head_any(&cst, &["footprint", "module"])?;
202 let ast = parse_ast(&cst);
203 let diagnostics = collect_diagnostics(&cst, ast.version);
204 Ok(FootprintDocument {
205 ast,
206 cst,
207 diagnostics,
208 ast_dirty: false,
209 })
210 }
211}
212
213fn collect_diagnostics(cst: &CstDocument, version: Option<i32>) -> Vec<Diagnostic> {
214 let mut diagnostics = collect_version_diagnostics(version);
215 if root_head(cst) == Some("module") {
216 diagnostics.push(Diagnostic {
217 severity: Severity::Warning,
218 code: "legacy_root",
219 message: "legacy root token `module` detected; parsing in compatibility mode"
220 .to_string(),
221 span: None,
222 hint: Some("save from newer KiCad to normalize root token to `footprint`".to_string()),
223 });
224 }
225 diagnostics
226}
227
228fn parse_ast(cst: &CstDocument) -> FootprintAst {
229 let mut lib_id = None;
230 let mut version = None;
231 let mut tedit = None;
232 let mut generator = None;
233 let mut generator_version = None;
234 let mut layer = None;
235 let mut descr = None;
236 let mut tags = None;
237 let mut property_count = 0usize;
238 let mut attr_present = false;
239 let mut locked_present = false;
240 let mut private_layers_present = false;
241 let mut net_tie_pad_groups_present = false;
242 let mut embedded_fonts_present = false;
243 let mut has_embedded_files = false;
244 let mut embedded_file_count = 0usize;
245 let mut clearance = None;
246 let mut solder_mask_margin = None;
247 let mut solder_paste_margin = None;
248 let mut solder_paste_margin_ratio = None;
249 let mut duplicate_pad_numbers_are_jumpers = None;
250 let mut pad_count = 0usize;
251 let mut model_count = 0usize;
252 let mut zone_count = 0usize;
253 let mut group_count = 0usize;
254 let mut fp_line_count = 0usize;
255 let mut fp_rect_count = 0usize;
256 let mut fp_circle_count = 0usize;
257 let mut fp_arc_count = 0usize;
258 let mut fp_poly_count = 0usize;
259 let mut fp_curve_count = 0usize;
260 let mut fp_text_count = 0usize;
261 let mut fp_text_box_count = 0usize;
262 let mut dimension_count = 0usize;
263 let mut graphic_count = 0usize;
264 let mut unknown_nodes = Vec::new();
265
266 if let Some(Node::List { items, .. }) = cst.nodes.first() {
267 lib_id = items.get(1).and_then(atom_as_string);
268 for item in items.iter().skip(2) {
269 match head_of(item) {
270 Some("version") => version = second_atom_i32(item),
271 Some("tedit") => tedit = second_atom_string(item),
272 Some("generator") => generator = second_atom_string(item),
273 Some("generator_version") => generator_version = second_atom_string(item),
274 Some("layer") => layer = second_atom_string(item),
275 Some("descr") => descr = second_atom_string(item),
276 Some("tags") => tags = second_atom_string(item),
277 Some("property") => property_count += 1,
278 Some("attr") => attr_present = true,
279 Some("locked") => locked_present = true,
280 Some("private_layers") => private_layers_present = true,
281 Some("net_tie_pad_groups") => net_tie_pad_groups_present = true,
282 Some("embedded_fonts") => embedded_fonts_present = true,
283 Some("embedded_files") => {
284 has_embedded_files = true;
285 embedded_file_count = list_child_head_count(item, "file");
286 }
287 Some("clearance") => clearance = second_atom_string(item),
288 Some("solder_mask_margin") => solder_mask_margin = second_atom_string(item),
289 Some("solder_paste_margin") => solder_paste_margin = second_atom_string(item),
290 Some("solder_paste_margin_ratio") => {
291 solder_paste_margin_ratio = second_atom_string(item)
292 }
293 Some("duplicate_pad_numbers_are_jumpers") => {
294 duplicate_pad_numbers_are_jumpers =
295 second_atom_string(item).and_then(|s| match s.as_str() {
296 "yes" => Some(true),
297 "no" => Some(false),
298 _ => None,
299 })
300 }
301 Some("pad") => pad_count += 1,
302 Some("model") => model_count += 1,
303 Some("zone") => zone_count += 1,
304 Some("group") => group_count += 1,
305 Some("fp_line") => {
306 fp_line_count += 1;
307 graphic_count += 1;
308 }
309 Some("fp_rect") => {
310 fp_rect_count += 1;
311 graphic_count += 1;
312 }
313 Some("fp_circle") => {
314 fp_circle_count += 1;
315 graphic_count += 1;
316 }
317 Some("fp_arc") => {
318 fp_arc_count += 1;
319 graphic_count += 1;
320 }
321 Some("fp_poly") => {
322 fp_poly_count += 1;
323 graphic_count += 1;
324 }
325 Some("fp_curve") => {
326 fp_curve_count += 1;
327 graphic_count += 1;
328 }
329 Some("fp_text") => {
330 fp_text_count += 1;
331 graphic_count += 1;
332 }
333 Some("fp_text_box") => {
334 fp_text_box_count += 1;
335 graphic_count += 1;
336 }
337 Some("dimension") => dimension_count += 1,
338 _ => {
339 if let Some(unknown) = UnknownNode::from_node(item) {
340 unknown_nodes.push(unknown);
341 }
342 }
343 }
344 }
345 }
346
347 FootprintAst {
348 lib_id,
349 version,
350 tedit,
351 generator,
352 generator_version,
353 layer,
354 descr,
355 tags,
356 property_count,
357 attr_present,
358 locked_present,
359 private_layers_present,
360 net_tie_pad_groups_present,
361 embedded_fonts_present,
362 has_embedded_files,
363 embedded_file_count,
364 clearance,
365 solder_mask_margin,
366 solder_paste_margin,
367 solder_paste_margin_ratio,
368 duplicate_pad_numbers_are_jumpers,
369 pad_count,
370 model_count,
371 zone_count,
372 group_count,
373 fp_line_count,
374 fp_rect_count,
375 fp_circle_count,
376 fp_arc_count,
377 fp_poly_count,
378 fp_curve_count,
379 fp_text_count,
380 fp_text_box_count,
381 dimension_count,
382 graphic_count,
383 unknown_nodes,
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use std::path::PathBuf;
390 use std::time::{SystemTime, UNIX_EPOCH};
391
392 use super::*;
393
394 fn tmp_file(name: &str) -> PathBuf {
395 let nanos = SystemTime::now()
396 .duration_since(UNIX_EPOCH)
397 .expect("clock")
398 .as_nanos();
399 std::env::temp_dir().join(format!("{name}_{nanos}.kicad_mod"))
400 }
401
402 #[test]
403 fn read_footprint_and_preserve_lossless() {
404 let path = tmp_file("footprint_read_ok");
405 let src = "(footprint \"R_0603\" (version 20260101) (generator pcbnew))\n";
406 fs::write(&path, src).expect("write fixture");
407
408 let doc = FootprintFile::read(&path).expect("read");
409 assert_eq!(doc.ast().lib_id.as_deref(), Some("R_0603"));
410 assert_eq!(doc.ast().version, Some(20260101));
411 assert_eq!(doc.ast().generator.as_deref(), Some("pcbnew"));
412 assert!(doc.ast().unknown_nodes.is_empty());
413 assert_eq!(doc.cst().to_lossless_string(), src);
414
415 let _ = fs::remove_file(path);
416 }
417
418 #[test]
419 fn read_footprint_warns_on_future_version() {
420 let path = tmp_file("footprint_future");
421 fs::write(
422 &path,
423 "(footprint \"R\" (version 20270101) (generator pcbnew))\n",
424 )
425 .expect("write fixture");
426
427 let doc = FootprintFile::read(&path).expect("read");
428 assert_eq!(doc.diagnostics().len(), 1);
429
430 let _ = fs::remove_file(path);
431 }
432
433 #[test]
434 fn read_footprint_warns_on_legacy_version() {
435 let path = tmp_file("footprint_legacy");
436 fs::write(
437 &path,
438 "(footprint \"R\" (version 20221018) (generator pcbnew))\n",
439 )
440 .expect("write fixture");
441
442 let doc = FootprintFile::read(&path).expect("read");
443 assert_eq!(doc.diagnostics().len(), 1);
444 assert_eq!(doc.diagnostics()[0].code, "legacy_format");
445
446 let _ = fs::remove_file(path);
447 }
448
449 #[test]
450 fn read_footprint_accepts_legacy_module_root() {
451 let path = tmp_file("footprint_module_root");
452 let src = "(module R_0603 (layer F.Cu) (tedit 5F0C7995) (attr smd))\n";
453 fs::write(&path, src).expect("write fixture");
454
455 let doc = FootprintFile::read(&path).expect("read");
456 assert_eq!(doc.ast().lib_id.as_deref(), Some("R_0603"));
457 assert_eq!(doc.ast().tedit.as_deref(), Some("5F0C7995"));
458 assert!(doc.ast().attr_present);
459 assert_eq!(doc.diagnostics().len(), 1);
460 assert_eq!(doc.diagnostics()[0].code, "legacy_root");
461
462 let _ = fs::remove_file(path);
463 }
464
465 #[test]
466 fn read_footprint_captures_unknown_nodes() {
467 let path = tmp_file("footprint_unknown");
468 let src =
469 "(footprint \"R\" (version 20260101) (generator pcbnew) (future_shape foo bar))\n";
470 fs::write(&path, src).expect("write fixture");
471
472 let doc = FootprintFile::read(&path).expect("read");
473 assert_eq!(doc.ast().unknown_nodes.len(), 1);
474 assert_eq!(
475 doc.ast().unknown_nodes[0].head.as_deref(),
476 Some("future_shape")
477 );
478
479 let _ = fs::remove_file(path);
480 }
481
482 #[test]
483 fn read_footprint_parses_top_level_counts() {
484 let path = tmp_file("footprint_counts");
485 let src = "(footprint \"X\" (version 20260101) (generator pcbnew) (generator_version \"10.0\") (layer \"F.Cu\")\n (descr \"demo\")\n (tags \"a b\")\n (property \"Reference\" \"R?\")\n (property \"Value\" \"X\")\n (attr smd)\n (private_layers \"In1.Cu\")\n (net_tie_pad_groups \"1,2\")\n (solder_mask_margin 0.02)\n (solder_paste_margin -0.01)\n (solder_paste_margin_ratio -0.2)\n (duplicate_pad_numbers_are_jumpers yes)\n (fp_text reference \"R1\" (at 0 0) (layer \"F.SilkS\"))\n (fp_line (start 0 0) (end 1 1) (layer \"F.SilkS\"))\n (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\" \"F.Mask\"))\n (model \"foo.step\")\n (zone)\n (group (id \"g1\"))\n (dimension)\n)\n";
486 fs::write(&path, src).expect("write fixture");
487
488 let doc = FootprintFile::read(&path).expect("read");
489 assert_eq!(doc.ast().lib_id.as_deref(), Some("X"));
490 assert_eq!(doc.ast().generator_version.as_deref(), Some("10.0"));
491 assert_eq!(doc.ast().layer.as_deref(), Some("F.Cu"));
492 assert_eq!(doc.ast().property_count, 2);
493 assert!(doc.ast().attr_present);
494 assert!(!doc.ast().locked_present);
495 assert!(doc.ast().private_layers_present);
496 assert!(doc.ast().net_tie_pad_groups_present);
497 assert!(!doc.ast().embedded_fonts_present);
498 assert!(!doc.ast().has_embedded_files);
499 assert_eq!(doc.ast().embedded_file_count, 0);
500 assert_eq!(doc.ast().clearance, None);
501 assert_eq!(doc.ast().solder_mask_margin.as_deref(), Some("0.02"));
502 assert_eq!(doc.ast().solder_paste_margin.as_deref(), Some("-0.01"));
503 assert_eq!(doc.ast().solder_paste_margin_ratio.as_deref(), Some("-0.2"));
504 assert_eq!(doc.ast().duplicate_pad_numbers_are_jumpers, Some(true));
505 assert_eq!(doc.ast().fp_text_count, 1);
506 assert_eq!(doc.ast().fp_line_count, 1);
507 assert_eq!(doc.ast().graphic_count, 2);
508 assert_eq!(doc.ast().pad_count, 1);
509 assert_eq!(doc.ast().model_count, 1);
510 assert_eq!(doc.ast().zone_count, 1);
511 assert_eq!(doc.ast().group_count, 1);
512 assert_eq!(doc.ast().dimension_count, 1);
513 assert!(doc.ast().unknown_nodes.is_empty());
514
515 let _ = fs::remove_file(path);
516 }
517
518 #[test]
519 fn parses_embedded_fonts_regression() {
520 let path = tmp_file("footprint_embedded_fonts");
521 let src = "(footprint \"X\" (version 20260101) (generator pcbnew) (embedded_fonts no))\n";
522 fs::write(&path, src).expect("write fixture");
523
524 let doc = FootprintFile::read(&path).expect("read");
525 assert!(doc.ast().embedded_fonts_present);
526 assert!(doc.ast().unknown_nodes.is_empty());
527
528 let _ = fs::remove_file(path);
529 }
530
531 #[test]
532 fn parses_locked_regression() {
533 let path = tmp_file("footprint_locked");
534 let src = "(footprint \"X\" (locked) (version 20260101) (generator pcbnew))\n";
535 fs::write(&path, src).expect("write fixture");
536
537 let doc = FootprintFile::read(&path).expect("read");
538 assert!(doc.ast().locked_present);
539 assert!(doc.ast().unknown_nodes.is_empty());
540
541 let _ = fs::remove_file(path);
542 }
543
544 #[test]
545 fn parses_solder_margins_and_jumpers_regression() {
546 let path = tmp_file("footprint_margins_jumpers");
547 let src = "(footprint \"X\" (version 20260101) (generator pcbnew)\n (clearance 0.15)\n (solder_mask_margin 0.03)\n (solder_paste_margin -0.02)\n (solder_paste_margin_ratio -0.3)\n (duplicate_pad_numbers_are_jumpers no)\n)\n";
548 fs::write(&path, src).expect("write fixture");
549
550 let doc = FootprintFile::read(&path).expect("read");
551 assert_eq!(doc.ast().clearance.as_deref(), Some("0.15"));
552 assert_eq!(doc.ast().solder_mask_margin.as_deref(), Some("0.03"));
553 assert_eq!(doc.ast().solder_paste_margin.as_deref(), Some("-0.02"));
554 assert_eq!(doc.ast().solder_paste_margin_ratio.as_deref(), Some("-0.3"));
555 assert_eq!(doc.ast().duplicate_pad_numbers_are_jumpers, Some(false));
556 assert!(doc.ast().unknown_nodes.is_empty());
557
558 let _ = fs::remove_file(path);
559 }
560
561 #[test]
562 fn parses_embedded_files_regression() {
563 let path = tmp_file("footprint_embedded_files");
564 let src = "(footprint \"X\" (version 20260101) (generator pcbnew)\n (embedded_files\n (file \"A\" \"base64\")\n (file \"B\" \"base64\")\n )\n)\n";
565 fs::write(&path, src).expect("write fixture");
566
567 let doc = FootprintFile::read(&path).expect("read");
568 assert!(doc.ast().has_embedded_files);
569 assert_eq!(doc.ast().embedded_file_count, 2);
570 assert!(doc.ast().unknown_nodes.is_empty());
571
572 let _ = fs::remove_file(path);
573 }
574
575 #[test]
576 fn edit_roundtrip_updates_core_fields_and_properties() {
577 let path = tmp_file("footprint_edit_input");
578 let src = "(footprint \"Old\" (version 20241229) (generator pcbnew) (layer \"F.Cu\")\n (property \"Reference\" \"R1\")\n (property \"Value\" \"10k\")\n (future_shape foo bar)\n)\n";
579 fs::write(&path, src).expect("write fixture");
580
581 let mut doc = FootprintFile::read(&path).expect("read");
582 doc.set_lib_id("New_Footprint")
583 .set_version(20260101)
584 .set_generator("kiutils")
585 .set_generator_version("dev")
586 .set_layer("B.Cu")
587 .set_descr("demo footprint")
588 .set_tags("r c passives")
589 .set_reference("R99")
590 .set_value("22k")
591 .upsert_property("LCSC", "C1234")
592 .remove_property("DoesNotExist");
593
594 let out = tmp_file("footprint_edit_output");
595 doc.write(&out).expect("write");
596 let written = fs::read_to_string(&out).expect("read out");
597 assert!(written.contains("(future_shape foo bar)"));
598 assert!(written.contains("(property \"LCSC\" \"C1234\")"));
599
600 let reread = FootprintFile::read(&out).expect("reread");
601 assert_eq!(reread.ast().lib_id.as_deref(), Some("New_Footprint"));
602 assert_eq!(reread.ast().version, Some(20260101));
603 assert_eq!(reread.ast().generator.as_deref(), Some("kiutils"));
604 assert_eq!(reread.ast().generator_version.as_deref(), Some("dev"));
605 assert_eq!(reread.ast().layer.as_deref(), Some("B.Cu"));
606 assert_eq!(reread.ast().descr.as_deref(), Some("demo footprint"));
607 assert_eq!(reread.ast().tags.as_deref(), Some("r c passives"));
608 assert_eq!(reread.ast().property_count, 3);
609 assert_eq!(reread.ast().unknown_nodes.len(), 1);
610
611 let _ = fs::remove_file(path);
612 let _ = fs::remove_file(out);
613 }
614
615 #[test]
616 fn remove_property_roundtrip_removes_entry() {
617 let path = tmp_file("footprint_remove_property");
618 let src = "(footprint \"X\" (version 20260101) (generator pcbnew)\n (property \"Reference\" \"R1\")\n (property \"Value\" \"10k\")\n)\n";
619 fs::write(&path, src).expect("write fixture");
620
621 let mut doc = FootprintFile::read(&path).expect("read");
622 doc.remove_property("Value");
623
624 let out = tmp_file("footprint_remove_property_out");
625 doc.write(&out).expect("write");
626 let reread = FootprintFile::read(&out).expect("reread");
627 assert_eq!(reread.ast().property_count, 1);
628
629 let _ = fs::remove_file(path);
630 let _ = fs::remove_file(out);
631 }
632
633 #[test]
634 fn no_op_setter_keeps_lossless_raw_unchanged() {
635 let path = tmp_file("footprint_noop_setter");
636 let src = "(footprint \"X\" (version 20260101) (generator pcbnew))\n";
637 fs::write(&path, src).expect("write fixture");
638
639 let mut doc = FootprintFile::read(&path).expect("read");
640 doc.set_version(20260101);
641
642 let out = tmp_file("footprint_noop_setter_out");
643 doc.write(&out).expect("write");
644 let written = fs::read_to_string(&out).expect("read out");
645 assert_eq!(written, src);
646
647 let _ = fs::remove_file(path);
648 let _ = fs::remove_file(out);
649 }
650}