Skip to main content

kiutils_kicad/
project.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::Path;
4
5use serde_json::Value;
6
7use crate::{Error, UnknownField, WriteMode};
8
9#[derive(Debug, Clone, PartialEq)]
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11pub struct ProjectAst {
12    pub meta_version: Option<i32>,
13    pub pinned_footprint_libs: Vec<String>,
14    pub unknown_fields: Vec<UnknownField>,
15}
16
17#[derive(Debug, Clone)]
18pub struct ProjectDocument {
19    ast: ProjectAst,
20    raw: String,
21    json: Value,
22    ast_dirty: bool,
23}
24
25impl ProjectDocument {
26    pub fn ast(&self) -> &ProjectAst {
27        &self.ast
28    }
29
30    pub fn ast_mut(&mut self) -> &mut ProjectAst {
31        self.ast_dirty = true;
32        &mut self.ast
33    }
34
35    pub fn raw(&self) -> &str {
36        &self.raw
37    }
38
39    pub fn json(&self) -> &Value {
40        &self.json
41    }
42
43    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
44        self.write_mode(path, WriteMode::Lossless)
45    }
46
47    pub fn write_mode<P: AsRef<Path>>(&self, path: P, mode: WriteMode) -> Result<(), Error> {
48        if self.ast_dirty {
49            return Err(Error::Validation(
50                "ast_mut changes are not serializable; use document setter APIs".to_string(),
51            ));
52        }
53        match mode {
54            WriteMode::Lossless => fs::write(path, &self.raw)?,
55            WriteMode::Canonical => {
56                let json = serde_json::to_string_pretty(&self.json)
57                    .map_err(|e| Error::Validation(format!("json serialization failed: {e}")))?;
58                fs::write(path, format!("{json}\n"))?;
59            }
60        }
61        Ok(())
62    }
63}
64
65pub struct ProjectFile;
66
67impl ProjectFile {
68    pub fn read<P: AsRef<Path>>(path: P) -> Result<ProjectDocument, Error> {
69        let raw = fs::read_to_string(path)?;
70        let json: Value = serde_json::from_str(&raw)
71            .map_err(|e| Error::Validation(format!("invalid .kicad_pro json: {e}")))?;
72
73        let meta_version = json
74            .get("meta")
75            .and_then(Value::as_object)
76            .and_then(|m| m.get("version"))
77            .and_then(Value::as_i64)
78            .map(i32::try_from)
79            .transpose()
80            .map_err(|_| Error::Validation("meta.version is out of i32 range".to_string()))?;
81
82        let pinned_footprint_libs = json
83            .get("libraries")
84            .and_then(Value::as_object)
85            .and_then(|l| l.get("pinned_footprint_libs"))
86            .and_then(Value::as_array)
87            .map(|arr| {
88                arr.iter()
89                    .filter_map(Value::as_str)
90                    .map(ToOwned::to_owned)
91                    .collect::<Vec<_>>()
92            })
93            .unwrap_or_default();
94
95        let known_top_level = [
96            "meta",
97            "libraries",
98            "board",
99            "sheets",
100            "boards",
101            "text_variables",
102        ];
103        let unknown_fields = json
104            .as_object()
105            .map(|o| {
106                o.iter()
107                    .filter(|(k, _)| !known_top_level.contains(&k.as_str()))
108                    .map(|(k, v)| UnknownField {
109                        key: k.clone(),
110                        value: v.clone(),
111                    })
112                    .collect::<Vec<_>>()
113            })
114            .unwrap_or_default();
115
116        Ok(ProjectDocument {
117            ast: ProjectAst {
118                meta_version,
119                pinned_footprint_libs,
120                unknown_fields,
121            },
122            raw,
123            json,
124            ast_dirty: false,
125        })
126    }
127}
128
129pub type ProjectExtra = BTreeMap<String, Value>;
130
131#[cfg(test)]
132mod tests {
133    use std::path::PathBuf;
134    use std::time::{SystemTime, UNIX_EPOCH};
135
136    use super::*;
137
138    fn tmp_file(name: &str) -> PathBuf {
139        let nanos = SystemTime::now()
140            .duration_since(UNIX_EPOCH)
141            .expect("clock")
142            .as_nanos();
143        std::env::temp_dir().join(format!("{name}_{nanos}.kicad_pro"))
144    }
145
146    #[test]
147    fn read_project_json() {
148        let path = tmp_file("pro_ok");
149        let src = r#"{
150  "meta": { "version": 3 },
151  "libraries": { "pinned_footprint_libs": ["A", "B"] },
152  "board": { "foo": true }
153}
154"#;
155        fs::write(&path, src).expect("write fixture");
156
157        let doc = ProjectFile::read(&path).expect("read");
158        assert_eq!(doc.ast().meta_version, Some(3));
159        assert_eq!(doc.ast().pinned_footprint_libs, vec!["A", "B"]);
160        assert!(doc.ast().unknown_fields.is_empty());
161        assert_eq!(doc.raw(), src);
162
163        let _ = fs::remove_file(path);
164    }
165
166    #[test]
167    fn read_project_captures_unknown_top_level_fields() {
168        let path = tmp_file("pro_unknown");
169        let src = r#"{
170  "meta": { "version": 3 },
171  "libraries": { "pinned_footprint_libs": ["A"] },
172  "custom_top": { "x": 1 }
173}
174"#;
175        fs::write(&path, src).expect("write fixture");
176
177        let doc = ProjectFile::read(&path).expect("read");
178        assert_eq!(doc.ast().unknown_fields.len(), 1);
179        assert_eq!(doc.ast().unknown_fields[0].key, "custom_top");
180
181        let _ = fs::remove_file(path);
182    }
183
184    #[test]
185    fn ast_mut_write_returns_validation_error() {
186        let path = tmp_file("pro_ast_mut_write_error");
187        let src = r#"{
188  "meta": { "version": 3 },
189  "libraries": { "pinned_footprint_libs": ["A"] }
190}
191"#;
192        fs::write(&path, src).expect("write fixture");
193
194        let mut doc = ProjectFile::read(&path).expect("read");
195        doc.ast_mut().meta_version = Some(4);
196
197        let out = tmp_file("pro_ast_mut_write_error_out");
198        let err = doc.write(&out).expect_err("write should fail");
199        match err {
200            Error::Validation(msg) => {
201                assert!(msg.contains("ast_mut changes are not serializable"));
202            }
203            _ => panic!("expected validation error"),
204        }
205
206        let _ = fs::remove_file(path);
207        let _ = fs::remove_file(out);
208    }
209
210    #[test]
211    fn read_project_rejects_out_of_range_meta_version() {
212        let path = tmp_file("pro_meta_version_oob");
213        let src = r#"{
214  "meta": { "version": 9223372036854775807 },
215  "libraries": { "pinned_footprint_libs": ["A"] }
216}
217"#;
218        fs::write(&path, src).expect("write fixture");
219
220        let err = ProjectFile::read(&path).expect_err("read should fail");
221        match err {
222            Error::Validation(msg) => assert!(msg.contains("meta.version is out of i32 range")),
223            _ => panic!("expected validation error"),
224        }
225
226        let _ = fs::remove_file(path);
227    }
228}