// 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. /// Append a new kvp to `object`, setting whitespace so the output is /// multi-line with the given indentation: /// /// ```text /// {first_key: first_val,} /// ``` /// /// If `object` already has kvps, the separator between the last one and /// ours goes in the prior kvp's wsc.3. If we're the first kvp, the /// lead-in after `{` goes in the object's own wsc.0. fn append_kvp_pretty( object: &mut JSONValue, key: JSONValue, value: JSONValue, inner_indent: &str, outer_indent: &str, ) -> Result<()> { let (pairs, ctx) = match object { JSONValue::JSONObject { key_value_pairs, context } => { let ctx = context.get_or_insert_with(|| JSONObjectContext { wsc: (String::new(),), }); (key_value_pairs, ctx) } _ => return Err(anyhow!("not an object")), }; if pairs.is_empty() { ctx.wsc.0 = format!("\n{}", inner_indent); } else { let prev = pairs.last_mut().unwrap(); let prev_ctx = prev.context.get_or_insert_with(|| KeyValuePairContext { wsc: (String::new(), String::from(" "), String::new(), None), }); prev_ctx.wsc.3 = Some(format!("\n{}", inner_indent)); } pairs.push(JSONKeyValuePair { key, value, context: Some(KeyValuePairContext { wsc: ( String::new(), String::from(" "), String::new(), Some(format!("\n{}", outer_indent)), ), }), }); Ok(()) } /// Find or create a child object under `parent`. Returns the index of /// the kvp in parent's key_value_pairs so the caller can re-borrow /// afterward. fn get_or_create_object_idx( parent: &mut JSONValue, section: &str, inner_indent: &str, outer_indent: &str, ) -> Result { let existing = match parent { JSONValue::JSONObject { key_value_pairs, .. } => { key_value_pairs.iter() .position(|kvp| key_matches(&kvp.key, section)) } _ => return Err(anyhow!("config root is not an object")), }; if let Some(i) = existing { return Ok(i); } append_kvp_pretty( parent, JSONValue::Identifier(section.to_string()), JSONValue::JSONObject { key_value_pairs: Vec::new(), context: Some(JSONObjectContext { wsc: (String::new(),) }), }, inner_indent, outer_indent, )?; match parent { JSONValue::JSONObject { key_value_pairs, .. } => Ok(key_value_pairs.len() - 1), _ => unreachable!(), } } /// 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| { // New top-level sections sit at column 4 (inside root `{`), // and the root's closing `}` sits at column 0. let section_idx = get_or_create_object_idx(root, section, " ", "")?; let section_value = match root { JSONValue::JSONObject { key_value_pairs, .. } => { &mut key_value_pairs[section_idx].value } _ => unreachable!(), }; // Update in place if the key already exists. if let JSONValue::JSONObject { key_value_pairs, .. } = section_value { if let Some(kvp) = key_value_pairs.iter_mut() .find(|k| key_matches(&k.key, key)) { kvp.value = value; return Ok(()); } } // Append a new kvp. Inner keys sit at column 8, the section's // closing `}` sits at column 4. append_kvp_pretty( section_value, JSONValue::Identifier(key.to_string()), value, " ", " ", ) }) } /// 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 section_idx = get_or_create_object_idx(root, section, " ", "")?; let section_value = match root { JSONValue::JSONObject { key_value_pairs, .. } => { &mut key_value_pairs[section_idx].value } _ => unreachable!(), }; if let JSONValue::JSONObject { key_value_pairs, .. } = section_value { if let Some(kvp) = key_value_pairs.iter_mut() .find(|k| key_matches(&k.key, key)) { kvp.value = value; return Ok(()); } } append_kvp_pretty( section_value, JSONValue::Identifier(key.to_string()), value, " ", " ", ) } 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 = json_five::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 = json_five::from_str(&out).unwrap(); assert_eq!(reparsed.pointer("/learn/threshold").and_then(|v| v.as_f64()), Some(5e-8)); } #[test] fn new_section_exact_multiline_layout() { let src = "{\n a: 1,\n}"; let out = edit_str(src, |root| { set_scalar_inline(root, "learn", "generate_alternates", "true")?; set_scalar_inline(root, "learn", "threshold", "1e-7") }).unwrap(); let expected = "\ { a: 1, learn: { generate_alternates: true, threshold: 1e-7, }, }"; assert_eq!(out, expected, "\n--- got ---\n{}\n--- want ---\n{}\n", out, expected); } #[test] fn new_section_and_key_format_cleanly() { // The kind of config we actually have in ~/.consciousness // (top-level sections separated by blank lines, 4-space indent // for keys within each section). Appending a fresh `learn` // section with one key should land cleanly, not as // `learn\n\n :{key\n :value}`. let src = "{\n memory: {\n user_name: \"Kent\",\n },\n}"; let out = edit_str(src, |root| { set_scalar_inline(root, "learn", "generate_alternates", "true") }).unwrap(); // No stray key-to-colon-on-next-line anywhere. assert!(!out.contains("learn\n"), "learn key wraps: {}", out); assert!(!out.contains("generate_alternates\n"), "inner key wraps: {}", out); // The output should reparse. let v: serde_json::Value = json_five::from_str(&out).unwrap(); assert_eq!( v.pointer("/learn/generate_alternates").and_then(|x| x.as_bool()), Some(true), "output: {}", out, ); } #[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); } }