Skip to main content

opencode_provider_manager/config_core/
jsonc.rs

1//! JSONC (JSON with Comments) parser and serializer.
2//!
3//! Handles reading and writing JSONC files while preserving comments,
4//! trailing commas, and formatting, using `jsonc-parser`'s CST API.
5//!
6//! Strategy:
7//! - Read: parse JSONC to clean JSON for serde deserialization.
8//! - Write: if the destination file already has JSONC source, reconcile the
9//!   new value against the existing CST node-by-node so comments and
10//!   structural formatting around unchanged keys are preserved. When a key's
11//!   value changes shape (scalar → object, array, etc.), the whole subtree is
12//!   replaced. New destinations fall back to `serde_json::to_string_pretty`.
13
14use 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
21/// Handler for JSONC file operations.
22///
23/// Currently parses JSONC and strips comments for serde deserialization.
24/// Full comment-preserving round-trip editing will require upgrading
25/// the jsonc-parser version or implementing custom CST handling.
26pub struct JsoncHandler {
27    /// The original source text (for preservation when possible).
28    source: String,
29}
30
31impl JsoncHandler {
32    /// Parse a JSONC string.
33    pub fn parse(source: &str) -> Result<Self> {
34        // Validate that it's parseable JSONC
35        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    /// Read and parse a JSONC file.
44    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    /// Get the original source text.
52    pub fn source(&self) -> &str {
53        &self.source
54    }
55
56    /// Extract clean JSON from the JSONC source (strips comments and trailing commas).
57    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    /// Write the JSONC content back to a file.
72    /// For now, this writes the original source (preserving comments in existing files).
73    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
82/// Convert jsonc_parser's JsonValue to serde_json::Value.
83fn 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            // Parse the number string
102            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
117/// Read a config file (JSONC or JSON) and return clean JSON for deserialization.
118pub 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
127/// Read a config file and deserialize into the target type.
128///
129/// Supports both JSON and JSONC files.
130pub 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
136/// Serialize a config value and write it to a file atomically.
137///
138/// Uses a temp file + rename to prevent partial writes on crash.
139/// If the destination already contains valid JSONC, the existing CST is
140/// reconciled with the new value so that comments and formatting around
141/// unchanged keys are preserved. Otherwise, a freshly formatted JSON
142/// document is written.
143pub 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
153/// Compute the output content, reconciling with existing JSONC if possible.
154fn compute_output(path: &Path, new_value: &serde_json::Value) -> Result<String> {
155    // Try comment-preserving round-trip when an existing source is present.
156    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
168/// Write content atomically: write to a temp file in the same directory,
169/// then rename. This prevents partial/corrupted files on crash.
170fn 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    // Generate temp file name in the same directory (required for atomic rename)
179    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    // Write to temp file
187    std::fs::write(&temp_path, content)?;
188
189    // Atomic rename (on Windows, replaces existing; on POSIX, atomically swaps)
190    match std::fs::rename(&temp_path, path) {
191        Ok(()) => Ok(()),
192        Err(e) => {
193            // Clean up temp file on failure
194            let _ = std::fs::remove_file(&temp_path);
195            Err(ConfigError::Io(e))
196        }
197    }
198}
199
200/// Reconcile the root CST with the new serde value, preserving structural
201/// formatting and comments wherever the shape still matches.
202fn 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    // Snapshot existing properties (Rc clones) so we can iterate while mutating.
214    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    // Update or add keys present in the new map.
224    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    // Remove keys that no longer exist in the new map.
233    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        // Comments should be stripped in JSON output
292        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        // Start with a JSONC file that has comments around keys that will stay
344        // intact as well as around a key whose value we will change.
345        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        // Read, mutate a single scalar, write back.
358        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        // All original comments survive.
366        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        // The new value landed.
370        assert!(after.contains("anthropic/claude-sonnet-4-5"));
371        // Old value is gone.
372        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}