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