use serde_json::Value;
use super::{WriteAction, WriteResult};
use crate::core::editor_registry::types::EditorTarget;
pub(super) fn toml_quote(value: &str) -> String {
if value.contains('\\') {
format!("'{value}'")
} else {
format!("\"{value}\"")
}
}
pub fn auto_approve_tools() -> Vec<&'static str> {
vec![
"ctx_read",
"ctx_shell",
"ctx_search",
"ctx_tree",
"ctx_overview",
"ctx_preload",
"ctx_compress",
"ctx_metrics",
"ctx_session",
"ctx_knowledge",
"ctx_agent",
"ctx_share",
"ctx_analyze",
"ctx_benchmark",
"ctx_cache",
"ctx_discover",
"ctx_smart_read",
"ctx_delta",
"ctx_edit",
"ctx_dedup",
"ctx_fill",
"ctx_intent",
"ctx_response",
"ctx_context",
"ctx_graph",
"ctx_multi_read",
"ctx_semantic_search",
"ctx_symbol",
"ctx_outline",
"ctx_callgraph",
"ctx_refactor",
"ctx_routes",
"ctx_cost",
"ctx_heatmap",
"ctx_gain",
"ctx_expand",
"ctx_task",
"ctx_impact",
"ctx_architecture",
"ctx_workflow",
"ctx_review",
"ctx_pack",
"ctx_index",
"ctx_artifacts",
"ctx_smells",
"ctx_proof",
"ctx_verify",
"ctx_execute",
"ctx_handoff",
"ctx_feedback",
"ctx_control",
"ctx_plan",
"ctx_compile",
"ctx_discover_tools",
"ctx_provider",
"ctx_radar",
"ctx_retrieve",
"ctx_compress_memory",
"ctx_load_tools",
"ctx",
]
}
pub(super) fn lean_ctx_server_entry(
binary: &str,
data_dir: &str,
include_auto_approve: bool,
) -> Value {
let mut entry = serde_json::json!({
"command": binary,
"env": {
"LEAN_CTX_DATA_DIR": data_dir
}
});
if include_auto_approve {
entry["autoApprove"] = serde_json::json!(auto_approve_tools());
}
entry
}
pub(super) fn lean_ctx_server_entry_with_instructions(
binary: &str,
data_dir: &str,
include_auto_approve: bool,
agent_key: &str,
) -> Value {
let mut entry = lean_ctx_server_entry(binary, data_dir, include_auto_approve);
let mode = crate::core::rules_canonical::Mode::from_hook_mode(
&crate::hooks::recommend_hook_mode(agent_key),
);
let instructions = crate::core::rules_canonical::mcp_instructions(mode);
let constraints = crate::core::client_constraints::by_client_id(agent_key);
if let Some(max_chars) = constraints.and_then(|c| c.mcp_instructions_max_chars) {
let truncated = if instructions.len() > max_chars {
&instructions[..max_chars]
} else {
instructions
};
entry["instructions"] = serde_json::json!(truncated);
}
entry
}
pub(super) fn supports_auto_approve(target: &EditorTarget) -> bool {
crate::core::client_constraints::by_editor_name(target.name)
.is_some_and(|c| c.supports_auto_approve)
}
pub(super) fn default_data_dir() -> Result<String, String> {
Ok(crate::core::data_dir::lean_ctx_data_dir()?
.to_string_lossy()
.to_string())
}
pub(super) const LEAN_CTX_AUGMENT_VSCODE_ID: &str = "6c65616e-c747-4000-8000-6c65616e6374";
pub(super) fn backup_invalid_file(path: &std::path::Path) -> Result<std::path::PathBuf, String> {
if !path.exists() {
return Ok(path.to_path_buf());
}
let parent = path
.parent()
.ok_or_else(|| "invalid path (no parent directory)".to_string())?;
let filename = path
.file_name()
.ok_or_else(|| "invalid path (no filename)".to_string())?
.to_string_lossy();
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
std::fs::copy(path, &bak).map_err(|e| e.to_string())?;
Ok(bak)
}
pub(super) fn handle_invalid_json_write(
path: &std::path::Path,
content: &str,
container_key: &str,
entry_key: &str,
value: &serde_json::Value,
allow_inject: bool,
) -> Result<WriteResult, String> {
if content.contains(&format!("\"{entry_key}\"")) {
eprintln!(
"\x1b[33m⚠\x1b[0m {} has JSON syntax errors but already contains \"{entry_key}\".",
path.display()
);
eprintln!(" Skipping — your config is untouched.");
return Ok(WriteResult {
action: WriteAction::Already,
note: Some(format!("invalid JSON, {entry_key} already present")),
});
}
if !allow_inject {
return Err(format!(
"{} contains invalid JSON. Fix the syntax and re-run lean-ctx setup.\n Path: {}",
path.display(),
path.display()
));
}
if let Some(patched) = try_text_inject_mcp_entry(content, container_key, entry_key, value) {
let bak = backup_invalid_file(path)?;
crate::config_io::write_atomic_with_backup(path, &patched)?;
eprintln!(
"\x1b[32m✓\x1b[0m Added {entry_key} to {} (text-based; file has syntax errors).",
path.display()
);
eprintln!(" \x1b[33mNote:\x1b[0m Your config has JSON syntax errors — please fix them.");
eprintln!(" Backup: {}", bak.display());
return Ok(WriteResult {
action: WriteAction::Updated,
note: Some(format!(
"text-injected into invalid JSON (backup: {})",
bak.display()
)),
});
}
eprintln!(
"\x1b[33m⚠\x1b[0m {} contains invalid JSON that lean-ctx cannot safely modify.",
path.display()
);
eprintln!(" \x1b[1mYour config was NOT changed.\x1b[0m");
eprintln!(" To fix:");
eprintln!(
" 1. Open {} and correct the JSON syntax errors",
path.display()
);
eprintln!(" 2. Re-run: lean-ctx setup");
eprintln!(" (Common issue: trailing commas, missing quotes, unmatched braces)");
Ok(WriteResult {
action: WriteAction::Already,
note: Some(format!(
"invalid JSON — user must fix manually: {}",
path.display()
)),
})
}
pub(super) fn try_text_inject_mcp_entry(
content: &str,
container_key: &str,
entry_key: &str,
value: &serde_json::Value,
) -> Option<String> {
let entry = serde_json::to_string_pretty(value).ok()?;
let indented_entry = entry
.lines()
.enumerate()
.map(|(i, line)| {
if i == 0 {
format!(" \"{entry_key}\": {line}")
} else {
format!(" {line}")
}
})
.collect::<Vec<_>>()
.join("\n");
let quoted_container = format!("\"{container_key}\"");
let search_keys: Vec<&str> = std::iter::once(quoted_container.as_str())
.chain(
[
"\"mcp\"",
"\"mcpServers\"",
"\"servers\"",
"\"context_servers\"",
]
.iter()
.filter(|k| **k != quoted_container.as_str())
.copied(),
)
.collect();
for container in &search_keys {
if let Some(pos) = content.find(container) {
let after = &content[pos..];
if let Some(brace_offset) = after.find('{') {
let insert_pos = pos + brace_offset + 1;
let before = &content[..insert_pos];
let rest = &content[insert_pos..];
let needs_comma = !rest.trim_start().starts_with('}');
let injection = if needs_comma {
format!("\n{indented_entry},")
} else {
format!("\n{indented_entry}\n ")
};
return Some(format!("{before}{injection}{rest}"));
}
}
}
if let Some(last_brace) = content.rfind('}') {
let before = &content[..last_brace];
let after = &content[last_brace..];
let needs_comma = before.trim_end().ends_with('}')
|| before.trim_end().ends_with('"')
|| before.trim_end().ends_with(']');
let comma = if needs_comma { "," } else { "" };
let block = format!("{comma}\n \"{container_key}\": {{\n{indented_entry}\n }}\n");
return Some(format!("{before}{block}{after}"));
}
None
}