1use anyhow::{Context, Result};
24use serde_json::{Map, Value, json};
25use std::path::{Path, PathBuf};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28pub fn home_dir() -> Result<PathBuf> {
35 crate::support::home_dir()
36 .context("cannot locate user home directory ($HOME or %USERPROFILE% not set)")
37}
38
39pub fn claude_config_path() -> Result<PathBuf> {
41 Ok(home_dir()?.join(".claude.json"))
42}
43
44pub fn claude_settings_path() -> Result<PathBuf> {
46 Ok(home_dir()?.join(".claude").join("settings.json"))
47}
48
49pub fn default_cargo_binary_path() -> Result<PathBuf> {
51 Ok(home_dir()?.join(".cargo").join("bin").join("spool-mcp"))
52}
53
54pub fn ensure_config_exists(config_path: &Path) -> Result<()> {
55 if config_path.exists() {
56 return Ok(());
57 }
58 if let Some(parent) = config_path.parent() {
59 std::fs::create_dir_all(parent)?;
60 }
61 let default_config = r#"[vault]
62root = ""
63
64[output]
65default_format = "prompt"
66max_chars = 12000
67max_notes = 8
68max_lifecycle = 5
69"#;
70 std::fs::write(config_path, default_config)?;
71 Ok(())
72}
73
74pub fn read_json_or_empty(path: &Path) -> Result<Value> {
76 if !path.exists() {
77 return Ok(Value::Object(Map::new()));
78 }
79 let raw = std::fs::read_to_string(path)
80 .with_context(|| format!("failed to read {}", path.display()))?;
81 if raw.trim().is_empty() {
82 return Ok(Value::Object(Map::new()));
83 }
84 serde_json::from_str::<Value>(&raw)
85 .with_context(|| format!("failed to parse JSON at {}", path.display()))
86}
87
88pub fn backup_file(path: &Path) -> Result<Option<PathBuf>> {
91 if !path.exists() {
92 return Ok(None);
93 }
94 let ts = SystemTime::now()
95 .duration_since(UNIX_EPOCH)
96 .map(|d| d.as_secs())
97 .unwrap_or(0);
98 let backup = path.with_file_name(format!(
99 "{}.bak-spool-{}",
100 path.file_name()
101 .and_then(|s| s.to_str())
102 .unwrap_or("unknown"),
103 ts
104 ));
105 std::fs::copy(path, &backup).with_context(|| {
106 format!(
107 "failed to back up {} to {}",
108 path.display(),
109 backup.display()
110 )
111 })?;
112 Ok(Some(backup))
113}
114
115pub fn write_json_atomic(path: &Path, value: &Value) -> Result<()> {
118 if let Some(parent) = path.parent()
119 && !parent.exists()
120 {
121 std::fs::create_dir_all(parent)
122 .with_context(|| format!("failed to create parent directory {}", parent.display()))?;
123 }
124 let tmp = path.with_extension("spool-tmp");
125 let body = serde_json::to_string_pretty(value).context("failed to serialize JSON")?;
126 std::fs::write(&tmp, body)
127 .with_context(|| format!("failed to write temp file {}", tmp.display()))?;
128 std::fs::rename(&tmp, path)
129 .with_context(|| format!("failed to atomically replace {}", path.display()))?;
130 Ok(())
131}
132
133pub fn build_mcp_entry(binary_path: &Path, config_path: &Path) -> Value {
138 json!({
139 "type": "stdio",
140 "command": path_to_string(binary_path),
141 "args": ["--config", path_to_string(config_path)],
142 })
143}
144
145fn path_to_string(p: &Path) -> String {
146 p.to_string_lossy().into_owned()
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum McpMergeOutcome {
152 Inserted,
154 Unchanged,
156 Conflict { force_applied: bool },
159}
160
161pub fn merge_mcp_entry(
166 doc: &mut Value,
167 client_id: &str,
168 desired: Value,
169 force: bool,
170) -> McpMergeOutcome {
171 let root = match doc.as_object_mut() {
172 Some(obj) => obj,
173 None => {
174 *doc = Value::Object(Map::new());
175 doc.as_object_mut().expect("just inserted")
176 }
177 };
178 let servers = root
179 .entry("mcpServers")
180 .or_insert_with(|| Value::Object(Map::new()))
181 .as_object_mut()
182 .expect("mcpServers must be object");
183
184 match servers.get(client_id) {
185 None => {
186 servers.insert(client_id.to_string(), desired);
187 McpMergeOutcome::Inserted
188 }
189 Some(existing) if existing == &desired => McpMergeOutcome::Unchanged,
190 Some(_) if force => {
191 servers.insert(client_id.to_string(), desired);
192 McpMergeOutcome::Conflict {
193 force_applied: true,
194 }
195 }
196 Some(_) => McpMergeOutcome::Conflict {
197 force_applied: false,
198 },
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
204pub enum McpRemoveOutcome {
205 Removed,
206 NotPresent,
207}
208
209pub fn remove_mcp_entry(doc: &mut Value, client_id: &str) -> McpRemoveOutcome {
211 let Some(root) = doc.as_object_mut() else {
212 return McpRemoveOutcome::NotPresent;
213 };
214 let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else {
215 return McpRemoveOutcome::NotPresent;
216 };
217 if servers.remove(client_id).is_some() {
218 McpRemoveOutcome::Removed
219 } else {
220 McpRemoveOutcome::NotPresent
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum SettingsHookOutcome {
247 Appended,
249 Unchanged,
251}
252
253pub fn upsert_settings_hook_command(
260 doc: &mut Value,
261 event: &str,
262 command_path: &str,
263) -> SettingsHookOutcome {
264 let root = match doc.as_object_mut() {
265 Some(obj) => obj,
266 None => {
267 *doc = Value::Object(Map::new());
268 doc.as_object_mut().expect("just inserted")
269 }
270 };
271 let hooks = root
272 .entry("hooks")
273 .or_insert_with(|| Value::Object(Map::new()));
274 if !hooks.is_object() {
275 *hooks = Value::Object(Map::new());
276 }
277 let hooks_obj = hooks.as_object_mut().expect("hooks must be object");
278 let entries = hooks_obj
279 .entry(event)
280 .or_insert_with(|| Value::Array(Vec::new()));
281 if !entries.is_array() {
282 *entries = Value::Array(Vec::new());
283 }
284 let array = entries.as_array_mut().expect("entries must be array");
285
286 for entry in array.iter() {
288 if entry_contains_command(entry, command_path) {
289 return SettingsHookOutcome::Unchanged;
290 }
291 }
292
293 array.push(json!({
294 "matcher": "",
295 "hooks": [{
296 "type": "command",
297 "command": command_path,
298 }]
299 }));
300 SettingsHookOutcome::Appended
301}
302
303pub fn purge_settings_hook_entries(doc: &mut Value, marker_substring: &str) -> usize {
309 let mut removed = 0usize;
310 let Some(root) = doc.as_object_mut() else {
311 return 0;
312 };
313 let Some(hooks) = root.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
314 return 0;
315 };
316 for (_event, entries) in hooks.iter_mut() {
317 let Some(array) = entries.as_array_mut() else {
318 continue;
319 };
320 let before = array.len();
321 array.retain(|entry| !entry_contains_command_substring(entry, marker_substring));
322 removed += before - array.len();
323 }
324 hooks.retain(|_event, entries| !entries.as_array().is_some_and(|a| a.is_empty()));
326 if hooks.is_empty() {
327 root.remove("hooks");
328 }
329 removed
330}
331
332fn entry_contains_command(entry: &Value, command_path: &str) -> bool {
333 entry
334 .get("hooks")
335 .and_then(|v| v.as_array())
336 .map(|arr| {
337 arr.iter().any(|h| {
338 h.get("command")
339 .and_then(|c| c.as_str())
340 .is_some_and(|c| c == command_path)
341 })
342 })
343 .unwrap_or(false)
344}
345
346fn entry_contains_command_substring(entry: &Value, needle: &str) -> bool {
347 entry
348 .get("hooks")
349 .and_then(|v| v.as_array())
350 .map(|arr| {
351 arr.iter().any(|h| {
352 h.get("command")
353 .and_then(|c| c.as_str())
354 .is_some_and(|c| c.contains(needle))
355 })
356 })
357 .unwrap_or(false)
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use std::fs;
364 use tempfile::tempdir;
365
366 #[test]
367 fn read_json_or_empty_returns_object_when_missing() {
368 let temp = tempdir().unwrap();
369 let path = temp.path().join("absent.json");
370 let v = read_json_or_empty(&path).unwrap();
371 assert!(v.as_object().unwrap().is_empty());
372 }
373
374 #[test]
375 fn read_json_or_empty_returns_object_when_blank() {
376 let temp = tempdir().unwrap();
377 let path = temp.path().join("blank.json");
378 fs::write(&path, " \n").unwrap();
379 let v = read_json_or_empty(&path).unwrap();
380 assert!(v.as_object().unwrap().is_empty());
381 }
382
383 #[test]
384 fn backup_file_skips_missing_source() {
385 let temp = tempdir().unwrap();
386 let path = temp.path().join("nope.json");
387 let backup = backup_file(&path).unwrap();
388 assert!(backup.is_none());
389 }
390
391 #[test]
392 fn backup_file_creates_unique_snapshot() {
393 let temp = tempdir().unwrap();
394 let path = temp.path().join("real.json");
395 fs::write(&path, "{}").unwrap();
396 let backup = backup_file(&path).unwrap().expect("backup expected");
397 assert!(backup.exists());
398 assert_eq!(fs::read_to_string(&backup).unwrap(), "{}");
399 }
400
401 #[test]
402 fn write_json_atomic_creates_parent_dirs() {
403 let temp = tempdir().unwrap();
404 let path = temp.path().join("nested").join("config.json");
405 write_json_atomic(&path, &json!({"k": 1})).unwrap();
406 assert!(path.exists());
407 let raw = fs::read_to_string(&path).unwrap();
408 assert!(raw.contains("\"k\""));
409 }
410
411 #[test]
412 fn merge_mcp_entry_inserts_when_absent() {
413 let mut doc = json!({"unrelated": true});
414 let entry = json!({"command": "/bin/foo"});
415 let outcome = merge_mcp_entry(&mut doc, "claude", entry.clone(), false);
416 assert_eq!(outcome, McpMergeOutcome::Inserted);
417 assert_eq!(doc["mcpServers"]["claude"], entry);
418 assert_eq!(doc["unrelated"], json!(true));
419 }
420
421 #[test]
422 fn merge_mcp_entry_unchanged_when_identical() {
423 let entry = json!({"command": "/bin/foo"});
424 let mut doc = json!({"mcpServers": {"claude": entry.clone()}});
425 let outcome = merge_mcp_entry(&mut doc, "claude", entry, false);
426 assert_eq!(outcome, McpMergeOutcome::Unchanged);
427 }
428
429 #[test]
430 fn merge_mcp_entry_conflict_without_force_keeps_existing() {
431 let existing = json!({"command": "/old"});
432 let mut doc = json!({"mcpServers": {"claude": existing.clone()}});
433 let desired = json!({"command": "/new"});
434 let outcome = merge_mcp_entry(&mut doc, "claude", desired.clone(), false);
435 assert_eq!(
436 outcome,
437 McpMergeOutcome::Conflict {
438 force_applied: false
439 }
440 );
441 assert_eq!(doc["mcpServers"]["claude"], existing);
442 }
443
444 #[test]
445 fn merge_mcp_entry_conflict_with_force_overwrites() {
446 let mut doc = json!({"mcpServers": {"claude": {"command": "/old"}}});
447 let desired = json!({"command": "/new"});
448 let outcome = merge_mcp_entry(&mut doc, "claude", desired.clone(), true);
449 assert_eq!(
450 outcome,
451 McpMergeOutcome::Conflict {
452 force_applied: true
453 }
454 );
455 assert_eq!(doc["mcpServers"]["claude"], desired);
456 }
457
458 #[test]
459 fn merge_mcp_entry_preserves_sibling_clients() {
460 let mut doc = json!({
461 "mcpServers": {
462 "proxyman": {"command": "/bin/proxyman"},
463 "pencil": {"command": "/bin/pencil"}
464 }
465 });
466 let entry = json!({"command": "/bin/spool"});
467 merge_mcp_entry(&mut doc, "claude", entry.clone(), false);
468 assert_eq!(doc["mcpServers"]["proxyman"]["command"], "/bin/proxyman");
469 assert_eq!(doc["mcpServers"]["pencil"]["command"], "/bin/pencil");
470 assert_eq!(doc["mcpServers"]["claude"], entry);
471 }
472
473 #[test]
474 fn remove_mcp_entry_drops_when_present() {
475 let mut doc = json!({"mcpServers": {"claude": {"command": "/x"}, "pencil": {}}});
476 let outcome = remove_mcp_entry(&mut doc, "claude");
477 assert_eq!(outcome, McpRemoveOutcome::Removed);
478 assert!(
479 doc["mcpServers"]
480 .as_object()
481 .unwrap()
482 .contains_key("pencil")
483 );
484 assert!(
485 !doc["mcpServers"]
486 .as_object()
487 .unwrap()
488 .contains_key("claude")
489 );
490 }
491
492 #[test]
493 fn remove_mcp_entry_not_present_when_missing() {
494 let mut doc = json!({"mcpServers": {"pencil": {}}});
495 let outcome = remove_mcp_entry(&mut doc, "claude");
496 assert_eq!(outcome, McpRemoveOutcome::NotPresent);
497 }
498
499 #[test]
500 fn build_mcp_entry_uses_absolute_paths() {
501 let entry = build_mcp_entry(Path::new("/abs/spool-mcp"), Path::new("/abs/config.toml"));
502 assert_eq!(entry["type"], "stdio");
503 assert_eq!(entry["command"], "/abs/spool-mcp");
504 assert_eq!(entry["args"], json!(["--config", "/abs/config.toml"]));
505 }
506
507 #[test]
508 fn upsert_settings_hook_appends_when_absent() {
509 let mut doc = json!({});
510 let outcome = upsert_settings_hook_command(
511 &mut doc,
512 "SessionStart",
513 "/abs/.claude/hooks/spool-SessionStart.sh",
514 );
515 assert_eq!(outcome, SettingsHookOutcome::Appended);
516 let entries = doc["hooks"]["SessionStart"].as_array().unwrap();
517 assert_eq!(entries.len(), 1);
518 assert_eq!(entries[0]["matcher"], "");
519 assert_eq!(
520 entries[0]["hooks"][0]["command"],
521 "/abs/.claude/hooks/spool-SessionStart.sh"
522 );
523 assert_eq!(entries[0]["hooks"][0]["type"], "command");
524 }
525
526 #[test]
527 fn upsert_settings_hook_preserves_existing_siblings() {
528 let mut doc = json!({
529 "hooks": {
530 "SessionStart": [
531 {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
532 ]
533 }
534 });
535 let outcome =
536 upsert_settings_hook_command(&mut doc, "SessionStart", "/abs/spool-SessionStart.sh");
537 assert_eq!(outcome, SettingsHookOutcome::Appended);
538 let entries = doc["hooks"]["SessionStart"].as_array().unwrap();
539 assert_eq!(entries.len(), 2);
540 assert_eq!(entries[0]["hooks"][0]["command"], "bd prime");
541 assert_eq!(
542 entries[1]["hooks"][0]["command"],
543 "/abs/spool-SessionStart.sh"
544 );
545 }
546
547 #[test]
548 fn upsert_settings_hook_unchanged_on_repeat() {
549 let mut doc = json!({});
550 let _ = upsert_settings_hook_command(&mut doc, "Stop", "/abs/spool-Stop.sh");
551 let outcome = upsert_settings_hook_command(&mut doc, "Stop", "/abs/spool-Stop.sh");
552 assert_eq!(outcome, SettingsHookOutcome::Unchanged);
553 assert_eq!(doc["hooks"]["Stop"].as_array().unwrap().len(), 1);
554 }
555
556 #[test]
557 fn purge_settings_hook_drops_spool_entries_only() {
558 let mut doc = json!({
559 "hooks": {
560 "SessionStart": [
561 {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]},
562 {"matcher": "", "hooks": [{"type": "command", "command": "/abs/.claude/hooks/spool-SessionStart.sh"}]}
563 ],
564 "Stop": [
565 {"matcher": "", "hooks": [{"type": "command", "command": "/abs/spool-Stop.sh"}]}
566 ]
567 }
568 });
569 let removed = purge_settings_hook_entries(&mut doc, "spool-");
570 assert_eq!(removed, 2);
571 let session_entries = doc["hooks"]["SessionStart"].as_array().unwrap();
572 assert_eq!(session_entries.len(), 1);
573 assert_eq!(session_entries[0]["hooks"][0]["command"], "bd prime");
574 assert!(doc["hooks"].get("Stop").is_none());
576 }
577
578 #[test]
579 fn purge_settings_hook_removes_empty_hooks_root() {
580 let mut doc = json!({
581 "hooks": {
582 "Stop": [
583 {"matcher": "", "hooks": [{"type": "command", "command": "/abs/spool-Stop.sh"}]}
584 ]
585 },
586 "other": true
587 });
588 let removed = purge_settings_hook_entries(&mut doc, "spool-");
589 assert_eq!(removed, 1);
590 assert!(doc.get("hooks").is_none());
591 assert_eq!(doc["other"], true);
592 }
593
594 #[test]
595 fn purge_settings_hook_no_op_when_marker_absent() {
596 let mut doc = json!({
597 "hooks": {
598 "SessionStart": [
599 {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
600 ]
601 }
602 });
603 let removed = purge_settings_hook_entries(&mut doc, "spool-");
604 assert_eq!(removed, 0);
605 assert_eq!(doc["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
606 }
607}