From ac40c2cb986681026d1f972d2977662651fa364a Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Thu, 16 Apr 2026 11:48:47 -0400 Subject: [PATCH] config_writer: json5 round-trip editing via json-five MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surgical edits to ~/.consciousness/config.json5 that preserve comments, whitespace, trailing commas, and unquoted identifier keys on round-trip. Uses json-five's rt::parser module — a real JSON5 parser with AST mutation + faithful serialization back. set_scalar(section, key, literal) locates or creates the target, replaces the value; set_learn_threshold is a convenience for the common F-screen use case. Co-Authored-By: Proof of Concept --- Cargo.lock | 17 +++ Cargo.toml | 1 + src/config_writer.rs | 331 +++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 4 files changed, 350 insertions(+) create mode 100644 src/config_writer.rs diff --git a/Cargo.lock b/Cargo.lock index eb53ed5..b474289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,6 +492,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "json-five", "json5", "libc", "log", @@ -1531,6 +1532,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-five" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865f2d01a4549c1fd8c60640c03ae5249eb374cd8cde8b905628d4b1af95c87c" +dependencies = [ + "serde", + "unicode-general-category", +] + [[package]] name = "json5" version = "1.3.1" @@ -3384,6 +3395,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index c253bd7..a722ad2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" json5 = "1.3" +json-five = "0.3" ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] } tui-markdown = { git = "https://github.com/koverstreet/tui-markdown", subdirectory = "tui-markdown" } diff --git a/src/config_writer.rs b/src/config_writer.rs new file mode 100644 index 0000000..65401b9 --- /dev/null +++ b/src/config_writer.rs @@ -0,0 +1,331 @@ +// 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)) +} + +#[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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1a71735..e6411e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,7 @@ pub mod subconscious; // Unified configuration pub mod config; +pub mod config_writer; // Session state pub mod session;