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}
64
65pub fn process_hook(input: &str) -> Result<String> {
84 process_hook_for_platform(input, HookPlatform::ClaudeCode)
85}
86
87pub fn process_hook_cursor(input: &str) -> Result<String> {
92 process_hook_for_platform(input, HookPlatform::Cursor)
93}
94
95pub fn process_hook_gemini(input: &str) -> Result<String> {
99 process_hook_for_platform(input, HookPlatform::GeminiCli)
100}
101
102pub fn process_hook_windsurf(input: &str) -> Result<String> {
107 process_hook_for_platform(input, HookPlatform::Windsurf)
108}
109
110fn process_hook_for_platform(input: &str, platform: HookPlatform) -> Result<String> {
114 let parsed: serde_json::Value = serde_json::from_str(input)
115 .map_err(|e| crate::error::SqzError::Other(format!("hook: invalid JSON input: {e}")))?;
116
117 let tool_name = parsed
121 .get("tool_name")
122 .or_else(|| parsed.get("toolName"))
123 .and_then(|v| v.as_str())
124 .unwrap_or("");
125
126 let hook_event = parsed
127 .get("hook_event_name")
128 .or_else(|| parsed.get("agent_action_name"))
129 .and_then(|v| v.as_str())
130 .unwrap_or("");
131
132 let is_shell = matches!(tool_name, "Bash" | "bash" | "Shell" | "shell" | "terminal"
141 | "run_terminal_command" | "run_shell_command")
142 || matches!(hook_event, "beforeShellExecution" | "pre_run_command");
143
144 if !is_shell {
145 return Ok(match platform {
148 HookPlatform::Cursor => "{}".to_string(),
149 _ => input.to_string(),
150 });
151 }
152
153 let command = parsed
158 .get("tool_input")
159 .and_then(|v| v.get("command"))
160 .and_then(|v| v.as_str())
161 .or_else(|| parsed.get("command").and_then(|v| v.as_str()))
162 .or_else(|| {
163 parsed
164 .get("tool_info")
165 .and_then(|v| v.get("command_line"))
166 .and_then(|v| v.as_str())
167 })
168 .or_else(|| {
169 parsed
170 .get("toolCall")
171 .and_then(|v| v.get("command"))
172 .and_then(|v| v.as_str())
173 })
174 .unwrap_or("");
175
176 if command.is_empty() {
177 return Ok(match platform {
178 HookPlatform::Cursor => "{}".to_string(),
179 _ => input.to_string(),
180 });
181 }
182
183 let base_cmd = extract_base_command(command);
187 if base_cmd == "sqz" || command.starts_with("SQZ_CMD=") {
188 return Ok(match platform {
189 HookPlatform::Cursor => "{}".to_string(),
190 _ => input.to_string(),
191 });
192 }
193
194 if is_interactive_command(command) {
196 return Ok(match platform {
197 HookPlatform::Cursor => "{}".to_string(),
198 _ => input.to_string(),
199 });
200 }
201
202 if has_shell_operators(command) {
207 return Ok(match platform {
208 HookPlatform::Cursor => "{}".to_string(),
209 _ => input.to_string(),
210 });
211 }
212
213 let rewritten = format!(
216 "SQZ_CMD={} {} 2>&1 | sqz compress",
217 shell_escape(extract_base_command(command)),
218 command
219 );
220
221 let output = match platform {
247 HookPlatform::ClaudeCode => serde_json::json!({
248 "hookSpecificOutput": {
249 "hookEventName": "PreToolUse",
250 "permissionDecision": "allow",
251 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
252 "updatedInput": {
253 "command": rewritten
254 }
255 }
256 }),
257 HookPlatform::Cursor => serde_json::json!({
258 "permission": "allow",
259 "updated_input": {
260 "command": rewritten
261 }
262 }),
263 HookPlatform::GeminiCli => serde_json::json!({
264 "decision": "allow",
265 "hookSpecificOutput": {
266 "tool_input": {
267 "command": rewritten
268 }
269 }
270 }),
271 HookPlatform::Windsurf => {
272 serde_json::json!({
276 "hookSpecificOutput": {
277 "hookEventName": "PreToolUse",
278 "permissionDecision": "allow",
279 "permissionDecisionReason": "sqz: command output will be compressed for token savings",
280 "updatedInput": {
281 "command": rewritten
282 }
283 }
284 })
285 }
286 };
287
288 serde_json::to_string(&output)
289 .map_err(|e| crate::error::SqzError::Other(format!("hook: JSON serialize error: {e}")))
290}
291
292pub fn generate_hook_configs(sqz_path: &str) -> Vec<ToolHookConfig> {
294 let sqz_path_raw = sqz_path;
305 let sqz_path_json = json_escape_string_value(sqz_path);
306 let sqz_path = &sqz_path_json;
307
308 vec![
309 ToolHookConfig {
323 tool_name: "Claude Code".to_string(),
324 config_path: PathBuf::from(".claude/settings.local.json"),
325 config_content: format!(
326 r#"{{
327 "hooks": {{
328 "PreToolUse": [
329 {{
330 "matcher": "Bash",
331 "hooks": [
332 {{
333 "type": "command",
334 "command": "{sqz_path} hook claude"
335 }}
336 ]
337 }}
338 ],
339 "PreCompact": [
340 {{
341 "hooks": [
342 {{
343 "type": "command",
344 "command": "{sqz_path} hook precompact"
345 }}
346 ]
347 }}
348 ],
349 "SessionStart": [
350 {{
351 "matcher": "compact",
352 "hooks": [
353 {{
354 "type": "command",
355 "command": "{sqz_path} resume"
356 }}
357 ]
358 }}
359 ]
360 }}
361}}"#
362 ),
363 scope: HookScope::Project,
364 },
365 ToolHookConfig {
374 tool_name: "Cursor".to_string(),
375 config_path: PathBuf::from(".cursor/rules/sqz.mdc"),
376 config_content: format!(
377 r#"---
378description: sqz — Token-Optimized CLI Output
379alwaysApply: true
380---
381
382# sqz — Token-Optimized CLI Output
383
384When running shell commands whose output may be long (directory listings,
385git log/diff, test runners, build logs, `docker ps`, `kubectl get`, etc.),
386pipe the output through `sqz compress` to reduce token consumption.
387
388`sqz` is a stdin-to-stdout compressor, not a command wrapper. The correct
389usage is to pipe the command's output into `sqz compress`:
390
391```bash
392# Instead of: Use:
393git status git status 2>&1 | {sqz_path_raw} compress
394cargo test cargo test 2>&1 | {sqz_path_raw} compress
395git log -10 git log -10 2>&1 | {sqz_path_raw} compress
396docker ps docker ps 2>&1 | {sqz_path_raw} compress
397ls -la ls -la 2>&1 | {sqz_path_raw} compress
398```
399
400The `2>&1` captures stderr too, which is useful for commands like `cargo
401test` where diagnostics go to stderr. `sqz compress` filters and compresses
402the combined output while preserving filenames, paths, and identifiers.
403It typically saves 60-90% tokens on verbose commands.
404
405Do NOT pipe output for:
406- Interactive commands (`vim`, `ssh`, `python`, REPLs)
407- Compound commands with operators (`cmd && other`, `cmd > file.txt`,
408 `cmd; other`) — run those directly
409- Short commands whose output is already a few lines
410
411If `sqz` is not on PATH, run commands normally.
412"#
413 ),
414 scope: HookScope::Project,
415 },
416 ToolHookConfig {
420 tool_name: "Windsurf".to_string(),
421 config_path: PathBuf::from(".windsurfrules"),
422 config_content: format!(
423 r#"# sqz — Token-Optimized CLI Output
424
425Pipe verbose shell command output through `sqz compress` to save tokens.
426`sqz` reads from stdin and writes the compressed output to stdout — it is
427NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
428
429```bash
430# Instead of: Use:
431git status git status 2>&1 | {sqz_path_raw} compress
432cargo test cargo test 2>&1 | {sqz_path_raw} compress
433git log -10 git log -10 2>&1 | {sqz_path_raw} compress
434docker ps docker ps 2>&1 | {sqz_path_raw} compress
435```
436
437sqz filters and compresses command outputs while preserving filenames,
438paths, and identifiers (typically 60-90% token reduction on verbose
439commands). Skip short commands, interactive commands (vim, ssh, python),
440and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
441not on PATH, run commands normally.
442"#
443 ),
444 scope: HookScope::Project,
445 },
446 ToolHookConfig {
450 tool_name: "Cline".to_string(),
451 config_path: PathBuf::from(".clinerules"),
452 config_content: format!(
453 r#"# sqz — Token-Optimized CLI Output
454
455Pipe verbose shell command output through `sqz compress` to save tokens.
456`sqz` reads from stdin and writes the compressed output to stdout — it is
457NOT a command wrapper, so `{sqz_path_raw} git status` is not valid.
458
459```bash
460# Instead of: Use:
461git status git status 2>&1 | {sqz_path_raw} compress
462cargo test cargo test 2>&1 | {sqz_path_raw} compress
463git log -10 git log -10 2>&1 | {sqz_path_raw} compress
464docker ps docker ps 2>&1 | {sqz_path_raw} compress
465```
466
467sqz filters and compresses command outputs while preserving filenames,
468paths, and identifiers (typically 60-90% token reduction on verbose
469commands). Skip short commands, interactive commands (vim, ssh, python),
470and commands with shell operators (`&&`, `||`, `;`, `>`, `<`). If sqz is
471not on PATH, run commands normally.
472"#
473 ),
474 scope: HookScope::Project,
475 },
476 ToolHookConfig {
478 tool_name: "Gemini CLI".to_string(),
479 config_path: PathBuf::from(".gemini/settings.json"),
480 config_content: format!(
481 r#"{{
482 "hooks": {{
483 "BeforeTool": [
484 {{
485 "matcher": "run_shell_command",
486 "hooks": [
487 {{
488 "type": "command",
489 "command": "{sqz_path} hook gemini"
490 }}
491 ]
492 }}
493 ]
494 }}
495}}"#
496 ),
497 scope: HookScope::Project,
498 },
499 ToolHookConfig {
505 tool_name: "OpenCode".to_string(),
506 config_path: PathBuf::from("opencode.json"),
507 config_content: format!(
508 r#"{{
509 "$schema": "https://opencode.ai/config.json",
510 "mcp": {{
511 "sqz": {{
512 "type": "local",
513 "command": ["sqz-mcp", "--transport", "stdio"]
514 }}
515 }},
516 "plugin": ["sqz"]
517}}"#
518 ),
519 scope: HookScope::Project,
520 },
521 ]
522}
523
524pub fn install_tool_hooks(project_dir: &Path, sqz_path: &str) -> Vec<String> {
528 let configs = generate_hook_configs(sqz_path);
529 let mut installed = Vec::new();
530
531 for config in &configs {
532 let full_path = project_dir.join(&config.config_path);
533
534 if full_path.exists() {
536 continue;
537 }
538
539 if let Some(parent) = full_path.parent() {
541 if std::fs::create_dir_all(parent).is_err() {
542 continue;
543 }
544 }
545
546 if std::fs::write(&full_path, &config.config_content).is_ok() {
547 installed.push(config.tool_name.clone());
548 }
549 }
550
551 if let Ok(true) = crate::opencode_plugin::install_opencode_plugin(sqz_path) {
553 if !installed.iter().any(|n| n == "OpenCode") {
554 installed.push("OpenCode".to_string());
555 }
556 }
557
558 installed
559}
560
561fn extract_base_command(cmd: &str) -> &str {
565 cmd.split_whitespace()
566 .next()
567 .unwrap_or("unknown")
568 .rsplit('/')
569 .next()
570 .unwrap_or("unknown")
571}
572
573pub(crate) fn json_escape_string_value(s: &str) -> String {
584 let mut out = String::with_capacity(s.len() + 2);
585 for ch in s.chars() {
586 match ch {
587 '\\' => out.push_str("\\\\"),
588 '"' => out.push_str("\\\""),
589 '\n' => out.push_str("\\n"),
590 '\r' => out.push_str("\\r"),
591 '\t' => out.push_str("\\t"),
592 '\x08' => out.push_str("\\b"),
593 '\x0c' => out.push_str("\\f"),
594 c if (c as u32) < 0x20 => {
595 out.push_str(&format!("\\u{:04x}", c as u32));
597 }
598 c => out.push(c),
599 }
600 }
601 out
602}
603
604fn shell_escape(s: &str) -> String {
606 if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
607 s.to_string()
608 } else {
609 format!("'{}'", s.replace('\'', "'\\''"))
610 }
611}
612
613fn has_shell_operators(cmd: &str) -> bool {
617 cmd.contains("&&")
620 || cmd.contains("||")
621 || cmd.contains(';')
622 || cmd.contains('>')
623 || cmd.contains('<')
624 || cmd.contains('|') || cmd.contains('&') && !cmd.contains("&&") || cmd.contains("<<") || cmd.contains("$(") || cmd.contains('`') }
630
631fn is_interactive_command(cmd: &str) -> bool {
633 let base = extract_base_command(cmd);
634 matches!(
635 base,
636 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
637 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
638 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
639 ) || cmd.contains("--watch")
640 || cmd.contains("-w ")
641 || cmd.ends_with(" -w")
642 || cmd.contains("run dev")
643 || cmd.contains("run start")
644 || cmd.contains("run serve")
645}
646
647#[cfg(test)]
650mod tests {
651 use super::*;
652
653 #[test]
654 fn test_process_hook_rewrites_bash_command() {
655 let input = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
657 let result = process_hook(input).unwrap();
658 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
659 let hook_output = &parsed["hookSpecificOutput"];
661 assert_eq!(hook_output["hookEventName"].as_str().unwrap(), "PreToolUse");
662 assert_eq!(hook_output["permissionDecision"].as_str().unwrap(), "allow");
663 let cmd = hook_output["updatedInput"]["command"].as_str().unwrap();
665 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
666 assert!(cmd.contains("git status"), "should preserve original command: {cmd}");
667 assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
668 assert!(parsed.get("decision").is_none(), "Claude Code format should not have top-level decision");
670 assert!(parsed.get("permission").is_none(), "Claude Code format should not have top-level permission");
671 assert!(parsed.get("continue").is_none(), "Claude Code format should not have top-level continue");
672 }
673
674 #[test]
675 fn test_process_hook_passes_through_non_bash() {
676 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
677 let result = process_hook(input).unwrap();
678 assert_eq!(result, input, "non-bash tools should pass through unchanged");
679 }
680
681 #[test]
682 fn test_process_hook_skips_sqz_commands() {
683 let input = r#"{"tool_name":"Bash","tool_input":{"command":"sqz stats"}}"#;
684 let result = process_hook(input).unwrap();
685 assert_eq!(result, input, "sqz commands should not be double-wrapped");
686 }
687
688 #[test]
689 fn test_process_hook_skips_interactive() {
690 let input = r#"{"tool_name":"Bash","tool_input":{"command":"vim file.txt"}}"#;
691 let result = process_hook(input).unwrap();
692 assert_eq!(result, input, "interactive commands should pass through");
693 }
694
695 #[test]
696 fn test_process_hook_skips_watch_mode() {
697 let input = r#"{"tool_name":"Bash","tool_input":{"command":"npm run dev --watch"}}"#;
698 let result = process_hook(input).unwrap();
699 assert_eq!(result, input, "watch mode should pass through");
700 }
701
702 #[test]
703 fn test_process_hook_empty_command() {
704 let input = r#"{"tool_name":"Bash","tool_input":{"command":""}}"#;
705 let result = process_hook(input).unwrap();
706 assert_eq!(result, input);
707 }
708
709 #[test]
710 fn test_process_hook_gemini_format() {
711 let input = r#"{"tool_name":"run_shell_command","tool_input":{"command":"git log"}}"#;
713 let result = process_hook_gemini(input).unwrap();
714 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
715 assert_eq!(parsed["decision"].as_str().unwrap(), "allow");
717 let cmd = parsed["hookSpecificOutput"]["tool_input"]["command"].as_str().unwrap();
719 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
720 assert!(parsed.get("hookSpecificOutput").unwrap().get("updatedInput").is_none(),
722 "Gemini format should not have updatedInput");
723 assert!(parsed.get("hookSpecificOutput").unwrap().get("permissionDecision").is_none(),
724 "Gemini format should not have permissionDecision");
725 }
726
727 #[test]
728 fn test_process_hook_legacy_format() {
729 let input = r#"{"toolName":"Bash","toolCall":{"command":"git status"}}"#;
731 let result = process_hook(input).unwrap();
732 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
733 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
734 assert!(cmd.contains("sqz compress"), "legacy format should still work: {cmd}");
735 }
736
737 #[test]
738 fn test_process_hook_cursor_format() {
739 let input = r#"{"tool_name":"Shell","tool_input":{"command":"git status"},"conversation_id":"abc"}"#;
741 let result = process_hook_cursor(input).unwrap();
742 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
743 assert_eq!(parsed["permission"].as_str().unwrap(), "allow");
745 let cmd = parsed["updated_input"]["command"].as_str().unwrap();
746 assert!(cmd.contains("sqz compress"), "cursor format should work: {cmd}");
747 assert!(cmd.contains("git status"));
748 assert!(parsed.get("hookSpecificOutput").is_none(),
750 "Cursor format should not have hookSpecificOutput");
751 }
752
753 #[test]
754 fn test_process_hook_cursor_passthrough_returns_empty_json() {
755 let input = r#"{"tool_name":"Read","tool_input":{"file_path":"file.txt"}}"#;
757 let result = process_hook_cursor(input).unwrap();
758 assert_eq!(result, "{}", "Cursor passthrough must return empty JSON object");
759 }
760
761 #[test]
762 fn test_process_hook_cursor_no_rewrite_returns_empty_json() {
763 let input = r#"{"tool_name":"Shell","tool_input":{"command":"sqz stats"}}"#;
765 let result = process_hook_cursor(input).unwrap();
766 assert_eq!(result, "{}", "Cursor no-rewrite must return empty JSON object");
767 }
768
769 #[test]
770 fn test_process_hook_windsurf_format() {
771 let input = r#"{"agent_action_name":"pre_run_command","tool_info":{"command_line":"cargo test","cwd":"/project"}}"#;
773 let result = process_hook_windsurf(input).unwrap();
774 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
775 let cmd = parsed["hookSpecificOutput"]["updatedInput"]["command"].as_str().unwrap();
777 assert!(cmd.contains("sqz compress"), "windsurf format should work: {cmd}");
778 assert!(cmd.contains("cargo test"));
779 assert!(cmd.contains("SQZ_CMD=cargo"));
780 }
781
782 #[test]
783 fn test_process_hook_invalid_json() {
784 let result = process_hook("not json");
785 assert!(result.is_err());
786 }
787
788 #[test]
789 fn test_extract_base_command() {
790 assert_eq!(extract_base_command("git status"), "git");
791 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
792 assert_eq!(extract_base_command("cargo test --release"), "cargo");
793 }
794
795 #[test]
796 fn test_is_interactive_command() {
797 assert!(is_interactive_command("vim file.txt"));
798 assert!(is_interactive_command("npm run dev --watch"));
799 assert!(is_interactive_command("python3"));
800 assert!(!is_interactive_command("git status"));
801 assert!(!is_interactive_command("cargo test"));
802 }
803
804 #[test]
805 fn test_generate_hook_configs() {
806 let configs = generate_hook_configs("sqz");
807 assert!(configs.len() >= 5, "should generate configs for multiple tools (including OpenCode)");
808 assert!(configs.iter().any(|c| c.tool_name == "Claude Code"));
809 assert!(configs.iter().any(|c| c.tool_name == "Cursor"));
810 assert!(configs.iter().any(|c| c.tool_name == "OpenCode"));
811 let windsurf = configs.iter().find(|c| c.tool_name == "Windsurf").unwrap();
814 assert_eq!(windsurf.config_path, PathBuf::from(".windsurfrules"),
815 "Windsurf should use .windsurfrules, not .windsurf/hooks.json");
816 let cline = configs.iter().find(|c| c.tool_name == "Cline").unwrap();
817 assert_eq!(cline.config_path, PathBuf::from(".clinerules"),
818 "Cline should use .clinerules, not .clinerules/hooks/PreToolUse");
819 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
823 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"),
824 "Cursor should use .cursor/rules/sqz.mdc (modern rules), not \
825 .cursor/hooks.json (non-functional) or .cursorrules (legacy)");
826 assert!(cursor.config_content.starts_with("---"),
827 "Cursor rule should start with YAML frontmatter");
828 assert!(cursor.config_content.contains("alwaysApply: true"),
829 "Cursor rule should use alwaysApply: true so the guidance loads \
830 for every agent interaction");
831 assert!(cursor.config_content.contains("sqz"),
832 "Cursor rule body should mention sqz");
833 }
834
835 #[test]
836 fn test_claude_config_includes_precompact_hook() {
837 let configs = generate_hook_configs("sqz");
842 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
843 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
844 .expect("Claude Code config must be valid JSON");
845
846 let precompact = parsed["hooks"]["PreCompact"]
847 .as_array()
848 .expect("PreCompact hook array must be present");
849 assert!(
850 !precompact.is_empty(),
851 "PreCompact must have at least one registered hook"
852 );
853
854 let cmd = precompact[0]["hooks"][0]["command"]
855 .as_str()
856 .expect("command field must be a string");
857 assert!(
858 cmd.ends_with(" hook precompact"),
859 "PreCompact hook should invoke `sqz hook precompact`; got: {cmd}"
860 );
861 }
862
863 #[test]
866 fn test_json_escape_string_value() {
867 assert_eq!(json_escape_string_value("sqz"), "sqz");
869 assert_eq!(json_escape_string_value("/usr/local/bin/sqz"), "/usr/local/bin/sqz");
870 assert_eq!(json_escape_string_value(r"C:\Users\Alice\sqz.exe"),
872 r"C:\\Users\\Alice\\sqz.exe");
873 assert_eq!(json_escape_string_value(r#"path with "quotes""#),
875 r#"path with \"quotes\""#);
876 assert_eq!(json_escape_string_value("a\nb\tc"), r"a\nb\tc");
878 }
879
880 #[test]
881 fn test_windows_path_produces_valid_json_for_claude() {
882 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
885 let configs = generate_hook_configs(windows_path);
886
887 let claude = configs.iter().find(|c| c.tool_name == "Claude Code")
888 .expect("Claude config should be generated");
889 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
890 .expect("Claude hook config must be valid JSON on Windows paths");
891
892 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
894 .as_str()
895 .expect("command field must be a string");
896 assert!(cmd.contains(windows_path),
897 "command '{cmd}' must contain the original Windows path '{windows_path}'");
898 }
899
900 #[test]
901 fn test_windows_path_in_cursor_rules_file() {
902 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
908 let configs = generate_hook_configs(windows_path);
909
910 let cursor = configs.iter().find(|c| c.tool_name == "Cursor").unwrap();
911 assert_eq!(cursor.config_path, PathBuf::from(".cursor/rules/sqz.mdc"));
912 assert!(cursor.config_content.contains(windows_path),
913 "Cursor rule must contain the raw (unescaped) path so users can \
914 copy-paste the shown commands — got:\n{}", cursor.config_content);
915 assert!(!cursor.config_content.contains(r"C:\\Users"),
916 "Cursor rule must NOT double-escape backslashes in markdown — \
917 got:\n{}", cursor.config_content);
918 }
919
920 #[test]
921 fn test_windows_path_produces_valid_json_for_gemini() {
922 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
923 let configs = generate_hook_configs(windows_path);
924
925 let gemini = configs.iter().find(|c| c.tool_name == "Gemini CLI").unwrap();
926 let parsed: serde_json::Value = serde_json::from_str(&gemini.config_content)
927 .expect("Gemini hook config must be valid JSON on Windows paths");
928 let cmd = parsed["hooks"]["BeforeTool"][0]["hooks"][0]["command"].as_str().unwrap();
929 assert!(cmd.contains(windows_path));
930 }
931
932 #[test]
933 fn test_rules_files_use_raw_path_for_readability() {
934 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
938 let configs = generate_hook_configs(windows_path);
939
940 for tool in &["Windsurf", "Cline", "Cursor"] {
941 let cfg = configs.iter().find(|c| &c.tool_name == tool).unwrap();
942 assert!(cfg.config_content.contains(windows_path),
943 "{tool} rules file must contain the raw (unescaped) path — got:\n{}",
944 cfg.config_content);
945 assert!(!cfg.config_content.contains(r"C:\\Users"),
946 "{tool} rules file must NOT double-escape backslashes — got:\n{}",
947 cfg.config_content);
948 }
949 }
950
951 #[test]
952 fn test_unix_path_still_works() {
953 let unix_path = "/usr/local/bin/sqz";
956 let configs = generate_hook_configs(unix_path);
957
958 let claude = configs.iter().find(|c| c.tool_name == "Claude Code").unwrap();
959 let parsed: serde_json::Value = serde_json::from_str(&claude.config_content)
960 .expect("Unix path should produce valid JSON");
961 let cmd = parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"].as_str().unwrap();
962 assert_eq!(cmd, "/usr/local/bin/sqz hook claude");
963 }
964
965 #[test]
966 fn test_shell_escape_simple() {
967 assert_eq!(shell_escape("git"), "git");
968 assert_eq!(shell_escape("cargo-test"), "cargo-test");
969 }
970
971 #[test]
972 fn test_shell_escape_special_chars() {
973 assert_eq!(shell_escape("git log --oneline"), "'git log --oneline'");
974 }
975
976 #[test]
977 fn test_install_tool_hooks_creates_files() {
978 let dir = tempfile::tempdir().unwrap();
979 let installed = install_tool_hooks(dir.path(), "sqz");
980 assert!(!installed.is_empty(), "should install at least one hook config");
982 for name in &installed {
984 let configs = generate_hook_configs("sqz");
985 let config = configs.iter().find(|c| &c.tool_name == name).unwrap();
986 let path = dir.path().join(&config.config_path);
987 assert!(path.exists(), "hook config should exist: {}", path.display());
988 }
989 }
990
991 #[test]
992 fn test_install_tool_hooks_does_not_overwrite() {
993 let dir = tempfile::tempdir().unwrap();
994 install_tool_hooks(dir.path(), "sqz");
996 let custom_path = dir.path().join(".claude/settings.local.json");
998 std::fs::write(&custom_path, "custom content").unwrap();
999 install_tool_hooks(dir.path(), "sqz");
1001 let content = std::fs::read_to_string(&custom_path).unwrap();
1002 assert_eq!(content, "custom content", "should not overwrite existing config");
1003 }
1004}