config_writer: json5 round-trip editing via json-five
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 <poc@bcachefs.org>
This commit is contained in:
parent
2b632d568b
commit
ac40c2cb98
4 changed files with 350 additions and 0 deletions
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -492,6 +492,7 @@ dependencies = [
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
|
"json-five",
|
||||||
"json5",
|
"json5",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
|
|
@ -1531,6 +1532,16 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "json5"
|
name = "json5"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
|
|
@ -3384,6 +3395,12 @@ version = "2.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-general-category"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ log = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
json5 = "1.3"
|
json5 = "1.3"
|
||||||
|
json-five = "0.3"
|
||||||
|
|
||||||
ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] }
|
ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] }
|
||||||
tui-markdown = { git = "https://github.com/koverstreet/tui-markdown", subdirectory = "tui-markdown" }
|
tui-markdown = { git = "https://github.com/koverstreet/tui-markdown", subdirectory = "tui-markdown" }
|
||||||
|
|
|
||||||
331
src/config_writer.rs
Normal file
331
src/config_writer.rs
Normal file
|
|
@ -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<F: FnOnce(&mut JSONValue) -> 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<JSONKeyValuePair>> {
|
||||||
|
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<JSONValue> {
|
||||||
|
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<F: FnOnce(&mut JSONValue) -> Result<()>>(src: &str, f: F) -> Result<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,7 @@ pub mod subconscious;
|
||||||
|
|
||||||
// Unified configuration
|
// Unified configuration
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod config_writer;
|
||||||
|
|
||||||
// Session state
|
// Session state
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue