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",
|
||||
"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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
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
|
||||
pub mod config;
|
||||
pub mod config_writer;
|
||||
|
||||
// Session state
|
||||
pub mod session;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue