1use std::collections::BTreeMap;
11use std::path::Path;
12
13use anyhow::{Context, Result};
14use serde::Deserialize;
15
16use super::{HookConfig, HookEvent};
17
18#[derive(Debug, Deserialize)]
20struct HooksFile {
21 #[serde(default)]
22 hooks: BTreeMap<String, HookEntry>,
23}
24
25#[derive(Debug, Deserialize)]
27struct HookEntry {
28 pub event: String,
29 #[serde(default)]
30 pub matcher: Option<String>,
31 pub command: String,
32 #[serde(default = "default_timeout")]
33 pub timeout_ms: u64,
34 #[serde(default)]
35 pub disabled: bool,
36}
37
38fn default_timeout() -> u64 {
39 10_000
40}
41
42pub fn load_hooks_config(project_dir: &Path) -> Vec<HookConfig> {
48 let global_path = crate::config::Config::config_dir().join("hooks.json");
49 let project_path = project_dir.join(".hooks.json");
50
51 let mut merged: BTreeMap<String, HookConfig> = BTreeMap::new();
52
53 if let Ok(hooks) = load_hooks_file(&global_path) {
55 for (name, hook) in hooks {
56 merged.insert(name, hook);
57 }
58 }
59
60 for assets in crate::plugin::loader::iter_installed_plugin_assets() {
67 if let Some(cc_map) = assets.manifest.inline_cc_hooks() {
68 for (name, hook) in cc_hooks_to_atomcode(cc_map, &assets.plugin_dir) {
69 let key = format!("{}:{}", assets.plugin, name);
70 merged.insert(key, hook);
71 }
72 continue;
73 }
74 let path = assets.hooks_file();
75 if let Ok(hooks) = load_hooks_file(&path) {
76 for (name, hook) in hooks {
77 let key = format!("{}:{}", assets.plugin, name);
78 merged.insert(key, hook);
79 }
80 }
81 }
82
83 if let Ok(hooks) = load_hooks_file(&project_path) {
85 for (name, hook) in hooks {
86 merged.insert(name, hook);
87 }
88 }
89
90 merged.into_values().collect()
91}
92
93pub(crate) fn cc_hooks_to_atomcode(
106 cc: &crate::plugin::manifest::CCHooksMap,
107 plugin_root: &Path,
108) -> Vec<(String, HookConfig)> {
109 use crate::plugin::manifest::CCHookGroup;
110
111 let mut out = Vec::new();
112
113 for (event_name, groups) in cc {
114 let event = match cc_event_name_to_event(event_name) {
115 Some(e) => e,
116 None => continue, };
118 for (gi, CCHookGroup { matcher, hooks }) in groups.iter().enumerate() {
119 for (hi, spec) in hooks.iter().enumerate() {
120 if spec.kind != "command" {
121 continue;
122 }
123 let timeout_ms = spec
125 .timeout
126 .map(|s| s.saturating_mul(1000))
127 .unwrap_or(10_000);
128 let name = format!("{}-{}-{}", event_name, gi, hi);
129 out.push((
130 name,
131 HookConfig {
132 event: event.clone(),
133 matcher: matcher.clone(),
134 command: spec.command.clone(),
135 timeout_ms,
136 plugin_root: Some(plugin_root.to_path_buf()),
137 },
138 ));
139 }
140 }
141 }
142 out
143}
144
145fn cc_event_name_to_event(name: &str) -> Option<HookEvent> {
149 Some(match name {
150 "PreToolUse" => HookEvent::PreToolUse,
151 "PostToolUse" => HookEvent::PostToolUse,
152 "SessionStart" => HookEvent::SessionStart,
153 "SessionEnd" => HookEvent::SessionEnd,
154 "Notification" => HookEvent::Notification,
155 "UserPromptSubmit" => HookEvent::UserPromptSubmit,
156 _ => return None,
157 })
158}
159
160fn parse_hook_event(name: &str) -> Option<HookEvent> {
161 serde_json::from_value::<HookEvent>(serde_json::Value::String(name.to_string()))
162 .ok()
163 .or_else(|| cc_event_name_to_event(name))
164}
165
166fn load_hooks_file(path: &Path) -> Result<Vec<(String, HookConfig)>> {
171 if !path.exists() {
172 return Ok(Vec::new());
173 }
174 let content = std::fs::read_to_string(path)
175 .with_context(|| format!("Failed to read hooks config from {}", path.display()))?;
176 let raw: HooksFile = serde_json::from_str(&content)
177 .with_context(|| format!("Failed to parse hooks config from {}", path.display()))?;
178
179 let mut configs = Vec::new();
180 for (name, entry) in raw.hooks {
181 if entry.disabled {
182 continue;
183 }
184 let Some(event) = parse_hook_event(&entry.event) else {
185 continue;
186 };
187 configs.push((
188 name,
189 HookConfig {
190 event,
191 matcher: entry.matcher,
192 command: entry.command,
193 timeout_ms: entry.timeout_ms,
194 plugin_root: None,
199 },
200 ));
201 }
202 Ok(configs)
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn cc_hooks_to_atomcode_records_plugin_root_without_substitution() {
211 use crate::plugin::manifest::{CCHookGroup, CCHookSpec};
216 let mut cc: crate::plugin::manifest::CCHooksMap = std::collections::BTreeMap::new();
217 cc.insert(
218 "UserPromptSubmit".into(),
219 vec![CCHookGroup {
220 matcher: None,
221 hooks: vec![CCHookSpec {
222 kind: "command".into(),
223 command: "python \"${CLAUDE_PLUGIN_ROOT}/h.py\"".into(),
224 timeout: Some(5),
225 }],
226 }],
227 );
228 let plugin_root = std::path::Path::new("/opt/x");
229 let out = cc_hooks_to_atomcode(&cc, plugin_root);
230 assert_eq!(out.len(), 1);
231 let (_, h) = &out[0];
232 assert_eq!(h.event, HookEvent::UserPromptSubmit);
233 assert_eq!(h.command, "python \"${CLAUDE_PLUGIN_ROOT}/h.py\"");
235 assert_eq!(h.plugin_root.as_deref(), Some(plugin_root));
236 assert_eq!(h.timeout_ms, 5_000); }
238
239 #[test]
240 fn cc_hooks_to_atomcode_skips_unsupported_events() {
241 use crate::plugin::manifest::{CCHookGroup, CCHookSpec};
242 let mut cc: crate::plugin::manifest::CCHooksMap = std::collections::BTreeMap::new();
243 cc.insert(
245 "Stop".into(),
246 vec![CCHookGroup {
247 matcher: None,
248 hooks: vec![CCHookSpec {
249 kind: "command".into(),
250 command: "echo".into(),
251 timeout: None,
252 }],
253 }],
254 );
255 assert!(cc_hooks_to_atomcode(&cc, std::path::Path::new("/")).is_empty());
256 }
257
258 #[test]
259 fn cc_hooks_to_atomcode_default_timeout_when_omitted() {
260 use crate::plugin::manifest::{CCHookGroup, CCHookSpec};
261 let mut cc: crate::plugin::manifest::CCHooksMap = std::collections::BTreeMap::new();
262 cc.insert(
263 "PreToolUse".into(),
264 vec![CCHookGroup {
265 matcher: Some("bash".into()),
266 hooks: vec![CCHookSpec {
267 kind: "command".into(),
268 command: "echo".into(),
269 timeout: None,
270 }],
271 }],
272 );
273 let out = cc_hooks_to_atomcode(&cc, std::path::Path::new("/"));
274 assert_eq!(out[0].1.timeout_ms, 10_000);
275 assert_eq!(out[0].1.matcher.as_deref(), Some("bash"));
276 }
277
278 #[test]
280 fn parse_single_hook() {
281 let json = r#"{
282 "hooks": {
283 "audit-all": {
284 "event": "pre_tool_use",
285 "command": "echo audit"
286 }
287 }
288 }"#;
289 let raw: HooksFile = serde_json::from_str(json).unwrap();
290 assert_eq!(raw.hooks.len(), 1);
291 let entry = &raw.hooks["audit-all"];
292 assert_eq!(entry.event, "pre_tool_use");
293 assert_eq!(entry.command, "echo audit");
294 assert_eq!(entry.timeout_ms, 10_000);
295 assert!(!entry.disabled);
296 }
297
298 #[test]
300 fn parse_multiple_hooks() {
301 let json = r#"{
302 "hooks": {
303 "audit": {
304 "event": "pre_tool_use",
305 "command": "echo audit"
306 },
307 "block-rm": {
308 "event": "pre_tool_use",
309 "matcher": "bash",
310 "command": "safety-check.sh",
311 "timeout_ms": 5000
312 },
313 "auto-format": {
314 "event": "post_tool_use",
315 "matcher": "edit_*",
316 "command": "cargo fmt"
317 }
318 }
319 }"#;
320 let raw: HooksFile = serde_json::from_str(json).unwrap();
321 assert_eq!(raw.hooks.len(), 3);
322 assert_eq!(raw.hooks["block-rm"].timeout_ms, 5000);
323 assert_eq!(
324 raw.hooks["block-rm"].matcher.as_deref(),
325 Some("bash")
326 );
327 assert_eq!(raw.hooks["auto-format"].event, "post_tool_use");
328 }
329
330 #[test]
332 fn disabled_hooks_are_skipped() {
333 let dir = tempfile::tempdir().unwrap();
334 let path = dir.path().join("hooks.json");
335 let json = r#"{
336 "hooks": {
337 "active": {
338 "event": "pre_tool_use",
339 "command": "echo yes"
340 },
341 "inactive": {
342 "event": "pre_tool_use",
343 "command": "echo no",
344 "disabled": true
345 }
346 }
347 }"#;
348 std::fs::write(&path, json).unwrap();
349 let hooks = load_hooks_file(&path).unwrap();
350 assert_eq!(hooks.len(), 1);
351 assert_eq!(hooks[0].0, "active");
352 }
353
354 #[test]
356 fn missing_file_returns_empty() {
357 let path = std::path::Path::new("/nonexistent/hooks.json");
358 let hooks = load_hooks_file(path).unwrap();
359 assert!(hooks.is_empty());
360 }
361
362 #[test]
364 fn empty_hooks_object() {
365 let json = r#"{ "hooks": {} }"#;
366 let raw: HooksFile = serde_json::from_str(json).unwrap();
367 assert!(raw.hooks.is_empty());
368 }
369
370 #[test]
372 fn project_overrides_global_by_name() {
373 let dir = tempfile::tempdir().unwrap();
374
375 let global_dir = dir.path().join("global");
377 std::fs::create_dir_all(&global_dir).unwrap();
378 let global_path = global_dir.join("hooks.json");
379 std::fs::write(
380 &global_path,
381 r#"{
382 "hooks": {
383 "audit": {
384 "event": "pre_tool_use",
385 "command": "echo global-audit"
386 },
387 "global-only": {
388 "event": "session_start",
389 "command": "echo global-only"
390 }
391 }
392 }"#,
393 )
394 .unwrap();
395
396 let project_dir = dir.path().join("project");
398 std::fs::create_dir_all(&project_dir).unwrap();
399 let project_path = project_dir.join(".hooks.json");
400 std::fs::write(
401 &project_path,
402 r#"{
403 "hooks": {
404 "audit": {
405 "event": "pre_tool_use",
406 "command": "echo project-audit"
407 },
408 "project-only": {
409 "event": "post_tool_use",
410 "command": "echo project-only"
411 }
412 }
413 }"#,
414 )
415 .unwrap();
416
417 let mut merged: BTreeMap<String, HookConfig> = BTreeMap::new();
419 for (name, hook) in load_hooks_file(&global_path).unwrap() {
420 merged.insert(name, hook);
421 }
422 for (name, hook) in load_hooks_file(&project_path).unwrap() {
423 merged.insert(name, hook);
424 }
425
426 assert_eq!(merged.len(), 3);
427
428 let audit = &merged["audit"];
430 assert_eq!(audit.command, "echo project-audit");
431
432 assert!(merged.contains_key("global-only"));
434
435 assert!(merged.contains_key("project-only"));
437 }
438
439 #[test]
441 fn event_string_mapping() {
442 let dir = tempfile::tempdir().unwrap();
443 let path = dir.path().join("hooks.json");
444 let json = r#"{
445 "hooks": {
446 "h1": { "event": "pre_tool_use", "command": "a" },
447 "h2": { "event": "post_tool_use", "command": "b" },
448 "h3": { "event": "session_start", "command": "c" },
449 "h4": { "event": "session_end", "command": "d" }
450 }
451 }"#;
452 std::fs::write(&path, json).unwrap();
453 let hooks = load_hooks_file(&path).unwrap();
454 let map: BTreeMap<String, HookConfig> = hooks.into_iter().collect();
455 assert_eq!(map["h1"].event, HookEvent::PreToolUse);
456 assert_eq!(map["h2"].event, HookEvent::PostToolUse);
457 assert_eq!(map["h3"].event, HookEvent::SessionStart);
458 assert_eq!(map["h4"].event, HookEvent::SessionEnd);
459 }
460
461 #[test]
462 fn pascal_case_event_names_are_accepted() {
463 let dir = tempfile::tempdir().unwrap();
464 let path = dir.path().join("hooks.json");
465 let json = r#"{
466 "hooks": {
467 "h1": { "event": "PreToolUse", "command": "a" },
468 "h2": { "event": "UserPromptSubmit", "command": "b" }
469 }
470 }"#;
471 std::fs::write(&path, json).unwrap();
472 let hooks = load_hooks_file(&path).unwrap();
473 let map: BTreeMap<String, HookConfig> = hooks.into_iter().collect();
474 assert_eq!(map["h1"].event, HookEvent::PreToolUse);
475 assert_eq!(map["h2"].event, HookEvent::UserPromptSubmit);
476 }
477
478 #[test]
479 fn invalid_event_name_is_skipped() {
480 let dir = tempfile::tempdir().unwrap();
481 let path = dir.path().join("hooks.json");
482 let json = r#"{
483 "hooks": {
484 "typo": { "event": "pre_tool", "command": "should-not-run" },
485 "valid": { "event": "post_tool_use", "command": "echo ok" }
486 }
487 }"#;
488 std::fs::write(&path, json).unwrap();
489 let hooks = load_hooks_file(&path).unwrap();
490 assert_eq!(hooks.len(), 1);
491 assert_eq!(hooks[0].0, "valid");
492 assert_eq!(hooks[0].1.event, HookEvent::PostToolUse);
493 }
494
495 #[test]
497 fn malformed_json_returns_error() {
498 let dir = tempfile::tempdir().unwrap();
499 let path = dir.path().join("hooks.json");
500 std::fs::write(&path, "not valid json").unwrap();
501 let result = load_hooks_file(&path);
502 assert!(result.is_err());
503 }
504
505 #[test]
507 fn default_timeout_is_10000() {
508 let json = r#"{
509 "hooks": {
510 "test": {
511 "event": "pre_tool_use",
512 "command": "echo test"
513 }
514 }
515 }"#;
516 let raw: HooksFile = serde_json::from_str(json).unwrap();
517 assert_eq!(raw.hooks["test"].timeout_ms, 10_000);
518 }
519
520 #[test]
522 fn custom_timeout_is_preserved() {
523 let json = r#"{
524 "hooks": {
525 "fast": {
526 "event": "pre_tool_use",
527 "command": "echo fast",
528 "timeout_ms": 500
529 }
530 }
531 }"#;
532 let raw: HooksFile = serde_json::from_str(json).unwrap();
533 assert_eq!(raw.hooks["fast"].timeout_ms, 500);
534 }
535
536 #[test]
537 #[serial_test::serial]
538 fn plugin_hooks_are_loaded_with_prefix() {
539 let tmp = tempfile::tempdir().unwrap();
540 std::env::set_var("ATOMCODE_HOME", tmp.path());
541
542 let plugin_dir = tmp.path().join("plugins/marketplaces/p");
543 std::fs::create_dir_all(&plugin_dir).unwrap();
544 std::fs::write(
545 plugin_dir.join("hooks.json"),
546 r#"{"hooks":{"on_pre":{"event":"PreToolUse","command":"echo hi"}}}"#,
547 )
548 .unwrap();
549 std::fs::write(
550 tmp.path().join("plugins/installed_plugins.json"),
551 r#"{"version":1,"plugins":{"p@p":{"marketplace":"p","plugin":"p","plugin_dir":"marketplaces/p","installed_at":"x"}}}"#,
552 )
553 .unwrap();
554
555 let working = tempfile::tempdir().unwrap();
556 let hooks = load_hooks_config(working.path());
557 assert!(hooks.iter().any(|h| h.command == "echo hi"));
558
559 std::env::remove_var("ATOMCODE_HOME");
560 }
561}