use anyhow::{Context, Result};
use serde_json::{Map, Value, json};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub fn home_dir() -> Result<PathBuf> {
crate::support::home_dir()
.context("cannot locate user home directory ($HOME or %USERPROFILE% not set)")
}
pub fn claude_config_path() -> Result<PathBuf> {
Ok(home_dir()?.join(".claude.json"))
}
pub fn claude_settings_path() -> Result<PathBuf> {
Ok(home_dir()?.join(".claude").join("settings.json"))
}
pub fn default_cargo_binary_path() -> Result<PathBuf> {
Ok(home_dir()?.join(".cargo").join("bin").join("spool-mcp"))
}
pub fn ensure_config_exists(config_path: &Path) -> Result<()> {
if config_path.exists() {
return Ok(());
}
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let default_config = r#"[vault]
root = ""
[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8
max_lifecycle = 5
"#;
std::fs::write(config_path, default_config)?;
Ok(())
}
pub fn read_json_or_empty(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(Value::Object(Map::new()));
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
if raw.trim().is_empty() {
return Ok(Value::Object(Map::new()));
}
serde_json::from_str::<Value>(&raw)
.with_context(|| format!("failed to parse JSON at {}", path.display()))
}
pub fn backup_file(path: &Path) -> Result<Option<PathBuf>> {
if !path.exists() {
return Ok(None);
}
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let backup = path.with_file_name(format!(
"{}.bak-spool-{}",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown"),
ts
));
std::fs::copy(path, &backup).with_context(|| {
format!(
"failed to back up {} to {}",
path.display(),
backup.display()
)
})?;
Ok(Some(backup))
}
pub fn write_json_atomic(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create parent directory {}", parent.display()))?;
}
let tmp = path.with_extension("spool-tmp");
let body = serde_json::to_string_pretty(value).context("failed to serialize JSON")?;
std::fs::write(&tmp, body)
.with_context(|| format!("failed to write temp file {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("failed to atomically replace {}", path.display()))?;
Ok(())
}
pub fn build_mcp_entry(binary_path: &Path, config_path: &Path) -> Value {
json!({
"type": "stdio",
"command": path_to_string(binary_path),
"args": ["--config", path_to_string(config_path)],
})
}
fn path_to_string(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpMergeOutcome {
Inserted,
Unchanged,
Conflict { force_applied: bool },
}
pub fn merge_mcp_entry(
doc: &mut Value,
client_id: &str,
desired: Value,
force: bool,
) -> McpMergeOutcome {
let root = match doc.as_object_mut() {
Some(obj) => obj,
None => {
*doc = Value::Object(Map::new());
doc.as_object_mut().expect("just inserted")
}
};
let servers = root
.entry("mcpServers")
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.expect("mcpServers must be object");
match servers.get(client_id) {
None => {
servers.insert(client_id.to_string(), desired);
McpMergeOutcome::Inserted
}
Some(existing) if existing == &desired => McpMergeOutcome::Unchanged,
Some(_) if force => {
servers.insert(client_id.to_string(), desired);
McpMergeOutcome::Conflict {
force_applied: true,
}
}
Some(_) => McpMergeOutcome::Conflict {
force_applied: false,
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpRemoveOutcome {
Removed,
NotPresent,
}
pub fn remove_mcp_entry(doc: &mut Value, client_id: &str) -> McpRemoveOutcome {
let Some(root) = doc.as_object_mut() else {
return McpRemoveOutcome::NotPresent;
};
let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else {
return McpRemoveOutcome::NotPresent;
};
if servers.remove(client_id).is_some() {
McpRemoveOutcome::Removed
} else {
McpRemoveOutcome::NotPresent
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SettingsHookOutcome {
Appended,
Unchanged,
}
pub fn upsert_settings_hook_command(
doc: &mut Value,
event: &str,
command_path: &str,
) -> SettingsHookOutcome {
let root = match doc.as_object_mut() {
Some(obj) => obj,
None => {
*doc = Value::Object(Map::new());
doc.as_object_mut().expect("just inserted")
}
};
let hooks = root
.entry("hooks")
.or_insert_with(|| Value::Object(Map::new()));
if !hooks.is_object() {
*hooks = Value::Object(Map::new());
}
let hooks_obj = hooks.as_object_mut().expect("hooks must be object");
let entries = hooks_obj
.entry(event)
.or_insert_with(|| Value::Array(Vec::new()));
if !entries.is_array() {
*entries = Value::Array(Vec::new());
}
let array = entries.as_array_mut().expect("entries must be array");
for entry in array.iter() {
if entry_contains_command(entry, command_path) {
return SettingsHookOutcome::Unchanged;
}
}
array.push(json!({
"matcher": "",
"hooks": [{
"type": "command",
"command": command_path,
}]
}));
SettingsHookOutcome::Appended
}
pub fn purge_settings_hook_entries(doc: &mut Value, marker_substring: &str) -> usize {
let mut removed = 0usize;
let Some(root) = doc.as_object_mut() else {
return 0;
};
let Some(hooks) = root.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
return 0;
};
for (_event, entries) in hooks.iter_mut() {
let Some(array) = entries.as_array_mut() else {
continue;
};
let before = array.len();
array.retain(|entry| !entry_contains_command_substring(entry, marker_substring));
removed += before - array.len();
}
hooks.retain(|_event, entries| !entries.as_array().is_some_and(|a| a.is_empty()));
if hooks.is_empty() {
root.remove("hooks");
}
removed
}
fn entry_contains_command(entry: &Value, command_path: &str) -> bool {
entry
.get("hooks")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c == command_path)
})
})
.unwrap_or(false)
}
fn entry_contains_command_substring(entry: &Value, needle: &str) -> bool {
entry
.get("hooks")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains(needle))
})
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn read_json_or_empty_returns_object_when_missing() {
let temp = tempdir().unwrap();
let path = temp.path().join("absent.json");
let v = read_json_or_empty(&path).unwrap();
assert!(v.as_object().unwrap().is_empty());
}
#[test]
fn read_json_or_empty_returns_object_when_blank() {
let temp = tempdir().unwrap();
let path = temp.path().join("blank.json");
fs::write(&path, " \n").unwrap();
let v = read_json_or_empty(&path).unwrap();
assert!(v.as_object().unwrap().is_empty());
}
#[test]
fn backup_file_skips_missing_source() {
let temp = tempdir().unwrap();
let path = temp.path().join("nope.json");
let backup = backup_file(&path).unwrap();
assert!(backup.is_none());
}
#[test]
fn backup_file_creates_unique_snapshot() {
let temp = tempdir().unwrap();
let path = temp.path().join("real.json");
fs::write(&path, "{}").unwrap();
let backup = backup_file(&path).unwrap().expect("backup expected");
assert!(backup.exists());
assert_eq!(fs::read_to_string(&backup).unwrap(), "{}");
}
#[test]
fn write_json_atomic_creates_parent_dirs() {
let temp = tempdir().unwrap();
let path = temp.path().join("nested").join("config.json");
write_json_atomic(&path, &json!({"k": 1})).unwrap();
assert!(path.exists());
let raw = fs::read_to_string(&path).unwrap();
assert!(raw.contains("\"k\""));
}
#[test]
fn merge_mcp_entry_inserts_when_absent() {
let mut doc = json!({"unrelated": true});
let entry = json!({"command": "/bin/foo"});
let outcome = merge_mcp_entry(&mut doc, "claude", entry.clone(), false);
assert_eq!(outcome, McpMergeOutcome::Inserted);
assert_eq!(doc["mcpServers"]["claude"], entry);
assert_eq!(doc["unrelated"], json!(true));
}
#[test]
fn merge_mcp_entry_unchanged_when_identical() {
let entry = json!({"command": "/bin/foo"});
let mut doc = json!({"mcpServers": {"claude": entry.clone()}});
let outcome = merge_mcp_entry(&mut doc, "claude", entry, false);
assert_eq!(outcome, McpMergeOutcome::Unchanged);
}
#[test]
fn merge_mcp_entry_conflict_without_force_keeps_existing() {
let existing = json!({"command": "/old"});
let mut doc = json!({"mcpServers": {"claude": existing.clone()}});
let desired = json!({"command": "/new"});
let outcome = merge_mcp_entry(&mut doc, "claude", desired.clone(), false);
assert_eq!(
outcome,
McpMergeOutcome::Conflict {
force_applied: false
}
);
assert_eq!(doc["mcpServers"]["claude"], existing);
}
#[test]
fn merge_mcp_entry_conflict_with_force_overwrites() {
let mut doc = json!({"mcpServers": {"claude": {"command": "/old"}}});
let desired = json!({"command": "/new"});
let outcome = merge_mcp_entry(&mut doc, "claude", desired.clone(), true);
assert_eq!(
outcome,
McpMergeOutcome::Conflict {
force_applied: true
}
);
assert_eq!(doc["mcpServers"]["claude"], desired);
}
#[test]
fn merge_mcp_entry_preserves_sibling_clients() {
let mut doc = json!({
"mcpServers": {
"proxyman": {"command": "/bin/proxyman"},
"pencil": {"command": "/bin/pencil"}
}
});
let entry = json!({"command": "/bin/spool"});
merge_mcp_entry(&mut doc, "claude", entry.clone(), false);
assert_eq!(doc["mcpServers"]["proxyman"]["command"], "/bin/proxyman");
assert_eq!(doc["mcpServers"]["pencil"]["command"], "/bin/pencil");
assert_eq!(doc["mcpServers"]["claude"], entry);
}
#[test]
fn remove_mcp_entry_drops_when_present() {
let mut doc = json!({"mcpServers": {"claude": {"command": "/x"}, "pencil": {}}});
let outcome = remove_mcp_entry(&mut doc, "claude");
assert_eq!(outcome, McpRemoveOutcome::Removed);
assert!(
doc["mcpServers"]
.as_object()
.unwrap()
.contains_key("pencil")
);
assert!(
!doc["mcpServers"]
.as_object()
.unwrap()
.contains_key("claude")
);
}
#[test]
fn remove_mcp_entry_not_present_when_missing() {
let mut doc = json!({"mcpServers": {"pencil": {}}});
let outcome = remove_mcp_entry(&mut doc, "claude");
assert_eq!(outcome, McpRemoveOutcome::NotPresent);
}
#[test]
fn build_mcp_entry_uses_absolute_paths() {
let entry = build_mcp_entry(Path::new("/abs/spool-mcp"), Path::new("/abs/config.toml"));
assert_eq!(entry["type"], "stdio");
assert_eq!(entry["command"], "/abs/spool-mcp");
assert_eq!(entry["args"], json!(["--config", "/abs/config.toml"]));
}
#[test]
fn upsert_settings_hook_appends_when_absent() {
let mut doc = json!({});
let outcome = upsert_settings_hook_command(
&mut doc,
"SessionStart",
"/abs/.claude/hooks/spool-SessionStart.sh",
);
assert_eq!(outcome, SettingsHookOutcome::Appended);
let entries = doc["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["matcher"], "");
assert_eq!(
entries[0]["hooks"][0]["command"],
"/abs/.claude/hooks/spool-SessionStart.sh"
);
assert_eq!(entries[0]["hooks"][0]["type"], "command");
}
#[test]
fn upsert_settings_hook_preserves_existing_siblings() {
let mut doc = json!({
"hooks": {
"SessionStart": [
{"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
]
}
});
let outcome =
upsert_settings_hook_command(&mut doc, "SessionStart", "/abs/spool-SessionStart.sh");
assert_eq!(outcome, SettingsHookOutcome::Appended);
let entries = doc["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["hooks"][0]["command"], "bd prime");
assert_eq!(
entries[1]["hooks"][0]["command"],
"/abs/spool-SessionStart.sh"
);
}
#[test]
fn upsert_settings_hook_unchanged_on_repeat() {
let mut doc = json!({});
let _ = upsert_settings_hook_command(&mut doc, "Stop", "/abs/spool-Stop.sh");
let outcome = upsert_settings_hook_command(&mut doc, "Stop", "/abs/spool-Stop.sh");
assert_eq!(outcome, SettingsHookOutcome::Unchanged);
assert_eq!(doc["hooks"]["Stop"].as_array().unwrap().len(), 1);
}
#[test]
fn purge_settings_hook_drops_spool_entries_only() {
let mut doc = json!({
"hooks": {
"SessionStart": [
{"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]},
{"matcher": "", "hooks": [{"type": "command", "command": "/abs/.claude/hooks/spool-SessionStart.sh"}]}
],
"Stop": [
{"matcher": "", "hooks": [{"type": "command", "command": "/abs/spool-Stop.sh"}]}
]
}
});
let removed = purge_settings_hook_entries(&mut doc, "spool-");
assert_eq!(removed, 2);
let session_entries = doc["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(session_entries.len(), 1);
assert_eq!(session_entries[0]["hooks"][0]["command"], "bd prime");
assert!(doc["hooks"].get("Stop").is_none());
}
#[test]
fn purge_settings_hook_removes_empty_hooks_root() {
let mut doc = json!({
"hooks": {
"Stop": [
{"matcher": "", "hooks": [{"type": "command", "command": "/abs/spool-Stop.sh"}]}
]
},
"other": true
});
let removed = purge_settings_hook_entries(&mut doc, "spool-");
assert_eq!(removed, 1);
assert!(doc.get("hooks").is_none());
assert_eq!(doc["other"], true);
}
#[test]
fn purge_settings_hook_no_op_when_marker_absent() {
let mut doc = json!({
"hooks": {
"SessionStart": [
{"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
]
}
});
let removed = purge_settings_hook_entries(&mut doc, "spool-");
assert_eq!(removed, 0);
assert_eq!(doc["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
}
}