use pretty_assertions::assert_eq; use std::collections::BTreeMap; use super::HookEventsToml; use super::HookHandlerConfig; use super::HooksFile; use super::HooksToml; use super::ManagedHooksRequirementsToml; use super::MatcherGroup; #[test] fn hooks_file_deserializes_existing_json_shape() { let parsed: HooksFile = serde_json::from_str( r#"{ "description": "Optional stop-time review gate for Codex Companion.", "hooks": { "PreToolUse": [ { "matcher": "^Bash$", "hooks": [ { "type": "command", "command": "python3 /tmp/pre.py", "timeout": 10, "statusMessage": "checking", "additionalContextLimit": 4096 } ] } ] } }"#, ) .expect("hooks.json should deserialize"); assert_eq!( parsed, HooksFile { description: Some("Optional stop-time review gate for Codex Companion.".to_string()), hooks: HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/pre.py".to_string(), command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), additional_context_limit: Some(4096), }], }], ..Default::default() }, } ); } #[test] fn hooks_file_deserializes_mcp_tool_handler_with_json_inputs() { let parsed: HooksFile = serde_json::from_value(serde_json::json!({ "hooks": { "PostToolUse": [{ "matcher": "Write|Edit", "hooks": [{ "type": "mcp_tool", "server": "security", "tool": "scan", "input": { "file_path": "${tool_input.file_path}", "include_ignored": false, }, "timeout": 30, "statusMessage": "Scanning file", }], }], }, })) .expect("MCP tool hooks should deserialize"); assert_eq!( parsed.hooks.post_tool_use[0].hooks, vec![HookHandlerConfig::McpTool { server: "security".to_string(), tool: "scan".to_string(), input: serde_json::Map::from_iter([ ( "file_path".to_string(), serde_json::Value::String("${tool_input.file_path}".to_string()), ), ( "include_ignored".to_string(), serde_json::Value::Bool(false) ), ]), timeout_sec: Some(30), status_message: Some("Scanning file".to_string()), }] ); } #[test] fn hooks_file_rejects_mcp_tool_handler_with_null_input() { for input in [ serde_json::json!({ "optional": null }), serde_json::json!({ "metadata": { "optional": null } }), serde_json::json!({ "values": [null] }), ] { let error = serde_json::from_value::(serde_json::json!({ "hooks": { "PostToolUse": [{ "hooks": [{ "type": "mcp_tool", "server": "security", "tool": "scan", "input": input, }], }], }, })) .expect_err("literal null MCP hook arguments should be rejected"); assert!( error .to_string() .contains("MCP hook input must be representable as TOML"), "unexpected parse error: {error}" ); } } #[test] fn hooks_file_rejects_events_outside_hooks_object() { let error = serde_json::from_str::( r#"{ "SessionStart": [ { "hooks": [ { "type": "command", "command": "python3 /tmp/session_start.py" } ] } ] }"#, ) .expect_err("root-level hook events should be rejected"); assert!( error.to_string().contains("unknown field `SessionStart`"), "unexpected parse error: {error}" ); } #[test] fn hook_events_deserialize_from_toml_arrays_of_tables() { let parsed: HookEventsToml = toml::from_str( r#" [[PreToolUse]] matcher = "^Bash$" [[PreToolUse.hooks]] type = "command" command = "python3 /tmp/pre.py" timeout = 10 statusMessage = "checking" additionalContextLimit = 4096 "#, ) .expect("hook events TOML should deserialize"); assert_eq!( parsed, HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/pre.py".to_string(), command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), additional_context_limit: Some(4096), }], }], ..Default::default() } ); } #[test] fn hooks_toml_deserializes_inline_events_and_state_map() { let parsed: HooksToml = toml::from_str( r#" [state."/tmp/hooks.json:pre_tool_use:0:0"] enabled = false trusted_hash = "sha256:abc123" [[PreToolUse]] matcher = "^Bash$" [[PreToolUse.hooks]] type = "command" command = "python3 /tmp/pre.py" "#, ) .expect("hooks TOML should deserialize"); assert_eq!( parsed, HooksToml { events: HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/pre.py".to_string(), command_windows: None, timeout_sec: None, r#async: false, status_message: None, additional_context_limit: None, }], }], ..Default::default() }, state: BTreeMap::from([( "/tmp/hooks.json:pre_tool_use:0:0".to_string(), super::HookStateToml { enabled: Some(false), trusted_hash: Some("sha256:abc123".to_string()), }, )]), } ); } #[test] fn managed_hooks_requirements_flatten_hook_events() { let parsed: ManagedHooksRequirementsToml = toml::from_str( r#" managed_dir = "/enterprise/place" [[PreToolUse]] matcher = "^Bash$" [[PreToolUse.hooks]] type = "command" command = "python3 /enterprise/place/pre.py" "#, ) .expect("requirements hooks TOML should deserialize"); assert_eq!( parsed, ManagedHooksRequirementsToml { managed_dir: Some(std::path::PathBuf::from("/enterprise/place")), windows_managed_dir: None, hooks: HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /enterprise/place/pre.py".to_string(), command_windows: None, timeout_sec: None, r#async: false, status_message: None, additional_context_limit: None, }], }], ..Default::default() }, } ); } #[test] fn hook_events_deserialize_windows_override_from_toml() { let parsed: HookEventsToml = toml::from_str( r#" [[PreToolUse]] matcher = "^Bash$" [[PreToolUse.hooks]] type = "command" command = "bash /enterprise/hooks/pre.sh" command_windows = "powershell -File C:\\enterprise\\hooks\\pre.ps1" "#, ) .expect("hook command Windows override TOML should deserialize"); assert_eq!( parsed, HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "bash /enterprise/hooks/pre.sh".to_string(), command_windows: Some( r"powershell -File C:\enterprise\hooks\pre.ps1".to_string(), ), timeout_sec: None, r#async: false, status_message: None, additional_context_limit: None, }], }], ..Default::default() } ); } #[test] fn hook_events_deserialize_camel_case_windows_override_from_toml() { let parsed: HookEventsToml = toml::from_str( r#" [[PreToolUse]] matcher = "^Bash$" [[PreToolUse.hooks]] type = "command" command = "bash /enterprise/hooks/pre.sh" commandWindows = "powershell -File C:\\enterprise\\hooks\\pre.ps1" "#, ) .expect("camelCase hook command Windows override TOML should deserialize"); assert_eq!( parsed, HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "bash /enterprise/hooks/pre.sh".to_string(), command_windows: Some( r"powershell -File C:\enterprise\hooks\pre.ps1".to_string(), ), timeout_sec: None, r#async: false, status_message: None, additional_context_limit: None, }], }], ..Default::default() } ); } #[test] fn hook_handler_omits_unset_additional_context_limit() { let handler = HookHandlerConfig::Command { command: "python3 /tmp/pre.py".to_string(), command_windows: None, timeout_sec: None, r#async: false, status_message: None, additional_context_limit: None, }; let serialized = serde_json::to_value(handler).expect("hook handler should serialize"); assert_eq!(serialized.get("additionalContextLimit"), None); }