use super::error::{ConfigError, Result};
use jsonc_parser::ParseOptions;
use jsonc_parser::cst::{
CstContainerNode, CstInputValue, CstNode, CstObject, CstObjectProp, CstRootNode,
};
use std::path::Path;
pub struct JsoncHandler {
source: String,
}
impl JsoncHandler {
pub fn parse(source: &str) -> Result<Self> {
let _ = jsonc_parser::parse_to_value(source, &Default::default())
.map_err(|e| ConfigError::JsoncParse(format!("{e:?}")))?;
Ok(Self {
source: source.to_string(),
})
}
pub fn read_file(path: &Path) -> Result<Self> {
let source = std::fs::read_to_string(path).map_err(|_| ConfigError::FileNotFound {
path: path.display().to_string(),
})?;
Self::parse(&source)
}
pub fn source(&self) -> &str {
&self.source
}
pub fn to_json_string(&self) -> Result<String> {
let value = jsonc_parser::parse_to_value(&self.source, &Default::default())
.map_err(|e| ConfigError::JsoncParse(format!("{e:?}")))?;
match value {
Some(v) => {
let sv = json_value_to_serde(&v)?;
serde_json::to_string_pretty(&sv)
.map_err(|e| ConfigError::JsoncParse(format!("{e}")))
}
None => Err(ConfigError::JsoncParse("Empty JSONC document".to_string())),
}
}
pub fn write_file(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &self.source)?;
Ok(())
}
}
fn json_value_to_serde(value: &jsonc_parser::JsonValue) -> Result<serde_json::Value> {
match value {
jsonc_parser::JsonValue::Object(obj) => {
let mut map = serde_json::Map::new();
for (key, val) in obj.clone().into_iter() {
map.insert(key, json_value_to_serde(&val)?);
}
Ok(serde_json::Value::Object(map))
}
jsonc_parser::JsonValue::Array(arr) => {
let mut vec = Vec::new();
for item in arr.iter() {
vec.push(json_value_to_serde(item)?);
}
Ok(serde_json::Value::Array(vec))
}
jsonc_parser::JsonValue::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
jsonc_parser::JsonValue::Number(n) => {
if let Ok(i) = n.parse::<i64>() {
Ok(serde_json::Value::Number(i.into()))
} else if let Ok(f) = n.parse::<f64>() {
serde_json::Number::from_f64(f)
.map(serde_json::Value::Number)
.ok_or_else(|| ConfigError::JsoncParse(format!("Invalid number: {n}")))
} else {
Err(ConfigError::JsoncParse(format!("Invalid number: {n}")))
}
}
jsonc_parser::JsonValue::String(s) => Ok(serde_json::Value::String(s.to_string())),
jsonc_parser::JsonValue::Null => Ok(serde_json::Value::Null),
}
}
pub fn read_config_to_json(path: &Path) -> Result<String> {
let source = std::fs::read_to_string(path).map_err(|_| ConfigError::FileNotFound {
path: path.display().to_string(),
})?;
let handler = JsoncHandler::parse(&source)?;
handler.to_json_string()
}
pub fn read_config<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
let json_str = read_config_to_json(path)?;
let value: T = serde_json::from_str(&json_str)?;
Ok(value)
}
pub fn write_config<T: serde::Serialize>(value: &T, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let new_value = serde_json::to_value(value)?;
let content = compute_output(path, &new_value)?;
atomic_write(path, &content)
}
fn compute_output(path: &Path, new_value: &serde_json::Value) -> Result<String> {
if path.exists() {
if let Ok(existing) = std::fs::read_to_string(path) {
if let Ok(root) = CstRootNode::parse(&existing, &ParseOptions::default()) {
reconcile_root(&root, new_value);
return Ok(root.to_string());
}
}
}
Ok(serde_json::to_string_pretty(new_value)?)
}
fn atomic_write(path: &Path, content: &str) -> Result<()> {
let parent = path.parent().ok_or_else(|| {
ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"path has no parent directory",
))
})?;
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let temp_name = format!(".{file_name}.tmp.{}", std::process::id());
let temp_path = parent.join(&temp_name);
std::fs::write(&temp_path, content)?;
match std::fs::rename(&temp_path, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&temp_path);
Err(ConfigError::Io(e))
}
}
}
fn reconcile_root(root: &CstRootNode, new_value: &serde_json::Value) {
match (root.value(), new_value) {
(
Some(CstNode::Container(CstContainerNode::Object(obj))),
serde_json::Value::Object(map),
) => reconcile_object(&obj, map),
_ => root.set_value(json_to_cst_input(new_value)),
}
}
fn reconcile_object(obj: &CstObject, new: &serde_json::Map<String, serde_json::Value>) {
let existing: Vec<(String, CstObjectProp)> = obj
.properties()
.into_iter()
.filter_map(|prop| {
let name = prop.name()?.decoded_value().ok()?;
Some((name, prop))
})
.collect();
for (key, new_val) in new.iter() {
if let Some(prop) = obj.get(key) {
reconcile_prop(&prop, new_val);
} else {
obj.append(key, json_to_cst_input(new_val));
}
}
for (key, prop) in existing {
if !new.contains_key(&key) {
prop.remove();
}
}
}
fn reconcile_prop(prop: &CstObjectProp, new: &serde_json::Value) {
match (prop.value(), new) {
(
Some(CstNode::Container(CstContainerNode::Object(obj))),
serde_json::Value::Object(map),
) => reconcile_object(&obj, map),
_ => prop.set_value(json_to_cst_input(new)),
}
}
fn json_to_cst_input(v: &serde_json::Value) -> CstInputValue {
match v {
serde_json::Value::Null => CstInputValue::Null,
serde_json::Value::Bool(b) => CstInputValue::Bool(*b),
serde_json::Value::Number(n) => CstInputValue::Number(n.to_string()),
serde_json::Value::String(s) => CstInputValue::String(s.clone()),
serde_json::Value::Array(a) => {
CstInputValue::Array(a.iter().map(json_to_cst_input).collect())
}
serde_json::Value::Object(o) => CstInputValue::Object(
o.iter()
.map(|(k, v)| (k.clone(), json_to_cst_input(v)))
.collect(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_parse_json() {
let source = r#"{"model": "anthropic/claude-sonnet-4-5"}"#;
let handler = JsoncHandler::parse(source).unwrap();
let json = handler.to_json_string().unwrap();
assert!(json.contains("anthropic/claude-sonnet-4-5"));
}
#[test]
fn test_parse_jsonc_with_comments() {
let source = r#"{
// This is a comment
"model": "anthropic/claude-sonnet-4-5",
/* Multi-line
comment */
"autoupdate": true
}"#;
let handler = JsoncHandler::parse(source).unwrap();
let json = handler.to_json_string().unwrap();
assert!(!json.contains("//"));
assert!(!json.contains("/*"));
assert!(json.contains("anthropic/claude-sonnet-4-5"));
assert!(json.contains("autoupdate"));
}
#[test]
fn test_parse_trailing_commas() {
let source = r#"{
"model": "anthropic/claude-sonnet-4-5",
}"#;
let handler = JsoncHandler::parse(source).unwrap();
let json = handler.to_json_string().unwrap();
assert!(json.contains("anthropic/claude-sonnet-4-5"));
}
#[test]
fn test_read_write_roundtrip() {
let temp_file = NamedTempFile::new().unwrap();
let config = crate::config_core::schema::OpenCodeConfig {
schema: Some("https://opencode.ai/config.json".to_string()),
model: Some("anthropic/claude-sonnet-4-5".to_string()),
autoupdate: Some(crate::config_core::schema::AutoupdateConfig::Bool(true)),
..Default::default()
};
let path = temp_file.path().to_path_buf();
write_config(&config, &path).unwrap();
let read_back: crate::config_core::schema::OpenCodeConfig = read_config(&path).unwrap();
assert_eq!(
read_back.model,
Some("anthropic/claude-sonnet-4-5".to_string())
);
assert!(matches!(
read_back.autoupdate,
Some(crate::config_core::schema::AutoupdateConfig::Bool(true))
));
}
#[test]
fn test_source_preservation() {
let source = r#"{ "model": "anthropic/claude-sonnet-4-5" }"#;
let handler = JsoncHandler::parse(source).unwrap();
assert_eq!(handler.source(), source);
}
#[test]
fn test_write_preserves_comments_on_edit() {
use std::io::Write;
let mut temp_file = NamedTempFile::new().unwrap();
let original = "{\n \
// keep this comment\n \
\"$schema\": \"https://opencode.ai/config.json\",\n \
// this comment sits next to a value that changes\n \
\"model\": \"anthropic/claude-haiku-4-5\",\n \
/* trailing block */\n \
\"autoupdate\": true\n\
}\n";
temp_file.write_all(original.as_bytes()).unwrap();
temp_file.flush().unwrap();
let mut config: crate::config_core::schema::OpenCodeConfig =
read_config(temp_file.path()).unwrap();
config.model = Some("anthropic/claude-sonnet-4-5".to_string());
write_config(&config, temp_file.path()).unwrap();
let after = std::fs::read_to_string(temp_file.path()).unwrap();
assert!(after.contains("// keep this comment"));
assert!(after.contains("// this comment sits next to a value that changes"));
assert!(after.contains("/* trailing block */"));
assert!(after.contains("anthropic/claude-sonnet-4-5"));
assert!(!after.contains("anthropic/claude-haiku-4-5"));
}
#[test]
fn test_write_preserves_comments_on_added_key() {
use std::io::Write;
let mut temp_file = NamedTempFile::new().unwrap();
let original = "{\n \
// annotation\n \
\"model\": \"anthropic/claude-haiku-4-5\"\n\
}\n";
temp_file.write_all(original.as_bytes()).unwrap();
temp_file.flush().unwrap();
let mut config: crate::config_core::schema::OpenCodeConfig =
read_config(temp_file.path()).unwrap();
config.small_model = Some("anthropic/claude-haiku-4-5".to_string());
write_config(&config, temp_file.path()).unwrap();
let after = std::fs::read_to_string(temp_file.path()).unwrap();
assert!(after.contains("// annotation"));
assert!(after.contains("\"smallModel\""));
}
}