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}