use anyhow::Result;
use serde_json::Value;
use std::fs;
mod common;
use common::TestProject;
#[tokio::test]
async fn test_hooks_install_and_format() -> Result<()> {
let project = TestProject::new()?;
let source_repo = project.create_source_repo("hooks")?;
let hooks_dir = source_repo.path.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let session_hook = serde_json::json!({
"events": ["SessionStart"],
"type": "command",
"command": "echo 'Session started'",
"description": "Session start hook"
});
fs::write(
hooks_dir.join("session-start.json"),
serde_json::to_string_pretty(&session_hook)?,
)?;
let pre_tool_hook = serde_json::json!({
"events": ["PreToolUse"],
"matcher": "Bash|Write",
"type": "command",
"command": "echo 'Before tool use'",
"timeout": 5000,
"description": "Pre-tool use hook"
});
fs::write(
hooks_dir.join("pre-tool-use.json"),
serde_json::to_string_pretty(&pre_tool_hook)?,
)?;
source_repo.commit_all("Add test hooks")?;
let source_url = source_repo.bare_file_url(project.sources_path())?;
let manifest_content = format!(
r#"
[sources]
hooks = "{}"
[hooks]
session-hook = {{ source = "hooks", path = "hooks/session-start.json" }}
tool-hook = {{ source = "hooks", path = "hooks/pre-tool-use.json" }}
"#,
source_url
);
project.write_manifest(&manifest_content)?;
let output = project.run_ccpm(&["install"])?;
output.assert_success();
output.assert_stdout_contains("✓ Configured 2 hook(s)");
let settings_path = project.project_path().join(".claude/settings.local.json");
let settings_content = fs::read_to_string(&settings_path)?;
let settings: Value = serde_json::from_str(&settings_content)?;
let hooks = settings.get("hooks").expect("Should have hooks section");
let session_start = hooks
.get("SessionStart")
.expect("Should have SessionStart")
.as_array()
.unwrap();
assert_eq!(session_start.len(), 1);
assert!(
session_start[0].get("matcher").is_none(),
"SessionStart should not have matcher"
);
let session_commands = session_start[0].get("hooks").unwrap().as_array().unwrap();
assert_eq!(session_commands.len(), 1);
assert_eq!(
session_commands[0]
.get("command")
.unwrap()
.as_str()
.unwrap(),
"echo 'Session started'"
);
let pre_tool_use = hooks
.get("PreToolUse")
.expect("Should have PreToolUse")
.as_array()
.unwrap();
assert_eq!(pre_tool_use.len(), 1);
assert_eq!(
pre_tool_use[0].get("matcher").unwrap().as_str().unwrap(),
"Bash|Write"
);
let pre_tool_commands = pre_tool_use[0].get("hooks").unwrap().as_array().unwrap();
assert_eq!(pre_tool_commands.len(), 1);
assert_eq!(
pre_tool_commands[0]
.get("command")
.unwrap()
.as_str()
.unwrap(),
"echo 'Before tool use'"
);
assert_eq!(
pre_tool_commands[0]
.get("timeout")
.unwrap()
.as_u64()
.unwrap(),
5000
);
Ok(())
}
#[tokio::test]
async fn test_hooks_deduplication() -> Result<()> {
let project = TestProject::new()?;
let source_repo = project.create_source_repo("hooks")?;
let hooks_dir = source_repo.path.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let session_hook = serde_json::json!({
"events": ["SessionStart"],
"type": "command",
"command": "ccpm update",
"description": "Update CCPM"
});
fs::write(
hooks_dir.join("hook1.json"),
serde_json::to_string_pretty(&session_hook)?,
)?;
fs::write(
hooks_dir.join("hook2.json"),
serde_json::to_string_pretty(&session_hook)?,
)?;
source_repo.commit_all("Add duplicate hooks")?;
let source_url = source_repo.bare_file_url(project.sources_path())?;
let manifest_content = format!(
r#"
[sources]
hooks = "{}"
[hooks]
first-hook = {{ source = "hooks", path = "hooks/hook1.json" }}
second-hook = {{ source = "hooks", path = "hooks/hook2.json" }}
"#,
source_url
);
project.write_manifest(&manifest_content)?;
let output = project.run_ccpm(&["install"])?;
output.assert_success();
output.assert_stdout_contains("✓ Configured 1 hook(s)");
let settings_path = project.project_path().join(".claude/settings.local.json");
let settings_content = fs::read_to_string(&settings_path)?;
let settings: Value = serde_json::from_str(&settings_content)?;
let hooks = settings.get("hooks").unwrap();
let session_start = hooks.get("SessionStart").unwrap().as_array().unwrap();
assert_eq!(session_start.len(), 1);
let hook_commands = session_start[0].get("hooks").unwrap().as_array().unwrap();
assert_eq!(
hook_commands.len(),
1,
"Identical hooks should be deduplicated"
);
assert_eq!(
hook_commands[0].get("command").unwrap().as_str().unwrap(),
"ccpm update"
);
Ok(())
}
#[tokio::test]
async fn test_hooks_unknown_event_type() -> Result<()> {
let project = TestProject::new()?;
let source_repo = project.create_source_repo("hooks")?;
let hooks_dir = source_repo.path.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let future_hook = serde_json::json!({
"events": ["FutureEvent"],
"type": "command",
"command": "echo 'future event'",
"description": "Testing future event type"
});
fs::write(
hooks_dir.join("future-hook.json"),
serde_json::to_string_pretty(&future_hook)?,
)?;
source_repo.commit_all("Add future hook")?;
let source_url = source_repo.bare_file_url(project.sources_path())?;
let manifest_content = format!(
r#"
[sources]
hooks = "{}"
[hooks]
future-hook = {{ source = "hooks", path = "hooks/future-hook.json" }}
"#,
source_url
);
project.write_manifest(&manifest_content)?;
let output = project.run_ccpm(&["install"])?;
output.assert_success();
output.assert_stdout_contains("✓ Configured 1 hook(s)");
let settings_path = project.project_path().join(".claude/settings.local.json");
let settings_content = fs::read_to_string(&settings_path)?;
let settings: Value = serde_json::from_str(&settings_content)?;
let hooks = settings.get("hooks").expect("Should have hooks section");
let future_event = hooks
.get("FutureEvent")
.expect("Should have FutureEvent")
.as_array()
.unwrap();
assert_eq!(future_event.len(), 1);
assert!(
future_event[0].get("matcher").is_none(),
"FutureEvent should not have matcher"
);
let commands = future_event[0].get("hooks").unwrap().as_array().unwrap();
assert_eq!(commands.len(), 1);
assert_eq!(
commands[0].get("command").unwrap().as_str().unwrap(),
"echo 'future event'"
);
Ok(())
}
#[tokio::test]
async fn test_hooks_empty_no_message() -> Result<()> {
let project = TestProject::new()?;
let manifest_content = r#"
[sources]
# No sources
[hooks]
# No hooks
"#;
project.write_manifest(manifest_content)?;
let output = project.run_ccpm(&["install"])?;
output.assert_success();
assert!(!output.stdout.contains("Configured"));
assert!(!output.stdout.contains("hook"));
let settings_path = project.project_path().join(".claude/settings.local.json");
if settings_path.exists() {
let settings_content = fs::read_to_string(&settings_path)?;
let settings: Value = serde_json::from_str(&settings_content)?;
if let Some(hooks) = settings.get("hooks") {
let hooks_obj = hooks.as_object().unwrap();
assert!(hooks_obj.is_empty(), "Hooks section should be empty");
}
}
Ok(())
}
#[tokio::test]
async fn test_hooks_no_change_no_message() -> Result<()> {
let project = TestProject::new()?;
let source_repo = project.create_source_repo("hooks")?;
let hooks_dir = source_repo.path.join("hooks");
fs::create_dir_all(&hooks_dir)?;
let session_hook = serde_json::json!({
"events": ["SessionStart"],
"type": "command",
"command": "echo 'test hook'",
"description": "Test hook"
});
fs::write(
hooks_dir.join("session-start.json"),
serde_json::to_string_pretty(&session_hook)?,
)?;
source_repo.commit_all("Add test hook")?;
let source_url = source_repo.bare_file_url(project.sources_path())?;
let manifest_content = format!(
r#"
[sources]
hooks = "{}"
[hooks]
session-hook = {{ source = "hooks", path = "hooks/session-start.json" }}
"#,
source_url
);
project.write_manifest(&manifest_content)?;
let output1 = project.run_ccpm(&["install"])?;
output1.assert_success();
output1.assert_stdout_contains("✓ Configured 1 hook(s)");
let output2 = project.run_ccpm(&["install"])?;
output2.assert_success();
assert!(!output2.stdout.contains("Configured"));
assert!(!output2.stdout.contains("hook"));
Ok(())
}