opencode_provider_manager/config_core/
jsonc.rs1use super::error::{ConfigError, Result};
15use jsonc_parser::ParseOptions;
16use jsonc_parser::cst::{
17 CstContainerNode, CstInputValue, CstNode, CstObject, CstObjectProp, CstRootNode,
18};
19use std::path::Path;
20
21pub struct JsoncHandler {
27 source: String,
29}
30
31impl JsoncHandler {
32 pub fn parse(source: &str) -> Result<Self> {
34 let _ = jsonc_parser::parse_to_value(source, &Default::default())
36 .map_err(|e| ConfigError::JsoncParse(format!("{e:?}")))?;
37
38 Ok(Self {
39 source: source.to_string(),
40 })
41 }
42
43 pub fn read_file(path: &Path) -> Result<Self> {
45 let source = std::fs::read_to_string(path).map_err(|_| ConfigError::FileNotFound {
46 path: path.display().to_string(),
47 })?;
48 Self::parse(&source)
49 }
50
51 pub fn source(&self) -> &str {
53 &self.source
54 }
55
56 pub fn to_json_string(&self) -> Result<String> {
58 let value = jsonc_parser::parse_to_value(&self.source, &Default::default())
59 .map_err(|e| ConfigError::JsoncParse(format!("{e:?}")))?;
60
61 match value {
62 Some(v) => {
63 let sv = json_value_to_serde(&v)?;
64 serde_json::to_string_pretty(&sv)
65 .map_err(|e| ConfigError::JsoncParse(format!("{e}")))
66 }
67 None => Err(ConfigError::JsoncParse("Empty JSONC document".to_string())),
68 }
69 }
70
71 pub fn write_file(&self, path: &Path) -> Result<()> {
74 if let Some(parent) = path.parent() {
75 std::fs::create_dir_all(parent)?;
76 }
77 std::fs::write(path, &self.source)?;
78 Ok(())
79 }
80}
81
82fn json_value_to_serde(value: &jsonc_parser::JsonValue) -> Result<serde_json::Value> {
84 match value {
85 jsonc_parser::JsonValue::Object(obj) => {
86 let mut map = serde_json::Map::new();
87 for (key, val) in obj.clone().into_iter() {
88 map.insert(key, json_value_to_serde(&val)?);
89 }
90 Ok(serde_json::Value::Object(map))
91 }
92 jsonc_parser::JsonValue::Array(arr) => {
93 let mut vec = Vec::new();
94 for item in arr.iter() {
95 vec.push(json_value_to_serde(item)?);
96 }
97 Ok(serde_json::Value::Array(vec))
98 }
99 jsonc_parser::JsonValue::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
100 jsonc_parser::JsonValue::Number(n) => {
101 if let Ok(i) = n.parse::<i64>() {
103 Ok(serde_json::Value::Number(i.into()))
104 } else if let Ok(f) = n.parse::<f64>() {
105 serde_json::Number::from_f64(f)
106 .map(serde_json::Value::Number)
107 .ok_or_else(|| ConfigError::JsoncParse(format!("Invalid number: {n}")))
108 } else {
109 Err(ConfigError::JsoncParse(format!("Invalid number: {n}")))
110 }
111 }
112 jsonc_parser::JsonValue::String(s) => Ok(serde_json::Value::String(s.to_string())),
113 jsonc_parser::JsonValue::Null => Ok(serde_json::Value::Null),
114 }
115}
116
117pub fn read_config_to_json(path: &Path) -> Result<String> {
119 let source = std::fs::read_to_string(path).map_err(|_| ConfigError::FileNotFound {
120 path: path.display().to_string(),
121 })?;
122
123 let handler = JsoncHandler::parse(&source)?;
124 handler.to_json_string()
125}
126
127pub fn read_config<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
131 let json_str = read_config_to_json(path)?;
132 let value: T = serde_json::from_str(&json_str)?;
133 Ok(value)
134}
135
136pub fn write_config<T: serde::Serialize>(value: &T, path: &Path) -> Result<()> {
144 if let Some(parent) = path.parent() {
145 std::fs::create_dir_all(parent)?;
146 }
147
148 let new_value = serde_json::to_value(value)?;
149 let content = compute_output(path, &new_value)?;
150 atomic_write(path, &content)
151}
152
153fn compute_output(path: &Path, new_value: &serde_json::Value) -> Result<String> {
155 if path.exists() {
157 if let Ok(existing) = std::fs::read_to_string(path) {
158 if let Ok(root) = CstRootNode::parse(&existing, &ParseOptions::default()) {
159 reconcile_root(&root, new_value);
160 return Ok(root.to_string());
161 }
162 }
163 }
164
165 Ok(serde_json::to_string_pretty(new_value)?)
166}
167
168fn atomic_write(path: &Path, content: &str) -> Result<()> {
171 let parent = path.parent().ok_or_else(|| {
172 ConfigError::Io(std::io::Error::new(
173 std::io::ErrorKind::InvalidInput,
174 "path has no parent directory",
175 ))
176 })?;
177
178 let file_name = path
180 .file_name()
181 .map(|n| n.to_string_lossy().to_string())
182 .unwrap_or_default();
183 let temp_name = format!(".{file_name}.tmp.{}", std::process::id());
184 let temp_path = parent.join(&temp_name);
185
186 std::fs::write(&temp_path, content)?;
188
189 match std::fs::rename(&temp_path, path) {
191 Ok(()) => Ok(()),
192 Err(e) => {
193 let _ = std::fs::remove_file(&temp_path);
195 Err(ConfigError::Io(e))
196 }
197 }
198}
199
200fn reconcile_root(root: &CstRootNode, new_value: &serde_json::Value) {
203 match (root.value(), new_value) {
204 (
205 Some(CstNode::Container(CstContainerNode::Object(obj))),
206 serde_json::Value::Object(map),
207 ) => reconcile_object(&obj, map),
208 _ => root.set_value(json_to_cst_input(new_value)),
209 }
210}
211
212fn reconcile_object(obj: &CstObject, new: &serde_json::Map<String, serde_json::Value>) {
213 let existing: Vec<(String, CstObjectProp)> = obj
215 .properties()
216 .into_iter()
217 .filter_map(|prop| {
218 let name = prop.name()?.decoded_value().ok()?;
219 Some((name, prop))
220 })
221 .collect();
222
223 for (key, new_val) in new.iter() {
225 if let Some(prop) = obj.get(key) {
226 reconcile_prop(&prop, new_val);
227 } else {
228 obj.append(key, json_to_cst_input(new_val));
229 }
230 }
231
232 for (key, prop) in existing {
234 if !new.contains_key(&key) {
235 prop.remove();
236 }
237 }
238}
239
240fn reconcile_prop(prop: &CstObjectProp, new: &serde_json::Value) {
241 match (prop.value(), new) {
242 (
243 Some(CstNode::Container(CstContainerNode::Object(obj))),
244 serde_json::Value::Object(map),
245 ) => reconcile_object(&obj, map),
246 _ => prop.set_value(json_to_cst_input(new)),
247 }
248}
249
250fn json_to_cst_input(v: &serde_json::Value) -> CstInputValue {
251 match v {
252 serde_json::Value::Null => CstInputValue::Null,
253 serde_json::Value::Bool(b) => CstInputValue::Bool(*b),
254 serde_json::Value::Number(n) => CstInputValue::Number(n.to_string()),
255 serde_json::Value::String(s) => CstInputValue::String(s.clone()),
256 serde_json::Value::Array(a) => {
257 CstInputValue::Array(a.iter().map(json_to_cst_input).collect())
258 }
259 serde_json::Value::Object(o) => CstInputValue::Object(
260 o.iter()
261 .map(|(k, v)| (k.clone(), json_to_cst_input(v)))
262 .collect(),
263 ),
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use tempfile::NamedTempFile;
271
272 #[test]
273 fn test_parse_json() {
274 let source = r#"{"model": "anthropic/claude-sonnet-4-5"}"#;
275 let handler = JsoncHandler::parse(source).unwrap();
276 let json = handler.to_json_string().unwrap();
277 assert!(json.contains("anthropic/claude-sonnet-4-5"));
278 }
279
280 #[test]
281 fn test_parse_jsonc_with_comments() {
282 let source = r#"{
283 // This is a comment
284 "model": "anthropic/claude-sonnet-4-5",
285 /* Multi-line
286 comment */
287 "autoupdate": true
288 }"#;
289 let handler = JsoncHandler::parse(source).unwrap();
290 let json = handler.to_json_string().unwrap();
291 assert!(!json.contains("//"));
293 assert!(!json.contains("/*"));
294 assert!(json.contains("anthropic/claude-sonnet-4-5"));
295 assert!(json.contains("autoupdate"));
296 }
297
298 #[test]
299 fn test_parse_trailing_commas() {
300 let source = r#"{
301 "model": "anthropic/claude-sonnet-4-5",
302 }"#;
303 let handler = JsoncHandler::parse(source).unwrap();
304 let json = handler.to_json_string().unwrap();
305 assert!(json.contains("anthropic/claude-sonnet-4-5"));
306 }
307
308 #[test]
309 fn test_read_write_roundtrip() {
310 let temp_file = NamedTempFile::new().unwrap();
311 let config = crate::config_core::schema::OpenCodeConfig {
312 schema: Some("https://opencode.ai/config.json".to_string()),
313 model: Some("anthropic/claude-sonnet-4-5".to_string()),
314 autoupdate: Some(crate::config_core::schema::AutoupdateConfig::Bool(true)),
315 ..Default::default()
316 };
317
318 let path = temp_file.path().to_path_buf();
319 write_config(&config, &path).unwrap();
320
321 let read_back: crate::config_core::schema::OpenCodeConfig = read_config(&path).unwrap();
322 assert_eq!(
323 read_back.model,
324 Some("anthropic/claude-sonnet-4-5".to_string())
325 );
326 assert!(matches!(
327 read_back.autoupdate,
328 Some(crate::config_core::schema::AutoupdateConfig::Bool(true))
329 ));
330 }
331
332 #[test]
333 fn test_source_preservation() {
334 let source = r#"{ "model": "anthropic/claude-sonnet-4-5" }"#;
335 let handler = JsoncHandler::parse(source).unwrap();
336 assert_eq!(handler.source(), source);
337 }
338
339 #[test]
340 fn test_write_preserves_comments_on_edit() {
341 use std::io::Write;
342
343 let mut temp_file = NamedTempFile::new().unwrap();
346 let original = "{\n \
347 // keep this comment\n \
348 \"$schema\": \"https://opencode.ai/config.json\",\n \
349 // this comment sits next to a value that changes\n \
350 \"model\": \"anthropic/claude-haiku-4-5\",\n \
351 /* trailing block */\n \
352 \"autoupdate\": true\n\
353 }\n";
354 temp_file.write_all(original.as_bytes()).unwrap();
355 temp_file.flush().unwrap();
356
357 let mut config: crate::config_core::schema::OpenCodeConfig =
359 read_config(temp_file.path()).unwrap();
360 config.model = Some("anthropic/claude-sonnet-4-5".to_string());
361 write_config(&config, temp_file.path()).unwrap();
362
363 let after = std::fs::read_to_string(temp_file.path()).unwrap();
364
365 assert!(after.contains("// keep this comment"));
367 assert!(after.contains("// this comment sits next to a value that changes"));
368 assert!(after.contains("/* trailing block */"));
369 assert!(after.contains("anthropic/claude-sonnet-4-5"));
371 assert!(!after.contains("anthropic/claude-haiku-4-5"));
373 }
374
375 #[test]
376 fn test_write_preserves_comments_on_added_key() {
377 use std::io::Write;
378
379 let mut temp_file = NamedTempFile::new().unwrap();
380 let original = "{\n \
381 // annotation\n \
382 \"model\": \"anthropic/claude-haiku-4-5\"\n\
383 }\n";
384 temp_file.write_all(original.as_bytes()).unwrap();
385 temp_file.flush().unwrap();
386
387 let mut config: crate::config_core::schema::OpenCodeConfig =
388 read_config(temp_file.path()).unwrap();
389 config.small_model = Some("anthropic/claude-haiku-4-5".to_string());
390 write_config(&config, temp_file.path()).unwrap();
391
392 let after = std::fs::read_to_string(temp_file.path()).unwrap();
393 assert!(after.contains("// annotation"));
394 assert!(after.contains("\"smallModel\""));
395 }
396}