use super::hooks_cla::append_convertible_hook_groups_cla;
use super::hooks_cla::hook_migration_cla;
use super::hooks_cla::rewrite_hook_command_cla;
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
const TEST_REWRITE_PROFILE: RewriteProfile = RewriteProfile::new(
"CLAUDE.md",
&[
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
],
);
fn source_path(relative_path: &str) -> PathBuf {
Path::new("/repo")
.join(external_agent_config_dir())
.join(relative_path)
}
fn source_hook_command(script_name: &str) -> String {
format!(
"python3 {}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/{script_name}",
external_agent_config_dir()
)
}
fn source_hook_command_with_project_dir(script_name: &str) -> String {
format!(
"python3 \"${}\"/{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/{script_name}",
external_agent_project_dir_env_var(),
external_agent_config_dir()
)
}
fn migrated_hook_command(script_name: &str) -> String {
migrated_quoted_hook_command(script_name)
}
fn migrated_quoted_hook_command(script_name: &str) -> String {
let hook_path = Path::new("/repo/.codex")
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
.join(script_name);
format!(
"python3 {}",
shell_single_quote(hook_path.to_string_lossy().as_ref())
)
}
#[test]
fn env_placeholder_accepts_defaults() {
assert_eq!(
parse_env_placeholder("${TOKEN:-fallback}"),
Some("TOKEN".to_string())
);
}
#[test]
fn mcp_migration_skips_placeholder_args() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join(".mcp.json"),
r#"{"mcpServers":{"db":{"command":"db-server","args":["${DATABASE_URL}"]}}}"#,
)
.expect("write mcp");
assert_eq!(
build_mcp_config_from_external(
root.path(),
None,
None,
)
.unwrap(),
TomlValue::Table(Default::default())
);
}
#[test]
fn mcp_migration_prefers_command_transport_for_mixed_server_config() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join(".mcp.json"),
r#"{
"mcpServers": {
"mixedTransport": {
"command": "mcp-remote-proxy",
"args": [
"https://example.com/mixed-transport",
"--transport",
"http"
],
"url": "https://example.com/mixed-transport"
}
}
}"#,
)
.expect("write mcp");
assert_eq!(
build_mcp_config_from_external(
root.path(),
None,
None,
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.mixedTransport]
command = "mcp-remote-proxy"
args = [
"https://example.com/mixed-transport",
"--transport",
"http",
]
"#
)
.unwrap()
);
}
#[test]
fn mcp_migration_skips_unsupported_transports() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join(".mcp.json"),
r#"{
"mcpServers": {
"legacy-sse": {"type": "sse", "url": "https://example.invalid/sse"},
"vault": {
"url": "https://example.invalid/vault",
"headers": {"Authorization": "Bearer ${VAULT_TOKEN:-dev-token}"}
}
}
}"#,
)
.expect("write mcp");
assert_eq!(
build_mcp_config_from_external(
root.path(),
None,
None,
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.vault]
url = "https://example.invalid/vault"
bearer_token_env_var = "VAULT_TOKEN"
"#
)
.unwrap()
);
}
#[test]
fn mcp_migration_reads_matching_project_entries_from_repo_external_project_config() {
let root = tempfile::TempDir::new().expect("tempdir");
let project = root.path().join("repo");
fs::create_dir_all(&project).expect("create repo");
let other = root.path().join("other");
fs::create_dir_all(&other).expect("create other");
fs::write(
project.join(external_agent_project_config_file()),
serde_json::json!({
"mcpServers": {
"top": {"command": "top-server"}
},
"projects": {
project.display().to_string(): {
"mcpServers": {
"repo": {"command": "repo-server"}
}
},
other.display().to_string(): {
"mcpServers": {
"other": {"command": "other-server"}
}
}
}
})
.to_string(),
)
.expect("write external agent project config");
assert_eq!(
build_mcp_config_from_external(
&project, None, None,
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.repo]
command = "repo-server"
[mcp_servers.top]
command = "top-server"
"#
)
.unwrap()
);
}
#[test]
fn mcp_migration_reads_matching_project_entries_from_home_external_project_config() {
let root = tempfile::TempDir::new().expect("tempdir");
let project = root.path().join("repo");
fs::create_dir_all(&project).expect("create repo");
let external_agent_home = root.path().join(external_agent_config_dir());
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
root.path().join(external_agent_project_config_file()),
serde_json::json!({
"projects": {
project.display().to_string(): {
"mcpServers": {
"repo": {"command": "repo-server"}
}
}
}
})
.to_string(),
)
.expect("write external agent project config");
assert_eq!(
build_mcp_config_from_external(
&project,
Some(&external_agent_home),
None,
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.repo]
command = "repo-server"
"#
)
.unwrap()
);
}
#[test]
fn mcp_migration_preserves_repo_servers_over_home_project_entries() {
let root = tempfile::TempDir::new().expect("tempdir");
let project = root.path().join("repo");
fs::create_dir_all(&project).expect("create repo");
let external_agent_home = root.path().join(external_agent_config_dir());
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
project.join(EXTERNAL_AGENT_MCP_CONFIG_FILE),
serde_json::json!({
"mcpServers": {
"shared": {"command": "repo-server"}
}
})
.to_string(),
)
.expect("write repo mcp");
fs::write(
root.path().join(external_agent_project_config_file()),
serde_json::json!({
"projects": {
project.display().to_string(): {
"mcpServers": {
"home-only": {"command": "home-only-server"},
"shared": {"command": "home-server"}
}
}
}
})
.to_string(),
)
.expect("write external agent project config");
assert_eq!(
build_mcp_config_from_external(
&project,
Some(&external_agent_home),
None,
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.home-only]
command = "home-only-server"
[mcp_servers.shared]
command = "repo-server"
"#
)
.unwrap()
);
}
#[test]
fn mcp_migration_skips_disabled_servers() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join(".mcp.json"),
r#"{
"mcpServers": {
"enabled": {"command": "enabled-server"},
"explicit-disabled": {"command": "disabled-server", "disabled": true},
"not-enabled": {"command": "not-enabled-server"}
}
}"#,
)
.expect("write mcp");
let settings = serde_json::json!({
"enabledMcpjsonServers": ["enabled"],
"disabledMcpjsonServers": ["explicit-disabled"]
});
assert_eq!(
build_mcp_config_from_external(
root.path(),
None,
Some(&settings),
)
.unwrap(),
toml::from_str(
r#"
[mcp_servers.enabled]
command = "enabled-server"
"#
)
.unwrap()
);
}
#[test]
fn subagent_accepts_yaml_block_lists_by_ignoring_unsupported_fields() {
let document = parse_document_content(
"---\nname: cloud-incident\ndescription: Debug incidents\nskills:\n - runbook-reader\ntools:\n - Read\n - Bash\ndisallowedTools:\n - Write\n---\nInvestigate carefully.\n",
);
assert!(agent_metadata(&document).is_some());
}
#[test]
fn subagent_requires_minimum_codex_agent_fields() {
let missing_description =
parse_document_content("---\nname: incomplete\n---\nInvestigate carefully.\n");
let missing_body =
parse_document_content("---\nname: incomplete\ndescription: Missing body\n---\n");
assert!(agent_metadata(&missing_description).is_none());
assert!(agent_metadata(&missing_body).is_none());
}
#[test]
fn subagent_preserves_default_model_when_source_model_is_present() {
let document = parse_document_content(
"---\nname: reviewer\ndescription: Review code\nmodel: source-opus\neffort: max\n---\nReview carefully.\n",
);
let metadata = agent_metadata(&document).expect("metadata");
let rendered: TomlValue = toml::from_str(
&render_agent_toml(&document.body, &metadata, TEST_REWRITE_PROFILE).expect("render agent"),
)
.expect("parse rendered agent");
let expected: TomlValue = toml::from_str(
r#"
name = "reviewer"
description = "Review code"
model_reasoning_effort = "xhigh"
developer_instructions = """
Review carefully."""
"#,
)
.expect("parse expected agent");
assert_eq!(rendered, expected);
}
#[test]
fn subagent_target_preserves_dotted_file_stem() {
let target_agents = Path::new("/repo/.codex/agents");
let source_file = source_path("agents/security.audit.md");
assert_eq!(
subagent_target_file(&source_file, target_agents),
Some(PathBuf::from("/repo/.codex/agents/security.audit.toml"))
);
}
#[test]
fn frontmatter_accepts_crlf_delimiters() {
let document = parse_document_content(
"---\r\nname: reviewer\r\ndescription: Review code\r\n---\r\nReview carefully.\r\n",
);
assert_eq!(
(
document
.frontmatter
.get("name")
.and_then(FrontmatterValue::as_scalar),
document
.frontmatter
.get("description")
.and_then(FrontmatterValue::as_scalar),
document.body.as_str(),
),
(
Some("reviewer"),
Some("Review code"),
"Review carefully.\r\n"
)
);
}
#[test]
fn hook_migration_ignores_unsupported_handlers() {
let settings = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"if": "tool_input.command contains 'rm'",
"hooks": [{
"type": "command",
"command": source_hook_command("policy_gate.py")
}]
}, {
"matcher": "Edit",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": source_hook_command("policy_gate.py")
},
{
"type": "http",
"url": "https://example.invalid/hook"
}
]
}],
"PermissionRequest": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": source_hook_command("approve.py")
}]
}],
"SessionEnd": [{
"matcher": "clear",
"hooks": [{
"type": "command",
"command": source_hook_command("cleanup.py")
}]
}],
"SubagentStart": [{
"matcher": "worker",
"hooks": [{"type": "prompt", "prompt": "check"}]
}]
}
});
let mut migration = serde_json::Map::new();
append_convertible_hook_groups_cla(
&settings,
&mut migration,
Some(Path::new("/repo/.codex")),
TEST_REWRITE_PROFILE,
);
assert_eq!(
migration,
serde_json::json!({
"PermissionRequest": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": migrated_hook_command("approve.py")
}]
}],
"SessionEnd": [{
"matcher": "clear",
"hooks": [{
"type": "command",
"command": migrated_hook_command("cleanup.py")
}]
}]
})
.as_object()
.cloned()
.expect("object")
);
}
#[test]
fn hook_migration_honors_disable_all_hooks() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join("settings.json"),
r#"{
"disableAllHooks": true,
"hooks": {
"SessionStart": [{
"matcher": "startup",
"hooks": [{"type": "command", "command": "echo setup"}]
}]
}
}"#,
)
.expect("write settings");
assert_eq!(
hook_migration_cla(
root.path(),
None,
TEST_REWRITE_PROFILE,
)
.unwrap(),
serde_json::Map::new()
);
}
#[test]
fn hook_migration_honors_settings_local_disable_override() {
let root = tempfile::TempDir::new().expect("tempdir");
fs::write(
root.path().join("settings.json"),
r#"{
"disableAllHooks": true,
"hooks": {
"SessionStart": [{
"matcher": "project",
"hooks": [{"type": "command", "command": "echo project"}]
}]
}
}"#,
)
.expect("write project settings");
fs::write(
root.path().join("settings.local.json"),
r#"{
"disableAllHooks": false,
"hooks": {
"SessionStart": [{
"matcher": "local",
"hooks": [{"type": "command", "command": "echo local"}]
}]
}
}"#,
)
.expect("write local settings");
assert_eq!(
hook_migration_cla(
root.path(),
None,
TEST_REWRITE_PROFILE,
)
.unwrap(),
serde_json::json!({
"SessionStart": [{
"matcher": "project",
"hooks": [{
"type": "command",
"command": "echo project"
}]
}, {
"matcher": "local",
"hooks": [{
"type": "command",
"command": "echo local"
}]
}]
})
.as_object()
.cloned()
.expect("object")
);
}
#[test]
fn hook_command_paths_rewrite_to_target_hook_dir() {
let project_dir_env_var = external_agent_project_dir_env_var();
let plugin_root_env_var = format!(
"{}_PLUGIN_ROOT",
SOURCE_EXTERNAL_AGENT_NAME.to_ascii_uppercase()
);
let source_hooks_path = format!(
"{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}",
external_agent_config_dir()
);
assert_eq!(
rewrite_hook_command_cla(
&source_hook_command_with_project_dir("check.py"),
Some(Path::new("/repo/.codex")),
),
migrated_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command_cla(
&format!("\"${project_dir_env_var}\"/{source_hooks_path}/check-style.sh"),
Some(Path::new("/repo/.codex")),
),
shell_single_quote(
Path::new("/repo/.codex")
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
.join("check-style.sh")
.to_string_lossy()
.as_ref()
)
);
assert_eq!(
rewrite_hook_command_cla(
&source_hook_command("check.py"),
Some(Path::new("/repo/.codex")),
),
migrated_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command_cla(
&format!("python3 ./{source_hooks_path}/check.py"),
Some(Path::new("/repo/.codex")),
),
migrated_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command_cla(
&format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/check.py'"),
Some(Path::new("/repo/.codex")),
),
migrated_quoted_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command_cla(
&format!("python3 \"${{{project_dir_env_var}}}/{source_hooks_path}/check.py\""),
Some(Path::new("/repo/.codex")),
),
migrated_quoted_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command_cla(
&format!("bash -lc \"python3 {source_hooks_path}/check.py\""),
Some(Path::new("/repo/.codex")),
),
format!("bash -lc \"python3 {source_hooks_path}/check.py\"")
);
assert_eq!(
rewrite_hook_command_cla(
&format!(
"HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\""
),
Some(Path::new("/repo/.codex")),
),
format!("HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\"")
);
assert_eq!(
rewrite_hook_command_cla(
&format!("python3 {source_hooks_path}/${{SCRIPT}}.py"),
Some(Path::new("/repo/.codex")),
),
format!("python3 {source_hooks_path}/${{SCRIPT}}.py")
);
assert_eq!(
rewrite_hook_command_cla(
&format!("python3 {source_hooks_path}/{{lint,fmt}}.sh"),
Some(Path::new("/repo/.codex")),
),
format!("python3 {source_hooks_path}/{{lint,fmt}}.sh")
);
assert_eq!(
rewrite_hook_command_cla(
&format!("python3 {source_hooks_path}/my\\ script.py"),
Some(Path::new("/repo/.codex")),
),
format!("python3 {source_hooks_path}/my\\ script.py")
);
assert_eq!(
rewrite_hook_command_cla(
&format!("python3 .{SOURCE_EXTERNAL_AGENT_NAME}\\hooks\\check.py"),
Some(Path::new("/repo/.codex")),
),
format!("python3 .{}\\hooks\\check.py", SOURCE_EXTERNAL_AGENT_NAME)
);
assert_eq!(
rewrite_hook_command_cla(
&format!(
"python3 \"%{}%\\{}\\hooks\\check.py\"",
project_dir_env_var,
external_agent_config_dir()
),
Some(Path::new("/repo/.codex")),
),
format!(
"python3 \"%{}%\\{}\\hooks\\check.py\"",
project_dir_env_var,
external_agent_config_dir()
)
);
assert_eq!(
rewrite_hook_command_cla(
&format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/my script.py'"),
Some(Path::new("/repo/.codex")),
),
migrated_quoted_hook_command("my script.py")
);
assert_eq!(
rewrite_hook_command_cla(
&format!("/repo/{source_hooks_path}/check.py 2>/dev/null || true"),
Some(Path::new("/repo/.codex")),
),
format!(
"{} 2>/dev/null || true",
shell_single_quote(
Path::new("/repo/.codex")
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
.join("check.py")
.to_string_lossy()
.as_ref()
)
)
);
let plugin_script_command = format!("${{{plugin_root_env_var}}}/scripts/format.sh");
assert_eq!(
rewrite_hook_command_cla(&plugin_script_command, Some(Path::new("/repo/.codex")),),
plugin_script_command
);
}
#[test]
fn hook_script_copy_keeps_existing_target_scripts() {
let root = tempfile::TempDir::new().expect("tempdir");
let source_external_agent_dir = root.path().join(external_agent_config_dir());
let source_hooks = source_external_agent_dir.join(EXTERNAL_AGENT_HOOKS_SUBDIR);
let target_config_dir = root.path().join(".codex");
let target_hooks = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
fs::create_dir_all(&source_hooks).expect("create source hooks");
fs::create_dir_all(&target_hooks).expect("create target hooks");
fs::write(source_hooks.join("check.py"), "new script").expect("write source hook");
fs::write(target_hooks.join("check.py"), "existing script").expect("write target hook");
copy_hook_scripts(&source_external_agent_dir, &target_config_dir).expect("copy hooks");
assert_eq!(
fs::read_to_string(target_hooks.join("check.py")).expect("read target hook"),
"existing script"
);
}
#[test]
fn hook_migration_drops_negative_timeouts() {
let settings = serde_json::json!({
"hooks": {
"SessionStart": [{
"matcher": "startup",
"hooks": [{
"type": "command",
"command": "echo setup",
"timeout": -1
}]
}]
}
});
let mut migration = serde_json::Map::new();
append_convertible_hook_groups_cla(
&settings,
&mut migration,
None,
TEST_REWRITE_PROFILE,
);
assert_eq!(
migration,
serde_json::json!({
"SessionStart": [{
"matcher": "startup",
"hooks": [{
"type": "command",
"command": "echo setup"
}]
}]
})
.as_object()
.cloned()
.expect("object")
);
}