| 1 | //! `/hooks` slash command — read-only listing of configured |
| 2 | //! lifecycle hooks (#460 MVP). |
| 3 | //! |
| 4 | //! The full picker / persisted enable-disable surface in #460 is |
| 5 | //! still M-sized. This MVP gives the user a no-typing view of what's |
| 6 | //! actually configured in `~/.codewhale/config.toml`'s `[hooks]` |
| 7 | //! table — the most-asked question once hooks start firing. |
| 8 | |
| 9 | use crate::commands::traits::{CommandInfo, RegisterCommand}; |
| 10 | use crate::hooks::HookEvent; |
| 11 | use crate::localization::MessageId; |
| 12 | use crate::tui::app::App; |
| 13 | |
| 14 | use super::CommandResult; |
| 15 | |
| 16 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 17 | name: "hooks", |
| 18 | aliases: &["hook", "gouzi"], |
| 19 | usage: "/hooks [list|events]", |
| 20 | description_id: MessageId::CmdHooksDescription, |
| 21 | }; |
| 22 | |
| 23 | pub(in crate::commands) struct HooksCmd; |
| 24 | |
| 25 | impl RegisterCommand for HooksCmd { |
| 26 | fn info() -> &'static CommandInfo { |
| 27 | &COMMAND_INFO |
| 28 | } |
| 29 | |
| 30 | fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 31 | hooks(app, arg) |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /// Top-level dispatch for `/hooks`. Subcommands: |
| 36 | /// |
| 37 | /// * `/hooks` — same as `/hooks list`. |
| 38 | /// * `/hooks list` — show every configured hook grouped by event, |
| 39 | /// noting whether the global `[hooks].enabled` flag suppresses |
| 40 | /// them. |
| 41 | /// * `/hooks events` — list every supported `HookEvent` value the |
| 42 | /// user can target in `[[hooks.hooks]]` entries. Useful for |
| 43 | /// discovery — without this, the only way to learn the event |
| 44 | /// names is to read source. |
| 45 | pub fn hooks(app: &App, arg: Option<&str>) -> CommandResult { |
| 46 | let sub = arg.map(str::trim).unwrap_or("list").to_ascii_lowercase(); |
| 47 | match sub.as_str() { |
| 48 | "" | "list" | "ls" | "show" => list(app), |
| 49 | "events" | "event" | "list-events" => events(), |
| 50 | other => CommandResult::error(format!( |
| 51 | "unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`." |
| 52 | )), |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | fn events() -> CommandResult { |
| 57 | let mut out = String::new(); |
| 58 | out.push_str( |
| 59 | "Available hook events (use one of these as `event = \"...\"` in your `[[hooks.hooks]]` entry).\n\ |
| 60 | Hooks are a TUI runtime feature: `codewhale exec`, the CLI dispatcher, the app-server,\n\ |
| 61 | and the workflow tool do not fire them.\n\n", |
| 62 | ); |
| 63 | // Order matters — group lifecycle events first, then per-tool, |
| 64 | // then situational. Stays stable across releases so users can |
| 65 | // grep on it. |
| 66 | let ordered = [ |
| 67 | (HookEvent::SessionStart, "fires once when the TUI launches"), |
| 68 | (HookEvent::SessionEnd, "fires once on graceful shutdown"), |
| 69 | ( |
| 70 | HookEvent::TurnEnd, |
| 71 | "fires after a turn completes (observer-only)", |
| 72 | ), |
| 73 | ( |
| 74 | HookEvent::MessageSubmit, |
| 75 | "fires before model dispatch; can transform or block submitted text", |
| 76 | ), |
| 77 | ( |
| 78 | HookEvent::ToolCallBefore, |
| 79 | "fires before each tool call; can allow/deny/ask, rewrite input, add context", |
| 80 | ), |
| 81 | ( |
| 82 | HookEvent::ToolCallAfter, |
| 83 | "fires after each tool call (observer-only)", |
| 84 | ), |
| 85 | ( |
| 86 | HookEvent::ModeChange, |
| 87 | "fires on Plan/Act/Operate transitions", |
| 88 | ), |
| 89 | ( |
| 90 | HookEvent::OnError, |
| 91 | "fires on transport / capacity / tool errors", |
| 92 | ), |
| 93 | ( |
| 94 | HookEvent::SubagentSpawn, |
| 95 | "fires when a sub-agent starts (observer-only)", |
| 96 | ), |
| 97 | ( |
| 98 | HookEvent::SubagentComplete, |
| 99 | "fires when a sub-agent completes, fails, or is cancelled (observer-only)", |
| 100 | ), |
| 101 | ( |
| 102 | HookEvent::ShellEnv, |
| 103 | "fires before each exec_shell; stdout KEY=VALUE lines are merged into its environment", |
| 104 | ), |
| 105 | ]; |
| 106 | for (event, desc) in ordered { |
| 107 | out.push_str(&format!(" - `{}` — {desc}\n", event_label(event))); |
| 108 | } |
| 109 | out.push_str( |
| 110 | "\nOnly `message_submit`, `tool_call_before`, and `shell_env` can steer a turn.\n\ |
| 111 | Observer-only means the *result* is ignored — the command still runs\n\ |
| 112 | and can have any external side effect.\n\n\ |
| 113 | Full contract: docs/HOOKS.md\n", |
| 114 | ); |
| 115 | CommandResult::message(out.trim_end().to_string()) |
| 116 | } |
| 117 | |
| 118 | fn list(app: &App) -> CommandResult { |
| 119 | let config = app.hooks.config(); |
| 120 | if config.hooks.is_empty() && config.problems.is_empty() { |
| 121 | return CommandResult::message( |
| 122 | "No hooks configured. Add a `[[hooks.hooks]]` entry to `~/.codewhale/config.toml` to define one.", |
| 123 | ); |
| 124 | } |
| 125 | if config.hooks.is_empty() { |
| 126 | let mut out = |
| 127 | String::from("No runnable hooks. Every configured entry was rejected at load:\n\n"); |
| 128 | out.push_str(&render_problems(&config.problems)); |
| 129 | return CommandResult::message(out.trim_end().to_string()); |
| 130 | } |
| 131 | |
| 132 | let mut out = String::new(); |
| 133 | out.push_str(&format!( |
| 134 | "{} configured hook(s) (global enabled: {}):\n\n", |
| 135 | config.hooks.len(), |
| 136 | if config.enabled { |
| 137 | "yes" |
| 138 | } else { |
| 139 | "no — all hooks suppressed" |
| 140 | } |
| 141 | )); |
| 142 | |
| 143 | let mut by_event: std::collections::BTreeMap<&str, Vec<&crate::hooks::Hook>> = |
| 144 | std::collections::BTreeMap::new(); |
| 145 | for hook in &config.hooks { |
| 146 | by_event |
| 147 | .entry(event_label(hook.event)) |
| 148 | .or_default() |
| 149 | .push(hook); |
| 150 | } |
| 151 | |
| 152 | for (event, hooks) in by_event { |
| 153 | out.push_str(&format!("### {event}\n")); |
| 154 | for hook in hooks { |
| 155 | // `name` is operator-supplied and otherwise unbounded: a name made |
| 156 | // of ANSI escapes would repaint this listing, and a long one would |
| 157 | // push the rest of the row off screen. |
| 158 | let label = crate::hooks::sanitize_hook_label(hook.name.as_deref()); |
| 159 | // `[bg]` describes actual scheduling: `shell_env` ignores the flag |
| 160 | // and always runs in the foreground, so it is not labelled. |
| 161 | let bg = if hook.background && hook.event.honors_background() { |
| 162 | " [bg, submitted not awaited]" |
| 163 | } else { |
| 164 | "" |
| 165 | }; |
| 166 | let timeout = render_timeout(config, hook); |
| 167 | let condition = match &hook.condition { |
| 168 | None | Some(crate::hooks::HookCondition::Always) => String::new(), |
| 169 | Some(c) => format!(" if {}", condition_summary(c)), |
| 170 | }; |
| 171 | let cmd_preview = preview_command(&hook.command, 60); |
| 172 | out.push_str(&format!( |
| 173 | " - {label}{bg} (timeout {timeout}){condition}\n $ {cmd_preview}\n", |
| 174 | )); |
| 175 | } |
| 176 | out.push('\n'); |
| 177 | } |
| 178 | |
| 179 | if !config.problems.is_empty() { |
| 180 | out.push_str("### configuration problems\n"); |
| 181 | out.push_str(&render_problems(&config.problems)); |
| 182 | out.push('\n'); |
| 183 | } |
| 184 | |
| 185 | if !config.enabled { |
| 186 | out.push_str( |
| 187 | "Hooks are globally disabled — set `[hooks].enabled = true` in `config.toml` to fire them.\n", |
| 188 | ); |
| 189 | } |
| 190 | |
| 191 | CommandResult::message(out.trim_end().to_string()) |
| 192 | } |
| 193 | |
| 194 | /// The timeout this hook will actually run with, plus where it came from. |
| 195 | /// |
| 196 | /// `[hooks].default_timeout_secs` *replaces* every hook's own `timeout_secs` |
| 197 | /// (see `HooksConfig::effective_timeout_secs`). Rendering `hook.timeout_secs` |
| 198 | /// unconditionally made `/hooks list` report a budget no hook would ever run |
| 199 | /// with — a listing that disagrees with the runtime is worse than no listing. |
| 200 | fn render_timeout(config: &crate::hooks::HooksConfig, hook: &crate::hooks::Hook) -> String { |
| 201 | let effective = config.effective_timeout_secs(hook); |
| 202 | if config.timeout_is_overridden() { |
| 203 | format!("{effective}s — `[hooks].default_timeout_secs` override") |
| 204 | } else { |
| 205 | format!("{effective}s") |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | /// Render load-time problems. Only the hook's own `name`, its event, and a |
| 210 | /// fixed explanation are shown — never the command line, stdin payload, or |
| 211 | /// any resolved filesystem path. |
| 212 | fn render_problems(problems: &[crate::hooks::HookConfigProblem]) -> String { |
| 213 | let mut out = String::new(); |
| 214 | for problem in problems { |
| 215 | out.push_str(&format!(" - {}\n", problem.summary())); |
| 216 | } |
| 217 | out |
| 218 | } |
| 219 | |
| 220 | fn event_label(event: HookEvent) -> &'static str { |
| 221 | match event { |
| 222 | HookEvent::SessionStart => "session_start", |
| 223 | HookEvent::SessionEnd => "session_end", |
| 224 | HookEvent::MessageSubmit => "message_submit", |
| 225 | HookEvent::ToolCallBefore => "tool_call_before", |
| 226 | HookEvent::ToolCallAfter => "tool_call_after", |
| 227 | HookEvent::ModeChange => "mode_change", |
| 228 | HookEvent::OnError => "on_error", |
| 229 | HookEvent::TurnEnd => "turn_end", |
| 230 | HookEvent::SubagentSpawn => "subagent_spawn", |
| 231 | HookEvent::SubagentComplete => "subagent_complete", |
| 232 | HookEvent::ShellEnv => "shell_env", |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | fn condition_summary(condition: &crate::hooks::HookCondition) -> String { |
| 237 | match condition { |
| 238 | crate::hooks::HookCondition::Always => "always".to_string(), |
| 239 | crate::hooks::HookCondition::ToolName { name } => { |
| 240 | format!("tool_name=`{}`", condition_value(name)) |
| 241 | } |
| 242 | crate::hooks::HookCondition::ToolCategory { category } => { |
| 243 | format!("tool_category=`{}`", condition_value(category)) |
| 244 | } |
| 245 | crate::hooks::HookCondition::Mode { mode } => { |
| 246 | format!("mode=`{}`", condition_value(mode)) |
| 247 | } |
| 248 | crate::hooks::HookCondition::ExitCode { code } => format!("exit_code={code}"), |
| 249 | crate::hooks::HookCondition::All { conditions } => format!( |
| 250 | "all of [{}]", |
| 251 | conditions |
| 252 | .iter() |
| 253 | .map(condition_summary) |
| 254 | .collect::<Vec<_>>() |
| 255 | .join(", ") |
| 256 | ), |
| 257 | crate::hooks::HookCondition::Any { conditions } => format!( |
| 258 | "any of [{}]", |
| 259 | conditions |
| 260 | .iter() |
| 261 | .map(condition_summary) |
| 262 | .collect::<Vec<_>>() |
| 263 | .join(", ") |
| 264 | ), |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | /// Single-line preview of the shell command, capped at `max_chars`. |
| 269 | /// |
| 270 | /// Filtering newlines is not enough on its own: the command is operator text |
| 271 | /// and can contain escape sequences, so it goes through the shared sanitizer |
| 272 | /// before the cap. |
| 273 | fn preview_command(command: &str, max_chars: usize) -> String { |
| 274 | let single_line = crate::hooks::sanitize_hook_line(command, usize::MAX); |
| 275 | if single_line.chars().count() <= max_chars { |
| 276 | return single_line; |
| 277 | } |
| 278 | let mut out: String = single_line |
| 279 | .chars() |
| 280 | .take(max_chars.saturating_sub(1)) |
| 281 | .collect(); |
| 282 | out.push('…'); |
| 283 | out |
| 284 | } |
| 285 | |
| 286 | /// Operator-supplied strings inside a rendered condition. |
| 287 | /// |
| 288 | /// `tool_name`, `tool_category`, and `mode` come from config verbatim, so the |
| 289 | /// same bound-and-de-fang rule that governs hook names governs these. |
| 290 | fn condition_value(value: &str) -> String { |
| 291 | crate::hooks::sanitize_hook_line(value, crate::hooks::HOOK_LABEL_MAX_CHARS) |
| 292 | } |
| 293 | |
| 294 | #[cfg(test)] |
| 295 | mod tests { |
| 296 | use super::*; |
| 297 | use crate::hooks::{Hook, HookCondition}; |
| 298 | |
| 299 | #[test] |
| 300 | fn preview_command_truncates_to_cap() { |
| 301 | let cmd = "x".repeat(200); |
| 302 | assert_eq!(preview_command(&cmd, 10).chars().count(), 10); |
| 303 | assert!(preview_command(&cmd, 10).ends_with('…')); |
| 304 | } |
| 305 | |
| 306 | #[test] |
| 307 | fn preview_command_strips_newlines() { |
| 308 | assert_eq!( |
| 309 | preview_command("line one\nline two", 50), |
| 310 | "line one line two" |
| 311 | ); |
| 312 | } |
| 313 | |
| 314 | /// A hook command is operator text. Filtering `\n` kept the row from |
| 315 | /// splitting, but left every other control character — including the CSI |
| 316 | /// introducer — free to repaint the terminal from inside `/hooks list`. |
| 317 | #[test] |
| 318 | fn preview_command_defangs_control_characters() { |
| 319 | let preview = preview_command("echo \u{1b}[2Jhi\r\tthere\u{7}", 100); |
| 320 | assert!(!preview.contains('\u{1b}'), "{preview:?}"); |
| 321 | assert!(!preview.contains('\r'), "{preview:?}"); |
| 322 | assert!(!preview.contains('\u{7}'), "{preview:?}"); |
| 323 | assert!(!preview.contains('\t'), "{preview:?}"); |
| 324 | assert!(preview.contains("there"), "{preview:?}"); |
| 325 | } |
| 326 | |
| 327 | #[test] |
| 328 | fn hook_labels_are_bounded_and_defanged() { |
| 329 | let noisy = format!("\u{1b}[31mgate\n{}", "x".repeat(500)); |
| 330 | let label = crate::hooks::sanitize_hook_label(Some(&noisy)); |
| 331 | assert!(!label.contains('\u{1b}'), "{label}"); |
| 332 | assert!(!label.contains('\n'), "{label}"); |
| 333 | assert!( |
| 334 | label.chars().count() <= crate::hooks::HOOK_LABEL_MAX_CHARS + 16, |
| 335 | "{} chars", |
| 336 | label.chars().count() |
| 337 | ); |
| 338 | // A name that is only whitespace, or absent, still renders something. |
| 339 | assert_eq!(crate::hooks::sanitize_hook_label(Some(" ")), "(unnamed)"); |
| 340 | assert_eq!(crate::hooks::sanitize_hook_label(None), "(unnamed)"); |
| 341 | } |
| 342 | |
| 343 | /// Condition values are config strings too, and they were being |
| 344 | /// interpolated raw into the same row the label was being sanitized in. |
| 345 | #[test] |
| 346 | fn condition_summary_defangs_operator_supplied_values() { |
| 347 | let rendered = condition_summary(&HookCondition::ToolName { |
| 348 | name: format!("\u{1b}[2Jexec_shell{}", "y".repeat(500)), |
| 349 | }); |
| 350 | assert!(!rendered.contains('\u{1b}'), "{rendered}"); |
| 351 | assert!( |
| 352 | rendered.chars().count() <= crate::hooks::HOOK_LABEL_MAX_CHARS + 32, |
| 353 | "{} chars", |
| 354 | rendered.chars().count() |
| 355 | ); |
| 356 | } |
| 357 | |
| 358 | /// `default_timeout_secs = 0` is rejected at load, so the listing must |
| 359 | /// report the per-hook budget the runtime will actually apply and must not |
| 360 | /// credit the provenance to a setting that was ignored — while the |
| 361 | /// rejection itself still shows up in the problems section. |
| 362 | #[test] |
| 363 | fn listed_timeout_ignores_a_rejected_zero_override() { |
| 364 | let hook = Hook::new(HookEvent::SessionStart, "echo hi").with_timeout(90); |
| 365 | let zeroed = crate::hooks::HooksConfig { |
| 366 | enabled: true, |
| 367 | hooks: vec![hook.clone()], |
| 368 | default_timeout_secs: Some(0), |
| 369 | ..crate::hooks::HooksConfig::default() |
| 370 | }; |
| 371 | assert_eq!(render_timeout(&zeroed, &hook), "90s"); |
| 372 | |
| 373 | let problems = zeroed.validate(); |
| 374 | let rendered = render_problems(&problems); |
| 375 | assert!(rendered.contains("`[hooks]` setting"), "{rendered}"); |
| 376 | assert!(rendered.contains("default_timeout_secs = 0"), "{rendered}"); |
| 377 | } |
| 378 | |
| 379 | #[test] |
| 380 | fn preview_command_keeps_short_input_intact() { |
| 381 | assert_eq!(preview_command("echo hi", 50), "echo hi"); |
| 382 | } |
| 383 | |
| 384 | #[test] |
| 385 | fn condition_summary_renders_all_variants() { |
| 386 | assert_eq!(condition_summary(&HookCondition::Always), "always"); |
| 387 | assert_eq!( |
| 388 | condition_summary(&HookCondition::ToolName { |
| 389 | name: "exec_shell".into() |
| 390 | }), |
| 391 | "tool_name=`exec_shell`" |
| 392 | ); |
| 393 | assert_eq!( |
| 394 | condition_summary(&HookCondition::ToolCategory { |
| 395 | category: "shell".into() |
| 396 | }), |
| 397 | "tool_category=`shell`" |
| 398 | ); |
| 399 | assert_eq!( |
| 400 | condition_summary(&HookCondition::Mode { |
| 401 | mode: "yolo".into() |
| 402 | }), |
| 403 | "mode=`yolo`" |
| 404 | ); |
| 405 | assert_eq!( |
| 406 | condition_summary(&HookCondition::ExitCode { code: 1 }), |
| 407 | "exit_code=1" |
| 408 | ); |
| 409 | assert_eq!( |
| 410 | condition_summary(&HookCondition::All { |
| 411 | conditions: vec![ |
| 412 | HookCondition::ToolName { |
| 413 | name: "exec_shell".into() |
| 414 | }, |
| 415 | HookCondition::Mode { |
| 416 | mode: "yolo".into() |
| 417 | } |
| 418 | ] |
| 419 | }), |
| 420 | "all of [tool_name=`exec_shell`, mode=`yolo`]" |
| 421 | ); |
| 422 | } |
| 423 | |
| 424 | #[test] |
| 425 | fn events_subcommand_lists_every_event_variant_in_documented_order() { |
| 426 | let result = events(); |
| 427 | let body = result.message.expect("non-empty body"); |
| 428 | let positions: Vec<(usize, &str)> = [ |
| 429 | "session_start", |
| 430 | "session_end", |
| 431 | "turn_end", |
| 432 | "message_submit", |
| 433 | "tool_call_before", |
| 434 | "tool_call_after", |
| 435 | "mode_change", |
| 436 | "on_error", |
| 437 | "subagent_spawn", |
| 438 | "subagent_complete", |
| 439 | "shell_env", |
| 440 | ] |
| 441 | .iter() |
| 442 | .map(|name| { |
| 443 | ( |
| 444 | body.find(name).unwrap_or_else(|| { |
| 445 | panic!("event `{name}` missing from /hooks events output:\n{body}") |
| 446 | }), |
| 447 | *name, |
| 448 | ) |
| 449 | }) |
| 450 | .collect(); |
| 451 | // Documented order is lifecycle → tool-call → situational. |
| 452 | // Each subsequent position must be greater than the previous. |
| 453 | for window in positions.windows(2) { |
| 454 | let (a_pos, a_name) = window[0]; |
| 455 | let (b_pos, b_name) = window[1]; |
| 456 | assert!( |
| 457 | a_pos < b_pos, |
| 458 | "expected `{a_name}` before `{b_name}` in events listing" |
| 459 | ); |
| 460 | } |
| 461 | // Each event line includes the descriptive blurb. |
| 462 | assert!(body.contains("fires once when the TUI launches")); |
| 463 | // `tool_call_before` has been a steering hook since #3026; the listing |
| 464 | // must not keep advertising it as read-only. |
| 465 | assert!( |
| 466 | !body.contains("read-only observer"), |
| 467 | "stale read-only wording in events listing:\n{body}" |
| 468 | ); |
| 469 | assert!(body.contains("can allow/deny/ask")); |
| 470 | assert!(body.contains("docs/HOOKS.md")); |
| 471 | } |
| 472 | |
| 473 | #[test] |
| 474 | fn event_label_covers_every_variant() { |
| 475 | // Compile-time `match` exhaustiveness; this just sanity-checks |
| 476 | // the rendered strings stay stable. |
| 477 | assert_eq!(event_label(HookEvent::SessionStart), "session_start"); |
| 478 | assert_eq!(event_label(HookEvent::SessionEnd), "session_end"); |
| 479 | assert_eq!(event_label(HookEvent::ToolCallBefore), "tool_call_before"); |
| 480 | assert_eq!(event_label(HookEvent::ToolCallAfter), "tool_call_after"); |
| 481 | assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit"); |
| 482 | assert_eq!(event_label(HookEvent::ModeChange), "mode_change"); |
| 483 | assert_eq!(event_label(HookEvent::OnError), "on_error"); |
| 484 | assert_eq!(event_label(HookEvent::TurnEnd), "turn_end"); |
| 485 | assert_eq!(event_label(HookEvent::SubagentSpawn), "subagent_spawn"); |
| 486 | assert_eq!( |
| 487 | event_label(HookEvent::SubagentComplete), |
| 488 | "subagent_complete" |
| 489 | ); |
| 490 | } |
| 491 | |
| 492 | #[test] |
| 493 | fn list_renders_hooks_grouped_by_event_and_notes_disabled_state() { |
| 494 | // We test the formatter directly via a synthetic HooksConfig |
| 495 | // because `App` is heavyweight to spin up here. The actual |
| 496 | // `list(&App)` path is exercised once we hand the real |
| 497 | // config in via `app.hooks.config()`; the formatter logic is |
| 498 | // unit-tested standalone below. |
| 499 | let cfg = crate::hooks::HooksConfig { |
| 500 | enabled: false, |
| 501 | hooks: vec![ |
| 502 | Hook::new(HookEvent::SessionStart, "echo started").with_name("greet"), |
| 503 | Hook::new(HookEvent::ToolCallAfter, "notify-send done") |
| 504 | .with_condition(HookCondition::ToolName { |
| 505 | name: "exec_shell".into(), |
| 506 | }) |
| 507 | .with_name("notify"), |
| 508 | ], |
| 509 | ..crate::hooks::HooksConfig::default() |
| 510 | }; |
| 511 | |
| 512 | // Synthesize the expected sections by re-running the same |
| 513 | // formatter logic against the BTreeMap grouping. |
| 514 | let mut by_event: std::collections::BTreeMap<&str, Vec<&Hook>> = |
| 515 | std::collections::BTreeMap::new(); |
| 516 | for h in &cfg.hooks { |
| 517 | by_event.entry(event_label(h.event)).or_default().push(h); |
| 518 | } |
| 519 | let events: Vec<&&str> = by_event.keys().collect(); |
| 520 | // BTreeMap sorts alphabetically — `session_start` before `tool_call_after`. |
| 521 | assert_eq!(events, vec![&"session_start", &"tool_call_after"]); |
| 522 | } |
| 523 | |
| 524 | #[test] |
| 525 | fn events_listing_states_scope_and_the_steering_allowlist() { |
| 526 | let body = events().message.expect("non-empty body"); |
| 527 | // Scope truth: this is a TUI runtime feature. |
| 528 | assert!(body.contains("TUI runtime feature"), "{body}"); |
| 529 | assert!(body.contains("codewhale exec"), "{body}"); |
| 530 | // Steering allowlist, matching docs/HOOKS.md. |
| 531 | assert!(body.contains("`message_submit`, `tool_call_before`, and `shell_env`")); |
| 532 | // And the honest caveat about what observer-only does not mean. |
| 533 | assert!(body.contains("can have any external side effect"), "{body}"); |
| 534 | } |
| 535 | |
| 536 | #[test] |
| 537 | fn events_listing_covers_every_runtime_event() { |
| 538 | let body = events().message.expect("non-empty body"); |
| 539 | for event in crate::hooks::ALL_HOOK_EVENTS { |
| 540 | assert!( |
| 541 | body.contains(event.as_str()), |
| 542 | "event `{}` missing from /hooks events", |
| 543 | event.as_str() |
| 544 | ); |
| 545 | } |
| 546 | } |
| 547 | |
| 548 | #[test] |
| 549 | fn listed_timeout_is_the_one_the_runtime_will_apply() { |
| 550 | let hook = Hook::new(HookEvent::SessionStart, "echo hi").with_timeout(90); |
| 551 | |
| 552 | let per_hook = crate::hooks::HooksConfig { |
| 553 | enabled: true, |
| 554 | hooks: vec![hook.clone()], |
| 555 | ..crate::hooks::HooksConfig::default() |
| 556 | }; |
| 557 | assert_eq!(render_timeout(&per_hook, &hook), "90s"); |
| 558 | |
| 559 | // With the global override set, the runtime uses 5s — the listing must |
| 560 | // say 5s, and say why. |
| 561 | let overridden = crate::hooks::HooksConfig { |
| 562 | enabled: true, |
| 563 | hooks: vec![hook.clone()], |
| 564 | default_timeout_secs: Some(5), |
| 565 | ..crate::hooks::HooksConfig::default() |
| 566 | }; |
| 567 | let rendered = render_timeout(&overridden, &hook); |
| 568 | assert!(rendered.starts_with("5s"), "{rendered}"); |
| 569 | assert!(!rendered.contains("90"), "{rendered}"); |
| 570 | assert!(rendered.contains("default_timeout_secs"), "{rendered}"); |
| 571 | } |
| 572 | |
| 573 | #[test] |
| 574 | fn rendered_problems_name_the_hook_without_leaking_the_command() { |
| 575 | let problems = vec![ |
| 576 | crate::hooks::HookConfigProblem { |
| 577 | name: Some("gate".to_string()), |
| 578 | event: Some(HookEvent::SessionStart), |
| 579 | detail: "condition can never match".to_string(), |
| 580 | rejected: true, |
| 581 | }, |
| 582 | crate::hooks::HookConfigProblem { |
| 583 | name: None, |
| 584 | event: Some(HookEvent::ShellEnv), |
| 585 | detail: "`background = true` is not honored".to_string(), |
| 586 | rejected: false, |
| 587 | }, |
| 588 | ]; |
| 589 | let rendered = render_problems(&problems); |
| 590 | assert!(rendered.contains("rejected: `session_start` hook `gate`")); |
| 591 | assert!(rendered.contains("warning: `shell_env` hook `(unnamed)`")); |
| 592 | // Only the hook name, event, and fixed detail — nothing else. |
| 593 | assert!(!rendered.contains('$'), "{rendered}"); |
| 594 | assert!(!rendered.contains('/'), "{rendered}"); |
| 595 | } |
| 596 | } |
| 597 |