1use 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 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 _ => 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
159fn write_mcp_json(target_dir: &Path, servers: &[&McpServerEntry]) -> Result<PathBuf, MarsError> {
180 let path = target_dir.join(".mcp.json");
181
182 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 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
234fn 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 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
266fn 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 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#[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 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 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}