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}