use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
static MANUAL_STEPS: AtomicUsize = AtomicUsize::new(0);
pub(crate) fn reset_manual_steps() {
MANUAL_STEPS.store(0, Ordering::SeqCst);
}
pub(crate) fn manual_steps() -> usize {
MANUAL_STEPS.load(Ordering::SeqCst)
}
pub(crate) enum Style {
Pretty,
Compact,
}
enum Loaded {
Strict(Value),
Lenient(Value),
Unusable(String),
}
fn load(path: &Path) -> Result<Loaded> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Loaded::Strict(json!({}))),
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
return Ok(Loaded::Unusable("is not UTF-8 text".into()))
}
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let raw = raw.strip_prefix('\u{feff}').unwrap_or(&raw);
if raw.trim().is_empty() {
return Ok(Loaded::Strict(json!({})));
}
Ok(match serde_json::from_str::<Value>(raw) {
Ok(cfg) if cfg.is_object() => Loaded::Strict(cfg),
Ok(_) => Loaded::Unusable("is not a JSON object".into()),
Err(e) => match serde_json::from_str::<Value>(&strip_jsonc(raw)) {
Ok(cfg) if cfg.is_object() => Loaded::Lenient(cfg),
_ => Loaded::Unusable(format!("is not valid JSON ({e})")),
},
})
}
pub(crate) fn parse_lenient(raw: &str) -> Option<Value> {
let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
serde_json::from_str(raw)
.ok()
.or_else(|| serde_json::from_str(&strip_jsonc(raw)).ok())
}
pub(crate) fn merge(
path: &Path,
add: &Value,
present: impl Fn(&Value) -> bool,
apply: impl FnOnce(&mut Value) -> bool,
style: Style,
) -> Result<bool> {
match load(path)? {
Loaded::Strict(mut cfg) => {
if !apply(&mut cfg) {
manual(path, "has a key bctx does not expect", add, Hint::Shape);
return Ok(false);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = match style {
Style::Pretty => serde_json::to_string_pretty(&cfg)?,
Style::Compact => serde_json::to_string(&cfg)?,
};
std::fs::write(path, text)?;
Ok(true)
}
Loaded::Lenient(cfg) if present(&cfg) => {
println!(
" ✓ {} already lists bctx (it has comments, so bctx left it as is)",
path.display()
);
Ok(true)
}
Loaded::Lenient(_) => {
manual(path, "has comments or trailing commas", add, Hint::Comments);
Ok(false)
}
Loaded::Unusable(why) => {
manual(path, &why, add, Hint::Fix);
Ok(false)
}
}
}
enum Hint {
Comments,
Shape,
Fix,
}
fn manual(path: &Path, why: &str, add: &Value, hint: Hint) {
MANUAL_STEPS.fetch_add(1, Ordering::SeqCst);
let keys: Vec<String> = add
.as_object()
.map(|m| m.keys().map(|k| format!("\"{k}\"")).collect())
.unwrap_or_default();
println!(" ! {} {why}; bctx left it untouched.", path.display());
let instead = match hint {
Hint::Comments => "or remove its comments and trailing commas",
Hint::Shape => "or fix the shape of that key",
Hint::Fix => "or make it valid JSON",
};
println!(
" Merge this into it by hand (into {} as bctx's entry), {instead}",
keys.join(" / ")
);
println!(" and run `bctx init` again:");
for line in serde_json::to_string_pretty(add)
.unwrap_or_default()
.lines()
{
println!(" {line}");
}
}
pub(crate) fn set_bctx(cfg: &mut Value, key: &str, entry: Value) -> bool {
let Some(root) = cfg.as_object_mut() else {
return false;
};
let slot = root.entry(key).or_insert_with(|| json!({}));
if slot.is_null() {
*slot = json!({});
}
match slot.as_object_mut() {
Some(map) => {
map.insert("bctx".into(), entry);
true
}
None => false,
}
}
pub(crate) fn has_bctx(cfg: &Value, key: &str) -> bool {
cfg.get(key).and_then(|v| v.get("bctx")).is_some()
}
pub(crate) fn array_at<'a>(cfg: &'a mut Value, key: &str) -> Option<&'a mut Vec<Value>> {
let root = cfg.as_object_mut()?;
let slot = root.entry(key).or_insert_with(|| json!([]));
if slot.is_null() {
*slot = json!([]);
}
slot.as_array_mut()
}
pub(crate) fn object_at<'a>(cfg: &'a mut Value, key: &str) -> Option<&'a mut Value> {
let root = cfg.as_object_mut()?;
let slot = root.entry(key).or_insert_with(|| json!({}));
if slot.is_null() {
*slot = json!({});
}
slot.is_object().then_some(slot)
}
pub(crate) fn strip_jsonc(raw: &str) -> String {
let mut out: Vec<char> = Vec::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
let mut in_string = false;
while let Some(c) = chars.next() {
if in_string {
out.push(c);
if c == '\\' {
if let Some(escaped) = chars.next() {
out.push(escaped);
}
} else if c == '"' {
in_string = false;
}
continue;
}
match c {
'"' => {
in_string = true;
out.push(c);
}
'/' if chars.peek() == Some(&'/') => {
for skipped in chars.by_ref() {
if skipped == '\n' {
out.push('\n');
break;
}
}
}
'/' if chars.peek() == Some(&'*') => {
chars.next();
let mut prev = '\0';
for skipped in chars.by_ref() {
if prev == '*' && skipped == '/' {
break;
}
prev = skipped;
}
out.push(' ');
}
'}' | ']' => {
let last = out.iter().rposition(|ch| !ch.is_whitespace());
if let Some(i) = last {
if out[i] == ',' {
out.remove(i);
}
}
out.push(c);
}
_ => out.push(c),
}
}
out.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_comments_and_trailing_commas_but_not_string_contents() {
let raw = r#"{
// line comment
"url": "http://example.com/a//b", /* block */
"quote": "a \" // not a comment",
"list": [1, 2,],
}"#;
let v: Value = serde_json::from_str(&strip_jsonc(raw)).expect("parses after stripping");
assert_eq!(v["url"], "http://example.com/a//b");
assert_eq!(v["quote"], "a \" // not a comment");
assert_eq!(v["list"], json!([1, 2]));
}
#[test]
fn a_block_comment_separates_tokens() {
assert!(serde_json::from_str::<Value>(&strip_jsonc("[1/**/2]")).is_err());
assert_eq!(
strip_jsonc("{\"a\":/* x */1}").replace(' ', ""),
"{\"a\":1}"
);
}
#[test]
fn plain_json_is_unchanged_by_stripping() {
let raw = r#"{"a": [1, {"b": "c,"}], "d": "e]"}"#;
assert_eq!(strip_jsonc(raw), raw);
}
#[test]
fn set_bctx_refuses_a_key_of_the_wrong_shape() {
let mut cfg = json!({ "mcpServers": [] });
assert!(!set_bctx(&mut cfg, "mcpServers", json!({})));
assert_eq!(cfg, json!({ "mcpServers": [] }));
let mut cfg = json!({ "mcpServers": null });
assert!(set_bctx(
&mut cfg,
"mcpServers",
json!({ "command": "bctx" })
));
assert_eq!(cfg["mcpServers"]["bctx"]["command"], "bctx");
}
}