Skip to main content

kiutils_kicad/
footprint.rs

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}