1use std::path::{Path, PathBuf};
27
28use crate::error::Result;
29
30#[derive(Debug, Clone)]
32pub struct ToolHookConfig {
33 pub tool_name: String,
35 pub config_path: PathBuf,
37 pub config_content: String,
39 pub scope: HookScope,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum HookScope {
45 Project,
47 User,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum HookPlatform {
55 ClaudeCode,
57 Cursor,
59 GeminiCli,
61 Windsurf,
63 Kiro,
65}
66
67pub fn process_hook(input: &str) -> Result<String> {
86 process_hook_for_platform(input, HookPlatform::ClaudeCode)
87}
88
89pub fn process_hook_cursor(input: &str) -> Result<String> {
94 process_hook_for_platform(input, HookPlatform::Cursor)
95}
96
97pub fn process_hook_gemini(input: &str) -> Result<String> {
101 process_hook_for_platform(input, HookPlatform::GeminiCli)
102}
103
104pub fn process_hook_windsurf(input: &str) -> Result<String> {
109 process_hook_for_platform(input, HookPlatform::Windsurf)
110}
111
112pub fn process_hook_kiro(input: &str) -> Result<String> {
122 process_hook_for_platform(input, HookPlatform::Kiro)
123}
124
125fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
129 let parsed: serde_json::Value = serde_json::from_str(input)
130 .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
131
132 let tool_name = parsed
136 .get("tool_name")
137 .or_else(|| parsed.get("toolName"))
138 .and_then(|v| v.as_str())
139 .unwrap_or("");
140
141 let hook_event = parsed
142 .get("hook_event_name")
143 .or_else(|| parsed.get("agent_action_name"))
144 .and_then(|v| v.as_str())
145 .unwrap_or("");
146
147 let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
156 | "run_terminal_command" | "run_shell_command" | "execute_bash"
157 | "PowerShell" | "powershell" | "pwsh")
158 || matches!(hook_event, "beforeShellExecution" | "pre_run_command" | "preToolUse");
159
160 if !is_shell {
161 return Ok(match platform {
164 HookPlatform::Cursor => "{}".to_string(),
165 _ => input.to_string(),
166 });
167 }
168
169 let command = parsed
174 .get("tool_input")
175 .and_then(|v| v.get("command"))
176 .and_then(|v| v.as_str())
177 .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
178 .or_else(|| {
179 parsed
180 .get("tool_info")
181 .and_then(|v| v.get("command_line"))
182 .and_then(|v| v.as_str())
183 })
184 .or_else(|| {
185 parsed
186 .get("toolCall")
187 .and_then(|v| v.get("command"))
188 .and_then(|v| v.as_str())
189 })
190 .unwrap_or("");
191
192 if command.is_empty() {
193 return Ok(match platform {
194 HookPlatform::Cursor => "{}".to_string(),
195 _ => input.to_string(),
196 });
197 }
198
199 let base_cmd = extract_base_command(command);
211 if base_cmd == "sqz"
212 || command.starts_with("SQZ_CMD=")
213 || command.contains("sqz compress --cmd ")
214 || command.contains("sqz.exe compress --cmd ")
215 {
216 return Ok(match platform {
217 HookPlatform::Cursor => "{}".to_string(),
218 _ => input.to_string(),
219 });
220 }
221
222 if is_interactive_command(command) {
224 return Ok(match platform {
225 HookPlatform::Cursor => "{}".to_string(),
226 _ => input.to_string(),
227 });
228 }
229
230 if has_shell_operators(command) {
235 return Ok(match platform {
236 HookPlatform::Cursor => "{}".to_string(),
237 _ => input.to_string(),
238 });
239 }
240
241 let rewritten = format!(
259 "{} 2>&1 | sqz compress --cmd {}",
260 command,
261 shell_escape(command),
262 );
263
264 let output = match platform {
290 HookPlatform::ClaudeCode => serde_json::json!({
291 "hookSpecificOutput": {
292 "hookEventName": "PreToolUse",
293 "permissionDecision": "allow",
294 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
295 "updatedInput": {
296 "command": rewritten
297 }
298 }
299 }),
300 HookPlatform::Cursor => serde_json::json!({
301 "permission": "allow",
302 "updated_input": {
303 "command": rewritten
304 }
305 }),
306 HookPlatform::GeminiCli => serde_json::json!({
307 "decision": "allow",
308 "hookSpecificOutput": {
309 "tool_input": {
310 "command": rewritten
311 }
312 }
313 }),
314 HookPlatform::Windsurf => {
315 serde_json::json!({
319 "hookSpecificOutput": {
320 "hookEventName": "PreToolUse",
321 "permissionDecision": "allow",
322 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
323 "updatedInput": {
324 "command": rewritten
325 }
326 }
327 })
328 }
329 HookPlatform::Kiro => {
330 serde_json::json!({
334 "tool_input": {
335 "command": rewritten
336 }
337 })
338 }
339 };
340
341 serde_json::to_string(&output)
342 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
343}
344
345pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
347 let sqz_path_raw = sqz_path;
358 let sqz_path_json = json_escape_string_value(sqz_path);
359 let sqz_path = &sqz_path_json;
360
361 let escape_hatch = format!(
369 r#"## Escape hatch — when sqz output confuses the agent
370
371If you see a `§ref:HASH§` token in tool output and can't parse it, or if
372compressed output is making it harder to work (loops, repeated tiny
373requests), or if a `«A1»` abbreviation symbol has replaced a value you
374need verbatim (a SHA, path, or URL), you have these ways out:
375
376- **`{sqz_path_raw} expand <prefix>`** — resolve a ref back to the
377 original bytes. Accepts the bare hex prefix (`sqz expand a1b2c3d4`)
378 or the full token pasted verbatim (`sqz expand §ref:a1b2c3d4§`).
379- **`SQZ_NO_DEDUP=1`** — set this env var (e.g. `SQZ_NO_DEDUP=1 git
380 status 2>&1 | sqz compress`) to disable dedup for that one command.
381 Strictly more tokens, strictly less ambiguous.
382- **`--no-cache`** — the same opt-out as a CLI flag:
383 `git status 2>&1 | sqz compress --no-cache`.
384- **`SQZ_NO_ABBREV=1`** (or **`--no-abbrev`**) — disable n-gram phrase
385 abbreviation, which replaces repeated phrases with `«A1»` symbols.
386 Use this for output full of SHAs/paths/URLs you'll copy-paste, since
387 abbreviation keeps only the first occurrence of a repeated phrase:
388 `SQZ_NO_ABBREV=1 git log 2>&1 | sqz compress`.
389
390Use these when you genuinely need raw data. The default compressed
391path is still what you want for most output."#,
392 );
393 let escape_hatch = &escape_hatch;
394
395 vec![
396 ToolHookConfig {
411 tool_name: "Claude Code".to_string(),
412 config_path: PathBuf::from(".claude/settings.local.json"),
413 config_content: format!(
414 r#"{{
415 "hooks": {{
416 "PreToolUse": [
417 {{
418 "matcher": "Bash|PowerShell",
419 "hooks": [
420 {{
421 "type": "command",
422 "command": "{sqz_path} hook claude"
423 }}
424 ]
425 }}
426 ],
427 "PreCompact": [
428 {{
429 "hooks": [
430 {{
431 "type": "command",
432 "command": "{sqz_path} hook precompact"
433 }}
434 ]
435 }}
436 ],
437 "SessionStart": [
438 {{
439 "matcher": "compact",
440 "hooks": [
441 {{
442 "type": "command",
443 "command": "{sqz_path} resume"
444 }}
445 ]
446 }}
447 ]
448 }}
449}}"#
450 ),
451 scope: HookScope::Project,
452 },
453 ToolHookConfig {
462 tool_name: "Cursor".to_string(),
463 config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
464 config_content: format!(
465 r#"---
466description: sqz — Token-Optimized CLI Output
467alwaysApply: true
468---
469
470# sqz — Token-Optimized CLI Output
471
472When running shell commands whose output may be long (directory listings,
473git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
474pipe the output through `sqz compress` to reduce token consumption.
475
476`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
477usage is to pipe the command's output into `sqz compress`:
478
479```bash
480# Instead of: Use:
481git status git status 2>&1 | {sqz_path_raw} compress
482cargo test cargo test 2>&1 | {sqz_path_raw} compress
483git log -10 git log -10 2>&1 | {sqz_path_raw} compress
484docker ps docker ps 2>&1 | {sqz_path_raw} compress
485ls -la ls -la 2>&1 | {sqz_path_raw} compress
486```
487
488The `2>&1` captures stderr too, which is useful for commands like `cargo
489test` where diagnostics go to stderr. `sqz compress` filters and compresses
490the combined output while preserving filenames, paths, and identifiers.
491It typically saves 60-90% tokens on verbose commands.
492
493Do NOT pipe output for:
494- Interactive commands (`vim`, `ssh`, `python`, REPLs)
495- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
496 `cmd; other`) — run those directly
497- Short commands whose output is already a few lines
498
499If `sqz` is not on PATH, run commands normally.
500
501{escape_hatch}
502"#
503 ),
504 scope: HookScope::Project,
505 },
506 ToolHookConfig {
510 tool_name: "Windsurf".to_string(),
511 config_path: PathBuf::from(".windsurfrules"),
512 config_content: format!(
513 r#"# sqz — Token-Optimized CLI Output
514
515Pipe verbose shell command output through `sqz compress` to save tokens.
516`sqz` reads from stdin and writes the compressed output to stdout — it is
517NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
518
519```bash
520# Instead of: Use:
521git status git status 2>&1 | {sqz_path_raw} compress
522cargo test cargo test 2>&1 | {sqz_path_raw} compress
523git log -10 git log -10 2>&1 | {sqz_path_raw} compress
524docker ps docker ps 2>&1 | {sqz_path_raw} compress
525```
526
527sqz filters and compresses command outputs while preserving filenames,
528paths, and identifiers (typically 60-90% token reduction on verbose
529commands). Skip short commands, interactive commands (vim, ssh, python),
530and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
531not on PATH, run commands normally.
532
533{escape_hatch}
534"#
535 ),
536 scope: HookScope::Project,
537 },
538 ToolHookConfig {
542 tool_name: "Cline".to_string(),
543 config_path: PathBuf::from(".clinerules"),
544 config_content: format!(
545 r#"# sqz — Token-Optimized CLI Output
546
547Pipe verbose shell command output through `sqz compress` to save tokens.
548`sqz` reads from stdin and writes the compressed output to stdout — it is
549NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
550
551```bash
552# Instead of: Use:
553git status git status 2>&1 | {sqz_path_raw} compress
554cargo test cargo test 2>&1 | {sqz_path_raw} compress
555git log -10 git log -10 2>&1 | {sqz_path_raw} compress
556docker ps docker ps 2>&1 | {sqz_path_raw} compress
557```
558
559sqz filters and compresses command outputs while preserving filenames,
560paths, and identifiers (typically 60-90% token reduction on verbose
561commands). Skip short commands, interactive commands (vim, ssh, python),
562and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
563not on PATH, run commands normally.
564
565{escape_hatch}
566"#
567 ),
568 scope: HookScope::Project,
569 },
570 ToolHookConfig {
572 tool_name: "Gemini CLI".to_string(),
573 config_path: PathBuf::from(".gemini/settings.json"),
574 config_content: format!(
575 r#"{{
576 "hooks": {{
577 "BeforeTool": [
578 {{
579 "matcher": "run_shell_command",
580 "hooks": [
581 {{
582 "type": "command",
583 "command": "{sqz_path} hook gemini"
584 }}
585 ]
586 }}
587 ]
588 }}
589}}"#
590 ),
591 scope: HookScope::Project,
592 },
593 ToolHookConfig {
596 tool_name: "Kiro".to_string(),
597 config_path: PathBuf::from(".kiro/hooks/sqz-compress.json"),
598 config_content: format!(
599 r#"{{
600 "name": "sqz compress",
601 "version": "1.0.0",
602 "description": "Compress shell command output through sqz for token savings",
603 "when": {{
604 "type": "preToolUse",
605 "toolTypes": ["shell"]
606 }},
607 "then": {{
608 "type": "runCommand",
609 "command": "{sqz_path} hook kiro"
610 }}
611}}"#
612 ),
613 scope: HookScope::Project,
614 },
615 ToolHookConfig {
624 tool_name: "OpenCode".to_string(),
625 config_path: PathBuf::from("opencode.json"),
626 config_content: format!(
627 r#"{{
628 "$schema": "https://opencode.ai/config.json",
629 "mcp": {{
630 "sqz": {{
631 "type": "local",
632 "command": ["sqz-mcp", "--transport", "stdio"]
633 }}
634 }},
635 "plugin": ["sqz"]
636}}"#
637 ),
638 scope: HookScope::Project,
639 },
640 ToolHookConfig {
659 tool_name: "Codex".to_string(),
660 config_path: PathBuf::from("AGENTS.md"),
661 config_content: crate::codex_integration::agents_md_guidance_block(
662 sqz_path_raw,
663 ),
664 scope: HookScope::Project,
665 },
666 ToolHookConfig {
681 tool_name: "Zed".to_string(),
682 config_path: PathBuf::from("AGENTS.md"),
683 config_content: crate::zed_integration::zed_guidance_block(sqz_path_raw),
684 scope: HookScope::Project,
685 },
686 ]
687}
688
689pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
695 install_tool_hooks_scoped(project_dir, sqz_path, InstallScope::Project)
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
722pub enum InstallScope {
723 Project,
726 Global,
729}
730
731#[derive(Debug, Clone, PartialEq, Eq)]
744pub enum ToolFilter {
745 All,
748 Only(Vec<String>),
752 Skip(Vec<String>),
757}
758
759impl Default for ToolFilter {
760 fn default() -> Self {
761 ToolFilter::All
762 }
763}
764
765impl ToolFilter {
766 pub fn includes(&self, tool_name: &str) -> bool {
777 let canon = canonicalize_tool_name(tool_name);
778 match self {
779 ToolFilter::All => true,
780 ToolFilter::Only(allow) => allow.iter().any(|n| {
781 n == &canon
784 }),
785 ToolFilter::Skip(deny) => !deny.iter().any(|n| n == &canon),
786 }
787 }
788}
789
790pub const SUPPORTED_TOOL_NAMES: &[&str] = &[
795 "Claude Code",
796 "Cursor",
797 "Windsurf",
798 "Cline",
799 "Gemini CLI",
800 "Kiro",
801 "OpenCode",
802 "Codex",
803 "Zed",
804];
805
806pub fn canonicalize_tool_name(name: &str) -> String {
825 let lowered: String = name
826 .chars()
827 .filter(|c| !c.is_whitespace())
828 .flat_map(|c| c.to_lowercase())
829 .filter(|c| *c != '-' && *c != '_')
830 .collect();
831 match lowered.as_str() {
832 "claude" | "claudecode" => "claudecode".to_string(),
833 "cursor" => "cursor".to_string(),
834 "windsurf" => "windsurf".to_string(),
835 "cline" | "roo" | "roocode" => "cline".to_string(),
839 "gemini" | "geminicli" => "gemini".to_string(),
840 "kiro" | "kirocli" | "kiroide" => "kiro".to_string(),
841 "opencode" => "opencode".to_string(),
842 "codex" => "codex".to_string(),
843 "zed" | "zededitor" | "zedagent" => "zed".to_string(),
844 other => other.to_string(),
845 }
846}
847
848pub fn parse_tool_list(raw: &str) -> Result<Vec<String>> {
858 let mut out = Vec::new();
859 let known: std::collections::HashSet<String> = SUPPORTED_TOOL_NAMES
860 .iter()
861 .map(|n| canonicalize_tool_name(n))
862 .collect();
863 for part in raw.split(',') {
864 let trimmed = part.trim();
865 if trimmed.is_empty() {
866 continue;
867 }
868 let canon = canonicalize_tool_name(trimmed);
869 if !known.contains(&canon) {
870 let valid: Vec<String> = SUPPORTED_TOOL_NAMES
871 .iter()
872 .map(|n| canonicalize_tool_name(n))
873 .collect();
874 return Err(crate::error::SqzError::Other(format!(
875 "unknown agent name '{}'. Valid options: {}",
876 trimmed,
877 valid.join(", ")
878 )));
879 }
880 if !out.contains(&canon) {
881 out.push(canon);
882 }
883 }
884 Ok(out)
885}
886
887pub fn install_tool_hooks_scoped(
913 project_dir: &Path,
914 sqz_path: &str,
915 scope: InstallScope,
916) -> Vec<String> {
917 install_tool_hooks_scoped_filtered(project_dir, sqz_path, scope, &ToolFilter::All)
918}
919
920pub fn install_tool_hooks_scoped_filtered(
934 project_dir: &Path,
935 sqz_path: &str,
936 scope: InstallScope,
937 filter: &ToolFilter,
938) -> Vec<String> {
939 let configs = generate_hook_configs(sqz_path);
940 let mut installed = Vec::new();
941
942 for config in &configs {
943 if !filter.includes(&config.tool_name) {
947 continue;
948 }
949
950 if config.tool_name == "OpenCode" {
959 match crate::opencode_plugin::update_opencode_config_detailed(project_dir) {
960 Ok((updated, _comments_lost)) => {
961 if updated && !installed.iter().any(|n| n == "OpenCode") {
962 installed.push("OpenCode".to_string());
963 }
964 }
965 Err(_e) => {
966 }
969 }
970 continue;
971 }
972
973 if config.tool_name == "Codex" {
978 let agents_changed = crate::codex_integration::install_agents_md_guidance(
979 project_dir, sqz_path,
980 )
981 .unwrap_or(false);
982 let mcp_changed = crate::codex_integration::install_codex_mcp_config()
983 .unwrap_or(false);
984 if (agents_changed || mcp_changed)
985 && !installed.iter().any(|n| n == "Codex")
986 {
987 installed.push("Codex".to_string());
988 }
989 continue;
990 }
991
992 if config.tool_name == "Zed" {
997 let guidance_changed =
998 crate::zed_integration::install_zed_guidance(project_dir, sqz_path)
999 .unwrap_or(false);
1000 let mcp_changed = matches!(
1001 crate::zed_integration::install_zed_mcp_config(),
1002 Ok(crate::zed_integration::ZedMcpInstall::Created)
1003 | Ok(crate::zed_integration::ZedMcpInstall::Merged)
1004 );
1005 if (guidance_changed || mcp_changed) && !installed.iter().any(|n| n == "Zed") {
1006 installed.push("Zed".to_string());
1007 }
1008 continue;
1009 }
1010
1011 if config.tool_name == "Claude Code" && scope == InstallScope::Global {
1021 let hook_installed = match install_claude_global(sqz_path) {
1022 Ok(v) => v,
1023 Err(_) => false,
1024 };
1025 let md_changed = crate::claude_md_integration::install_claude_md_guidance(
1026 project_dir, sqz_path,
1027 )
1028 .unwrap_or(false);
1029 let mcp_changed =
1030 crate::claude_md_integration::install_claude_mcp_config()
1031 .unwrap_or(false);
1032 if (hook_installed || md_changed || mcp_changed)
1033 && !installed.iter().any(|n| n == "Claude Code")
1034 {
1035 installed.push("Claude Code".to_string());
1036 }
1037 continue;
1038 }
1039
1040 let full_path = project_dir.join(&config.config_path);
1041
1042 if full_path.exists() {
1044 if config.tool_name == "Claude Code" {
1053 let hook_changed =
1054 merge_sqz_hooks_into_settings(&full_path, sqz_path).unwrap_or(false);
1055 let md_changed =
1056 crate::claude_md_integration::install_claude_md_guidance(
1057 project_dir, sqz_path,
1058 )
1059 .unwrap_or(false);
1060 let mcp_changed =
1061 crate::claude_md_integration::install_claude_mcp_config()
1062 .unwrap_or(false);
1063 if (hook_changed || md_changed || mcp_changed)
1064 && !installed.iter().any(|n| n == "Claude Code")
1065 {
1066 installed.push("Claude Code".to_string());
1067 }
1068 }
1069 continue;
1070 }
1071
1072 if let Some(parent) = full_path.parent() {
1074 if std::fs::create_dir_all(parent).is_err() {
1075 continue;
1076 }
1077 }
1078
1079 if std::fs::write(&full_path, &config.config_content).is_ok() {
1080 installed.push(config.tool_name.clone());
1081 if config.tool_name == "Claude Code" {
1088 let _ = crate::claude_md_integration::install_claude_md_guidance(
1089 project_dir, sqz_path,
1090 );
1091 let _ = crate::claude_md_integration::install_claude_mcp_config();
1092 }
1093 }
1094 }
1095
1096 if filter.includes("OpenCode") {
1107 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
1108 if !installed.iter().any(|n| n == "OpenCode") {
1109 installed.push("OpenCode".to_string());
1110 }
1111 }
1112 }
1113
1114 installed
1115}
1116
1117pub fn claude_user_settings_path() -> Option<PathBuf> {
1130 dirs_next::home_dir().map(|h| h.join(".claude").join("settings.json"))
1131}
1132
1133fn install_claude_global(sqz_path: &str) -> Result<bool> {
1148 install_claude_global_at(sqz_path, None)
1149}
1150
1151fn install_claude_global_at(sqz_path: &str, home_override: Option<&Path>) -> Result<bool> {
1156 let path = match home_override {
1157 Some(h) => h.join(".claude").join("settings.json"),
1158 None => claude_user_settings_path().ok_or_else(|| {
1159 crate::error::SqzError::Other(
1160 "Could not resolve home directory for ~/.claude/settings.json".to_string(),
1161 )
1162 })?,
1163 };
1164 merge_sqz_hooks_into_settings(&path, sqz_path)
1165}
1166
1167pub fn claude_project_settings_needs_update(path: &Path, sqz_path: &str) -> Result<bool> {
1180 if !path.exists() {
1181 return Ok(true);
1182 }
1183 let content = std::fs::read_to_string(path).map_err(|e| {
1184 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1185 })?;
1186 let mut root: serde_json::Value = if content.trim().is_empty() {
1187 serde_json::Value::Object(serde_json::Map::new())
1188 } else {
1189 serde_json::from_str(&content).map_err(|e| {
1190 crate::error::SqzError::Other(format!("parse {}: {e}", path.display()))
1191 })?
1192 };
1193 let Some(root_obj) = root.as_object_mut() else {
1194 return Err(crate::error::SqzError::Other(format!(
1195 "{} is not a JSON object",
1196 path.display()
1197 )));
1198 };
1199
1200 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1201 let hooks = root_obj
1202 .entry("hooks".to_string())
1203 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1204 let Some(hooks_obj) = hooks.as_object_mut() else {
1205 return Ok(true);
1206 };
1207 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1208 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1209 }
1210 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1211 Ok(before != after)
1212}
1213
1214fn sqz_hook_entries(sqz_path: &str) -> [(&'static str, serde_json::Value, &'static str); 3] {
1218 [
1219 (
1220 "PreToolUse",
1221 serde_json::json!({
1222 "matcher": "Bash|PowerShell",
1223 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook claude") }]
1224 }),
1225 "hook claude",
1226 ),
1227 (
1228 "PreCompact",
1229 serde_json::json!({
1230 "hooks": [{ "type": "command", "command": format!("{sqz_path} hook precompact") }]
1231 }),
1232 "hook precompact",
1233 ),
1234 (
1235 "SessionStart",
1236 serde_json::json!({
1237 "matcher": "compact",
1238 "hooks": [{ "type": "command", "command": format!("{sqz_path} resume") }]
1239 }),
1240 "resume",
1241 ),
1242 ]
1243}
1244
1245fn merge_sqz_hooks_into_settings(path: &Path, sqz_path: &str) -> Result<bool> {
1254 let mut root: serde_json::Value = if path.exists() {
1256 let content = std::fs::read_to_string(&path).map_err(|e| {
1257 crate::error::SqzError::Other(format!(
1258 "read {}: {e}",
1259 path.display()
1260 ))
1261 })?;
1262 if content.trim().is_empty() {
1263 serde_json::Value::Object(serde_json::Map::new())
1264 } else {
1265 serde_json::from_str(&content).map_err(|e| {
1266 crate::error::SqzError::Other(format!(
1267 "parse {}: {e} — please fix or move the file before re-running sqz init",
1268 path.display()
1269 ))
1270 })?
1271 }
1272 } else {
1273 serde_json::Value::Object(serde_json::Map::new())
1274 };
1275
1276 let root_obj = root.as_object_mut().ok_or_else(|| {
1279 crate::error::SqzError::Other(format!(
1280 "{} is not a JSON object — refusing to overwrite",
1281 path.display()
1282 ))
1283 })?;
1284
1285 let before = serde_json::to_string(&root_obj).unwrap_or_default();
1287
1288 let hooks = root_obj
1290 .entry("hooks".to_string())
1291 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1292 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
1293 crate::error::SqzError::Other(format!(
1294 "{}: `hooks` is not an object — refusing to overwrite",
1295 path.display()
1296 ))
1297 })?;
1298
1299 for (event, entry, sentinel) in sqz_hook_entries(sqz_path) {
1300 upsert_sqz_hook_entry(hooks_obj, event, entry, sentinel);
1301 }
1302
1303 let after = serde_json::to_string(&root_obj).unwrap_or_default();
1304 if before == after && path.exists() {
1305 return Ok(false);
1307 }
1308
1309 if let Some(parent) = path.parent() {
1311 std::fs::create_dir_all(parent).map_err(|e| {
1312 crate::error::SqzError::Other(format!(
1313 "create {}: {e}",
1314 parent.display()
1315 ))
1316 })?;
1317 }
1318
1319 let parent = path.parent().ok_or_else(|| {
1323 crate::error::SqzError::Other(format!(
1324 "path {} has no parent directory",
1325 path.display()
1326 ))
1327 })?;
1328 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1329 crate::error::SqzError::Other(format!(
1330 "create temp file in {}: {e}",
1331 parent.display()
1332 ))
1333 })?;
1334 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1335 .map_err(|e| crate::error::SqzError::Other(format!("serialize settings.json: {e}")))?;
1336 std::fs::write(tmp.path(), serialized).map_err(|e| {
1337 crate::error::SqzError::Other(format!(
1338 "write to temp file {}: {e}",
1339 tmp.path().display()
1340 ))
1341 })?;
1342 tmp.persist(&path).map_err(|e| {
1343 crate::error::SqzError::Other(format!(
1344 "rename temp file into place at {}: {e}",
1345 path.display()
1346 ))
1347 })?;
1348
1349 Ok(true)
1350}
1351
1352pub fn remove_claude_global_hook() -> Result<Option<(PathBuf, bool)>> {
1366 remove_claude_global_hook_at(None)
1367}
1368
1369fn remove_claude_global_hook_at(
1372 home_override: Option<&Path>,
1373) -> Result<Option<(PathBuf, bool)>> {
1374 let path = match home_override {
1375 Some(h) => h.join(".claude").join("settings.json"),
1376 None => match claude_user_settings_path() {
1377 Some(p) => p,
1378 None => return Ok(None),
1379 },
1380 };
1381 if !path.exists() {
1382 return Ok(None);
1383 }
1384
1385 let content = std::fs::read_to_string(&path).map_err(|e| {
1386 crate::error::SqzError::Other(format!("read {}: {e}", path.display()))
1387 })?;
1388 if content.trim().is_empty() {
1389 return Ok(Some((path, false)));
1390 }
1391
1392 let mut root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1393 crate::error::SqzError::Other(format!(
1394 "parse {}: {e} — refusing to rewrite an unparseable file",
1395 path.display()
1396 ))
1397 })?;
1398 let Some(root_obj) = root.as_object_mut() else {
1399 return Ok(Some((path, false)));
1400 };
1401
1402 let mut changed = false;
1403 if let Some(hooks) = root_obj.get_mut("hooks").and_then(|h| h.as_object_mut()) {
1404 for (event, sentinel) in &[
1405 ("PreToolUse", "hook claude"),
1406 ("PreCompact", "hook precompact"),
1407 ("SessionStart", "resume"),
1408 ] {
1409 if let Some(arr) = hooks.get_mut(*event).and_then(|v| v.as_array_mut()) {
1410 let before = arr.len();
1411 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1412 if arr.len() != before {
1413 changed = true;
1414 }
1415 }
1416 }
1417
1418 hooks.retain(|_, v| match v {
1421 serde_json::Value::Array(a) => !a.is_empty(),
1422 _ => true,
1423 });
1424
1425 let hooks_empty = hooks.is_empty();
1428 if hooks_empty {
1429 root_obj.remove("hooks");
1430 changed = true;
1431 }
1432 }
1433
1434 if !changed {
1435 return Ok(Some((path, false)));
1436 }
1437
1438 if root_obj.is_empty() {
1442 std::fs::remove_file(&path).map_err(|e| {
1443 crate::error::SqzError::Other(format!(
1444 "remove {}: {e}",
1445 path.display()
1446 ))
1447 })?;
1448 return Ok(Some((path, true)));
1449 }
1450
1451 let parent = path.parent().ok_or_else(|| {
1453 crate::error::SqzError::Other(format!(
1454 "path {} has no parent directory",
1455 path.display()
1456 ))
1457 })?;
1458 let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| {
1459 crate::error::SqzError::Other(format!(
1460 "create temp file in {}: {e}",
1461 parent.display()
1462 ))
1463 })?;
1464 let serialized = serde_json::to_string_pretty(&serde_json::Value::Object(root_obj.clone()))
1465 .map_err(|e| {
1466 crate::error::SqzError::Other(format!("serialize settings.json: {e}"))
1467 })?;
1468 std::fs::write(tmp.path(), serialized).map_err(|e| {
1469 crate::error::SqzError::Other(format!(
1470 "write to temp file {}: {e}",
1471 tmp.path().display()
1472 ))
1473 })?;
1474 tmp.persist(&path).map_err(|e| {
1475 crate::error::SqzError::Other(format!(
1476 "rename temp file into place at {}: {e}",
1477 path.display()
1478 ))
1479 })?;
1480
1481 Ok(Some((path, true)))
1482}
1483
1484fn upsert_sqz_hook_entry(
1491 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
1492 event_name: &str,
1493 new_entry: serde_json::Value,
1494 sentinel: &str,
1495) {
1496 let arr = hooks_obj
1497 .entry(event_name.to_string())
1498 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
1499 let Some(arr) = arr.as_array_mut() else {
1500 hooks_obj.insert(
1504 event_name.to_string(),
1505 serde_json::Value::Array(vec![new_entry]),
1506 );
1507 return;
1508 };
1509
1510 arr.retain(|entry| !hook_entry_command_contains(entry, sentinel));
1512
1513 arr.push(new_entry);
1514}
1515
1516fn hook_entry_command_contains(entry: &serde_json::Value, needle: &str) -> bool {
1520 entry
1521 .get("hooks")
1522 .and_then(|h| h.as_array())
1523 .map(|hooks_arr| {
1524 hooks_arr.iter().any(|h| {
1525 h.get("command")
1526 .and_then(|c| c.as_str())
1527 .map(|c| c.contains(needle))
1528 .unwrap_or(false)
1529 })
1530 })
1531 .unwrap_or(false)
1532}
1533
1534fn extract_base_command(cmd: &str) -> &str {
1538 cmd.split_whitespace()
1539 .next()
1540 .unwrap_or("unknown")
1541 .rsplit('/')
1542 .next()
1543 .unwrap_or("unknown")
1544}
1545
1546pub(crate) fn json_escape_string_value(s: &str) -> String {
1557 let mut out = String::with_capacity(s.len() + 2);
1558 for ch in s.chars() {
1559 match ch {
1560 '\\' => out.push_str("\\\\"),
1561 '"' => out.push_str("\\\""),
1562 '\n' => out.push_str("\\n"),
1563 '\r' => out.push_str("\\r"),
1564 '\t' => out.push_str("\\t"),
1565 '\x08' => out.push_str("\\b"),
1566 '\x0c' => out.push_str("\\f"),
1567 c if (c as u32) < 0x20 => {
1568 out.push_str(&format!("\\u{:04x}", c as u32));
1570 }
1571 c => out.push(c),
1572 }
1573 }
1574 out
1575}
1576
1577fn shell_escape(s: &str) -> String {
1579 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
1580 s.to_string()
1581 } else {
1582 format!("'{}'", s.replace('\'', "'\\''"))
1583 }
1584}
1585
1586pub(crate) fn has_shell_operators(cmd: &str) -> bool {
1590 cmd.contains("&&")
1593 || cmd.contains("||")
1594 || cmd.contains(';')
1595 || cmd.contains('>')
1596 || cmd.contains('<')
1597 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
1603
1604fn is_interactive_command(cmd: &str) -> bool {
1606 let base = extract_base_command(cmd);
1607 matches!(
1608 base,
1609 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
1610 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
1611 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
1612 ) || cmd.contains("--watch")
1613 || cmd.contains("-w ")
1614 || cmd.ends_with(" -w")
1615 || cmd.contains("run dev")
1616 || cmd.contains("run start")
1617 || cmd.contains("run serve")
1618}
1619
1620#[cfg(test)]
1623mod tests {
1624 use super::*;
1625
1626 #[test]
1627 fn test_process_hook_rewrites_bash_command() {
1628 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
1630 let result = process_hook(input).unwrap();
1631 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1632 let hook_output = &parsed["hookSpecificOutput"];
1634 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
1635 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
1636 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
1638 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1639 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
1640 assert!(
1643 cmd.contains("--cmd 'git status'"),
1644 "should pass the full command as --cmd so subcommand formatters dispatch: {cmd}"
1645 );
1646 assert!(
1647 !cmd.contains("SQZ_CMD="),
1648 "new rewrites must not emit the legacy sh-style env prefix: {cmd}"
1649 );
1650 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
1652 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
1653 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
1654 }
1655
1656 #[test]
1657 fn test_process_hook_passes_through_non_bash() {
1658 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1659 let result = process_hook(input).unwrap();
1660 assert_eq!(result, input, "non-bash tools should pass through unchanged");
1661 }
1662
1663 #[test]
1664 fn test_process_hook_rewrites_powershell_command() {
1665 let input = r#"{"tool_name":"PowerShell","tool_input":{"command":"Get-Content big.log"}}"#;
1666 let result = process_hook(input).unwrap();
1667 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1668 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1669 .as_str()
1670 .expect("PowerShell tool calls should be rewritten");
1671 assert!(
1672 cmd.contains("Get-Content big.log"),
1673 "should preserve original PowerShell command: {cmd}"
1674 );
1675 assert!(
1676 cmd.contains("sqz compress"),
1677 "should pipe through sqz compress: {cmd}"
1678 );
1679 assert!(
1680 cmd.contains("--cmd 'Get-Content big.log'"),
1681 "should pass full command via --cmd: {cmd}"
1682 );
1683 }
1684
1685 #[test]
1686 fn test_process_hook_rewrites_pwsh_command() {
1687 let input = r#"{"tool_name":"pwsh","tool_input":{"command":"ls"}}"#;
1688 let result = process_hook(input).unwrap();
1689 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1690 assert!(
1691 parsed["hookSpecificOutput"]["updatedInput"]["command"].is_string(),
1692 "pwsh tool calls should be rewritten"
1693 );
1694 }
1695
1696 #[test]
1697 fn test_process_hook_skips_sqz_commands() {
1698 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
1699 let result = process_hook(input).unwrap();
1700 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1701 }
1702
1703 #[test]
1704 fn test_process_hook_skips_interactive() {
1705 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
1706 let result = process_hook(input).unwrap();
1707 assert_eq!(result, input, "interactive commands should pass through");
1708 }
1709
1710 #[test]
1711 fn test_process_hook_skips_watch_mode() {
1712 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
1713 let result = process_hook(input).unwrap();
1714 assert_eq!(result, input, "watch mode should pass through");
1715 }
1716
1717 #[test]
1718 fn test_process_hook_empty_command() {
1719 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
1720 let result = process_hook(input).unwrap();
1721 assert_eq!(result, input);
1722 }
1723
1724 #[test]
1725 fn test_process_hook_gemini_format() {
1726 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
1728 let result = process_hook_gemini(input).unwrap();
1729 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1730 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
1732 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
1734 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1735 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
1737 "Gemini format should not have updatedInput");
1738 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
1739 "Gemini format should not have permissionDecision");
1740 }
1741
1742 #[test]
1743 fn test_process_hook_legacy_format() {
1744 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
1746 let result = process_hook(input).unwrap();
1747 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1748 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1749 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
1750 }
1751
1752 #[test]
1753 fn test_process_hook_cursor_format() {
1754 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
1756 let result = process_hook_cursor(input).unwrap();
1757 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1758 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
1760 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
1761 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
1762 assert!(cmd.contains("git status"));
1763 assert!(parsed.get("hookSpecificOutput").is_none(),
1765 "Cursor format should not have hookSpecificOutput");
1766 }
1767
1768 #[test]
1769 fn test_process_hook_cursor_passthrough_returns_empty_json() {
1770 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
1772 let result = process_hook_cursor(input).unwrap();
1773 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
1774 }
1775
1776 #[test]
1777 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
1778 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
1780 let result = process_hook_cursor(input).unwrap();
1781 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
1782 }
1783
1784 #[test]
1785 fn test_process_hook_windsurf_format() {
1786 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
1788 let result = process_hook_windsurf(input).unwrap();
1789 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1790 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
1792 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
1793 assert!(cmd.contains("cargo test"));
1794 assert!(
1796 cmd.contains("--cmd 'cargo test'"),
1797 "full command must be passed via --cmd flag: {cmd}"
1798 );
1799 assert!(!cmd.contains("SQZ_CMD="), "must not emit legacy env prefix: {cmd}");
1800 }
1801
1802 #[test]
1803 fn test_process_hook_invalid_json() {
1804 let result = process_hook("not json");
1805 assert!(result.is_err());
1806 }
1807
1808 #[test]
1809 fn test_extract_base_command() {
1810 assert_eq!(extract_base_command("git status"), "git");
1811 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
1812 assert_eq!(extract_base_command("cargo test --release"), "cargo");
1813 }
1814
1815 #[test]
1816 fn test_is_interactive_command() {
1817 assert!(is_interactive_command("vim file.txt"));
1818 assert!(is_interactive_command("npm run dev --watch"));
1819 assert!(is_interactive_command("python3"));
1820 assert!(!is_interactive_command("git status"));
1821 assert!(!is_interactive_command("cargo test"));
1822 }
1823
1824 #[test]
1839 fn issue_10_rewrite_is_shell_neutral() {
1840 let input = r#"{"tool_name":"Bash","tool_input":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1841 let result = process_hook(input).unwrap();
1842 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1843 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"]
1844 .as_str()
1845 .unwrap();
1846
1847 assert!(
1849 cmd.contains("--cmd 'dotnet build NewNeonCheckers3.sln'"),
1850 "issue #10: rewrite must pass label via --cmd, got: {cmd}"
1851 );
1852 assert!(
1854 !cmd.contains("SQZ_CMD="),
1855 "issue #10: rewrite must NOT emit `SQZ_CMD=` prefix \
1856 (broken in PowerShell and cmd.exe), got: {cmd}"
1857 );
1858 assert!(
1860 cmd.contains("dotnet build NewNeonCheckers3.sln"),
1861 "original command must be preserved verbatim: {cmd}"
1862 );
1863 assert!(cmd.contains("| sqz compress"), "must pipe through sqz: {cmd}");
1865 }
1866
1867 #[test]
1875 fn issue_10_already_wrapped_command_passes_through() {
1876 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status 2>&1 | sqz compress --cmd git"}}"#;
1877 let result = process_hook(input).unwrap();
1878 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1879 assert_eq!(
1882 result, input,
1883 "already-wrapped command must pass through unchanged; \
1884 otherwise each pass accumulates another `| sqz compress` tail"
1885 );
1886 let _ = parsed; }
1891
1892 #[test]
1893 fn test_generate_hook_configs() {
1894 let configs = generate_hook_configs("sqz");
1895 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
1896 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
1897 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
1898 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
1899 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
1902 assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
1903 "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
1904 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
1905 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
1906 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
1907 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1911 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
1912 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
1913 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
1914 assert!(cursor.config_content.starts_with("---"),
1915 "Cursor rule should start with YAML frontmatter");
1916 assert!(cursor.config_content.contains("alwaysApply: true"),
1917 "Cursor rule should use alwaysApply: true so the guidance loads \
1918 for every agent interaction");
1919 assert!(cursor.config_content.contains("sqz"),
1920 "Cursor rule body should mention sqz");
1921 }
1922
1923 #[test]
1924 fn test_claude_config_includes_precompact_hook() {
1925 let configs = generate_hook_configs("sqz");
1930 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
1931 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1932 .expect("Claude Code config must be valid JSON");
1933
1934 let precompact = parsed["hooks"]["PreCompact"]
1935 .as_array()
1936 .expect("PreCompact hook array must be present");
1937 assert!(
1938 !precompact.is_empty(),
1939 "PreCompact must have at least one registered hook"
1940 );
1941
1942 let cmd = precompact[0]["hooks"][0]["command"]
1943 .as_str()
1944 .expect("command field must be a string");
1945 assert!(
1946 cmd.ends_with(" hook precompact"),
1947 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
1948 );
1949 }
1950
1951 #[test]
1954 fn test_json_escape_string_value() {
1955 assert_eq!(json_escape_string_value("sqz"), "sqz");
1957 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
1958 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
1960 r"C:\\Users\\Alice\\sqz.exe");
1961 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
1963 r#"path with \"quotes\""#);
1964 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
1966 }
1967
1968 #[test]
1969 fn test_windows_path_produces_valid_json_for_claude() {
1970 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1973 let configs = generate_hook_configs(windows_path);
1974
1975 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
1976 .expect("Claude config should be generated");
1977 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
1978 .expect("Claude hook config must be valid JSON on Windows paths");
1979
1980 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
1982 .as_str()
1983 .expect("command field must be a string");
1984 assert!(cmd.contains(windows_path),
1985 "command '{cmd}' must contain the original Windows path '{windows_path}'");
1986 }
1987
1988 #[test]
1989 fn test_windows_path_in_cursor_rules_file() {
1990 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
1996 let configs = generate_hook_configs(windows_path);
1997
1998 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
1999 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
2000 assert!(cursor.config_content.contains(windows_path),
2001 "Cursor rule must contain the raw (unescaped) path so users can \
2002 copy-paste the shown commands — got:\n{}", cursor.config_content);
2003 assert!(!cursor.config_content.contains(r"C:\\Users"),
2004 "Cursor rule must NOT double-escape backslashes in markdown — \
2005 got:\n{}", cursor.config_content);
2006 }
2007
2008 #[test]
2009 fn test_windows_path_produces_valid_json_for_gemini() {
2010 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2011 let configs = generate_hook_configs(windows_path);
2012
2013 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
2014 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
2015 .expect("Gemini hook config must be valid JSON on Windows paths");
2016 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
2017 assert!(cmd.contains(windows_path));
2018 }
2019
2020 #[test]
2021 fn test_rules_files_use_raw_path_for_readability() {
2022 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
2026 let configs = generate_hook_configs(windows_path);
2027
2028 for tool in &["Windsurf", "Cline", "Cursor"] {
2029 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
2030 assert!(cfg.config_content.contains(windows_path),
2031 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
2032 cfg.config_content);
2033 assert!(!cfg.config_content.contains(r"C:\\Users"),
2034 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
2035 cfg.config_content);
2036 }
2037 }
2038
2039 #[test]
2040 fn test_unix_path_still_works() {
2041 let unix_path = "/usr/local/bin/sqz";
2044 let configs = generate_hook_configs(unix_path);
2045
2046 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
2047 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
2048 .expect("Unix path should produce valid JSON");
2049 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
2050 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
2051 }
2052
2053 #[test]
2054 fn test_shell_escape_simple() {
2055 assert_eq!(shell_escape("git"), "git");
2056 assert_eq!(shell_escape("cargo-test"), "cargo-test");
2057 }
2058
2059 #[test]
2060 fn test_shell_escape_special_chars() {
2061 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
2062 }
2063
2064 #[test]
2065 fn test_install_tool_hooks_creates_files() {
2066 let dir = tempfile::tempdir().unwrap();
2067 let installed = install_tool_hooks(dir.path(), "sqz");
2068 assert!(!installed.is_empty(), "should install at least one hook config");
2070 for name in &installed {
2072 let configs = generate_hook_configs("sqz");
2073 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
2074 let path = dir.path().join(&config.config_path);
2075 assert!(path.exists(), "hook config should exist: {}", path.display());
2076 }
2077 }
2078
2079 #[test]
2080 fn test_install_tool_hooks_does_not_overwrite() {
2081 let dir = tempfile::tempdir().unwrap();
2082 install_tool_hooks(dir.path(), "sqz");
2084 let custom_path = dir.path().join(".claude/settings.local.json");
2088 std::fs::write(&custom_path, "custom content").unwrap();
2089 install_tool_hooks(dir.path(), "sqz");
2091 let content = std::fs::read_to_string(&custom_path).unwrap();
2092 assert_eq!(content, "custom content", "should not overwrite existing config");
2093 }
2094
2095 #[test]
2102 fn test_reinit_upgrades_stale_project_scope_matcher() {
2103 let dir = tempfile::tempdir().unwrap();
2104 let settings_path = dir.path().join(".claude").join("settings.local.json");
2105 std::fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
2106
2107 let stale = serde_json::json!({
2110 "permissions": { "allow": ["Bash(ls:*)"] },
2111 "hooks": {
2112 "PreToolUse": [
2113 {
2114 "matcher": "Bash",
2115 "hooks": [{ "type": "command", "command": "/old/path/sqz hook claude" }]
2116 },
2117 {
2118 "matcher": "Edit",
2119 "hooks": [{ "type": "command", "command": "my-linter --check" }]
2120 }
2121 ]
2122 }
2123 });
2124 std::fs::write(&settings_path, serde_json::to_string_pretty(&stale).unwrap()).unwrap();
2125
2126 install_tool_hooks(dir.path(), "/new/path/sqz");
2127
2128 let content = std::fs::read_to_string(&settings_path).unwrap();
2129 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2130
2131 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2133 let sqz_entries: Vec<_> = pre
2134 .iter()
2135 .filter(|e| {
2136 e["hooks"][0]["command"]
2137 .as_str()
2138 .is_some_and(|c| c.contains("hook claude"))
2139 })
2140 .collect();
2141 assert_eq!(sqz_entries.len(), 1, "exactly one sqz PreToolUse entry: {content}");
2142 assert_eq!(
2143 sqz_entries[0]["matcher"].as_str().unwrap(),
2144 "Bash|PowerShell",
2145 "stale Bash-only matcher must be upgraded"
2146 );
2147 assert!(
2148 sqz_entries[0]["hooks"][0]["command"]
2149 .as_str()
2150 .unwrap()
2151 .contains("/new/path/sqz"),
2152 "binary path must be refreshed"
2153 );
2154
2155 assert!(
2157 pre.iter().any(|e| e["matcher"] == "Edit"),
2158 "user's non-sqz PreToolUse entry must be preserved"
2159 );
2160 assert_eq!(
2161 parsed["permissions"]["allow"][0].as_str().unwrap(),
2162 "Bash(ls:*)",
2163 "user's permissions must be preserved"
2164 );
2165
2166 assert!(parsed["hooks"]["PreCompact"].is_array());
2168 assert!(parsed["hooks"]["SessionStart"].is_array());
2169 }
2170
2171 #[test]
2173 fn test_reinit_is_idempotent_at_project_scope() {
2174 let dir = tempfile::tempdir().unwrap();
2175 install_tool_hooks(dir.path(), "/usr/bin/sqz");
2176 let settings_path = dir.path().join(".claude").join("settings.local.json");
2177 let first = std::fs::read_to_string(&settings_path).unwrap();
2178
2179 install_tool_hooks(dir.path(), "/usr/bin/sqz");
2180 let second = std::fs::read_to_string(&settings_path).unwrap();
2181
2182 let a: serde_json::Value = serde_json::from_str(&first).unwrap();
2183 let b: serde_json::Value = serde_json::from_str(&second).unwrap();
2184 assert_eq!(a, b, "second init must not change project settings");
2185 assert_eq!(b["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
2187 }
2188}
2189
2190#[cfg(test)]
2191mod global_install_tests {
2192 use super::*;
2193
2194 #[test]
2195 fn global_install_creates_fresh_settings_json() {
2196 let tmp = tempfile::tempdir().unwrap();
2197 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2198 assert!(changed, "first install should report a change");
2199
2200 let path = tmp.path().join(".claude").join("settings.json");
2201 assert!(path.exists(), "user settings.json should be created");
2202
2203 let content = std::fs::read_to_string(&path).unwrap();
2204 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2205
2206 let pre = &parsed["hooks"]["PreToolUse"];
2208 assert!(pre.is_array(), "PreToolUse should be an array");
2209 assert_eq!(pre.as_array().unwrap().len(), 1);
2210 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2211 assert!(
2212 cmd.contains("/usr/local/bin/sqz"),
2213 "hook command should use the passed sqz_path, got: {cmd}"
2214 );
2215 assert!(cmd.contains("hook claude"));
2216
2217 let precompact = &parsed["hooks"]["PreCompact"];
2218 assert!(precompact.is_array());
2219 let precompact_cmd = precompact[0]["hooks"][0]["command"].as_str().unwrap();
2220 assert!(precompact_cmd.contains("hook precompact"));
2221
2222 let session = &parsed["hooks"]["SessionStart"];
2223 assert!(session.is_array());
2224 assert_eq!(
2225 session[0]["matcher"].as_str().unwrap(),
2226 "compact",
2227 "SessionStart should only match /compact resume"
2228 );
2229 }
2230
2231 #[test]
2232 fn global_install_preserves_existing_user_config() {
2233 let tmp = tempfile::tempdir().unwrap();
2234 let settings = tmp.path().join(".claude").join("settings.json");
2235 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2236
2237 let existing = serde_json::json!({
2238 "permissions": {
2239 "allow": ["Bash(npm test *)"],
2240 "deny": ["Read(./.env)"]
2241 },
2242 "env": { "FOO": "bar" },
2243 "statusLine": {
2244 "type": "command",
2245 "command": "~/.claude/statusline.sh"
2246 },
2247 "hooks": {
2248 "PreToolUse": [
2249 {
2250 "matcher": "Edit",
2251 "hooks": [
2252 {
2253 "type": "command",
2254 "command": "~/.claude/hooks/format-on-edit.sh"
2255 }
2256 ]
2257 }
2258 ]
2259 }
2260 });
2261 std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
2262
2263 let changed = install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2264 assert!(changed, "install should report a change on new hook");
2265
2266 let content = std::fs::read_to_string(&settings).unwrap();
2267 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2268
2269 assert_eq!(
2271 parsed["permissions"]["allow"][0].as_str().unwrap(),
2272 "Bash(npm test *)"
2273 );
2274 assert_eq!(
2275 parsed["permissions"]["deny"][0].as_str().unwrap(),
2276 "Read(./.env)"
2277 );
2278 assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar");
2280 assert_eq!(
2282 parsed["statusLine"]["command"].as_str().unwrap(),
2283 "~/.claude/statusline.sh"
2284 );
2285
2286 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2289 assert_eq!(pre.len(), 2, "expected user's hook + sqz's hook, got: {pre:?}");
2290 let matchers: Vec<&str> = pre
2291 .iter()
2292 .map(|e| e["matcher"].as_str().unwrap_or(""))
2293 .collect();
2294 assert!(matchers.contains(&"Edit"), "user's Edit hook must survive");
2295 assert!(
2296 matchers.contains(&"Bash|PowerShell"),
2297 "sqz shell hook must be present (Bash|PowerShell matcher covers Windows CC, issue #26)"
2298 );
2299 }
2300
2301 #[test]
2302 fn global_install_is_idempotent() {
2303 let tmp = tempfile::tempdir().unwrap();
2304 assert!(install_claude_global_at("sqz", Some(tmp.path())).unwrap());
2305 assert!(
2306 !install_claude_global_at("sqz", Some(tmp.path())).unwrap(),
2307 "second install with identical args should report no change"
2308 );
2309
2310 let path = tmp.path().join(".claude").join("settings.json");
2311 let parsed: serde_json::Value =
2312 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2313 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2314 let arr = parsed["hooks"][event].as_array().unwrap();
2315 assert_eq!(
2316 arr.len(),
2317 1,
2318 "{event} must have exactly one sqz entry after 2 installs, got {arr:?}"
2319 );
2320 }
2321 }
2322
2323 #[test]
2330 fn global_install_is_idempotent_with_exe_path() {
2331 let tmp = tempfile::tempdir().unwrap();
2332 let exe_path = r"C:\Users\user\.cargo\bin\sqz.exe";
2333 assert!(install_claude_global_at(exe_path, Some(tmp.path())).unwrap());
2334 assert!(
2335 !install_claude_global_at(exe_path, Some(tmp.path())).unwrap(),
2336 "second install with .exe path should report no change (issue #21)"
2337 );
2338
2339 let path = tmp.path().join(".claude").join("settings.json");
2340 let parsed: serde_json::Value =
2341 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2342 for event in &["PreToolUse", "PreCompact", "SessionStart"] {
2343 let arr = parsed["hooks"][event].as_array().unwrap();
2344 assert_eq!(
2345 arr.len(),
2346 1,
2347 "{event} must have exactly one entry after 2 installs with .exe path, got {arr:?}"
2348 );
2349 }
2350 }
2351
2352 #[test]
2353 fn global_install_upgrades_stale_sqz_hook_in_place() {
2354 let tmp = tempfile::tempdir().unwrap();
2355 install_claude_global_at("/old/path/sqz", Some(tmp.path())).unwrap();
2356 let changed = install_claude_global_at("/new/path/sqz", Some(tmp.path())).unwrap();
2357 assert!(changed, "different sqz_path must be seen as a change");
2358
2359 let path = tmp.path().join(".claude").join("settings.json");
2360 let parsed: serde_json::Value =
2361 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2362 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2363 assert_eq!(pre.len(), 1, "stale sqz entry must be replaced, not duplicated");
2364 let cmd = pre[0]["hooks"][0]["command"].as_str().unwrap();
2365 assert!(cmd.contains("/new/path/sqz"));
2366 assert!(!cmd.contains("/old/path/sqz"));
2367 }
2368
2369 #[test]
2370 fn global_uninstall_removes_sqz_and_preserves_the_rest() {
2371 let tmp = tempfile::tempdir().unwrap();
2372 let settings = tmp.path().join(".claude").join("settings.json");
2373 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2374 std::fs::write(
2375 &settings,
2376 serde_json::json!({
2377 "permissions": { "allow": ["Bash(git status)"] },
2378 "hooks": {
2379 "PreToolUse": [
2380 {
2381 "matcher": "Edit",
2382 "hooks": [
2383 { "type": "command", "command": "~/format.sh" }
2384 ]
2385 }
2386 ]
2387 }
2388 })
2389 .to_string(),
2390 )
2391 .unwrap();
2392
2393 install_claude_global_at("/usr/local/bin/sqz", Some(tmp.path())).unwrap();
2394 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2395 assert_eq!(result.0, settings);
2396 assert!(result.1, "should report that the file was modified");
2397
2398 assert!(settings.exists(), "settings.json should be preserved");
2399 let parsed: serde_json::Value =
2400 serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
2401
2402 assert_eq!(
2403 parsed["permissions"]["allow"][0].as_str().unwrap(),
2404 "Bash(git status)"
2405 );
2406
2407 let pre = parsed["hooks"]["PreToolUse"].as_array().unwrap();
2408 assert_eq!(pre.len(), 1, "only the user's Edit hook should remain");
2409 assert_eq!(pre[0]["matcher"].as_str().unwrap(), "Edit");
2410
2411 assert!(parsed["hooks"].get("PreCompact").is_none());
2412 assert!(parsed["hooks"].get("SessionStart").is_none());
2413 }
2414
2415 #[test]
2416 fn global_uninstall_deletes_settings_json_if_it_was_sqz_only() {
2417 let tmp = tempfile::tempdir().unwrap();
2418 install_claude_global_at("sqz", Some(tmp.path())).unwrap();
2419 let path = tmp.path().join(".claude").join("settings.json");
2420 assert!(path.exists(), "precondition: install created the file");
2421
2422 let result = remove_claude_global_hook_at(Some(tmp.path())).unwrap().unwrap();
2423 assert!(result.1);
2424 assert!(!path.exists(), "sqz-only settings.json should be removed on uninstall");
2425 }
2426
2427 #[test]
2428 fn global_uninstall_on_missing_file_is_noop() {
2429 let tmp = tempfile::tempdir().unwrap();
2430 assert!(
2431 remove_claude_global_hook_at(Some(tmp.path())).unwrap().is_none(),
2432 "missing file should return None, not error"
2433 );
2434 }
2435
2436 #[test]
2437 fn global_uninstall_refuses_to_touch_unparseable_file() {
2438 let tmp = tempfile::tempdir().unwrap();
2439 let settings = tmp.path().join(".claude").join("settings.json");
2440 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
2441 std::fs::write(&settings, "{ invalid json because").unwrap();
2442
2443 assert!(
2444 remove_claude_global_hook_at(Some(tmp.path())).is_err(),
2445 "bad JSON must surface as an error"
2446 );
2447
2448 let after = std::fs::read_to_string(&settings).unwrap();
2449 assert_eq!(after, "{ invalid json because");
2450 }
2451}
2452
2453#[cfg(test)]
2454mod issue_11_tool_filter_tests {
2455 use super::*;
2464
2465 #[test]
2466 fn canonicalize_collapses_common_aliases() {
2467 for aliases in &[
2470 (vec!["Claude Code", "claude-code", "claude", "CLAUDE", "ClaudeCode"], "claudecode"),
2471 (vec!["Cursor", "cursor", "CURSOR"], "cursor"),
2472 (vec!["Windsurf", "WINDSURF"], "windsurf"),
2473 (vec!["Cline", "cline", "Roo", "roo-code", "RooCode"], "cline"),
2477 (vec!["Gemini CLI", "gemini-cli", "gemini", "GEMINI"], "gemini"),
2478 (vec!["OpenCode", "open-code", "opencode", "OPENCODE"], "opencode"),
2479 (vec!["Codex", "codex"], "codex"),
2480 ] {
2481 for alias in &aliases.0 {
2482 assert_eq!(
2483 canonicalize_tool_name(alias),
2484 aliases.1,
2485 "alias '{}' must canonicalise to '{}'",
2486 alias,
2487 aliases.1
2488 );
2489 }
2490 }
2491 }
2492
2493 #[test]
2494 fn canonicalize_leaves_unknown_names_unchanged_but_normalised() {
2495 assert_eq!(canonicalize_tool_name("unknown-tool"), "unknowntool");
2500 assert_eq!(canonicalize_tool_name("Some Thing"), "something");
2501 }
2502
2503 #[test]
2504 fn parse_tool_list_accepts_comma_separated_with_whitespace() {
2505 let names = parse_tool_list("opencode,codex").unwrap();
2508 assert_eq!(names, vec!["opencode", "codex"]);
2509
2510 let names = parse_tool_list(" opencode , codex ").unwrap();
2511 assert_eq!(names, vec!["opencode", "codex"]);
2512
2513 let names = parse_tool_list("opencode").unwrap();
2515 assert_eq!(names, vec!["opencode"]);
2516
2517 let names = parse_tool_list("claude-code").unwrap();
2519 assert_eq!(names, vec!["claudecode"]);
2520 }
2521
2522 #[test]
2523 fn parse_tool_list_dedupes_repeated_entries() {
2524 let names = parse_tool_list("opencode,opencode").unwrap();
2529 assert_eq!(names, vec!["opencode"]);
2530
2531 let names = parse_tool_list("Claude Code, claude, claude-code").unwrap();
2534 assert_eq!(names, vec!["claudecode"]);
2535 }
2536
2537 #[test]
2538 fn parse_tool_list_rejects_unknown_names_with_helpful_error() {
2539 let err = parse_tool_list("opncode").unwrap_err();
2546 let msg = err.to_string();
2547 assert!(
2548 msg.contains("unknown agent name 'opncode'"),
2549 "error must quote the bad input: {msg}"
2550 );
2551 assert!(msg.contains("opencode"), "error must list valid options: {msg}");
2552 assert!(msg.contains("cursor"), "error must list valid options: {msg}");
2553 }
2554
2555 #[test]
2556 fn parse_tool_list_rejects_one_bad_entry_in_a_list() {
2557 let err = parse_tool_list("opencode,xyz").unwrap_err();
2562 assert!(err.to_string().contains("xyz"));
2563 }
2564
2565 #[test]
2566 fn parse_tool_list_empty_and_whitespace_return_empty_vec() {
2567 assert_eq!(parse_tool_list("").unwrap(), Vec::<String>::new());
2573 assert_eq!(parse_tool_list(" ").unwrap(), Vec::<String>::new());
2574 assert_eq!(parse_tool_list(" , , ").unwrap(), Vec::<String>::new());
2575 }
2576
2577 #[test]
2578 fn tool_filter_all_includes_every_supported_tool() {
2579 let filter = ToolFilter::All;
2580 for tool in SUPPORTED_TOOL_NAMES {
2581 assert!(
2582 filter.includes(tool),
2583 "default filter must include {tool}"
2584 );
2585 }
2586 }
2587
2588 #[test]
2589 fn tool_filter_only_opencode_excludes_everything_else() {
2590 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2592 assert!(filter.includes("OpenCode"));
2593 for tool in SUPPORTED_TOOL_NAMES {
2595 if *tool == "OpenCode" {
2596 continue;
2597 }
2598 assert!(
2599 !filter.includes(tool),
2600 "--only opencode must not include {tool}"
2601 );
2602 }
2603 }
2604
2605 #[test]
2606 fn tool_filter_only_multi_tool_includes_exactly_those() {
2607 let filter = ToolFilter::Only(vec!["opencode".to_string(), "codex".to_string()]);
2608 assert!(filter.includes("OpenCode"));
2609 assert!(filter.includes("Codex"));
2610 assert!(!filter.includes("Claude Code"));
2612 assert!(!filter.includes("Cursor"));
2613 assert!(!filter.includes("Windsurf"));
2614 assert!(!filter.includes("Cline"));
2615 assert!(!filter.includes("Gemini CLI"));
2616 }
2617
2618 #[test]
2619 fn tool_filter_skip_inverts_the_set() {
2620 let filter = ToolFilter::Skip(vec!["cursor".to_string(), "windsurf".to_string()]);
2623 assert!(!filter.includes("Cursor"));
2624 assert!(!filter.includes("Windsurf"));
2625 assert!(filter.includes("Claude Code"));
2627 assert!(filter.includes("Cline"));
2628 assert!(filter.includes("Gemini CLI"));
2629 assert!(filter.includes("OpenCode"));
2630 assert!(filter.includes("Codex"));
2631 }
2632
2633 #[test]
2634 fn tool_filter_only_empty_excludes_everything() {
2635 let filter = ToolFilter::Only(vec![]);
2641 for tool in SUPPORTED_TOOL_NAMES {
2642 assert!(
2643 !filter.includes(tool),
2644 "empty --only must exclude every tool, got {tool}"
2645 );
2646 }
2647 }
2648
2649 #[test]
2650 fn tool_filter_only_accepts_display_name_or_canonical() {
2651 let filter = ToolFilter::Only(vec!["claudecode".to_string()]);
2657 assert!(filter.includes("Claude Code"));
2658 assert!(!filter.includes("Cursor"));
2659
2660 let filter = ToolFilter::Only(vec!["gemini".to_string()]);
2661 assert!(filter.includes("Gemini CLI"));
2662 }
2663
2664 #[test]
2665 fn supported_tool_names_matches_generate_hook_configs_exactly() {
2666 let configs = generate_hook_configs("sqz");
2672 let emitted: std::collections::HashSet<&str> =
2673 configs.iter().map(|c| c.tool_name.as_str()).collect();
2674 let declared: std::collections::HashSet<&str> =
2675 SUPPORTED_TOOL_NAMES.iter().copied().collect();
2676 assert_eq!(
2677 emitted, declared,
2678 "SUPPORTED_TOOL_NAMES must equal the set of tool_name values \
2679 from generate_hook_configs. emitted={:?}, declared={:?}",
2680 emitted, declared
2681 );
2682 }
2683
2684 #[test]
2685 fn filtered_install_only_opencode_writes_only_opencode_files() {
2686 let dir = tempfile::tempdir().unwrap();
2692 let filter = ToolFilter::Only(vec!["opencode".to_string()]);
2693 let _installed = install_tool_hooks_scoped_filtered(
2694 dir.path(),
2695 "sqz",
2696 InstallScope::Project,
2697 &filter,
2698 );
2699
2700 assert!(
2702 dir.path().join("opencode.json").exists(),
2703 "OpenCode config must be written when --only opencode is used"
2704 );
2705
2706 for (path, tool) in &[
2708 (".claude/settings.local.json", "Claude Code"),
2709 (".cursor/rules/sqz.mdc", "Cursor"),
2710 (".windsurfrules", "Windsurf"),
2711 (".clinerules", "Cline"),
2712 (".gemini/settings.json", "Gemini CLI"),
2713 ("AGENTS.md", "Codex"),
2714 ] {
2715 assert!(
2716 !dir.path().join(path).exists(),
2717 "filter rejected {tool} but the installer still wrote {path}"
2718 );
2719 }
2720 }
2721
2722 #[test]
2723 fn filtered_install_skip_cursor_omits_only_cursor() {
2724 let dir = tempfile::tempdir().unwrap();
2726 let filter = ToolFilter::Skip(vec!["cursor".to_string()]);
2727 let _installed = install_tool_hooks_scoped_filtered(
2728 dir.path(),
2729 "sqz",
2730 InstallScope::Project,
2731 &filter,
2732 );
2733
2734 assert!(
2736 !dir.path().join(".cursor/rules/sqz.mdc").exists(),
2737 "skip cursor: .cursor/rules/sqz.mdc must not be written"
2738 );
2739 assert!(
2741 dir.path().join(".windsurfrules").exists(),
2742 "skip cursor should not skip windsurf"
2743 );
2744 assert!(
2745 dir.path().join(".clinerules").exists(),
2746 "skip cursor should not skip cline"
2747 );
2748 }
2749}
2750