Skip to main content

mars_agents/target/
claude.rs

1/// `.claude` target adapter.
2///
3/// Handles MCP server registration in `.mcp.json` and hook binding in
4/// `settings.local.json` within the `.claude/` target directory.
5///
6/// Claude-native lowering:
7/// - MCP: writes to `.mcp.json` (mcpServers section)
8/// - Hooks: writes to `settings.local.json` (hooks section). Hook commands
9///   carry machine-local cache paths, so they belong in the gitignored
10///   `settings.local.json` rather than the committed `settings.json`.
11/// - Env references: rendered as `${VAR_NAME}` for Claude Desktop config compat
12use std::path::{Path, PathBuf};
13
14use crate::error::{ConfigError, MarsError};
15use crate::lock::ItemKind;
16use crate::types::DestPath;
17
18use super::{ConfigEntry, HookEntry, HookFragmentMode, McpServerEntry, TargetAdapter};
19
20#[derive(Debug)]
21pub struct ClaudeAdapter;
22
23impl TargetAdapter for ClaudeAdapter {
24    fn name(&self) -> &str {
25        ".claude"
26    }
27
28    fn known_hook_events(&self) -> Option<&'static [&'static str]> {
29        // https://code.claude.com/docs/en/hooks — verified 2026-07-24.
30        Some(&[
31            "SessionStart",
32            "Setup",
33            "UserPromptSubmit",
34            "UserPromptExpansion",
35            "PreToolUse",
36            "PermissionRequest",
37            "PermissionDenied",
38            "PostToolUse",
39            "PostToolUseFailure",
40            "PostToolBatch",
41            "SubagentStart",
42            "SubagentStop",
43            "TaskCreated",
44            "TaskCompleted",
45            "Stop",
46            "StopFailure",
47            "TeammateIdle",
48            "PreCompact",
49            "PostCompact",
50            "Elicitation",
51            "ElicitationResult",
52            "Notification",
53            "ConfigChange",
54            "InstructionsLoaded",
55            "CwdChanged",
56            "FileChanged",
57            "WorktreeCreate",
58            "WorktreeRemove",
59            "SessionEnd",
60        ])
61    }
62
63    fn hook_fragment_mode(&self) -> Option<HookFragmentMode> {
64        Some(HookFragmentMode::MergeJson)
65    }
66
67    fn skill_variant_key(&self) -> Option<&str> {
68        Some("claude")
69    }
70
71    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
72        match kind {
73            ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
74            // Agent, Hook, McpServer, BootstrapDoc routing is deferred.
75            _ => None,
76        }
77    }
78
79    fn write_config_entries(
80        &self,
81        write: crate::surface_ownership::retention::ConfigWrite<'_>,
82        project_root: &Path,
83    ) -> Result<Vec<PathBuf>, MarsError> {
84        let (target_dir, entries) = write.into_parts(project_root);
85        let mut written = Vec::new();
86
87        let mcp_servers: Vec<&McpServerEntry> = entries
88            .iter()
89            .filter_map(|e| {
90                if let ConfigEntry::McpServer(s) = e {
91                    Some(s)
92                } else {
93                    None
94                }
95            })
96            .collect();
97
98        let hooks: Vec<&HookEntry> = entries
99            .iter()
100            .filter_map(|e| {
101                if let ConfigEntry::Hook(h) = e {
102                    Some(h)
103                } else {
104                    None
105                }
106            })
107            .collect();
108
109        if !mcp_servers.is_empty() {
110            let path = (write_mcp_json)(&target_dir, &mcp_servers)?;
111            written.push(path);
112        }
113
114        if !hooks.is_empty() {
115            let path = (write_hooks_settings)(&target_dir, &hooks)?;
116            written.push(path);
117        }
118
119        Ok(written)
120    }
121
122    fn mcp_config_file_names(&self) -> &'static [&'static str] {
123        &[".mcp.json"]
124    }
125    fn hook_config_file_names(&self) -> &'static [&'static str] {
126        &["settings.local.json"]
127    }
128
129    fn legacy_hook_config_file_names(&self) -> &'static [&'static str] {
130        &["settings.json"]
131    }
132
133    fn remove_owned_hook_entries(
134        &self,
135        operation: crate::surface_ownership::retention::RemovalOperation<'_>,
136        project_root: &Path,
137        diag: &mut crate::diagnostic::DiagnosticCollector,
138    ) -> crate::surface_ownership::retention::RemovalReport {
139        let (target_dir, removal) = operation.into_parts(project_root);
140        remove_owned_claude_hooks(&removal.prior_records, &target_dir, diag)
141    }
142
143    fn remove_config_entries(
144        &self,
145        operation: crate::surface_ownership::retention::RemovalOperation<'_>,
146        project_root: &Path,
147    ) -> crate::surface_ownership::retention::RemovalReport {
148        let (target_dir, removal) = operation.into_parts(project_root);
149        match remove_mcp_entries_by_key(&removal.keys_to_remove, &target_dir) {
150            Ok(()) => crate::surface_ownership::retention::RemovalReport::confirmed(),
151            Err(error) => crate::surface_ownership::retention::RemovalReport::failed(
152                error,
153                removal.prior_records.clone(),
154            ),
155        }
156    }
157}
158
159// ---------------------------------------------------------------------------
160// MCP JSON — `.mcp.json` format
161// ---------------------------------------------------------------------------
162
163/// Write (or merge) MCP servers into `<target_dir>/.mcp.json`.
164///
165/// The file format is:
166/// ```json
167/// {
168///   "mcpServers": {
169///     "server-name": {
170///       "command": "npx",
171///       "args": [...],
172///       "env": { "KEY": "${ENV_VAR}" }
173///     }
174///   }
175/// }
176/// ```
177///
178/// Existing entries with other names are preserved (merge, not replace).
179fn write_mcp_json(target_dir: &Path, servers: &[&McpServerEntry]) -> Result<PathBuf, MarsError> {
180    let path = target_dir.join(".mcp.json");
181
182    // Load existing config or start fresh.
183    let mut root: serde_json::Value = if path.is_file() {
184        super::parse_json_file(&path)?
185    } else {
186        serde_json::json!({})
187    };
188
189    // Ensure mcpServers key exists.
190    let mcp_obj = root
191        .as_object_mut()
192        .ok_or_else(|| {
193            MarsError::Config(crate::error::ConfigError::Invalid {
194                message: format!("{} is not a JSON object", path.display()),
195            })
196        })?
197        .entry("mcpServers")
198        .or_insert_with(|| serde_json::json!({}));
199
200    let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
201        MarsError::Config(crate::error::ConfigError::Invalid {
202            message: format!("{}: mcpServers is not an object", path.display()),
203        })
204    })?;
205
206    for server in servers {
207        let mut entry = serde_json::json!({
208            "command": server.command,
209            "args": server.args,
210        });
211
212        if !server.env.is_empty() {
213            let env_obj: serde_json::Map<String, serde_json::Value> = server
214                .env
215                .iter()
216                .map(|(k, v)| (k.clone(), serde_json::Value::String(format!("${{{v}}}"))))
217                .collect();
218            entry["env"] = serde_json::Value::Object(env_obj);
219        }
220
221        mcp_map.insert(server.name.clone(), entry);
222    }
223
224    let content = serde_json::to_string_pretty(&root).map_err(|e| {
225        MarsError::Config(crate::error::ConfigError::Invalid {
226            message: format!("failed to serialize {}: {e}", path.display()),
227        })
228    })?;
229    crate::fs::atomic_write(&path, content.as_bytes())?;
230
231    Ok(path)
232}
233
234/// Remove MCP server entries by key from `.mcp.json`.
235fn remove_mcp_entries_by_key(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
236    let path = target_dir.join(".mcp.json");
237    if !path.is_file() {
238        return Ok(());
239    }
240
241    let mut root = super::parse_json_file(&path)?;
242
243    if let Some(mcp_map) = root
244        .as_object_mut()
245        .and_then(|o| o.get_mut("mcpServers"))
246        .and_then(|v| v.as_object_mut())
247    {
248        for key in entry_keys {
249            // Keys are "mcp:<name>" — strip the prefix.
250            if let Some(name) = key.strip_prefix("mcp:") {
251                mcp_map.remove(name);
252            }
253        }
254    }
255
256    let content = serde_json::to_string_pretty(&root).map_err(|e| {
257        MarsError::Config(crate::error::ConfigError::Invalid {
258            message: format!("failed to serialize {}: {e}", path.display()),
259        })
260    })?;
261    crate::fs::atomic_write(&path, content.as_bytes())?;
262
263    Ok(())
264}
265
266// ---------------------------------------------------------------------------
267// Hooks — `settings.local.json` format
268// ---------------------------------------------------------------------------
269
270/// Write (or merge) hook bindings into `<target_dir>/settings.local.json`.
271///
272/// Hooks go to `settings.local.json` (gitignored) rather than `settings.json`
273/// because hook commands embed machine-local cache paths that change on every
274/// sync and every machine.
275///
276/// Claude hooks live in the `hooks` section:
277/// ```json
278/// {
279///   "hooks": {
280///     "PreToolUse": [
281///       { "hooks": [{ "type": "command", "command": "bash /path/to/script.sh" }] }
282///     ]
283///   }
284/// }
285/// ```
286fn write_hooks_settings(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
287    let path = target_dir.join("settings.local.json");
288
289    let mut root: serde_json::Value = if path.is_file() {
290        super::parse_json_file(&path)?
291    } else {
292        serde_json::json!({})
293    };
294
295    let hooks_section = root
296        .as_object_mut()
297        .ok_or_else(|| {
298            MarsError::Config(crate::error::ConfigError::Invalid {
299                message: format!("{} is not a JSON object", path.display()),
300            })
301        })?
302        .entry("hooks")
303        .or_insert_with(|| serde_json::json!({}));
304
305    let hooks_map = hooks_section.as_object_mut().ok_or_else(|| {
306        MarsError::Config(crate::error::ConfigError::Invalid {
307            message: format!("{}: hooks is not an object", path.display()),
308        })
309    })?;
310
311    for hook in hooks {
312        super::append_json_event_entries(hooks_map, &hook.native_event, &hook.entries, &path)?;
313    }
314
315    let content = serde_json::to_string_pretty(&root).map_err(|e| {
316        MarsError::Config(crate::error::ConfigError::Invalid {
317            message: format!("failed to serialize {}: {e}", path.display()),
318        })
319    })?;
320    crate::fs::atomic_write(&path, content.as_bytes())?;
321
322    Ok(path)
323}
324
325fn remove_managed_hook_bindings(bindings: &mut Vec<serde_json::Value>, hook_name: &str) {
326    bindings.retain(|binding| {
327        let Some(inner_hooks) = binding.get("hooks").and_then(|h| h.as_array()) else {
328            return true;
329        };
330        !inner_hooks.iter().any(|h| {
331            h.get("command")
332                .and_then(|c| c.as_str())
333                .map(|cmd| is_managed_hook_command_for(cmd, hook_name))
334                .unwrap_or(false)
335        })
336    });
337}
338
339fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
340    let normalized = command.replace('\\', "/").replace("//", "/");
341    normalized.contains(&format!("/hooks/{hook_name}/"))
342}
343
344fn remove_owned_claude_hooks(
345    records: &std::collections::BTreeMap<String, crate::lock::ConfigEntryRecord>,
346    target_dir: &Path,
347    diag: &mut crate::diagnostic::DiagnosticCollector,
348) -> crate::surface_ownership::retention::RemovalReport {
349    if let Err(error) = remove_owned_claude_hooks_from_file(
350        records,
351        &target_dir.join("settings.local.json"),
352        Some(diag),
353    ) {
354        return crate::surface_ownership::retention::RemovalReport::failed(error, records.clone());
355    }
356    // One-release bridge: v0.11.0 command-path emissions and pre-local-settings residue.
357    // Delete with the other #130 sweeps after the next release.
358    let legacy_records: std::collections::BTreeMap<_, _> = records
359        .iter()
360        .filter(|(_, record)| record.emitted_json.is_none())
361        .map(|(key, record)| (key.clone(), record.clone()))
362        .collect();
363    if legacy_records.is_empty() {
364        return crate::surface_ownership::retention::RemovalReport::confirmed();
365    }
366    match remove_owned_claude_hooks_from_file(
367        &legacy_records,
368        &target_dir.join("settings.json"),
369        None,
370    ) {
371        Ok(()) => crate::surface_ownership::retention::RemovalReport::confirmed(),
372        Err(error) => {
373            crate::surface_ownership::retention::RemovalReport::failed(error, legacy_records)
374        }
375    }
376}
377
378fn remove_owned_claude_hooks_from_file(
379    records: &std::collections::BTreeMap<String, crate::lock::ConfigEntryRecord>,
380    path: &Path,
381    mut diag: Option<&mut crate::diagnostic::DiagnosticCollector>,
382) -> Result<(), MarsError> {
383    if !path.is_file() {
384        return Ok(());
385    }
386    let mut root = super::parse_json_file(path)?;
387    let mut changed = false;
388    if let Some(hooks_map) = root
389        .as_object_mut()
390        .and_then(|o| o.get_mut("hooks"))
391        .and_then(|v| v.as_object_mut())
392    {
393        let mut emptied_events = std::collections::BTreeSet::new();
394        for (key, record) in records.iter().filter(|(key, _)| key.starts_with("hook:")) {
395            let Some((event, name)) = key
396                .strip_prefix("hook:")
397                .and_then(|rest| rest.split_once(':'))
398            else {
399                continue;
400            };
401            if let Some(expected) = record
402                .emitted_json
403                .as_deref()
404                .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(json).ok())
405            {
406                let update = super::remove_json_event_entries(hooks_map, event, &expected);
407                changed |= update.changed;
408                if update.missing > 0
409                    && let Some(diag) = diag.as_deref_mut()
410                {
411                    diag.warn(
412                        "config-divergence",
413                        format!(
414                            "config-divergence: managed hook `{name}` diverged in target `.claude` at `{}`; preserving edited config and appending the package entry",
415                            path.display()
416                        ),
417                    );
418                }
419            } else {
420                for (event, value) in hooks_map.iter_mut() {
421                    if let Some(bindings) = value.as_array_mut() {
422                        let before = bindings.len();
423                        remove_managed_hook_bindings(bindings, name);
424                        changed |= bindings.len() != before;
425                        if before > 0 && bindings.is_empty() {
426                            emptied_events.insert(event.clone());
427                        }
428                    }
429                }
430            }
431        }
432        for event in emptied_events {
433            hooks_map.remove(&event);
434        }
435    }
436    if changed
437        && root
438            .get("hooks")
439            .and_then(|v| v.as_object())
440            .is_some_and(serde_json::Map::is_empty)
441    {
442        root.as_object_mut().unwrap().remove("hooks");
443    }
444    if !changed {
445        return Ok(());
446    }
447    crate::fs::atomic_write(
448        path,
449        serde_json::to_string_pretty(&root)
450            .map_err(|e| {
451                MarsError::Config(ConfigError::Invalid {
452                    message: format!("failed to serialize {}: {e}", path.display()),
453                })
454            })?
455            .as_bytes(),
456    )
457}
458
459// ---------------------------------------------------------------------------
460// Tests
461// ---------------------------------------------------------------------------
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::surface_ownership::retention::{Surface, WritePermit};
467
468    fn write_permit(entries: &[ConfigEntry]) -> WritePermit<'static> {
469        WritePermit::for_test("", entries[0].surface())
470    }
471    use indexmap::IndexMap;
472    use tempfile::TempDir;
473
474    fn make_mcp_entry(name: &str) -> ConfigEntry {
475        ConfigEntry::McpServer(McpServerEntry {
476            name: name.to_string(),
477            command: "npx".to_string(),
478            args: vec!["-y".to_string(), "some-mcp@latest".to_string()],
479            env: IndexMap::new(),
480        })
481    }
482
483    fn make_mcp_entry_with_env(name: &str, env_key: &str, env_var: &str) -> ConfigEntry {
484        let mut env = IndexMap::new();
485        env.insert(env_key.to_string(), env_var.to_string());
486        ConfigEntry::McpServer(McpServerEntry {
487            name: name.to_string(),
488            command: "npx".to_string(),
489            args: vec![],
490            env,
491        })
492    }
493
494    fn make_hook_entry(name: &str, _event: &str, native: &str) -> ConfigEntry {
495        ConfigEntry::Hook(HookEntry {
496            name: name.to_string(),
497            native_event: native.to_string(),
498            entries: vec![
499                serde_json::json!({"hooks": [{"type": "command", "command": format!("bash '/hooks/{name}/run.sh'")} ]}),
500            ],
501        })
502    }
503
504    fn make_hook_entry_with_path(
505        name: &str,
506        _event: &str,
507        native: &str,
508        script_path: &str,
509    ) -> ConfigEntry {
510        ConfigEntry::Hook(HookEntry {
511            name: name.to_string(),
512            native_event: native.to_string(),
513            entries: vec![
514                serde_json::json!({"hooks": [{"type": "command", "command": format!("bash '{script_path}'")} ]}),
515            ],
516        })
517    }
518
519    #[test]
520    fn write_mcp_creates_mcp_json() {
521        let tmp = TempDir::new().unwrap();
522        std::fs::create_dir_all(tmp.path()).unwrap();
523
524        let adapter = ClaudeAdapter;
525        let entries = vec![make_mcp_entry("context7")];
526        let written = adapter
527            .write_config_entries(
528                write_permit(&entries)
529                    .bind_config_entries(entries.clone())
530                    .unwrap(),
531                tmp.path(),
532            )
533            .unwrap();
534
535        assert_eq!(written.len(), 1);
536        assert!(tmp.path().join(".mcp.json").exists());
537
538        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
539        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
540        assert!(json["mcpServers"]["context7"].is_object());
541        assert_eq!(json["mcpServers"]["context7"]["command"], "npx");
542    }
543
544    #[test]
545    fn write_mcp_merges_with_existing() {
546        let tmp = TempDir::new().unwrap();
547        let existing = serde_json::json!({
548            "mcpServers": { "existing-server": { "command": "old" } }
549        });
550        std::fs::write(
551            tmp.path().join(".mcp.json"),
552            serde_json::to_string_pretty(&existing).unwrap(),
553        )
554        .unwrap();
555
556        let adapter = ClaudeAdapter;
557        let entries = vec![make_mcp_entry("new-server")];
558        adapter
559            .write_config_entries(
560                write_permit(&entries)
561                    .bind_config_entries(entries.clone())
562                    .unwrap(),
563                tmp.path(),
564            )
565            .unwrap();
566
567        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
568        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
569        assert!(json["mcpServers"]["existing-server"].is_object());
570        assert!(json["mcpServers"]["new-server"].is_object());
571    }
572
573    #[test]
574    fn write_mcp_env_renders_as_interpolation() {
575        let tmp = TempDir::new().unwrap();
576        let adapter = ClaudeAdapter;
577        let entries = vec![make_mcp_entry_with_env("server", "API_KEY", "MY_SECRET")];
578        adapter
579            .write_config_entries(
580                write_permit(&entries)
581                    .bind_config_entries(entries.clone())
582                    .unwrap(),
583                tmp.path(),
584            )
585            .unwrap();
586
587        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
588        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
589        assert_eq!(
590            json["mcpServers"]["server"]["env"]["API_KEY"],
591            "${MY_SECRET}"
592        );
593    }
594
595    #[test]
596    fn write_hooks_creates_settings_local_json() {
597        let tmp = TempDir::new().unwrap();
598        let adapter = ClaudeAdapter;
599        let entries = vec![make_hook_entry("audit", "tool.pre", "PreToolUse")];
600        let written = adapter
601            .write_config_entries(
602                write_permit(&entries)
603                    .bind_config_entries(entries.clone())
604                    .unwrap(),
605                tmp.path(),
606            )
607            .unwrap();
608
609        assert_eq!(written.len(), 1);
610        assert!(tmp.path().join("settings.local.json").exists());
611        assert!(!tmp.path().join("settings.json").exists());
612
613        let raw = std::fs::read_to_string(tmp.path().join("settings.local.json")).unwrap();
614        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
615        assert!(json["hooks"]["PreToolUse"].is_array());
616        assert!(!json["hooks"]["PreToolUse"].as_array().unwrap().is_empty());
617    }
618
619    #[test]
620    fn write_hooks_appends_opaque_entries_in_call_order() {
621        let tmp = TempDir::new().unwrap();
622        let adapter = ClaudeAdapter;
623        adapter
624            .write_config_entries(
625                WritePermit::for_test("", Surface::Hook)
626                    .bind_config_entries(vec![make_hook_entry_with_path(
627                        "audit",
628                        "tool.pre",
629                        "PreToolUse",
630                        "/old/hooks/audit/run.sh",
631                    )])
632                    .unwrap(),
633                tmp.path(),
634            )
635            .unwrap();
636        adapter
637            .write_config_entries(
638                WritePermit::for_test("", Surface::Hook)
639                    .bind_config_entries(vec![make_hook_entry_with_path(
640                        "audit",
641                        "tool.pre",
642                        "PreToolUse",
643                        "/new/hooks/audit/run.sh",
644                    )])
645                    .unwrap(),
646                tmp.path(),
647            )
648            .unwrap();
649
650        let raw = std::fs::read_to_string(tmp.path().join("settings.local.json")).unwrap();
651        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
652        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
653        assert_eq!(hooks.len(), 2);
654        assert!(
655            hooks[0]["hooks"][0]["command"]
656                .as_str()
657                .unwrap()
658                .contains("/old/hooks/audit/")
659        );
660        assert!(
661            hooks[1]["hooks"][0]["command"]
662                .as_str()
663                .unwrap()
664                .contains("/new/hooks/audit/")
665        );
666    }
667
668    #[test]
669    fn remove_mcp_entries_removes_by_name() {
670        let tmp = TempDir::new().unwrap();
671        let adapter = ClaudeAdapter;
672        let entries = vec![make_mcp_entry("context7"), make_mcp_entry("other")];
673        adapter
674            .write_config_entries(
675                write_permit(&entries)
676                    .bind_config_entries(entries.clone())
677                    .unwrap(),
678                tmp.path(),
679            )
680            .unwrap();
681
682        remove_mcp_entries_by_key(&["mcp:context7".to_string()], tmp.path()).unwrap();
683
684        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
685        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
686        assert!(json["mcpServers"]["context7"].is_null());
687        assert!(json["mcpServers"]["other"].is_object());
688    }
689
690    #[test]
691    fn legacy_failure_retains_only_records_not_removed_by_the_successful_current_write() {
692        let tmp = TempDir::new().unwrap();
693        std::fs::write(
694            tmp.path().join("settings.local.json"),
695            r#"{"hooks":{"PreToolUse":[{"matcher":"owned"}]}}"#,
696        )
697        .unwrap();
698        std::fs::write(tmp.path().join("settings.json"), "{ malformed").unwrap();
699        let structural_key = "hook:PreToolUse:current".to_owned();
700        let legacy_key = "hook:PreToolUse:legacy".to_owned();
701        let records = std::collections::BTreeMap::from([
702            (
703                structural_key.clone(),
704                crate::lock::ConfigEntryRecord {
705                    emitted_json: Some(r#"[{"matcher":"owned"}]"#.to_owned()),
706                },
707            ),
708            (
709                legacy_key.clone(),
710                crate::lock::ConfigEntryRecord { emitted_json: None },
711            ),
712        ]);
713
714        let report = remove_owned_claude_hooks(
715            &records,
716            tmp.path(),
717            &mut crate::diagnostic::DiagnosticCollector::new(),
718        );
719
720        let crate::surface_ownership::retention::RemovalReport::Unconfirmed { retained, .. } =
721            report
722        else {
723            panic!("malformed legacy file must make removal unconfirmed");
724        };
725        assert_eq!(retained.keys().collect::<Vec<_>>(), [&legacy_key]);
726        assert!(!retained.contains_key(&structural_key));
727        let current: serde_json::Value = serde_json::from_str(
728            &std::fs::read_to_string(tmp.path().join("settings.local.json")).unwrap(),
729        )
730        .unwrap();
731        assert!(current.get("hooks").is_none());
732    }
733
734    #[test]
735    fn write_mcp_and_hooks_both_written() {
736        let tmp = TempDir::new().unwrap();
737        let adapter = ClaudeAdapter;
738        let mcp_entries = vec![make_mcp_entry("context7")];
739        let hook_entries = vec![make_hook_entry("audit", "tool.pre", "PreToolUse")];
740        let mut written = adapter
741            .write_config_entries(
742                write_permit(&mcp_entries)
743                    .bind_config_entries(mcp_entries)
744                    .unwrap(),
745                tmp.path(),
746            )
747            .unwrap();
748        written.extend(
749            adapter
750                .write_config_entries(
751                    write_permit(&hook_entries)
752                        .bind_config_entries(hook_entries)
753                        .unwrap(),
754                    tmp.path(),
755                )
756                .unwrap(),
757        );
758        assert_eq!(written.len(), 2);
759        assert!(tmp.path().join(".mcp.json").exists());
760        assert!(tmp.path().join("settings.local.json").exists());
761        assert!(!tmp.path().join("settings.json").exists());
762    }
763
764    #[test]
765    fn remove_hook_entries_matches_backslash_commands() {
766        let tmp = TempDir::new().unwrap();
767        let existing = serde_json::json!({
768            "hooks": {
769                "PreToolUse": [
770                    {
771                        "matcher": "",
772                        "hooks": [
773                            { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit\\\\run.sh\"" }
774                        ]
775                    },
776                    {
777                        "matcher": "",
778                        "hooks": [
779                            { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit-extended\\\\run.sh\"" }
780                        ]
781                    }
782                ]
783            }
784        });
785        std::fs::write(
786            tmp.path().join("settings.local.json"),
787            serde_json::to_string_pretty(&existing).unwrap(),
788        )
789        .unwrap();
790
791        let records = std::collections::BTreeMap::from([(
792            "hook:tool.pre:audit".to_string(),
793            crate::lock::ConfigEntryRecord { emitted_json: None },
794        )]);
795        remove_owned_claude_hooks(
796            &records,
797            tmp.path(),
798            &mut crate::diagnostic::DiagnosticCollector::new(),
799        )
800        .unwrap();
801
802        let raw = std::fs::read_to_string(tmp.path().join("settings.local.json")).unwrap();
803        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
804        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
805        assert_eq!(hooks.len(), 1);
806    }
807
808    #[test]
809    fn divergent_structural_removal_does_not_rewrite_settings() {
810        let tmp = TempDir::new().unwrap();
811        let path = tmp.path().join("settings.local.json");
812        let original =
813            br#"{"hooks":{"SessionStart":[{"hooks":[{"command":"edited"}]}]},"keep":true}"#;
814        std::fs::write(&path, original).unwrap();
815        let before_modified = std::fs::metadata(&path).unwrap().modified().unwrap();
816        std::thread::sleep(std::time::Duration::from_millis(20));
817        let records = std::collections::BTreeMap::from([(
818            "hook:SessionStart:audit".to_string(),
819            crate::lock::ConfigEntryRecord {
820                emitted_json: Some(
821                    serde_json::json!([{"hooks":[{"command":"original"}]}]).to_string(),
822                ),
823            },
824        )]);
825
826        remove_owned_claude_hooks(
827            &records,
828            tmp.path(),
829            &mut crate::diagnostic::DiagnosticCollector::new(),
830        )
831        .unwrap();
832
833        assert_eq!(std::fs::read(&path).unwrap(), original);
834        assert_eq!(
835            std::fs::metadata(&path).unwrap().modified().unwrap(),
836            before_modified
837        );
838    }
839
840    #[test]
841    fn write_hooks_never_name_matches_committed_settings_json() {
842        let tmp = TempDir::new().unwrap();
843
844        // Without a legacy lock record, path-like user commands are not evidence
845        // of ownership and the committed file must remain byte-for-byte intact.
846        let stale = serde_json::json!({
847            "hooks": {
848                "PreToolUse": [
849                    {
850                        "matcher": "",
851                        "hooks": [
852                            { "type": "command", "command": "bash /old/cache/hooks/audit/run.sh" }
853                        ]
854                    },
855                    {
856                        "matcher": "",
857                        "hooks": [
858                            { "type": "command", "command": "echo user-owned" }
859                        ]
860                    }
861                ]
862            }
863        });
864        std::fs::write(
865            tmp.path().join("settings.json"),
866            serde_json::to_string_pretty(&stale).unwrap(),
867        )
868        .unwrap();
869        let before = std::fs::read(tmp.path().join("settings.json")).unwrap();
870
871        let adapter = ClaudeAdapter;
872        let entries = vec![make_hook_entry("audit", "tool.pre", "PreToolUse")];
873        adapter
874            .write_config_entries(
875                write_permit(&entries)
876                    .bind_config_entries(entries.clone())
877                    .unwrap(),
878                tmp.path(),
879            )
880            .unwrap();
881
882        // New hook lands in settings.local.json.
883        let local_raw = std::fs::read_to_string(tmp.path().join("settings.local.json")).unwrap();
884        let local: serde_json::Value = serde_json::from_str(&local_raw).unwrap();
885        let local_hooks = local["hooks"]["PreToolUse"].as_array().unwrap();
886        assert_eq!(local_hooks.len(), 1);
887        assert!(
888            local_hooks[0]["hooks"][0]["command"]
889                .as_str()
890                .unwrap()
891                .contains("/hooks/audit/")
892        );
893
894        assert_eq!(
895            std::fs::read(tmp.path().join("settings.json")).unwrap(),
896            before
897        );
898    }
899}