1#![warn(missing_docs)]
36
37mod error;
38mod registry;
39mod settings;
40mod types;
41
42pub use error::{Error, HookError, RegistryError, Result, SettingsError};
44pub use types::{HookEvent, HookHandler, ListEntry, MatcherGroup, RegistryEntry, RegistryMetadata};
45
46pub fn install(
74 event: HookEvent,
75 handler: HookHandler,
76 matcher: Option<String>,
77 installed_by: &str,
78) -> Result<()> {
79 use chrono::Local;
80
81 let registry_entries = registry::read_registry()?;
83
84 if registry_entries
86 .iter()
87 .any(|e| e.matches(event, &handler.command))
88 {
89 return Err(HookError::AlreadyExists {
90 event,
91 command: handler.command.clone(),
92 }
93 .into());
94 }
95
96 let settings_value = settings::read_settings()?;
98
99 let existing_hooks = settings::list_hooks(&settings_value);
101 for (hook_event, _, hook_handler) in &existing_hooks {
102 if *hook_event == event && hook_handler.command == handler.command {
103 return Err(HookError::AlreadyExists {
104 event,
105 command: handler.command.clone(),
106 }
107 .into());
108 }
109 }
110
111 let updated_settings = settings::add_hook(settings_value, event, handler.clone(), matcher.clone());
113
114 settings::write_settings_atomic(updated_settings)?;
116
117 let timestamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
119 let entry = RegistryEntry {
120 event,
121 matcher,
122 r#type: handler.r#type.clone(),
123 command: handler.command.clone(),
124 timeout: handler.timeout,
125 r#async: handler.r#async,
126 scope: "user".to_string(),
127 enabled: true,
128 added_at: timestamp,
129 installed_by: installed_by.to_string(),
130 description: None,
131 reason: None,
132 optional: None,
133 };
134
135 let updated_registry = registry::add_entry(registry_entries, entry);
137
138 if let Err(e) = registry::write_registry(updated_registry) {
140 log::warn!("Failed to write registry after successful settings write: {}", e);
141 log::warn!(
142 "Hook installed but not tracked. Remove manually from settings.json if needed."
143 );
144 }
145
146 Ok(())
147}
148
149pub fn uninstall(event: HookEvent, command: &str) -> Result<()> {
169 let registry_entries = registry::read_registry()?;
171
172 if !registry_entries.iter().any(|e| e.matches(event, command)) {
174 return Err(HookError::NotManaged {
175 event,
176 command: command.to_string(),
177 }
178 .into());
179 }
180
181 let settings_value = settings::read_settings()?;
183
184 let existing_hooks = settings::list_hooks(&settings_value);
186 let hook_in_settings = existing_hooks
187 .iter()
188 .any(|(e, _, h)| *e == event && h.command == command);
189
190 if !hook_in_settings {
191 log::warn!(
192 "Hook in registry but not in settings.json: {:?} - {}",
193 event,
194 command
195 );
196 log::warn!("Removing from registry anyway (user may have manually deleted)");
197 }
198
199 let updated_settings = settings::remove_hook(settings_value, event, command);
201
202 settings::write_settings_atomic(updated_settings)?;
204
205 let updated_registry = registry::remove_entry(registry_entries, event, command);
207
208 if let Err(e) = registry::write_registry(updated_registry) {
210 log::warn!("Failed to write registry after successful settings write: {}", e);
211 log::warn!("Hook removed but registry dirty. May show as managed until registry fixed.");
212 }
213
214 Ok(())
215}
216
217pub fn list() -> Result<Vec<ListEntry>> {
238 let registry_entries = registry::read_registry()?;
240
241 let settings_value = settings::read_settings()?;
243
244 let hooks = settings::list_hooks(&settings_value);
246
247 let mut results = Vec::new();
248
249 for (event, _matcher, handler) in hooks {
250 let registry_entry = registry_entries
252 .iter()
253 .find(|e| e.matches(event, &handler.command));
254
255 let (managed, metadata) = if let Some(entry) = registry_entry {
256 let metadata = RegistryMetadata {
257 added_at: entry.added_at.clone(),
258 installed_by: entry.installed_by.clone(),
259 description: entry.description.clone(),
260 reason: entry.reason.clone(),
261 optional: entry.optional,
262 };
263 (true, Some(metadata))
264 } else {
265 (false, None)
266 };
267
268 results.push(ListEntry {
269 event,
270 handler,
271 managed,
272 metadata,
273 });
274 }
275
276 Ok(results)
277}
278
279#[cfg(test)]
280mod integration_tests {
281 use super::*;
282 use serial_test::serial;
283 use std::env;
284 use std::fs;
285 use tempfile::tempdir;
286
287 fn setup_test_env() -> tempfile::TempDir {
289 let dir = tempdir().expect("Failed to create temp directory");
290
291 env::set_var("HOME", dir.path());
293
294 let claude_dir = dir.path().join(".claude");
296 fs::create_dir_all(&claude_dir).expect("Failed to create .claude directory");
297
298 let settings = serde_json::json!({
300 "hooks": {},
301 "cleanupPeriodDays": 7
302 });
303 let settings_path = claude_dir.join("settings.json");
304 fs::write(
305 &settings_path,
306 serde_json::to_string_pretty(&settings).expect("Failed to serialize settings"),
307 )
308 .expect("Failed to write settings.json");
309
310 dir
311 }
312
313 #[test]
314 #[serial(home)]
315 fn test_install_list_uninstall_workflow() {
316 let _dir = setup_test_env();
317
318 let handler = HookHandler {
320 r#type: "command".to_string(),
321 command: "/path/to/stop.sh".to_string(),
322 timeout: Some(600),
323 r#async: None,
324 status_message: None,
325 };
326
327 let result = install(HookEvent::Stop, handler.clone(), None, "test");
328 assert!(result.is_ok(), "Install should succeed: {:?}", result.err());
329
330 let entries = list().expect("List should succeed");
332 assert_eq!(entries.len(), 1, "Should have exactly 1 hook");
333 assert!(entries[0].managed, "Hook should be managed");
334 assert_eq!(entries[0].event, HookEvent::Stop);
335 assert_eq!(entries[0].handler.command, "/path/to/stop.sh");
336 assert!(
337 entries[0].metadata.is_some(),
338 "Managed hook should have metadata"
339 );
340
341 let result = uninstall(HookEvent::Stop, "/path/to/stop.sh");
343 assert!(
344 result.is_ok(),
345 "Uninstall should succeed: {:?}",
346 result.err()
347 );
348
349 let entries = list().expect("List should succeed");
351 assert_eq!(entries.len(), 0, "Should have no hooks after uninstall");
352 }
353
354 #[test]
355 #[serial(home)]
356 fn test_install_duplicate_fails() {
357 let _dir = setup_test_env();
358
359 let handler = HookHandler {
360 r#type: "command".to_string(),
361 command: "/path/to/stop.sh".to_string(),
362 timeout: Some(600),
363 r#async: None,
364 status_message: None,
365 };
366
367 let result = install(HookEvent::Stop, handler.clone(), None, "test");
369 assert!(result.is_ok(), "First install should succeed");
370
371 let result = install(HookEvent::Stop, handler, None, "test");
373 assert!(result.is_err(), "Second install should fail");
374
375 match result.unwrap_err() {
376 Error::Hook(HookError::AlreadyExists { event, command }) => {
377 assert_eq!(event, HookEvent::Stop);
378 assert_eq!(command, "/path/to/stop.sh");
379 }
380 e => panic!("Expected AlreadyExists error, got: {:?}", e),
381 }
382 }
383
384 #[test]
385 #[serial(home)]
386 fn test_uninstall_unmanaged_fails() {
387 let _dir = setup_test_env();
388
389 let result = uninstall(HookEvent::Stop, "/unmanaged/hook.sh");
391 assert!(result.is_err(), "Uninstall of unmanaged hook should fail");
392
393 match result.unwrap_err() {
394 Error::Hook(HookError::NotManaged { event, command }) => {
395 assert_eq!(event, HookEvent::Stop);
396 assert_eq!(command, "/unmanaged/hook.sh");
397 }
398 e => panic!("Expected NotManaged error, got: {:?}", e),
399 }
400 }
401
402 #[test]
403 #[serial(home)]
404 fn test_list_shows_unmanaged_hooks() {
405 let _dir = setup_test_env();
406
407 let settings = settings::read_settings().expect("Failed to read settings");
409 let handler = HookHandler {
410 r#type: "command".to_string(),
411 command: "/unmanaged/hook.sh".to_string(),
412 timeout: None,
413 r#async: None,
414 status_message: None,
415 };
416 let updated = settings::add_hook(settings, HookEvent::SessionStart, handler, None);
417 settings::write_settings_atomic(updated).expect("Failed to write settings");
418
419 let entries = list().expect("List should succeed");
421 assert_eq!(entries.len(), 1, "Should have 1 hook");
422 assert!(!entries[0].managed, "Hook should be unmanaged");
423 assert_eq!(entries[0].event, HookEvent::SessionStart);
424 assert!(
425 entries[0].metadata.is_none(),
426 "Unmanaged hook should not have metadata"
427 );
428 }
429
430 #[test]
431 #[serial(home)]
432 fn test_install_multiple_hooks() {
433 let _dir = setup_test_env();
434
435 let stop_handler = HookHandler {
437 r#type: "command".to_string(),
438 command: "/path/to/stop.sh".to_string(),
439 timeout: Some(600),
440 r#async: None,
441 status_message: None,
442 };
443 install(HookEvent::Stop, stop_handler, None, "test").expect("Stop install should succeed");
444
445 let start_handler = HookHandler {
447 r#type: "command".to_string(),
448 command: "/path/to/start.sh".to_string(),
449 timeout: Some(300),
450 r#async: None,
451 status_message: None,
452 };
453 install(HookEvent::SessionStart, start_handler, None, "test").expect("SessionStart install should succeed");
454
455 let entries = list().expect("List should succeed");
457 assert_eq!(entries.len(), 2, "Should have 2 hooks");
458 assert!(entries.iter().all(|e| e.managed), "All hooks should be managed");
459
460 let events: Vec<HookEvent> = entries.iter().map(|e| e.event).collect();
462 assert!(events.contains(&HookEvent::Stop));
463 assert!(events.contains(&HookEvent::SessionStart));
464 }
465
466 #[test]
467 #[serial(home)]
468 fn test_uninstall_preserves_other_hooks() {
469 let _dir = setup_test_env();
470
471 let stop_handler = HookHandler {
473 r#type: "command".to_string(),
474 command: "/path/to/stop.sh".to_string(),
475 timeout: Some(600),
476 r#async: None,
477 status_message: None,
478 };
479 install(HookEvent::Stop, stop_handler, None, "test").expect("Stop install should succeed");
480
481 let start_handler = HookHandler {
482 r#type: "command".to_string(),
483 command: "/path/to/start.sh".to_string(),
484 timeout: Some(300),
485 r#async: None,
486 status_message: None,
487 };
488 install(HookEvent::SessionStart, start_handler, None, "test").expect("SessionStart install should succeed");
489
490 uninstall(HookEvent::Stop, "/path/to/stop.sh").expect("Uninstall should succeed");
492
493 let entries = list().expect("List should succeed");
495 assert_eq!(entries.len(), 1, "Should have 1 hook remaining");
496 assert_eq!(entries[0].event, HookEvent::SessionStart);
497 assert_eq!(entries[0].handler.command, "/path/to/start.sh");
498 }
499
500 #[test]
501 #[serial(home)]
502 fn test_install_with_optional_fields() {
503 let _dir = setup_test_env();
504
505 let handler = HookHandler {
507 r#type: "command".to_string(),
508 command: "/path/to/async.sh".to_string(),
509 timeout: Some(900),
510 r#async: Some(true),
511 status_message: Some("Running...".to_string()),
512 };
513
514 install(HookEvent::PostToolUse, handler, None, "test").expect("Install should succeed");
515
516 let entries = list().expect("List should succeed");
518 assert_eq!(entries.len(), 1);
519 assert_eq!(entries[0].handler.timeout, Some(900));
520 assert_eq!(entries[0].handler.r#async, Some(true));
521 }
522
523 #[test]
524 #[serial(home)]
525 fn test_metadata_is_preserved() {
526 let _dir = setup_test_env();
527
528 let handler = HookHandler {
530 r#type: "command".to_string(),
531 command: "/path/to/test.sh".to_string(),
532 timeout: None,
533 r#async: None,
534 status_message: None,
535 };
536
537 install(HookEvent::Stop, handler, None, "test-installer").expect("Install should succeed");
538
539 let entries = list().expect("List should succeed");
541 assert_eq!(entries.len(), 1);
542
543 let metadata = entries[0].metadata.as_ref().expect("Should have metadata");
544 assert_eq!(metadata.installed_by, "test-installer");
545 assert!(!metadata.added_at.is_empty(), "Should have timestamp");
546 }
547
548 #[test]
549 #[serial(home)]
550 fn test_hook_in_registry_but_not_settings() {
551 let _dir = setup_test_env();
552
553 let handler = HookHandler {
555 r#type: "command".to_string(),
556 command: "/path/to/test.sh".to_string(),
557 timeout: None,
558 r#async: None,
559 status_message: None,
560 };
561
562 install(HookEvent::Stop, handler, None, "test").expect("Install should succeed");
563
564 let settings = settings::read_settings().expect("Failed to read settings");
566 let updated = settings::remove_hook(settings, HookEvent::Stop, "/path/to/test.sh");
567 settings::write_settings_atomic(updated).expect("Failed to write settings");
568
569 let result = uninstall(HookEvent::Stop, "/path/to/test.sh");
571 assert!(
572 result.is_ok(),
573 "Uninstall should succeed even if hook not in settings"
574 );
575
576 let entries = list().expect("List should succeed");
578 assert_eq!(entries.len(), 0, "Should have no hooks");
579 }
580
581 #[test]
582 #[serial(home)]
583 fn test_different_commands_same_event() {
584 let _dir = setup_test_env();
585
586 let handler1 = HookHandler {
588 r#type: "command".to_string(),
589 command: "/path/to/stop1.sh".to_string(),
590 timeout: None,
591 r#async: None,
592 status_message: None,
593 };
594 install(HookEvent::Stop, handler1, None, "test").expect("First install should succeed");
595
596 let handler2 = HookHandler {
597 r#type: "command".to_string(),
598 command: "/path/to/stop2.sh".to_string(),
599 timeout: None,
600 r#async: None,
601 status_message: None,
602 };
603 install(HookEvent::Stop, handler2, None, "test").expect("Second install should succeed");
604
605 let entries = list().expect("List should succeed");
607 assert_eq!(entries.len(), 2, "Should have 2 hooks");
608
609 assert!(entries.iter().all(|e| e.event == HookEvent::Stop));
611
612 let commands: Vec<&str> = entries.iter().map(|e| e.handler.command.as_str()).collect();
614 assert!(commands.contains(&"/path/to/stop1.sh"));
615 assert!(commands.contains(&"/path/to/stop2.sh"));
616
617 uninstall(HookEvent::Stop, "/path/to/stop1.sh").expect("Uninstall should succeed");
619
620 let entries = list().expect("List should succeed");
622 assert_eq!(entries.len(), 1, "Should have 1 hook remaining");
623 assert_eq!(entries[0].handler.command, "/path/to/stop2.sh");
624 }
625
626 #[test]
627 #[serial(home)]
628 fn test_install_with_matcher() {
629 let _dir = setup_test_env();
630
631 let handler = HookHandler {
633 r#type: "command".to_string(),
634 command: "/path/to/pre-bash.sh".to_string(),
635 timeout: Some(10),
636 r#async: None,
637 status_message: None,
638 };
639
640 install(HookEvent::PreToolUse, handler, Some("Bash".to_string()), "test")
641 .expect("Install should succeed");
642
643 let entries = list().expect("List should succeed");
645 assert_eq!(entries.len(), 1);
646 assert_eq!(entries[0].event, HookEvent::PreToolUse);
647 assert_eq!(entries[0].handler.command, "/path/to/pre-bash.sh");
648 }
649}