Skip to main content

spool/installers/
shared.rs

1//! Cross-installer helpers.
2//!
3//! ## Responsibilities
4//! - Resolve `~` and well-known directories without pulling external
5//!   crates (`std::env::var("HOME")`).
6//! - Atomic JSON read/modify/write with timestamped backup files.
7//! - Merge a single mcpServers entry while preserving sibling clients
8//!   (proxyman, pencil, …) untouched.
9//!
10//! ## Atomicity model
11//! - We always read the full JSON document, mutate in-memory, then write
12//!   a sibling `<file>.spool-tmp` and `rename` to the destination.
13//! - We snapshot the previous bytes to `<file>.bak-spool-<unix-ts>`
14//!   BEFORE writing — never overwriting an existing backup. The backup
15//!   is created at most once per `install` call.
16//!
17//! ## Why not `serde_json::Value::as_object_mut` directly?
18//! We keep the surrounding object as `serde_json::Value` to preserve
19//! every key the user (or another tool) may have set. We never re-emit
20//! the document with `to_string` — we use `serde_json::to_string_pretty`
21//! to keep diffs readable.
22
23use anyhow::{Context, Result};
24use serde_json::{Map, Value, json};
25use std::path::{Path, PathBuf};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28/// Returns the absolute home directory by reading `$HOME`.
29///
30/// We deliberately avoid `dirs` / `home` crates: spool already keeps
31/// dependencies tight (see Cargo.toml), and the only platforms we
32/// support set `$HOME` reliably (macOS, Linux). On Windows we'd need
33/// `USERPROFILE` — out of scope for the R1 MVP.
34pub fn home_dir() -> Result<PathBuf> {
35    crate::support::home_dir()
36        .context("cannot locate user home directory ($HOME or %USERPROFILE% not set)")
37}
38
39/// Path to the Claude Code top-level config (`~/.claude.json`).
40pub fn claude_config_path() -> Result<PathBuf> {
41    Ok(home_dir()?.join(".claude.json"))
42}
43
44/// Path to the Claude Code settings file (`~/.claude/settings.json`).
45pub fn claude_settings_path() -> Result<PathBuf> {
46    Ok(home_dir()?.join(".claude").join("settings.json"))
47}
48
49/// Default spool-mcp install path under `~/.cargo/bin/`.
50pub 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
74/// Read a JSON document from disk; missing files yield an empty object.
75pub 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
88/// Snapshot `path` to `<path>.bak-spool-<ts>`. Returns the backup path
89/// or `None` when the source file does not exist (fresh install).
90pub 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
115/// Write JSON to `path` atomically: write to `<path>.spool-tmp` then
116/// rename. The parent directory MUST exist.
117pub 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
133/// Build the canonical spool mcpServers entry.
134///
135/// We keep this as a free function so installers can render it for
136/// dry-run previews without performing the merge.
137pub 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/// Outcome of [`merge_mcp_entry`].
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum McpMergeOutcome {
152    /// Entry did not exist — was added.
153    Inserted,
154    /// Entry exists with identical command/args — no change required.
155    Unchanged,
156    /// Entry exists but differs from desired. With `force=true` it is
157    /// overwritten; otherwise the document is left unchanged.
158    Conflict { force_applied: bool },
159}
160
161/// Merge `desired` into `doc.mcpServers.{client_id}`.
162///
163/// `doc` MUST be an object (or convertible to one). Returns the merge
164/// outcome and whether the doc was mutated.
165pub 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/// Outcome of [`remove_mcp_entry`].
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub enum McpRemoveOutcome {
205    Removed,
206    NotPresent,
207}
208
209/// Drop `doc.mcpServers.{client_id}` if it exists.
210pub 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// ─────────────────────────────────────────────────────────────────────
225// Claude Code settings.json `hooks` merge helpers.
226//
227// settings.json `hooks` shape (see ~/.claude/settings.json):
228// {
229//   "hooks": {
230//     "SessionStart": [
231//       { "matcher": "", "hooks": [{ "type": "command", "command": "..." }] },
232//       ...
233//     ],
234//     "PreCompact": [...],
235//     ...
236//   }
237// }
238//
239// Strategy: we APPEND a spool-specific entry instead of merging into an
240// existing entry's `hooks` array, so removing spool is a clean filter
241// without risking damage to sibling tools (e.g. `bd prime`, Trellis).
242// ─────────────────────────────────────────────────────────────────────
243
244/// Result of [`upsert_settings_hook_command`].
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum SettingsHookOutcome {
247    /// New entry appended.
248    Appended,
249    /// Entry already present with the same command — no change.
250    Unchanged,
251}
252
253/// Ensure `doc.hooks.{event}` contains an entry referencing
254/// `command_path`. Returns whether the document was mutated.
255///
256/// We never edit existing entries in place; we either find a matching
257/// spool entry or append a new one. Sibling entries (other tools) are
258/// untouched.
259pub 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    // Look for an existing spool command pointing to the same path.
287    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
303/// Remove every spool-* entry from `doc.hooks.{event}`. An entry is
304/// considered "spool-managed" when any of its inner `hooks[].command`
305/// strings contains the marker `marker_substring` (typically
306/// `spool-`). We drop the entire wrapper entry to keep the structure
307/// tidy; sibling tools' entries are not touched.
308pub 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    // Sweep empty event arrays for tidiness.
325    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        // Stop event is now empty → swept.
575        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}