diff --git a/Cargo.lock b/Cargo.lock index a0ddf207f..4c3d6c640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1395,6 +1395,7 @@ dependencies = [ "dirs", "fabro-util", "serde", + "serde_json", "strsim", "tempfile", "toml", diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index c159b9547..447c9e79e 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -24,4 +24,6 @@ toml.workspace = true tracing.workspace = true [dev-dependencies] +serde_json.workspace = true tempfile = "3" +toml.workspace = true diff --git a/lib/crates/fabro-config/src/hook.rs b/lib/crates/fabro-config/src/hook.rs index 5ceea43c3..66b8c8167 100644 --- a/lib/crates/fabro-config/src/hook.rs +++ b/lib/crates/fabro-config/src/hook.rs @@ -228,3 +228,533 @@ impl HookConfig { Self { hooks } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hook_event_serde_round_trip() { + let events = [ + HookEvent::RunStart, + HookEvent::RunComplete, + HookEvent::RunFailed, + HookEvent::StageStart, + HookEvent::StageComplete, + HookEvent::StageFailed, + HookEvent::StageRetrying, + HookEvent::EdgeSelected, + HookEvent::ParallelStart, + HookEvent::ParallelComplete, + HookEvent::SandboxReady, + HookEvent::SandboxCleanup, + HookEvent::CheckpointSaved, + HookEvent::PreToolUse, + HookEvent::PostToolUse, + HookEvent::PostToolUseFailure, + ]; + for event in events { + let json = serde_json::to_string(&event).unwrap(); + let back: HookEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(event, back); + } + } + + #[test] + fn hook_event_serializes_as_snake_case() { + assert_eq!( + serde_json::to_string(&HookEvent::RunStart).unwrap(), + "\"run_start\"" + ); + assert_eq!( + serde_json::to_string(&HookEvent::StageRetrying).unwrap(), + "\"stage_retrying\"" + ); + } + + #[test] + fn hook_event_display() { + assert_eq!(HookEvent::RunStart.to_string(), "run_start"); + assert_eq!(HookEvent::CheckpointSaved.to_string(), "checkpoint_saved"); + } + + #[test] + fn hook_event_blocking_defaults() { + assert!(HookEvent::RunStart.is_blocking_by_default()); + assert!(HookEvent::StageStart.is_blocking_by_default()); + assert!(HookEvent::EdgeSelected.is_blocking_by_default()); + assert!(HookEvent::SandboxReady.is_blocking_by_default()); + assert!(!HookEvent::SandboxCleanup.is_blocking_by_default()); + assert!(!HookEvent::RunComplete.is_blocking_by_default()); + assert!(!HookEvent::StageFailed.is_blocking_by_default()); + assert!(!HookEvent::CheckpointSaved.is_blocking_by_default()); + } + + #[test] + fn pre_tool_use_serde_round_trip() { + let json = serde_json::to_string(&HookEvent::PreToolUse).unwrap(); + assert_eq!(json, "\"pre_tool_use\""); + let back: HookEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(back, HookEvent::PreToolUse); + } + + #[test] + fn pre_tool_use_is_blocking_by_default() { + assert!(HookEvent::PreToolUse.is_blocking_by_default()); + } + + #[test] + fn post_tool_use_is_not_blocking_by_default() { + assert!(!HookEvent::PostToolUse.is_blocking_by_default()); + } + + #[test] + fn post_tool_use_failure_is_not_blocking_by_default() { + assert!(!HookEvent::PostToolUseFailure.is_blocking_by_default()); + } + + #[test] + fn parse_command_shorthand() { + let toml = r#" +[[hooks]] +event = "stage_start" +command = "./scripts/pre-check.sh" +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.hooks.len(), 1); + let hook = &config.hooks[0]; + assert_eq!(hook.event, HookEvent::StageStart); + assert_eq!(hook.command.as_deref(), Some("./scripts/pre-check.sh")); + let resolved = hook.resolved_hook_type().unwrap(); + assert!( + matches!(&*resolved, HookType::Command { command } if command == "./scripts/pre-check.sh") + ); + } + + #[test] + fn parse_explicit_command_type() { + let toml = r#" +[[hooks]] +event = "run_start" +type = "command" +command = "echo hello" +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.hooks.len(), 1); + let hook = &config.hooks[0]; + assert_eq!(hook.event, HookEvent::RunStart); + assert!(hook.resolved_hook_type().is_some()); + } + + #[test] + fn parse_http_hook() { + let toml = r#" +[[hooks]] +event = "run_complete" +type = "http" +url = "https://hooks.example.com/done" +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + let hook = &config.hooks[0]; + assert!(matches!( + hook.resolved_hook_type().as_deref(), + Some(HookType::Http { url, .. }) if url == "https://hooks.example.com/done" + )); + } + + #[test] + fn parse_http_hook_with_allowed_env_vars() { + let toml = r#" +[[hooks]] +event = "run_start" +type = "http" +url = "https://hooks.example.com/start" +allowed_env_vars = ["API_KEY", "SECRET"] + +[hooks.headers] +Authorization = "Bearer $API_KEY" +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + let hook = &config.hooks[0]; + match &*hook.resolved_hook_type().unwrap() { + HookType::Http { + url, + headers, + allowed_env_vars, + .. + } => { + assert_eq!(url, "https://hooks.example.com/start"); + assert_eq!(allowed_env_vars, &["API_KEY", "SECRET"]); + assert_eq!( + headers.as_ref().unwrap().get("Authorization").unwrap(), + "Bearer $API_KEY" + ); + } + _ => panic!("expected Http hook type"), + } + } + + #[test] + fn parse_http_hook_allowed_env_vars_defaults_empty() { + let toml = r#" +[[hooks]] +event = "run_complete" +type = "http" +url = "https://hooks.example.com/done" +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + let hook = &config.hooks[0]; + match &*hook.resolved_hook_type().unwrap() { + HookType::Http { + allowed_env_vars, .. + } => { + assert!(allowed_env_vars.is_empty()); + } + _ => panic!("expected Http hook type"), + } + } + + #[test] + fn parse_full_hook_definition() { + let toml = r#" +[[hooks]] +name = "pre-check" +event = "stage_start" +command = "./check.sh" +matcher = "agent_loop" +blocking = true +timeout_ms = 30000 +sandbox = false +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + let hook = &config.hooks[0]; + assert_eq!(hook.name.as_deref(), Some("pre-check")); + assert_eq!(hook.event, HookEvent::StageStart); + assert_eq!(hook.matcher.as_deref(), Some("agent_loop")); + assert!(hook.is_blocking()); + assert_eq!(hook.timeout(), std::time::Duration::from_millis(30_000)); + assert!(!hook.runs_in_sandbox()); + } + + #[test] + fn blocking_defaults_to_event() { + let blocking_def = HookDefinition { + name: None, + event: HookEvent::StageStart, + command: Some("echo".into()), + hook_type: None, + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }; + assert!(blocking_def.is_blocking()); + + let non_blocking_def = HookDefinition { + event: HookEvent::StageComplete, + ..blocking_def.clone() + }; + assert!(!non_blocking_def.is_blocking()); + } + + #[test] + fn blocking_override() { + let def = HookDefinition { + name: None, + event: HookEvent::StageComplete, + command: Some("echo".into()), + hook_type: None, + matcher: None, + blocking: Some(true), + timeout_ms: None, + sandbox: None, + }; + assert!(def.is_blocking()); + } + + #[test] + fn timeout_defaults_to_60s() { + let def = HookDefinition { + name: None, + event: HookEvent::RunStart, + command: Some("echo".into()), + hook_type: None, + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }; + assert_eq!(def.timeout(), std::time::Duration::from_secs(60)); + } + + #[test] + fn sandbox_defaults_to_true() { + let def = HookDefinition { + name: None, + event: HookEvent::RunStart, + command: Some("echo".into()), + hook_type: None, + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }; + assert!(def.runs_in_sandbox()); + } + + #[test] + fn effective_name_uses_explicit() { + let def = HookDefinition { + name: Some("my-hook".into()), + event: HookEvent::RunStart, + command: Some("echo hi".into()), + hook_type: None, + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }; + assert_eq!(def.effective_name(), "my-hook"); + } + + #[test] + fn effective_name_generated_from_event_and_command() { + let def = HookDefinition { + name: None, + event: HookEvent::RunStart, + command: Some("echo hi".into()), + hook_type: None, + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }; + assert_eq!(def.effective_name(), "run_start:echo hi"); + } + + #[test] + fn config_merge_concatenates() { + let a = HookConfig { + hooks: vec![HookDefinition { + name: Some("hook-a".into()), + event: HookEvent::RunStart, + command: Some("echo a".into()), + hook_type: None, + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }], + }; + let b = HookConfig { + hooks: vec![HookDefinition { + name: Some("hook-b".into()), + event: HookEvent::RunComplete, + command: Some("echo b".into()), + hook_type: None, + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }], + }; + let merged = a.merge(b); + assert_eq!(merged.hooks.len(), 2); + assert_eq!(merged.hooks[0].name.as_deref(), Some("hook-a")); + assert_eq!(merged.hooks[1].name.as_deref(), Some("hook-b")); + } + + #[test] + fn config_merge_name_collision_later_wins() { + let a = HookConfig { + hooks: vec![HookDefinition { + name: Some("shared".into()), + event: HookEvent::RunStart, + command: Some("echo a".into()), + hook_type: None, + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }], + }; + let b = HookConfig { + hooks: vec![HookDefinition { + name: Some("shared".into()), + event: HookEvent::RunComplete, + command: Some("echo b".into()), + hook_type: None, + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }], + }; + let merged = a.merge(b); + assert_eq!(merged.hooks.len(), 1); + assert_eq!(merged.hooks[0].event, HookEvent::RunComplete); + } + + #[test] + fn parse_http_hook_tls_defaults_to_verify() { + let toml = r#" +[[hooks]] +event = "run_complete" +type = "http" +url = "https://hooks.example.com/done" +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + let hook = &config.hooks[0]; + match &*hook.resolved_hook_type().unwrap() { + HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::Verify), + _ => panic!("expected Http hook type"), + } + } + + #[test] + fn parse_http_hook_tls_no_verify() { + let toml = r#" +[[hooks]] +event = "run_complete" +type = "http" +url = "https://hooks.example.com/done" +tls = "no_verify" +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + let hook = &config.hooks[0]; + match &*hook.resolved_hook_type().unwrap() { + HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::NoVerify), + _ => panic!("expected Http hook type"), + } + } + + #[test] + fn parse_http_hook_tls_off() { + let toml = r#" +[[hooks]] +event = "run_complete" +type = "http" +url = "http://localhost:8080/done" +tls = "off" +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + let hook = &config.hooks[0]; + match &*hook.resolved_hook_type().unwrap() { + HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::Off), + _ => panic!("expected Http hook type"), + } + } + + #[test] + fn parse_prompt_hook() { + let toml = r#" +[[hooks]] +event = "stage_start" +type = "prompt" +prompt = "Should this stage proceed?" +model = "haiku" +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + let hook = &config.hooks[0]; + assert!(matches!( + hook.resolved_hook_type().as_deref(), + Some(HookType::Prompt { prompt, model }) + if prompt == "Should this stage proceed?" && *model == Some("haiku".into()) + )); + } + + #[test] + fn parse_agent_hook() { + let toml = r#" +[[hooks]] +event = "run_complete" +type = "agent" +prompt = "Verify tests pass." +model = "sonnet" +max_tool_rounds = 10 +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + let hook = &config.hooks[0]; + assert!(matches!( + hook.resolved_hook_type().as_deref(), + Some(HookType::Agent { prompt, model, max_tool_rounds }) + if prompt == "Verify tests pass." + && *model == Some("sonnet".into()) + && *max_tool_rounds == Some(10) + )); + } + + #[test] + fn prompt_hook_default_timeout_30s() { + let def = HookDefinition { + name: None, + event: HookEvent::RunStart, + command: None, + hook_type: Some(HookType::Prompt { + prompt: "check".into(), + model: None, + }), + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }; + assert_eq!(def.timeout(), std::time::Duration::from_secs(30)); + } + + #[test] + fn agent_hook_default_timeout_60s() { + let def = HookDefinition { + name: None, + event: HookEvent::RunStart, + command: None, + hook_type: Some(HookType::Agent { + prompt: "check".into(), + model: None, + max_tool_rounds: None, + }), + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }; + assert_eq!(def.timeout(), std::time::Duration::from_secs(60)); + } + + #[test] + fn effective_name_generated_from_prompt_hook() { + let def = HookDefinition { + name: None, + event: HookEvent::StageStart, + command: None, + hook_type: Some(HookType::Prompt { + prompt: "Should this stage proceed?".into(), + model: None, + }), + matcher: None, + blocking: None, + timeout_ms: None, + sandbox: None, + }; + assert!(def.effective_name().starts_with("stage_start:")); + } + + #[test] + fn parse_multiple_hooks() { + let toml = r#" +[[hooks]] +event = "run_start" +command = "echo start" + +[[hooks]] +event = "stage_complete" +command = "echo done" +matcher = "agent_loop" +"#; + let config: HookConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.hooks.len(), 2); + assert_eq!(config.hooks[0].event, HookEvent::RunStart); + assert_eq!(config.hooks[1].event, HookEvent::StageComplete); + assert_eq!(config.hooks[1].matcher.as_deref(), Some("agent_loop")); + } +} diff --git a/lib/crates/fabro-config/src/mcp.rs b/lib/crates/fabro-config/src/mcp.rs index 8fa917215..5be45bfaa 100644 --- a/lib/crates/fabro-config/src/mcp.rs +++ b/lib/crates/fabro-config/src/mcp.rs @@ -80,3 +80,93 @@ impl McpServerEntry { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn stdio_config_construction() { + let config = McpServerConfig { + name: "test-server".into(), + transport: McpTransport::Stdio { + command: vec![ + "npx".into(), + "-y".into(), + "@modelcontextprotocol/server-filesystem".into(), + ], + env: HashMap::new(), + }, + startup_timeout_secs: 10, + tool_timeout_secs: 60, + }; + assert_eq!(config.name, "test-server"); + assert_eq!(config.startup_timeout(), Duration::from_secs(10)); + assert_eq!(config.tool_timeout(), Duration::from_secs(60)); + } + + #[test] + fn http_config_construction() { + let config = McpServerConfig { + name: "remote-server".into(), + transport: McpTransport::Http { + url: "https://example.com/mcp".into(), + headers: HashMap::from([("Authorization".into(), "Bearer token".into())]), + }, + startup_timeout_secs: 30, + tool_timeout_secs: 60, + }; + assert_eq!(config.name, "remote-server"); + assert_eq!(config.startup_timeout(), Duration::from_secs(30)); + assert_eq!(config.tool_timeout(), Duration::from_secs(60)); + } + + #[test] + fn serde_round_trip_stdio() { + let config = McpServerConfig { + name: "fs".into(), + transport: McpTransport::Stdio { + command: vec!["node".into(), "server.js".into()], + env: HashMap::from([("NODE_ENV".into(), "production".into())]), + }, + startup_timeout_secs: 15, + tool_timeout_secs: 90, + }; + let json = serde_json::to_string(&config).unwrap(); + let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "fs"); + assert_eq!(deserialized.startup_timeout_secs, 15); + assert_eq!(deserialized.tool_timeout_secs, 90); + assert!( + matches!(deserialized.transport, McpTransport::Stdio { command, .. } if command == vec!["node", "server.js"]) + ); + } + + #[test] + fn serde_round_trip_http() { + let config = McpServerConfig { + name: "remote".into(), + transport: McpTransport::Http { + url: "https://mcp.example.com".into(), + headers: HashMap::new(), + }, + startup_timeout_secs: 10, + tool_timeout_secs: 60, + }; + let json = serde_json::to_string(&config).unwrap(); + let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "remote"); + assert!( + matches!(deserialized.transport, McpTransport::Http { url, .. } if url == "https://mcp.example.com") + ); + } + + #[test] + fn serde_defaults_applied() { + let json = r#"{"name":"minimal","transport":{"type":"stdio","command":["echo"]}}"#; + let config: McpServerConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.startup_timeout_secs, 10); + assert_eq!(config.tool_timeout_secs, 60); + } +} diff --git a/lib/crates/fabro-hooks/src/config.rs b/lib/crates/fabro-hooks/src/config.rs index 13815df1a..93ebea090 100644 --- a/lib/crates/fabro-hooks/src/config.rs +++ b/lib/crates/fabro-hooks/src/config.rs @@ -1,451 +1 @@ pub use fabro_config::hook::{HookConfig, HookDefinition, HookEvent, HookType, TlsMode}; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_command_shorthand() { - let toml = r#" -[[hooks]] -event = "stage_start" -command = "./scripts/pre-check.sh" -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.hooks.len(), 1); - let hook = &config.hooks[0]; - assert_eq!(hook.event, HookEvent::StageStart); - assert_eq!(hook.command.as_deref(), Some("./scripts/pre-check.sh")); - let resolved = hook.resolved_hook_type().unwrap(); - assert!( - matches!(&*resolved, HookType::Command { command } if command == "./scripts/pre-check.sh") - ); - } - - #[test] - fn parse_explicit_command_type() { - let toml = r#" -[[hooks]] -event = "run_start" -type = "command" -command = "echo hello" -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.hooks.len(), 1); - let hook = &config.hooks[0]; - assert_eq!(hook.event, HookEvent::RunStart); - assert!(hook.resolved_hook_type().is_some()); - } - - #[test] - fn parse_http_hook() { - let toml = r#" -[[hooks]] -event = "run_complete" -type = "http" -url = "https://hooks.example.com/done" -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - let hook = &config.hooks[0]; - assert!(matches!( - hook.resolved_hook_type().as_deref(), - Some(HookType::Http { url, .. }) if url == "https://hooks.example.com/done" - )); - } - - #[test] - fn parse_http_hook_with_allowed_env_vars() { - let toml = r#" -[[hooks]] -event = "run_start" -type = "http" -url = "https://hooks.example.com/start" -allowed_env_vars = ["API_KEY", "SECRET"] - -[hooks.headers] -Authorization = "Bearer $API_KEY" -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - let hook = &config.hooks[0]; - match &*hook.resolved_hook_type().unwrap() { - HookType::Http { - url, - headers, - allowed_env_vars, - .. - } => { - assert_eq!(url, "https://hooks.example.com/start"); - assert_eq!(allowed_env_vars, &["API_KEY", "SECRET"]); - assert_eq!( - headers.as_ref().unwrap().get("Authorization").unwrap(), - "Bearer $API_KEY" - ); - } - _ => panic!("expected Http hook type"), - } - } - - #[test] - fn parse_http_hook_allowed_env_vars_defaults_empty() { - let toml = r#" -[[hooks]] -event = "run_complete" -type = "http" -url = "https://hooks.example.com/done" -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - let hook = &config.hooks[0]; - match &*hook.resolved_hook_type().unwrap() { - HookType::Http { - allowed_env_vars, .. - } => { - assert!(allowed_env_vars.is_empty()); - } - _ => panic!("expected Http hook type"), - } - } - - #[test] - fn parse_full_hook_definition() { - let toml = r#" -[[hooks]] -name = "pre-check" -event = "stage_start" -command = "./check.sh" -matcher = "agent_loop" -blocking = true -timeout_ms = 30000 -sandbox = false -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - let hook = &config.hooks[0]; - assert_eq!(hook.name.as_deref(), Some("pre-check")); - assert_eq!(hook.event, HookEvent::StageStart); - assert_eq!(hook.matcher.as_deref(), Some("agent_loop")); - assert!(hook.is_blocking()); - assert_eq!(hook.timeout(), std::time::Duration::from_millis(30_000)); - assert!(!hook.runs_in_sandbox()); - } - - #[test] - fn blocking_defaults_to_event() { - let blocking_def = HookDefinition { - name: None, - event: HookEvent::StageStart, - command: Some("echo".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }; - assert!(blocking_def.is_blocking()); - - let non_blocking_def = HookDefinition { - event: HookEvent::StageComplete, - ..blocking_def.clone() - }; - assert!(!non_blocking_def.is_blocking()); - } - - #[test] - fn blocking_override() { - let def = HookDefinition { - name: None, - event: HookEvent::StageComplete, - command: Some("echo".into()), - hook_type: None, - matcher: None, - blocking: Some(true), - timeout_ms: None, - sandbox: None, - }; - assert!(def.is_blocking()); - } - - #[test] - fn timeout_defaults_to_60s() { - let def = HookDefinition { - name: None, - event: HookEvent::RunStart, - command: Some("echo".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }; - assert_eq!(def.timeout(), std::time::Duration::from_secs(60)); - } - - #[test] - fn sandbox_defaults_to_true() { - let def = HookDefinition { - name: None, - event: HookEvent::RunStart, - command: Some("echo".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }; - assert!(def.runs_in_sandbox()); - } - - #[test] - fn effective_name_uses_explicit() { - let def = HookDefinition { - name: Some("my-hook".into()), - event: HookEvent::RunStart, - command: Some("echo hi".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }; - assert_eq!(def.effective_name(), "my-hook"); - } - - #[test] - fn effective_name_generated_from_event_and_command() { - let def = HookDefinition { - name: None, - event: HookEvent::RunStart, - command: Some("echo hi".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }; - assert_eq!(def.effective_name(), "run_start:echo hi"); - } - - #[test] - fn config_merge_concatenates() { - let a = HookConfig { - hooks: vec![HookDefinition { - name: Some("hook-a".into()), - event: HookEvent::RunStart, - command: Some("echo a".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }], - }; - let b = HookConfig { - hooks: vec![HookDefinition { - name: Some("hook-b".into()), - event: HookEvent::RunComplete, - command: Some("echo b".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }], - }; - let merged = a.merge(b); - assert_eq!(merged.hooks.len(), 2); - assert_eq!(merged.hooks[0].name.as_deref(), Some("hook-a")); - assert_eq!(merged.hooks[1].name.as_deref(), Some("hook-b")); - } - - #[test] - fn config_merge_name_collision_later_wins() { - let a = HookConfig { - hooks: vec![HookDefinition { - name: Some("shared".into()), - event: HookEvent::RunStart, - command: Some("echo a".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }], - }; - let b = HookConfig { - hooks: vec![HookDefinition { - name: Some("shared".into()), - event: HookEvent::RunComplete, - command: Some("echo b".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }], - }; - let merged = a.merge(b); - assert_eq!(merged.hooks.len(), 1); - assert_eq!(merged.hooks[0].event, HookEvent::RunComplete); - } - - #[test] - fn parse_http_hook_tls_defaults_to_verify() { - let toml = r#" -[[hooks]] -event = "run_complete" -type = "http" -url = "https://hooks.example.com/done" -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - let hook = &config.hooks[0]; - match &*hook.resolved_hook_type().unwrap() { - HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::Verify), - _ => panic!("expected Http hook type"), - } - } - - #[test] - fn parse_http_hook_tls_no_verify() { - let toml = r#" -[[hooks]] -event = "run_complete" -type = "http" -url = "https://hooks.example.com/done" -tls = "no_verify" -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - let hook = &config.hooks[0]; - match &*hook.resolved_hook_type().unwrap() { - HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::NoVerify), - _ => panic!("expected Http hook type"), - } - } - - #[test] - fn parse_http_hook_tls_off() { - let toml = r#" -[[hooks]] -event = "run_complete" -type = "http" -url = "http://localhost:8080/done" -tls = "off" -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - let hook = &config.hooks[0]; - match &*hook.resolved_hook_type().unwrap() { - HookType::Http { tls, .. } => assert_eq!(*tls, TlsMode::Off), - _ => panic!("expected Http hook type"), - } - } - - #[test] - fn parse_prompt_hook() { - let toml = r#" -[[hooks]] -event = "stage_start" -type = "prompt" -prompt = "Should this stage proceed?" -model = "haiku" -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - let hook = &config.hooks[0]; - assert!(matches!( - hook.resolved_hook_type().as_deref(), - Some(HookType::Prompt { prompt, model }) - if prompt == "Should this stage proceed?" && *model == Some("haiku".into()) - )); - } - - #[test] - fn parse_agent_hook() { - let toml = r#" -[[hooks]] -event = "run_complete" -type = "agent" -prompt = "Verify tests pass." -model = "sonnet" -max_tool_rounds = 10 -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - let hook = &config.hooks[0]; - assert!(matches!( - hook.resolved_hook_type().as_deref(), - Some(HookType::Agent { prompt, model, max_tool_rounds }) - if prompt == "Verify tests pass." - && *model == Some("sonnet".into()) - && *max_tool_rounds == Some(10) - )); - } - - #[test] - fn prompt_hook_default_timeout_30s() { - let def = HookDefinition { - name: None, - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Prompt { - prompt: "check".into(), - model: None, - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }; - assert_eq!(def.timeout(), std::time::Duration::from_secs(30)); - } - - #[test] - fn agent_hook_default_timeout_60s() { - let def = HookDefinition { - name: None, - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Agent { - prompt: "check".into(), - model: None, - max_tool_rounds: None, - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }; - assert_eq!(def.timeout(), std::time::Duration::from_secs(60)); - } - - #[test] - fn effective_name_generated_from_prompt_hook() { - let def = HookDefinition { - name: None, - event: HookEvent::StageStart, - command: None, - hook_type: Some(HookType::Prompt { - prompt: "Should this stage proceed?".into(), - model: None, - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }; - assert!(def.effective_name().starts_with("stage_start:")); - } - - #[test] - fn parse_multiple_hooks() { - let toml = r#" -[[hooks]] -event = "run_start" -command = "echo start" - -[[hooks]] -event = "stage_complete" -command = "echo done" -matcher = "agent_loop" -"#; - let config: HookConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.hooks.len(), 2); - assert_eq!(config.hooks[0].event, HookEvent::RunStart); - assert_eq!(config.hooks[1].event, HookEvent::StageComplete); - assert_eq!(config.hooks[1].matcher.as_deref(), Some("agent_loop")); - } -} diff --git a/lib/crates/fabro-hooks/src/types.rs b/lib/crates/fabro-hooks/src/types.rs index 59913d392..c1c0578d3 100644 --- a/lib/crates/fabro-hooks/src/types.rs +++ b/lib/crates/fabro-hooks/src/types.rs @@ -127,63 +127,6 @@ pub struct HookResult { mod tests { use super::*; - #[test] - fn hook_event_serde_round_trip() { - let events = [ - HookEvent::RunStart, - HookEvent::RunComplete, - HookEvent::RunFailed, - HookEvent::StageStart, - HookEvent::StageComplete, - HookEvent::StageFailed, - HookEvent::StageRetrying, - HookEvent::EdgeSelected, - HookEvent::ParallelStart, - HookEvent::ParallelComplete, - HookEvent::SandboxReady, - HookEvent::SandboxCleanup, - HookEvent::CheckpointSaved, - HookEvent::PreToolUse, - HookEvent::PostToolUse, - HookEvent::PostToolUseFailure, - ]; - for event in events { - let json = serde_json::to_string(&event).unwrap(); - let back: HookEvent = serde_json::from_str(&json).unwrap(); - assert_eq!(event, back); - } - } - - #[test] - fn hook_event_serializes_as_snake_case() { - assert_eq!( - serde_json::to_string(&HookEvent::RunStart).unwrap(), - "\"run_start\"" - ); - assert_eq!( - serde_json::to_string(&HookEvent::StageRetrying).unwrap(), - "\"stage_retrying\"" - ); - } - - #[test] - fn hook_event_display() { - assert_eq!(HookEvent::RunStart.to_string(), "run_start"); - assert_eq!(HookEvent::CheckpointSaved.to_string(), "checkpoint_saved"); - } - - #[test] - fn hook_event_blocking_defaults() { - assert!(HookEvent::RunStart.is_blocking_by_default()); - assert!(HookEvent::StageStart.is_blocking_by_default()); - assert!(HookEvent::EdgeSelected.is_blocking_by_default()); - assert!(HookEvent::SandboxReady.is_blocking_by_default()); - assert!(!HookEvent::SandboxCleanup.is_blocking_by_default()); - assert!(!HookEvent::RunComplete.is_blocking_by_default()); - assert!(!HookEvent::StageFailed.is_blocking_by_default()); - assert!(!HookEvent::CheckpointSaved.is_blocking_by_default()); - } - #[test] fn hook_context_serde_round_trip() { let ctx = HookContext { @@ -313,29 +256,6 @@ mod tests { assert_eq!(resp.reason.as_deref(), Some("not ready")); } - #[test] - fn pre_tool_use_serde_round_trip() { - let json = serde_json::to_string(&HookEvent::PreToolUse).unwrap(); - assert_eq!(json, "\"pre_tool_use\""); - let back: HookEvent = serde_json::from_str(&json).unwrap(); - assert_eq!(back, HookEvent::PreToolUse); - } - - #[test] - fn pre_tool_use_is_blocking_by_default() { - assert!(HookEvent::PreToolUse.is_blocking_by_default()); - } - - #[test] - fn post_tool_use_is_not_blocking_by_default() { - assert!(!HookEvent::PostToolUse.is_blocking_by_default()); - } - - #[test] - fn post_tool_use_failure_is_not_blocking_by_default() { - assert!(!HookEvent::PostToolUseFailure.is_blocking_by_default()); - } - #[test] fn hook_context_with_tool_fields() { let mut ctx = HookContext::new(HookEvent::PreToolUse, "run-1".into(), "wf".into()); diff --git a/lib/crates/fabro-mcp/src/config.rs b/lib/crates/fabro-mcp/src/config.rs index 0b0511dfe..186f0d8b3 100644 --- a/lib/crates/fabro-mcp/src/config.rs +++ b/lib/crates/fabro-mcp/src/config.rs @@ -2,94 +2,3 @@ pub use fabro_config::mcp::{ default_startup_timeout_secs, default_tool_timeout_secs, McpServerConfig, McpServerEntry, McpTransport, }; - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - use std::time::Duration; - - #[test] - fn stdio_config_construction() { - let config = McpServerConfig { - name: "test-server".into(), - transport: McpTransport::Stdio { - command: vec![ - "npx".into(), - "-y".into(), - "@modelcontextprotocol/server-filesystem".into(), - ], - env: HashMap::new(), - }, - startup_timeout_secs: 10, - tool_timeout_secs: 60, - }; - assert_eq!(config.name, "test-server"); - assert_eq!(config.startup_timeout(), Duration::from_secs(10)); - assert_eq!(config.tool_timeout(), Duration::from_secs(60)); - } - - #[test] - fn http_config_construction() { - let config = McpServerConfig { - name: "remote-server".into(), - transport: McpTransport::Http { - url: "https://example.com/mcp".into(), - headers: HashMap::from([("Authorization".into(), "Bearer token".into())]), - }, - startup_timeout_secs: 30, - tool_timeout_secs: 60, - }; - assert_eq!(config.name, "remote-server"); - assert_eq!(config.startup_timeout(), Duration::from_secs(30)); - assert_eq!(config.tool_timeout(), Duration::from_secs(60)); - } - - #[test] - fn serde_round_trip_stdio() { - let config = McpServerConfig { - name: "fs".into(), - transport: McpTransport::Stdio { - command: vec!["node".into(), "server.js".into()], - env: HashMap::from([("NODE_ENV".into(), "production".into())]), - }, - startup_timeout_secs: 15, - tool_timeout_secs: 90, - }; - let json = serde_json::to_string(&config).unwrap(); - let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.name, "fs"); - assert_eq!(deserialized.startup_timeout_secs, 15); - assert_eq!(deserialized.tool_timeout_secs, 90); - assert!( - matches!(deserialized.transport, McpTransport::Stdio { command, .. } if command == vec!["node", "server.js"]) - ); - } - - #[test] - fn serde_round_trip_http() { - let config = McpServerConfig { - name: "remote".into(), - transport: McpTransport::Http { - url: "https://mcp.example.com".into(), - headers: HashMap::new(), - }, - startup_timeout_secs: 10, - tool_timeout_secs: 60, - }; - let json = serde_json::to_string(&config).unwrap(); - let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.name, "remote"); - assert!( - matches!(deserialized.transport, McpTransport::Http { url, .. } if url == "https://mcp.example.com") - ); - } - - #[test] - fn serde_defaults_applied() { - let json = r#"{"name":"minimal","transport":{"type":"stdio","command":["echo"]}}"#; - let config: McpServerConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.startup_timeout_secs, 10); - assert_eq!(config.tool_timeout_secs, 60); - } -}