Move config type tests into fabro-config

Tests for types defined in fabro-config (HookEvent, HookDefinition,
HookConfig, McpServerConfig, McpTransport, etc.) now live alongside
their definitions rather than in the downstream re-exporting crates.

Tests for types that remain in fabro-hooks (HookContext, HookDecision,
PromptHookResponse) stay in fabro-hooks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-17 15:21:16 -04:00
parent 98e8a9b7f6
commit 6ebbad800d
7 changed files with 623 additions and 621 deletions

1
Cargo.lock generated
View file

@ -1395,6 +1395,7 @@ dependencies = [
"dirs",
"fabro-util",
"serde",
"serde_json",
"strsim",
"tempfile",
"toml",

View file

@ -24,4 +24,6 @@ toml.workspace = true
tracing.workspace = true
[dev-dependencies]
serde_json.workspace = true
tempfile = "3"
toml.workspace = true

View file

@ -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"));
}
}

View file

@ -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);
}
}

View file

@ -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"));
}
}

View file

@ -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());

View file

@ -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);
}
}