Skip to main content

kiutils_kicad/
project.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::Path;
4
5use serde_json::Map;
6use serde_json::Value;
7
8use crate::{Error, UnknownField, WriteMode};
9
10#[derive(Debug, Clone, PartialEq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub struct ProjectAst {
13    pub meta_version: Option<i32>,
14    pub pinned_symbol_libs: Vec<String>,
15    pub pinned_footprint_libs: Vec<String>,
16    pub unknown_fields: Vec<UnknownField>,
17}
18
19#[derive(Debug, Clone)]
20pub struct ProjectDocument {
21    ast: ProjectAst,
22    raw: String,
23    json: Value,
24    ast_dirty: bool,
25}
26
27impl ProjectDocument {
28    pub fn ast(&self) -> &ProjectAst {
29        &self.ast
30    }
31
32    pub fn ast_mut(&mut self) -> &mut ProjectAst {
33        self.ast_dirty = true;
34        &mut self.ast
35    }
36
37    pub fn raw(&self) -> &str {
38        &self.raw
39    }
40
41    pub fn json(&self) -> &Value {
42        &self.json
43    }
44
45    pub fn set_pinned_symbol_libs<I, S>(&mut self, libs: I) -> &mut Self
46    where
47        I: IntoIterator<Item = S>,
48        S: Into<String>,
49    {
50        let was_ast_dirty = self.ast_dirty;
51        let libs = libs.into_iter().map(Into::into).collect::<Vec<_>>();
52        self.set_library_array("pinned_symbol_libs", &libs);
53        self.refresh_ast_from_json();
54        self.ast_dirty = was_ast_dirty;
55        self
56    }
57
58    pub fn set_pinned_footprint_libs<I, S>(&mut self, libs: I) -> &mut Self
59    where
60        I: IntoIterator<Item = S>,
61        S: Into<String>,
62    {
63        let was_ast_dirty = self.ast_dirty;
64        let libs = libs.into_iter().map(Into::into).collect::<Vec<_>>();
65        self.set_library_array("pinned_footprint_libs", &libs);
66        self.refresh_ast_from_json();
67        self.ast_dirty = was_ast_dirty;
68        self
69    }
70
71    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
72        self.write_mode(path, WriteMode::Lossless)
73    }
74
75    pub fn write_mode<P: AsRef<Path>>(&self, path: P, mode: WriteMode) -> Result<(), Error> {
76        if self.ast_dirty {
77            return Err(Error::Validation(
78                "ast_mut changes are not serializable; use document setter APIs".to_string(),
79            ));
80        }
81        match mode {
82            WriteMode::Lossless => fs::write(path, &self.raw)?,
83            WriteMode::Canonical => {
84                let json = serde_json::to_string_pretty(&self.json)
85                    .map_err(|e| Error::Validation(format!("json serialization failed: {e}")))?;
86                fs::write(path, format!("{json}\n"))?;
87            }
88        }
89        Ok(())
90    }
91
92    fn set_library_array(&mut self, key: &str, libs: &[String]) {
93        if !self.json.is_object() {
94            self.json = Value::Object(Map::new());
95        }
96        if let Some(root) = self.json.as_object_mut() {
97            let libraries = root
98                .entry("libraries".to_string())
99                .or_insert_with(|| Value::Object(Map::new()));
100            if !libraries.is_object() {
101                *libraries = Value::Object(Map::new());
102            }
103            if let Some(libraries) = libraries.as_object_mut() {
104                libraries.insert(
105                    key.to_string(),
106                    Value::Array(libs.iter().cloned().map(Value::String).collect()),
107                );
108            }
109        }
110        if let Ok(json) = serde_json::to_string_pretty(&self.json) {
111            self.raw = format!("{json}\n");
112        }
113    }
114
115    fn refresh_ast_from_json(&mut self) {
116        let meta_version = self
117            .json
118            .get("meta")
119            .and_then(Value::as_object)
120            .and_then(|m| m.get("version"))
121            .and_then(Value::as_i64)
122            .and_then(|v| i32::try_from(v).ok());
123
124        let pinned_footprint_libs = self
125            .json
126            .get("libraries")
127            .and_then(Value::as_object)
128            .and_then(|l| l.get("pinned_footprint_libs"))
129            .and_then(Value::as_array)
130            .map(|arr| {
131                arr.iter()
132                    .filter_map(Value::as_str)
133                    .map(ToOwned::to_owned)
134                    .collect::<Vec<_>>()
135            })
136            .unwrap_or_default();
137
138        let pinned_symbol_libs = self
139            .json
140            .get("libraries")
141            .and_then(Value::as_object)
142            .and_then(|l| l.get("pinned_symbol_libs"))
143            .and_then(Value::as_array)
144            .map(|arr| {
145                arr.iter()
146                    .filter_map(Value::as_str)
147                    .map(ToOwned::to_owned)
148                    .collect::<Vec<_>>()
149            })
150            .unwrap_or_default();
151
152        let known_top_level = [
153            "meta",
154            "libraries",
155            "board",
156            "sheets",
157            "boards",
158            "text_variables",
159        ];
160        let unknown_fields = self
161            .json
162            .as_object()
163            .map(|o| {
164                o.iter()
165                    .filter(|(k, _)| !known_top_level.contains(&k.as_str()))
166                    .map(|(k, v)| UnknownField {
167                        key: k.clone(),
168                        value: v.clone(),
169                    })
170                    .collect::<Vec<_>>()
171            })
172            .unwrap_or_default();
173
174        self.ast = ProjectAst {
175            meta_version,
176            pinned_symbol_libs,
177            pinned_footprint_libs,
178            unknown_fields,
179        };
180    }
181}
182
183pub struct ProjectFile;
184
185impl ProjectFile {
186    pub fn read<P: AsRef<Path>>(path: P) -> Result<ProjectDocument, Error> {
187        let raw = fs::read_to_string(path)?;
188        let json: Value = serde_json::from_str(&raw)
189            .map_err(|e| Error::Validation(format!("invalid .kicad_pro json: {e}")))?;
190
191        let meta_version = json
192            .get("meta")
193            .and_then(Value::as_object)
194            .and_then(|m| m.get("version"))
195            .and_then(Value::as_i64)
196            .map(i32::try_from)
197            .transpose()
198            .map_err(|_| Error::Validation("meta.version is out of i32 range".to_string()))?;
199
200        let pinned_footprint_libs = json
201            .get("libraries")
202            .and_then(Value::as_object)
203            .and_then(|l| l.get("pinned_footprint_libs"))
204            .and_then(Value::as_array)
205            .map(|arr| {
206                arr.iter()
207                    .filter_map(Value::as_str)
208                    .map(ToOwned::to_owned)
209                    .collect::<Vec<_>>()
210            })
211            .unwrap_or_default();
212
213        let pinned_symbol_libs = json
214            .get("libraries")
215            .and_then(Value::as_object)
216            .and_then(|l| l.get("pinned_symbol_libs"))
217            .and_then(Value::as_array)
218            .map(|arr| {
219                arr.iter()
220                    .filter_map(Value::as_str)
221                    .map(ToOwned::to_owned)
222                    .collect::<Vec<_>>()
223            })
224            .unwrap_or_default();
225
226        let known_top_level = [
227            "meta",
228            "libraries",
229            "board",
230            "sheets",
231            "boards",
232            "text_variables",
233        ];
234        let unknown_fields = json
235            .as_object()
236            .map(|o| {
237                o.iter()
238                    .filter(|(k, _)| !known_top_level.contains(&k.as_str()))
239                    .map(|(k, v)| UnknownField {
240                        key: k.clone(),
241                        value: v.clone(),
242                    })
243                    .collect::<Vec<_>>()
244            })
245            .unwrap_or_default();
246
247        Ok(ProjectDocument {
248            ast: ProjectAst {
249                meta_version,
250                pinned_symbol_libs,
251                pinned_footprint_libs,
252                unknown_fields,
253            },
254            raw,
255            json,
256            ast_dirty: false,
257        })
258    }
259}
260
261pub type ProjectExtra = BTreeMap<String, Value>;
262
263#[cfg(test)]
264mod tests {
265    use std::path::PathBuf;
266    use std::time::{SystemTime, UNIX_EPOCH};
267
268    use super::*;
269
270    fn tmp_file(name: &str) -> PathBuf {
271        let nanos = SystemTime::now()
272            .duration_since(UNIX_EPOCH)
273            .expect("clock")
274            .as_nanos();
275        std::env::temp_dir().join(format!("{name}_{nanos}.kicad_pro"))
276    }
277
278    #[test]
279    fn read_project_json() {
280        let path = tmp_file("pro_ok");
281        let src = r#"{
282  "meta": { "version": 3 },
283  "libraries": {
284    "pinned_symbol_libs": ["S1", "S2"],
285    "pinned_footprint_libs": ["A", "B"]
286  },
287  "board": { "foo": true }
288}
289"#;
290        fs::write(&path, src).expect("write fixture");
291
292        let doc = ProjectFile::read(&path).expect("read");
293        assert_eq!(doc.ast().meta_version, Some(3));
294        assert_eq!(doc.ast().pinned_symbol_libs, vec!["S1", "S2"]);
295        assert_eq!(doc.ast().pinned_footprint_libs, vec!["A", "B"]);
296        assert!(doc.ast().unknown_fields.is_empty());
297        assert_eq!(doc.raw(), src);
298
299        let _ = fs::remove_file(path);
300    }
301
302    #[test]
303    fn read_project_captures_unknown_top_level_fields() {
304        let path = tmp_file("pro_unknown");
305        let src = r#"{
306  "meta": { "version": 3 },
307  "libraries": { "pinned_footprint_libs": ["A"] },
308  "custom_top": { "x": 1 }
309}
310"#;
311        fs::write(&path, src).expect("write fixture");
312
313        let doc = ProjectFile::read(&path).expect("read");
314        assert_eq!(doc.ast().unknown_fields.len(), 1);
315        assert_eq!(doc.ast().unknown_fields[0].key, "custom_top");
316
317        let _ = fs::remove_file(path);
318    }
319
320    #[test]
321    fn setters_update_project_libraries_and_allow_write() {
322        let path = tmp_file("pro_setters");
323        let src = r#"{
324  "meta": { "version": 3 },
325  "libraries": { "pinned_footprint_libs": ["A"] }
326}
327"#;
328        fs::write(&path, src).expect("write fixture");
329
330        let mut doc = ProjectFile::read(&path).expect("read");
331        doc.set_pinned_symbol_libs(vec!["SYM_A", "SYM_B"])
332            .set_pinned_footprint_libs(vec!["FP_A", "FP_B"]);
333        assert_eq!(doc.ast().pinned_symbol_libs, vec!["SYM_A", "SYM_B"]);
334        assert_eq!(doc.ast().pinned_footprint_libs, vec!["FP_A", "FP_B"]);
335        assert_eq!(
336            doc.json()
337                .get("libraries")
338                .and_then(Value::as_object)
339                .and_then(|l| l.get("pinned_symbol_libs"))
340                .and_then(Value::as_array)
341                .map(|x| x.len()),
342            Some(2)
343        );
344
345        let out = tmp_file("pro_setters_out");
346        doc.write(&out).expect("write should work");
347        let reread = ProjectFile::read(&out).expect("reread");
348        assert_eq!(reread.ast().pinned_symbol_libs, vec!["SYM_A", "SYM_B"]);
349        assert_eq!(reread.ast().pinned_footprint_libs, vec!["FP_A", "FP_B"]);
350
351        let _ = fs::remove_file(path);
352        let _ = fs::remove_file(out);
353    }
354
355    #[test]
356    fn setters_do_not_clear_ast_mut_dirty_guard() {
357        let path = tmp_file("pro_setter_does_not_clear_dirty");
358        let src = r#"{
359  "meta": { "version": 3 },
360  "libraries": { "pinned_footprint_libs": ["A"] }
361}
362"#;
363        fs::write(&path, src).expect("write fixture");
364
365        let mut doc = ProjectFile::read(&path).expect("read");
366        doc.ast_mut().meta_version = Some(4);
367        doc.set_pinned_symbol_libs(vec!["SYM_A"]);
368        assert_eq!(doc.ast().meta_version, Some(3));
369
370        let out = tmp_file("pro_setter_does_not_clear_dirty_out");
371        let err = doc.write(&out).expect_err("write should fail");
372        match err {
373            Error::Validation(msg) => {
374                assert!(msg.contains("ast_mut changes are not serializable"));
375            }
376            _ => panic!("expected validation error"),
377        }
378
379        let _ = fs::remove_file(path);
380        let _ = fs::remove_file(out);
381    }
382
383    #[test]
384    fn ast_mut_write_returns_validation_error() {
385        let path = tmp_file("pro_ast_mut_write_error");
386        let src = r#"{
387  "meta": { "version": 3 },
388  "libraries": { "pinned_footprint_libs": ["A"] }
389}
390"#;
391        fs::write(&path, src).expect("write fixture");
392
393        let mut doc = ProjectFile::read(&path).expect("read");
394        doc.ast_mut().meta_version = Some(4);
395
396        let out = tmp_file("pro_ast_mut_write_error_out");
397        let err = doc.write(&out).expect_err("write should fail");
398        match err {
399            Error::Validation(msg) => {
400                assert!(msg.contains("ast_mut changes are not serializable"));
401            }
402            _ => panic!("expected validation error"),
403        }
404
405        let _ = fs::remove_file(path);
406        let _ = fs::remove_file(out);
407    }
408
409    #[test]
410    fn read_project_rejects_out_of_range_meta_version() {
411        let path = tmp_file("pro_meta_version_oob");
412        let src = r#"{
413  "meta": { "version": 9223372036854775807 },
414  "libraries": { "pinned_footprint_libs": ["A"] }
415}
416"#;
417        fs::write(&path, src).expect("write fixture");
418
419        let err = ProjectFile::read(&path).expect_err("read should fail");
420        match err {
421            Error::Validation(msg) => assert!(msg.contains("meta.version is out of i32 range")),
422            _ => panic!("expected validation error"),
423        }
424
425        let _ = fs::remove_file(path);
426    }
427}