Previously when append_kvp created a new section or added a key, it
stuffed the "\n " separator into the new kvp's wsc.0 (the whitespace
between its own key and colon) instead of the prior kvp's wsc.3 (the
whitespace after the prior trailing comma). Result looked like:
lsp_servers: [...],
learn
: {generate_alternates
: true,},}
The writer also didn't set any interior whitespace on the new section's
JSONObjectContext, so everything crammed onto one line — `{key: val,}`
compact, not `{\n key: val,\n}` multi-line.
Rewrote the appender as append_kvp_pretty(object, key, value,
inner_indent, outer_indent):
- separator between kvps goes in the prior kvp's wsc.3, or if we're the
first kvp in a fresh object, in the object's own wsc.0 (after its
opening `{`)
- new kvp's wsc.3 carries `,\n<outer_indent>` so the parent's closing
`}` lands correctly indented
- interior indent vs outer indent are both explicit, so we don't have
to rewrite this logic every time we add another nesting level
New tests: new_section_exact_multiline_layout asserts byte-exact
output shape; new_section_and_key_format_cleanly verifies no key wraps
to the next line. Prior tests just substring-matched and happily passed
on the broken output — that's why this shipped in the first place.
Also: dropped the json5 crate dependency. json-five's serde feature
(default) provides the same from_str / to_string API. One fewer
dependency, and the two were doing the same job.
Co-Authored-By: Proof of Concept <poc@bcachefs.org>
448 lines
14 KiB
Rust
448 lines
14 KiB
Rust
// 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.
|
|
/// Append a new kvp to `object`, setting whitespace so the output is
|
|
/// multi-line with the given indentation:
|
|
///
|
|
/// ```text
|
|
/// {<newline><inner_indent>first_key: first_val,<newline><outer_indent>}
|
|
/// ```
|
|
///
|
|
/// 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<usize> {
|
|
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<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))?;
|
|
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<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 = 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);
|
|
}
|
|
}
|