use std::path::{Path, PathBuf};
use crate::error::Result;
#[derive(Debug, Clone)]
pub struct ToolHookConfig {
pub tool_name: String,
pub config_path: PathBuf,
pub config_content: String,
pub scope: HookScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookScope {
Project,
User,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookPlatform {
ClaudeCode,
Cursor,
GeminiCli,
Windsurf,
}
pub fn process_hook(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::ClaudeCode)
}
pub fn process_hook_cursor(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::Cursor)
}
pub fn process_hook_gemini(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::GeminiCli)
}
pub fn process_hook_windsurf(input: &str) -> Result<String> {
process_hook_for_platform(input, HookPlatform::Windsurf)
}
fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
let parsed: serde_json::Value = serde_json::from_str(input)
.map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
let tool_name = parsed
.get("tool_name")
.or_else(|| parsed.get("toolName"))
.and_then(|v| v.as_str())
.unwrap_or("");
let hook_event = parsed
.get("hook_event_name")
.or_else(|| parsed.get("agent_action_name"))
.and_then(|v| v.as_str())
.unwrap_or("");
let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
| "run_terminal_command" | "run_shell_command")
|| matches!(hook_event, "beforeShellExecution" | "pre_run_command");
if !is_shell {
return Ok(match platform {
HookPlatform::Cursor => "{}".to_string(),
_ => input.to_string(),
});
}
let command = parsed
.get("tool_input")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.or_else(|| parsed.get("command").and_then(|v| v.as_str()))
.or_else(|| {
parsed
.get("tool_info")
.and_then(|v| v.get("command_line"))
.and_then(|v| v.as_str())
})
.or_else(|| {
parsed
.get("toolCall")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
})
.unwrap_or("");
if command.is_empty() {
return Ok(match platform {
HookPlatform::Cursor => "{}".to_string(),
_ => input.to_string(),
});
}
let base_cmd = extract_base_command(command);
if base_cmd == "sqz" || command.starts_with("SQZ_CMD=") {
return Ok(match platform {
HookPlatform::Cursor => "{}".to_string(),
_ => input.to_string(),
});
}
if is_interactive_command(command) {
return Ok(match platform {
HookPlatform::Cursor => "{}".to_string(),
_ => input.to_string(),
});
}
if has_shell_operators(command) {
return Ok(match platform {
HookPlatform::Cursor => "{}".to_string(),
_ => input.to_string(),
});
}
let rewritten = format!(
"SQZ_CMD={} {} 2>&1 | sqz compress",
shell_escape(extract_base_command(command)),
command
);
let output = match platform {
HookPlatform::ClaudeCode => serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "sqz: command output will be compressed for token savings",
"updatedInput": {
"command": rewritten
}
}
}),
HookPlatform::Cursor => serde_json::json!({
"permission": "allow",
"updated_input": {
"command": rewritten
}
}),
HookPlatform::GeminiCli => serde_json::json!({
"decision": "allow",
"hookSpecificOutput": {
"tool_input": {
"command": rewritten
}
}
}),
HookPlatform::Windsurf => {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "sqz: command output will be compressed for token savings",
"updatedInput": {
"command": rewritten
}
}
})
}
};
serde_json::to_string(&output)
.map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
}
pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
let sqz_path_raw = sqz_path;
let sqz_path_json = json_escape_string_value(sqz_path);
let sqz_path = &sqz_path_json;
vec![
ToolHookConfig {
tool_name: "Claude Code".to_string(),
config_path: PathBuf::from(".claude/settings.local.json"),
config_content: format!(
r#"{{
"hooks": {{
"PreToolUse": [
{{
"matcher": "Bash",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook claude"
}}
]
}}
],
"PreCompact": [
{{
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook precompact"
}}
]
}}
],
"SessionStart": [
{{
"matcher": "compact",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} resume"
}}
]
}}
]
}}
}}"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Cursor".to_string(),
config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
config_content: format!(
r#"---
description: sqz — Token-Optimized CLI Output
alwaysApply: true
---
# sqz — Token-Optimized CLI Output
When running shell commands whose output may be long (directory listings,
git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
pipe the output through `sqz compress` to reduce token consumption.
`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
usage is to pipe the command's output into `sqz compress`:
```bash
# Instead of: Use:
git status git status 2>&1 | {sqz_path_raw} compress
cargo test cargo test 2>&1 | {sqz_path_raw} compress
git log -10 git log -10 2>&1 | {sqz_path_raw} compress
docker ps docker ps 2>&1 | {sqz_path_raw} compress
ls -la ls -la 2>&1 | {sqz_path_raw} compress
```
The `2>&1` captures stderr too, which is useful for commands like `cargo
test` where diagnostics go to stderr. `sqz compress` filters and compresses
the combined output while preserving filenames, paths, and identifiers.
It typically saves 60-90% tokens on verbose commands.
Do NOT pipe output for:
- Interactive commands (`vim`, `ssh`, `python`, REPLs)
- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
`cmd; other`) — run those directly
- Short commands whose output is already a few lines
If `sqz` is not on PATH, run commands normally.
"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Windsurf".to_string(),
config_path: PathBuf::from(".windsurfrules"),
config_content: format!(
r#"# sqz — Token-Optimized CLI Output
Pipe verbose shell command output through `sqz compress` to save tokens.
`sqz` reads from stdin and writes the compressed output to stdout — it is
NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
```bash
# Instead of: Use:
git status git status 2>&1 | {sqz_path_raw} compress
cargo test cargo test 2>&1 | {sqz_path_raw} compress
git log -10 git log -10 2>&1 | {sqz_path_raw} compress
docker ps docker ps 2>&1 | {sqz_path_raw} compress
```
sqz filters and compresses command outputs while preserving filenames,
paths, and identifiers (typically 60-90% token reduction on verbose
commands). Skip short commands, interactive commands (vim, ssh, python),
and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
not on PATH, run commands normally.
"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Cline".to_string(),
config_path: PathBuf::from(".clinerules"),
config_content: format!(
r#"# sqz — Token-Optimized CLI Output
Pipe verbose shell command output through `sqz compress` to save tokens.
`sqz` reads from stdin and writes the compressed output to stdout — it is
NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
```bash
# Instead of: Use:
git status git status 2>&1 | {sqz_path_raw} compress
cargo test cargo test 2>&1 | {sqz_path_raw} compress
git log -10 git log -10 2>&1 | {sqz_path_raw} compress
docker ps docker ps 2>&1 | {sqz_path_raw} compress
```
sqz filters and compresses command outputs while preserving filenames,
paths, and identifiers (typically 60-90% token reduction on verbose
commands). Skip short commands, interactive commands (vim, ssh, python),
and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
not on PATH, run commands normally.
"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "Gemini CLI".to_string(),
config_path: PathBuf::from(".gemini/settings.json"),
config_content: format!(
r#"{{
"hooks": {{
"BeforeTool": [
{{
"matcher": "run_shell_command",
"hooks": [
{{
"type": "command",
"command": "{sqz_path} hook gemini"
}}
]
}}
]
}}
}}"#
),
scope: HookScope::Project,
},
ToolHookConfig {
tool_name: "OpenCode".to_string(),
config_path: PathBuf::from("opencode.json"),
config_content: format!(
r#"{{
"$schema": "https://opencode.ai/config.json",
"mcp": {{
"sqz": {{
"type": "local",
"command": ["sqz-mcp", "--transport", "stdio"]
}}
}},
"plugin": ["sqz"]
}}"#
),
scope: HookScope::Project,
},
]
}
pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
let configs = generate_hook_configs(sqz_path);
let mut installed = Vec::new();
for config in &configs {
let full_path = project_dir.join(&config.config_path);
if full_path.exists() {
continue;
}
if let Some(parent) = full_path.parent() {
if std::fs::create_dir_all(parent).is_err() {
continue;
}
}
if std::fs::write(&full_path, &config.config_content).is_ok() {
installed.push(config.tool_name.clone());
}
}
if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
if !installed.iter().any(|n| n == "OpenCode") {
installed.push("OpenCode".to_string());
}
}
installed
}
fn extract_base_command(cmd: &str) -> &str {
cmd.split_whitespace()
.next()
.unwrap_or("unknown")
.rsplit('/')
.next()
.unwrap_or("unknown")
}
pub(crate) fn json_escape_string_value(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\x08' => out.push_str("\\b"),
'\x0c' => out.push_str("\\f"),
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out
}
fn shell_escape(s: &str) -> String {
if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
s.to_string()
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
fn has_shell_operators(cmd: &str) -> bool {
cmd.contains("&&")
|| cmd.contains("||")
|| cmd.contains(';')
|| cmd.contains('>')
|| cmd.contains('<')
|| cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
fn is_interactive_command(cmd: &str) -> bool {
let base = extract_base_command(cmd);
matches!(
base,
"vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
| "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
| "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
) || cmd.contains("--watch")
|| cmd.contains("-w ")
|| cmd.ends_with(" -w")
|| cmd.contains("run dev")
|| cmd.contains("run start")
|| cmd.contains("run serve")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_process_hook_rewrites_bash_command() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let hook_output = &parsed["hookSpecificOutput"];
assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
}
#[test]
fn test_process_hook_passes_through_non_bash() {
let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
let result = process_hook(input).unwrap();
assert_eq!(result, input, "non-bash tools should pass through unchanged");
}
#[test]
fn test_process_hook_skips_sqz_commands() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
let result = process_hook(input).unwrap();
assert_eq!(result, input, "sqz commands should not be double-wrapped");
}
#[test]
fn test_process_hook_skips_interactive() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
let result = process_hook(input).unwrap();
assert_eq!(result, input, "interactive commands should pass through");
}
#[test]
fn test_process_hook_skips_watch_mode() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
let result = process_hook(input).unwrap();
assert_eq!(result, input, "watch mode should pass through");
}
#[test]
fn test_process_hook_empty_command() {
let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
let result = process_hook(input).unwrap();
assert_eq!(result, input);
}
#[test]
fn test_process_hook_gemini_format() {
let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
let result = process_hook_gemini(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
"Gemini format should not have updatedInput");
assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
"Gemini format should not have permissionDecision");
}
#[test]
fn test_process_hook_legacy_format() {
let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
let result = process_hook(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
}
#[test]
fn test_process_hook_cursor_format() {
let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
let result = process_hook_cursor(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
let cmd = parsed["updated_input"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
assert!(cmd.contains("git status"));
assert!(parsed.get("hookSpecificOutput").is_none(),
"Cursor format should not have hookSpecificOutput");
}
#[test]
fn test_process_hook_cursor_passthrough_returns_empty_json() {
let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
let result = process_hook_cursor(input).unwrap();
assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
}
#[test]
fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
let result = process_hook_cursor(input).unwrap();
assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
}
#[test]
fn test_process_hook_windsurf_format() {
let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
let result = process_hook_windsurf(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
assert!(cmd.contains("cargo test"));
assert!(cmd.contains("SQZ_CMD=cargo"));
}
#[test]
fn test_process_hook_invalid_json() {
let result = process_hook("not json");
assert!(result.is_err());
}
#[test]
fn test_extract_base_command() {
assert_eq!(extract_base_command("git status"), "git");
assert_eq!(extract_base_command("/usr/bin/git log"), "git");
assert_eq!(extract_base_command("cargo test --release"), "cargo");
}
#[test]
fn test_is_interactive_command() {
assert!(is_interactive_command("vim file.txt"));
assert!(is_interactive_command("npm run dev --watch"));
assert!(is_interactive_command("python3"));
assert!(!is_interactive_command("git status"));
assert!(!is_interactive_command("cargo test"));
}
#[test]
fn test_generate_hook_configs() {
let configs = generate_hook_configs("sqz");
assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
"Windsurf should use .windsurfrules, not .windsurf/hooks.json");
let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
"Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
"Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
.cursor/hooks.json (non-functional) or .cursorrules (legacy)");
assert!(cursor.config_content.starts_with("---"),
"Cursor rule should start with YAML frontmatter");
assert!(cursor.config_content.contains("alwaysApply: true"),
"Cursor rule should use alwaysApply: true so the guidance loads \
for every agent interaction");
assert!(cursor.config_content.contains("sqz"),
"Cursor rule body should mention sqz");
}
#[test]
fn test_claude_config_includes_precompact_hook() {
let configs = generate_hook_configs("sqz");
let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
.expect("Claude Code config must be valid JSON");
let precompact = parsed["hooks"]["PreCompact"]
.as_array()
.expect("PreCompact hook array must be present");
assert!(
!precompact.is_empty(),
"PreCompact must have at least one registered hook"
);
let cmd = precompact[0]["hooks"][0]["command"]
.as_str()
.expect("command field must be a string");
assert!(
cmd.ends_with(" hook precompact"),
"PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
);
}
#[test]
fn test_json_escape_string_value() {
assert_eq!(json_escape_string_value("sqz"), "sqz");
assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
r"C:\\Users\\Alice\\sqz.exe");
assert_eq!(json_escape_string_value(r#"path with "quotes""#),
r#"path with \"quotes\""#);
assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
}
#[test]
fn test_windows_path_produces_valid_json_for_claude() {
let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
let configs = generate_hook_configs(windows_path);
let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
.expect("Claude config should be generated");
let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
.expect("Claude hook config must be valid JSON on Windows paths");
let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.expect("command field must be a string");
assert!(cmd.contains(windows_path),
"command '{cmd}' must contain the original Windows path '{windows_path}'");
}
#[test]
fn test_windows_path_in_cursor_rules_file() {
let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
let configs = generate_hook_configs(windows_path);
let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
assert!(cursor.config_content.contains(windows_path),
"Cursor rule must contain the raw (unescaped) path so users can \
copy-paste the shown commands — got:\n{}", cursor.config_content);
assert!(!cursor.config_content.contains(r"C:\\Users"),
"Cursor rule must NOT double-escape backslashes in markdown — \
got:\n{}", cursor.config_content);
}
#[test]
fn test_windows_path_produces_valid_json_for_gemini() {
let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
let configs = generate_hook_configs(windows_path);
let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
.expect("Gemini hook config must be valid JSON on Windows paths");
let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
assert!(cmd.contains(windows_path));
}
#[test]
fn test_rules_files_use_raw_path_for_readability() {
let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
let configs = generate_hook_configs(windows_path);
for tool in &["Windsurf", "Cline", "Cursor"] {
let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
assert!(cfg.config_content.contains(windows_path),
"{tool} rules file must contain the raw (unescaped) path — got:\n{}",
cfg.config_content);
assert!(!cfg.config_content.contains(r"C:\\Users"),
"{tool} rules file must NOT double-escape backslashes — got:\n{}",
cfg.config_content);
}
}
#[test]
fn test_unix_path_still_works() {
let unix_path = "/usr/local/bin/sqz";
let configs = generate_hook_configs(unix_path);
let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
.expect("Unix path should produce valid JSON");
let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
}
#[test]
fn test_shell_escape_simple() {
assert_eq!(shell_escape("git"), "git");
assert_eq!(shell_escape("cargo-test"), "cargo-test");
}
#[test]
fn test_shell_escape_special_chars() {
assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
}
#[test]
fn test_install_tool_hooks_creates_files() {
let dir = tempfile::tempdir().unwrap();
let installed = install_tool_hooks(dir.path(), "sqz");
assert!(!installed.is_empty(), "should install at least one hook config");
for name in &installed {
let configs = generate_hook_configs("sqz");
let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
let path = dir.path().join(&config.config_path);
assert!(path.exists(), "hook config should exist: {}", path.display());
}
}
#[test]
fn test_install_tool_hooks_does_not_overwrite() {
let dir = tempfile::tempdir().unwrap();
install_tool_hooks(dir.path(), "sqz");
let custom_path = dir.path().join(".claude/settings.local.json");
std::fs::write(&custom_path, "custom content").unwrap();
install_tool_hooks(dir.path(), "sqz");
let content = std::fs::read_to_string(&custom_path).unwrap();
assert_eq!(content, "custom content", "should not overwrite existing config");
}
}