// config_writer.rs — Surgical edits to ~/.consciousness/config.json5 // // Uses json-five's round-trip parser to mutate specific fields while // preserving the surrounding comments, whitespace, and formatting. use std::path::Path; use anyhow::{anyhow, Context as _, Result}; use json_five::rt::parser::{ from_str, JSONKeyValuePair, JSONObjectContext, JSONValue, KeyValuePairContext, }; use crate::config::config_path; /// Read the config, apply `mutate` to the root JSONValue, write it back atomically. fn edit_config Result<()>>(mutate: F) -> Result<()> { let path = config_path(); let src = std::fs::read_to_string(&path) .with_context(|| format!("read {}", path.display()))?; let mut text = from_str(&src) .map_err(|e| anyhow!("parse {}: {}", path.display(), e))?; mutate(&mut text.value)?; write_atomic(&path, &text.to_string()) } fn write_atomic(path: &Path, content: &str) -> Result<()> { let parent = path.parent() .ok_or_else(|| anyhow!("config path has no parent: {}", path.display()))?; let tmp = parent.join(format!( ".{}.tmp", path.file_name().unwrap_or_default().to_string_lossy(), )); std::fs::write(&tmp, content) .with_context(|| format!("write {}", tmp.display()))?; std::fs::rename(&tmp, path) .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?; Ok(()) } /// Match a key JSONValue against a string name. JSON5 allows keys to be /// unquoted identifiers or single/double-quoted strings. fn key_matches(key: &JSONValue, name: &str) -> bool { match key { JSONValue::Identifier(s) | JSONValue::DoubleQuotedString(s) | JSONValue::SingleQuotedString(s) => s == name, _ => false, } } /// Find (or create) a child object under `parent`, returning a mutable borrow /// of its key_value_pairs vector. fn get_or_create_object<'a>( parent: &'a mut JSONValue, section: &str, ) -> Result<&'a mut Vec> { let pairs = match parent { JSONValue::JSONObject { key_value_pairs, .. } => key_value_pairs, _ => return Err(anyhow!("config root is not an object")), }; // Separate the lookup from the mutable borrow we return — needed to // satisfy the borrow checker when we create a new entry. let idx = pairs.iter().position(|kvp| key_matches(&kvp.key, section)); let idx = match idx { Some(i) => i, None => { pairs.push(JSONKeyValuePair { key: JSONValue::Identifier(section.to_string()), value: JSONValue::JSONObject { key_value_pairs: Vec::new(), context: Some(JSONObjectContext { wsc: (String::new(),), }), }, context: Some(KeyValuePairContext { wsc: ( String::from("\n\n "), // whitespace before ':' String::from(" "), // whitespace after ':' String::new(), // whitespace after value Some(String::new()), // whitespace after trailing comma ), }), }); pairs.len() - 1 } }; match &mut pairs[idx].value { JSONValue::JSONObject { key_value_pairs, .. } => Ok(key_value_pairs), _ => Err(anyhow!("config key '{}' is not an object", section)), } } /// Set `section.key` to a literal scalar value (e.g., "1e-7", "42", "true"). /// The literal is parsed as JSON5 so we preserve its source-form on round-trip. pub fn set_scalar(section: &str, key: &str, literal: &str) -> Result<()> { let value = parse_scalar_literal(literal)?; edit_config(|root| { let pairs = get_or_create_object(root, section)?; if let Some(kvp) = pairs.iter_mut().find(|k| key_matches(&k.key, key)) { kvp.value = value; return Ok(()); } pairs.push(JSONKeyValuePair { key: JSONValue::Identifier(key.to_string()), value, context: Some(KeyValuePairContext { wsc: ( String::from("\n "), String::from(" "), String::new(), Some(String::new()), ), }), }); Ok(()) }) } /// Parse a scalar literal by round-tripping it through json-five. Keeps us /// consistent with whatever scalars the library considers valid (hex, /// exponents, Infinity, etc.). fn parse_scalar_literal(literal: &str) -> Result { let text = from_str(literal) .map_err(|e| anyhow!("parse literal {:?}: {}", literal, e))?; match text.value { JSONValue::JSONObject { .. } | JSONValue::JSONArray { .. } => { Err(anyhow!("set_scalar only accepts scalar literals, got {:?}", literal)) } v => Ok(v), } } /// Convenience: set `learn.threshold` to the given f64. pub fn set_learn_threshold(value: f64) -> Result<()> { // {:e} gives the minimal scientific notation that preserves the value. set_scalar("learn", "threshold", &format!("{:e}", value))?; crate::config::update_app(|app| app.learn.threshold = value); Ok(()) } /// Convenience: set `learn.generate_alternates` to the given bool. pub fn set_learn_generate_alternates(value: bool) -> Result<()> { set_scalar("learn", "generate_alternates", if value { "true" } else { "false" })?; crate::config::update_app(|app| app.learn.generate_alternates = value); Ok(()) } #[cfg(test)] mod tests { use super::*; // In-memory variant of set_scalar — used to test the mutation logic // without touching disk. fn set_scalar_inline( root: &mut JSONValue, section: &str, key: &str, literal: &str, ) -> Result<()> { let value = parse_scalar_literal(literal)?; let pairs = get_or_create_object(root, section)?; if let Some(kvp) = pairs.iter_mut().find(|k| key_matches(&k.key, key)) { kvp.value = value; return Ok(()); } pairs.push(JSONKeyValuePair { key: JSONValue::Identifier(key.to_string()), value, context: Some(KeyValuePairContext { wsc: ( String::from("\n "), String::from(" "), String::new(), Some(String::new()), ), }), }); Ok(()) } fn edit_str Result<()>>(src: &str, f: F) -> Result { let mut text = from_str(src).map_err(|e| anyhow!("{}", e))?; f(&mut text.value)?; Ok(text.to_string()) } #[test] fn replaces_existing_scalar() { let src = r#"{ // threshold for learning learn: { threshold: 0.001, // the old value }, }"#; let out = edit_str(src, |root| { set_scalar_inline(root, "learn", "threshold", "1e-7") }).unwrap(); assert!(out.contains("1e-7"), "output: {}", out); assert!(out.contains("// threshold for learning")); assert!(out.contains("// the old value")); assert!(!out.contains("0.001")); } #[test] fn creates_missing_section() { let src = r#"{ // comment memory: { user_name: "Kent" }, }"#; let out = edit_str(src, |root| { set_scalar_inline(root, "learn", "threshold", "1e-7") }).unwrap(); assert!(out.contains("learn")); assert!(out.contains("1e-7")); assert!(out.contains("// comment")); assert!(out.contains(r#"user_name: "Kent""#)); } #[test] fn preserves_comments_in_siblings() { let src = r#"{ memory: { // sensitive setting user_name: "Kent", // name }, learn: { threshold: 0.5, }, }"#; let out = edit_str(src, |root| { set_scalar_inline(root, "learn", "threshold", "1e-9") }).unwrap(); assert!(out.contains("// sensitive setting")); assert!(out.contains("// name")); assert!(out.contains("1e-9")); assert!(!out.contains("0.5")); } #[test] fn adds_key_to_existing_empty_section() { let src = r#"{ learn: {}, }"#; let out = edit_str(src, |root| { set_scalar_inline(root, "learn", "threshold", "42") }).unwrap(); assert!(out.contains("threshold"), "output: {}", out); assert!(out.contains("42")); } #[test] fn realistic_config_adds_learn_section() { // Mirrors the shape of ~/.consciousness/config.json5 — multiple // sections, comments, mixed tab/space indent, trailing commas. let src = r#"{ deepinfra: { api_key: "bcachefs-agents-2026", base_url: "http://example/v1", }, // Named models models: { "27b": { backend: "deepinfra", model_id: "Qwen/Qwen3.5-27B", }, }, default_model: "27b", memory: { user_name: "Kent", // Active agent types agent_types: ["linker", "organize"], }, compaction: { hard_threshold_pct: 90, }, }"#; let out = edit_str(src, |root| { set_scalar_inline(root, "learn", "threshold", "1e-7") }).unwrap(); // Core assertions: comments and sibling sections survive. assert!(out.contains(r#"api_key: "bcachefs-agents-2026""#)); assert!(out.contains("// Named models")); assert!(out.contains("// Active agent types")); assert!(out.contains(r#"user_name: "Kent""#)); assert!(out.contains("hard_threshold_pct: 90")); // New section added. assert!(out.contains("learn")); assert!(out.contains("1e-7")); // Parse result should parse back without error (real json5 parser). let reparsed: serde_json::Value = json5::from_str(&out) .expect("mutated output must be valid JSON5"); let threshold = reparsed.pointer("/learn/threshold").expect("learn.threshold exists"); assert_eq!(threshold.as_f64(), Some(1e-7)); } #[test] fn realistic_config_updates_existing_threshold() { let src = r#"{ learn: { // The divergence threshold threshold: 0.001, }, memory: { user_name: "Kent" }, }"#; let out = edit_str(src, |root| { set_scalar_inline(root, "learn", "threshold", "5e-8") }).unwrap(); assert!(out.contains("5e-8")); assert!(!out.contains("0.001")); assert!(out.contains("// The divergence threshold")); let reparsed: serde_json::Value = json5::from_str(&out).unwrap(); assert_eq!(reparsed.pointer("/learn/threshold").and_then(|v| v.as_f64()), Some(5e-8)); } #[test] fn roundtrip_stable_without_change() { let src = r#"{ // heading a: 1, b: { c: 2 }, // inline }"#; let text = from_str(src).unwrap(); assert_eq!(text.to_string(), src); } }