1use std::path::{Path, PathBuf};
18
19use crate::error::Result;
20
21pub fn generate_opencode_plugin(sqz_path: &str) -> String {
26 let sqz_path = crate::tool_hooks::json_escape_string_value(sqz_path);
30 format!(
31 r#"/**
32 * sqz — OpenCode plugin for transparent context compression.
33 *
34 * Intercepts shell commands and pipes output through sqz for token savings.
35 * Install: copy to ~/.config/opencode/plugins/sqz.ts
36 * Config: add "plugin": ["sqz"] to opencode.json or opencode.jsonc
37 */
38
39export const SqzPlugin = async (ctx: any) => {{
40 const SQZ_PATH = "{sqz_path}";
41
42 // Commands that should not be intercepted.
43 const INTERACTIVE = new Set([
44 "vim", "vi", "nano", "emacs", "less", "more", "top", "htop",
45 "ssh", "python", "python3", "node", "irb", "ghci",
46 "psql", "mysql", "sqlite3", "mongo", "redis-cli",
47 ]);
48
49 function isInteractive(cmd: string): boolean {{
50 const base = cmd.split(/\s+/)[0]?.split("/").pop() ?? "";
51 if (INTERACTIVE.has(base)) return true;
52 if (cmd.includes("--watch") || cmd.includes("run dev") ||
53 cmd.includes("run start") || cmd.includes("run serve")) return true;
54 return false;
55 }}
56
57 function shouldIntercept(tool: string): boolean {{
58 return ["bash", "shell", "terminal", "run_shell_command"].includes(tool.toLowerCase());
59 }}
60
61 // Detect that a command has already been wrapped by sqz. Before this
62 // guard was in place OpenCode could call the hook twice on the same
63 // command (for retried tool calls, or when a previous rewrite was
64 // echoed back to the agent and the agent re-submitted it) and each
65 // pass would prepend another `SQZ_CMD=$base` prefix, producing monsters
66 // like `SQZ_CMD=SQZ_CMD=ddev SQZ_CMD=ddev ddev exec ...` (reported as
67 // a follow-up to issue #5). We skip if any of these markers appear:
68 // * the case-insensitive substring "sqz_cmd=" or "sqz compress"
69 // (covers the tail of prior wraps regardless of case)
70 // * a leading `VAR=` assignment that starts with SQZ_
71 // (defensive catch-all for exotic wrap variants)
72 // * the base command itself is sqz or sqz-mcp (running sqz directly
73 // — compressing sqz's own output is pointless and causes loops)
74 function isAlreadyWrapped(cmd: string): boolean {{
75 const lowered = cmd.toLowerCase();
76 if (lowered.includes("sqz_cmd=")) return true;
77 if (lowered.includes("sqz compress")) return true;
78 if (lowered.includes("| sqz ") || lowered.includes("| sqz\t")) return true;
79 if (/^\s*SQZ_[A-Z0-9_]+=/.test(cmd)) return true;
80 const base = extractBaseCmd(cmd);
81 if (base === "sqz" || base === "sqz-mcp" || base === "sqz.exe") return true;
82 return false;
83 }}
84
85 // Extract the base command name defensively. If the command has
86 // leading env-var assignments (VAR=val VAR2=val2 actual_cmd arg1),
87 // skip past them so the base is `actual_cmd` — not `VAR=val`.
88 function extractBaseCmd(cmd: string): string {{
89 const tokens = cmd.split(/\s+/).filter(t => t.length > 0);
90 for (const tok of tokens) {{
91 // A token is an env assignment if it matches NAME=VALUE where NAME
92 // is a valid env var identifier. Skip it and keep looking.
93 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) continue;
94 return tok.split("/").pop() ?? "unknown";
95 }}
96 return "unknown";
97 }}
98
99 return {{
100 "tool.execute.before": async (input: any, output: any) => {{
101 const tool = input.tool ?? "";
102 if (!shouldIntercept(tool)) return;
103
104 const cmd = output.args?.command ?? "";
105 if (!cmd || isAlreadyWrapped(cmd) || isInteractive(cmd)) return;
106
107 // Rewrite: pipe through sqz compress
108 const base = extractBaseCmd(cmd);
109 output.args.command = `SQZ_CMD=${{base}} ${{cmd}} 2>&1 | ${{SQZ_PATH}} compress`;
110 }},
111 }};
112}};
113"#
114 )
115}
116
117pub fn opencode_plugin_path() -> PathBuf {
119 let home = std::env::var("HOME")
120 .or_else(|_| std::env::var("USERPROFILE"))
121 .map(PathBuf::from)
122 .unwrap_or_else(|_| PathBuf::from("."));
123 home.join(".config")
124 .join("opencode")
125 .join("plugins")
126 .join("sqz.ts")
127}
128
129pub fn install_opencode_plugin(sqz_path: &str) -> Result<bool> {
133 let plugin_path = opencode_plugin_path();
134
135 if plugin_path.exists() {
136 return Ok(false);
137 }
138
139 if let Some(parent) = plugin_path.parent() {
140 std::fs::create_dir_all(parent).map_err(|e| {
141 crate::error::SqzError::Other(format!(
142 "failed to create OpenCode plugins dir {}: {e}",
143 parent.display()
144 ))
145 })?;
146 }
147
148 let content = generate_opencode_plugin(sqz_path);
149 std::fs::write(&plugin_path, &content).map_err(|e| {
150 crate::error::SqzError::Other(format!(
151 "failed to write OpenCode plugin to {}: {e}",
152 plugin_path.display()
153 ))
154 })?;
155
156 Ok(true)
157}
158
159pub fn find_opencode_config(project_dir: &Path) -> Option<PathBuf> {
166 let jsonc = project_dir.join("opencode.jsonc");
167 if jsonc.exists() {
168 return Some(jsonc);
169 }
170 let json = project_dir.join("opencode.json");
171 if json.exists() {
172 return Some(json);
173 }
174 None
175}
176
177pub fn opencode_config_has_comments(project_dir: &Path) -> bool {
182 let path = match find_opencode_config(project_dir) {
183 Some(p) => p,
184 None => return false,
185 };
186 if path.extension().map(|e| e != "jsonc").unwrap_or(true) {
187 return false;
188 }
189 let content = match std::fs::read_to_string(&path) {
190 Ok(s) => s,
191 Err(_) => return false,
192 };
193 strip_jsonc_comments(&content) != content
194}
195
196pub fn strip_jsonc_comments(src: &str) -> String {
206 let mut out = String::with_capacity(src.len());
207 let bytes = src.as_bytes();
208 let mut i = 0;
209 let len = bytes.len();
210
211 while i < len {
212 let b = bytes[i];
213
214 if b == b'"' {
217 out.push('"');
218 i += 1;
219 while i < len {
220 let c = bytes[i];
221 out.push(c as char);
222 if c == b'\\' && i + 1 < len {
223 out.push(bytes[i + 1] as char);
225 i += 2;
226 continue;
227 }
228 i += 1;
229 if c == b'"' {
230 break;
231 }
232 }
233 continue;
234 }
235
236 if b == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
239 i += 2;
240 while i < len && bytes[i] != b'\n' {
241 i += 1;
242 }
243 continue;
244 }
245
246 if b == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
248 i += 2;
249 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
250 if bytes[i] == b'\n' {
252 out.push('\n');
253 }
254 i += 1;
255 }
256 if i + 1 < len {
259 i += 2;
260 }
261 continue;
262 }
263
264 out.push(b as char);
265 i += 1;
266 }
267
268 out
269}
270
271pub fn update_opencode_config(project_dir: &Path) -> Result<bool> {
287 let (updated, _) = update_opencode_config_detailed(project_dir)?;
288 Ok(updated)
289}
290
291pub fn update_opencode_config_detailed(project_dir: &Path) -> Result<(bool, bool)> {
295 fn sqz_mcp_value() -> serde_json::Value {
297 serde_json::json!({
298 "type": "local",
299 "command": ["sqz-mcp", "--transport", "stdio"]
300 })
301 }
302
303 if let Some(existing_path) = find_opencode_config(project_dir) {
304 let is_jsonc = existing_path
305 .extension()
306 .map(|e| e == "jsonc")
307 .unwrap_or(false);
308 let content = std::fs::read_to_string(&existing_path).map_err(|e| {
309 crate::error::SqzError::Other(format!(
310 "failed to read {}: {e}",
311 existing_path.display()
312 ))
313 })?;
314
315 let parseable = if is_jsonc {
316 strip_jsonc_comments(&content)
317 } else {
318 content.clone()
319 };
320
321 let had_comments = is_jsonc && parseable != content;
324
325 let mut config: serde_json::Value = serde_json::from_str(&parseable).map_err(|e| {
327 crate::error::SqzError::Other(format!(
328 "failed to parse {}: {e}",
329 existing_path.display()
330 ))
331 })?;
332
333 let obj = config.as_object_mut().ok_or_else(|| {
334 crate::error::SqzError::Other(format!(
335 "{} root is not a JSON object",
336 existing_path.display()
337 ))
338 })?;
339
340 let mut changed = false;
341
342 let plugin_entry = obj.entry("plugin").or_insert_with(|| serde_json::json!([]));
345 if let Some(arr) = plugin_entry.as_array_mut() {
346 let has_sqz = arr.iter().any(|v| v.as_str() == Some("sqz"));
347 if !has_sqz {
348 arr.push(serde_json::json!("sqz"));
349 changed = true;
350 }
351 } else {
352 return Err(crate::error::SqzError::Other(format!(
355 "{} has a `plugin` field that is not an array; \
356 refusing to modify it automatically",
357 existing_path.display()
358 )));
359 }
360
361 let mcp_entry = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
363 if let Some(mcp_obj) = mcp_entry.as_object_mut() {
364 if !mcp_obj.contains_key("sqz") {
365 mcp_obj.insert("sqz".to_string(), sqz_mcp_value());
366 changed = true;
367 }
368 } else {
371 return Err(crate::error::SqzError::Other(format!(
372 "{} has an `mcp` field that is not an object; \
373 refusing to modify it automatically",
374 existing_path.display()
375 )));
376 }
377
378 if !changed {
379 return Ok((false, false));
380 }
381
382 let updated = serde_json::to_string_pretty(&config).map_err(|e| {
385 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
386 })?;
387 std::fs::write(&existing_path, format!("{updated}\n")).map_err(|e| {
388 crate::error::SqzError::Other(format!(
389 "failed to write {}: {e}",
390 existing_path.display()
391 ))
392 })?;
393
394 Ok((true, had_comments))
395 } else {
396 let config = serde_json::json!({
398 "$schema": "https://opencode.ai/config.json",
399 "mcp": {
400 "sqz": sqz_mcp_value()
401 },
402 "plugin": ["sqz"]
403 });
404 let content = serde_json::to_string_pretty(&config).map_err(|e| {
405 crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
406 })?;
407 let path = project_dir.join("opencode.json");
408 std::fs::write(&path, format!("{content}\n")).map_err(|e| {
409 crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
410 })?;
411 Ok((true, false))
412 }
413}
414
415pub fn remove_sqz_from_opencode_config(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
428 let path = match find_opencode_config(project_dir) {
429 Some(p) => p,
430 None => return Ok(None),
431 };
432 let is_jsonc = path.extension().map(|e| e == "jsonc").unwrap_or(false);
433 let raw = std::fs::read_to_string(&path).map_err(|e| {
434 crate::error::SqzError::Other(format!("failed to read {}: {e}", path.display()))
435 })?;
436 let parseable = if is_jsonc {
437 strip_jsonc_comments(&raw)
438 } else {
439 raw.clone()
440 };
441 let mut config: serde_json::Value = match serde_json::from_str(&parseable) {
442 Ok(v) => v,
443 Err(_) => {
444 return Ok(Some((path, false)));
446 }
447 };
448
449 let mut changed = false;
450
451 if let Some(obj) = config.as_object_mut() {
452 if let Some(plugin) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
454 let before = plugin.len();
455 plugin.retain(|v| v.as_str() != Some("sqz"));
456 if plugin.len() != before {
457 changed = true;
458 }
459 if plugin.is_empty() {
461 obj.remove("plugin");
462 }
463 }
464
465 if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut()) {
467 if mcp.remove("sqz").is_some() {
468 changed = true;
469 }
470 if mcp.is_empty() {
471 obj.remove("mcp");
472 }
473 }
474 }
475
476 if !changed {
477 return Ok(Some((path, false)));
478 }
479
480 let essentially_empty = match config.as_object() {
485 Some(obj) => {
486 obj.is_empty()
487 || (obj.len() == 1
488 && obj.get("$schema").and_then(|v| v.as_str())
489 == Some("https://opencode.ai/config.json"))
490 }
491 None => false,
492 };
493
494 if essentially_empty {
495 std::fs::remove_file(&path).map_err(|e| {
496 crate::error::SqzError::Other(format!(
497 "failed to remove {}: {e}",
498 path.display()
499 ))
500 })?;
501 return Ok(Some((path, true)));
502 }
503
504 let updated = serde_json::to_string_pretty(&config).map_err(|e| {
507 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
508 })?;
509 std::fs::write(&path, format!("{updated}\n")).map_err(|e| {
510 crate::error::SqzError::Other(format!(
511 "failed to write {}: {e}",
512 path.display()
513 ))
514 })?;
515 Ok(Some((path, true)))
516}
517
518fn is_already_wrapped(command: &str) -> bool {
530 let lowered = command.to_ascii_lowercase();
531 if lowered.contains("sqz_cmd=") {
532 return true;
533 }
534 if lowered.contains("sqz compress") {
535 return true;
536 }
537 if lowered.contains("| sqz ") || lowered.contains("| sqz\t") {
538 return true;
539 }
540 let trimmed = command.trim_start();
542 if let Some(eq_idx) = trimmed.find('=') {
543 let name = &trimmed[..eq_idx];
544 if name.starts_with("SQZ_")
545 && !name.is_empty()
546 && name
547 .chars()
548 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
549 {
550 return true;
551 }
552 }
553 let base = extract_base_cmd(command);
555 if base == "sqz" || base == "sqz-mcp" || base == "sqz.exe" {
556 return true;
557 }
558 false
559}
560
561fn extract_base_cmd(command: &str) -> &str {
568 for tok in command.split_whitespace() {
569 if is_env_assignment(tok) {
570 continue;
571 }
572 return tok.rsplit('/').next().unwrap_or("unknown");
573 }
574 "unknown"
575}
576
577fn is_env_assignment(token: &str) -> bool {
581 let eq = match token.find('=') {
582 Some(i) => i,
583 None => return false,
584 };
585 if eq == 0 {
586 return false;
587 }
588 let name = &token[..eq];
589 let mut chars = name.chars();
590 match chars.next() {
591 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
592 _ => return false,
593 }
594 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
595}
596
597pub fn process_opencode_hook(input: &str) -> Result<String> {
607 let parsed: serde_json::Value = serde_json::from_str(input)
608 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: invalid JSON: {e}")))?;
609
610 let tool = parsed
611 .get("tool")
612 .or_else(|| parsed.get("toolName"))
613 .or_else(|| parsed.get("tool_name"))
614 .and_then(|v| v.as_str())
615 .unwrap_or("");
616
617 if !matches!(
619 tool.to_lowercase().as_str(),
620 "bash" | "shell" | "terminal" | "run_shell_command"
621 ) {
622 return Ok(input.to_string());
623 }
624
625 let command = parsed
627 .get("args")
628 .or_else(|| parsed.get("toolCall"))
629 .or_else(|| parsed.get("tool_input"))
630 .and_then(|v| v.get("command"))
631 .and_then(|v| v.as_str())
632 .unwrap_or("");
633
634 if command.is_empty() || is_already_wrapped(command) {
635 return Ok(input.to_string());
636 }
637
638 let base = extract_base_cmd(command);
642
643 if matches!(
644 base,
645 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
646 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
647 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
648 ) || command.contains("--watch")
649 || command.contains("run dev")
650 || command.contains("run start")
651 || command.contains("run serve")
652 {
653 return Ok(input.to_string());
654 }
655
656 let base_cmd = base;
658
659 let escaped_base = if base_cmd
660 .chars()
661 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
662 {
663 base_cmd.to_string()
664 } else {
665 format!("'{}'", base_cmd.replace('\'', "'\\''"))
666 };
667
668 let rewritten = format!(
669 "SQZ_CMD={} {} 2>&1 | sqz compress",
670 escaped_base, command
671 );
672
673 let output = serde_json::json!({
675 "decision": "approve",
676 "reason": "sqz: command output will be compressed for token savings",
677 "updatedInput": {
678 "command": rewritten
679 },
680 "args": {
681 "command": rewritten
682 }
683 });
684
685 serde_json::to_string(&output)
686 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: serialize error: {e}")))
687}
688
689#[cfg(test)]
692mod tests {
693 use super::*;
694
695 #[test]
696 fn test_generate_opencode_plugin_contains_sqz_path() {
697 let content = generate_opencode_plugin("/usr/local/bin/sqz");
698 assert!(content.contains("/usr/local/bin/sqz"));
699 assert!(content.contains("SqzPlugin"));
700 assert!(content.contains("tool.execute.before"));
701 }
702
703 #[test]
704 fn test_generate_opencode_plugin_windows_path_escaped() {
705 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
710 let content = generate_opencode_plugin(windows_path);
711 assert!(
715 content.contains(r#"const SQZ_PATH = "C:\\Users\\SqzUser\\.cargo\\bin\\sqz.exe""#),
716 "expected JS-escaped path in plugin — got:\n{content}"
717 );
718 assert!(
721 !content.contains(r#"const SQZ_PATH = "C:\U"#),
722 "plugin must not contain unescaped backslashes in the string literal"
723 );
724 }
725
726 #[test]
727 fn test_generate_opencode_plugin_has_interactive_check() {
728 let content = generate_opencode_plugin("sqz");
729 assert!(content.contains("isInteractive"));
730 assert!(content.contains("vim"));
731 assert!(content.contains("--watch"));
732 }
733
734 #[test]
741 fn test_process_opencode_hook_rewrites_bash() {
742 let input = r#"{"tool":"bash","args":{"command":"git status"}}"#;
743 let result = process_opencode_hook(input).unwrap();
744 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
745 assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
746 let cmd = parsed["args"]["command"].as_str().unwrap();
747 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
748 assert!(cmd.contains("git status"), "should preserve original: {cmd}");
749 assert!(cmd.contains("SQZ_CMD=git"), "should set SQZ_CMD: {cmd}");
750 }
751
752 #[test]
753 fn test_process_opencode_hook_passes_non_shell() {
754 let input = r#"{"tool":"read_file","args":{"path":"file.txt"}}"#;
755 let result = process_opencode_hook(input).unwrap();
756 assert_eq!(result, input, "non-shell tools should pass through");
757 }
758
759 #[test]
760 fn test_process_opencode_hook_skips_sqz_commands() {
761 let input = r#"{"tool":"bash","args":{"command":"sqz stats"}}"#;
762 let result = process_opencode_hook(input).unwrap();
763 assert_eq!(result, input, "sqz commands should not be double-wrapped");
764 }
765
766 #[test]
767 fn test_process_opencode_hook_skips_interactive() {
768 let input = r#"{"tool":"bash","args":{"command":"vim file.txt"}}"#;
769 let result = process_opencode_hook(input).unwrap();
770 assert_eq!(result, input, "interactive commands should pass through");
771 }
772
773 #[test]
774 fn test_process_opencode_hook_skips_watch() {
775 let input = r#"{"tool":"bash","args":{"command":"npm run dev --watch"}}"#;
776 let result = process_opencode_hook(input).unwrap();
777 assert_eq!(result, input, "watch mode should pass through");
778 }
779
780 #[test]
781 fn test_process_opencode_hook_invalid_json() {
782 let result = process_opencode_hook("not json");
783 assert!(result.is_err());
784 }
785
786 #[test]
787 fn test_process_opencode_hook_empty_command() {
788 let input = r#"{"tool":"bash","args":{"command":""}}"#;
789 let result = process_opencode_hook(input).unwrap();
790 assert_eq!(result, input);
791 }
792
793 #[test]
794 fn test_process_opencode_hook_run_shell_command() {
795 let input = r#"{"tool":"run_shell_command","args":{"command":"ls -la"}}"#;
796 let result = process_opencode_hook(input).unwrap();
797 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
798 let cmd = parsed["args"]["command"].as_str().unwrap();
799 assert!(cmd.contains("sqz compress"));
800 }
801
802 #[test]
803 fn test_install_opencode_plugin_creates_file() {
804 let dir = tempfile::tempdir().unwrap();
805 std::env::set_var("HOME", dir.path());
807 let result = install_opencode_plugin("sqz");
808 assert!(result.is_ok());
809 let plugin_path = dir
811 .path()
812 .join(".config/opencode/plugins/sqz.ts");
813 assert!(plugin_path.exists(), "plugin file should exist");
814 let content = std::fs::read_to_string(&plugin_path).unwrap();
815 assert!(content.contains("SqzPlugin"));
816 }
817
818 #[test]
819 fn test_update_opencode_config_creates_new() {
820 let dir = tempfile::tempdir().unwrap();
821 let result = update_opencode_config(dir.path()).unwrap();
822 assert!(result, "should create new config");
823 let config_path = dir.path().join("opencode.json");
824 assert!(config_path.exists());
825 let content = std::fs::read_to_string(&config_path).unwrap();
826 assert!(content.contains("\"sqz\""));
827 assert!(content.contains("sqz-mcp"));
828 }
829
830 #[test]
831 fn test_update_opencode_config_adds_to_existing() {
832 let dir = tempfile::tempdir().unwrap();
833 let config_path = dir.path().join("opencode.json");
834 std::fs::write(
835 &config_path,
836 r#"{"$schema":"https://opencode.ai/config.json","plugin":["other"]}"#,
837 )
838 .unwrap();
839
840 let result = update_opencode_config(dir.path()).unwrap();
841 assert!(result, "should update existing config");
842 let content = std::fs::read_to_string(&config_path).unwrap();
843 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
844 let plugins = parsed["plugin"].as_array().unwrap();
845 assert!(plugins.iter().any(|v| v.as_str() == Some("sqz")));
846 assert!(plugins.iter().any(|v| v.as_str() == Some("other")));
847 }
848
849 #[test]
850 fn test_update_opencode_config_skips_if_present() {
851 let dir = tempfile::tempdir().unwrap();
852 let config_path = dir.path().join("opencode.json");
853 std::fs::write(
859 &config_path,
860 r#"{
861 "plugin": ["sqz"],
862 "mcp": {
863 "sqz": {
864 "type": "local",
865 "command": ["sqz-mcp", "--transport", "stdio"]
866 }
867 }
868}"#,
869 )
870 .unwrap();
871
872 let result = update_opencode_config(dir.path()).unwrap();
873 assert!(
874 !result,
875 "complete install (plugin + mcp.sqz) must be idempotent"
876 );
877 }
878
879 #[test]
884 fn test_update_opencode_config_adds_missing_mcp_entry() {
885 let dir = tempfile::tempdir().unwrap();
886 let config_path = dir.path().join("opencode.json");
887 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
888
889 let changed = update_opencode_config(dir.path()).unwrap();
890 assert!(changed, "must report that mcp.sqz was added");
891
892 let after = std::fs::read_to_string(&config_path).unwrap();
893 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
894 assert_eq!(
895 parsed["mcp"]["sqz"]["type"].as_str(),
896 Some("local"),
897 "mcp.sqz must be populated with the default server entry"
898 );
899 }
900
901 #[test]
912 fn test_process_opencode_hook_skips_already_wrapped_sqz_cmd_prefix() {
913 let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=ddev ddev exec --dir=/var/www/html php -v 2>&1 | /home/user/.cargo/bin/sqz compress"}}"#;
914 let result = process_opencode_hook(input).unwrap();
915 assert_eq!(
916 result, input,
917 "already-wrapped command must pass through unchanged; \
918 otherwise each pass accumulates another SQZ_CMD= prefix"
919 );
920 }
921
922 #[test]
925 fn test_process_opencode_hook_guard_is_case_insensitive() {
926 let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=git git status"}}"#;
927 let result = process_opencode_hook(input).unwrap();
928 assert_eq!(
929 result, input,
930 "uppercase SQZ_CMD= prefix must short-circuit the wrap"
931 );
932 }
933
934 #[test]
939 fn test_process_opencode_hook_skips_leading_env_assignments_for_base() {
940 let input = r#"{"tool":"bash","args":{"command":"FOO=bar BAZ=qux make test"}}"#;
941 let result = process_opencode_hook(input).unwrap();
942 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
943 let cmd = parsed["args"]["command"].as_str().unwrap();
944 assert!(
945 cmd.contains("SQZ_CMD=make"),
946 "base command must be `make`, not `FOO=bar`; got: {cmd}"
947 );
948 assert!(
949 cmd.contains("FOO=bar BAZ=qux make test"),
950 "original command must be preserved: {cmd}"
951 );
952 }
953
954 #[test]
956 fn test_process_opencode_hook_skips_bare_sqz_invocation() {
957 for cmd in ["sqz stats", "sqz gain", "/usr/local/bin/sqz compress"] {
958 let input = format!(
959 r#"{{"tool":"bash","args":{{"command":"{cmd}"}}}}"#
960 );
961 let result = process_opencode_hook(&input).unwrap();
962 assert_eq!(
963 result, input,
964 "sqz-invoking command `{cmd}` must not be rewrapped"
965 );
966 }
967 }
968
969 #[test]
973 fn test_generate_opencode_plugin_has_double_wrap_guard() {
974 let content = generate_opencode_plugin("sqz");
975 assert!(
976 content.contains("function isAlreadyWrapped(cmd: string): boolean"),
977 "generated plugin must define isAlreadyWrapped helper"
978 );
979 assert!(
980 content.contains(r#"lowered.includes("sqz_cmd=")"#),
981 "plugin must check for the SQZ_CMD= prior-wrap prefix"
982 );
983 assert!(
984 content.contains(r#"lowered.includes("sqz compress")"#),
985 "plugin must check for the `sqz compress` prior-wrap tail"
986 );
987 assert!(
988 content.contains("isAlreadyWrapped(cmd)"),
989 "plugin hook body must call isAlreadyWrapped on the command"
990 );
991 assert!(
992 content.contains("function extractBaseCmd(cmd: string): string"),
993 "plugin must define extractBaseCmd that skips env assignments"
994 );
995 assert!(
996 content.contains("extractBaseCmd(cmd)"),
997 "plugin hook body must use extractBaseCmd, not raw split"
998 );
999 }
1000
1001 #[test]
1004 fn test_is_already_wrapped_detects_all_marker_shapes() {
1005 assert!(is_already_wrapped("SQZ_CMD=git git status"));
1006 assert!(is_already_wrapped("sqz_cmd=git git status"));
1007 assert!(is_already_wrapped("git status | sqz compress"));
1008 assert!(is_already_wrapped("git status 2>&1 | /path/sqz compress"));
1009 assert!(is_already_wrapped("ls -la | sqz compress-stream"));
1010 assert!(is_already_wrapped("sqz stats"));
1011 assert!(is_already_wrapped("/usr/local/bin/sqz gain"));
1012 assert!(is_already_wrapped("SQZ_FOO=bar cmd"));
1013 assert!(!is_already_wrapped("git status"));
1014 assert!(!is_already_wrapped("grep sqz logfile.txt"));
1015 assert!(!is_already_wrapped("cargo test --package my-sqz-crate"));
1016 }
1017
1018 #[test]
1019 fn test_extract_base_cmd_skips_env_assignments() {
1020 assert_eq!(extract_base_cmd("make test"), "make");
1021 assert_eq!(extract_base_cmd("FOO=bar make test"), "make");
1022 assert_eq!(extract_base_cmd("FOO=bar BAZ=qux make test"), "make");
1023 assert_eq!(extract_base_cmd("/usr/bin/git status"), "git");
1024 assert_eq!(extract_base_cmd(""), "unknown");
1025 assert_eq!(extract_base_cmd("FOO=bar"), "unknown");
1026 }
1027
1028 #[test]
1029 fn test_is_env_assignment() {
1030 assert!(is_env_assignment("FOO=bar"));
1031 assert!(is_env_assignment("FOO="));
1032 assert!(is_env_assignment("_underscore=1"));
1033 assert!(is_env_assignment("MixedCase_1=x"));
1034 assert!(!is_env_assignment("=bar"));
1035 assert!(!is_env_assignment("FOO"));
1036 assert!(!is_env_assignment("--flag=value"));
1037 assert!(!is_env_assignment("123=value"));
1038 assert!(!is_env_assignment("FOO BAR=baz"));
1039 }
1040
1041 #[test]
1050 fn test_update_merges_into_existing_jsonc() {
1051 let dir = tempfile::tempdir().unwrap();
1052 let jsonc = dir.path().join("opencode.jsonc");
1053 std::fs::write(
1054 &jsonc,
1055 r#"{
1056 // user's own config with a comment
1057 "$schema": "https://opencode.ai/config.json",
1058 "model": "anthropic/claude-sonnet-4-5",
1059 /* another comment */
1060 "plugin": ["other-plugin"]
1061}
1062"#,
1063 )
1064 .unwrap();
1065
1066 let changed = update_opencode_config(dir.path()).unwrap();
1067 assert!(changed, "must merge sqz entries into the existing .jsonc");
1068
1069 assert!(jsonc.exists(), "original .jsonc must still exist");
1071 assert!(
1072 !dir.path().join("opencode.json").exists(),
1073 "must not create a parallel opencode.json alongside .jsonc \
1074 (that's the issue #6 bug)"
1075 );
1076
1077 let after = std::fs::read_to_string(&jsonc).unwrap();
1078 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1079 let plugins = parsed["plugin"].as_array().unwrap();
1080 assert!(
1081 plugins.iter().any(|v| v.as_str() == Some("sqz")),
1082 "plugin[] must contain sqz after merge"
1083 );
1084 assert!(
1085 plugins.iter().any(|v| v.as_str() == Some("other-plugin")),
1086 "pre-existing plugin entries must be preserved"
1087 );
1088 assert_eq!(
1089 parsed["model"].as_str(),
1090 Some("anthropic/claude-sonnet-4-5"),
1091 "unrelated user keys must survive the merge"
1092 );
1093 assert_eq!(
1094 parsed["mcp"]["sqz"]["type"].as_str(),
1095 Some("local"),
1096 "mcp.sqz must be registered"
1097 );
1098 }
1099
1100 #[test]
1104 fn test_update_opencode_config_detailed_reports_comments_lost() {
1105 let dir = tempfile::tempdir().unwrap();
1106 let jsonc = dir.path().join("opencode.jsonc");
1107 std::fs::write(
1108 &jsonc,
1109 r#"{
1110 // comment to be dropped
1111 "plugin": ["other"]
1112}
1113"#,
1114 )
1115 .unwrap();
1116
1117 let (changed, comments_lost) =
1118 update_opencode_config_detailed(dir.path()).unwrap();
1119 assert!(changed);
1120 assert!(
1121 comments_lost,
1122 "merger must report that comments were dropped from .jsonc"
1123 );
1124 }
1125
1126 #[test]
1130 fn test_update_creates_plain_json_when_nothing_exists() {
1131 let dir = tempfile::tempdir().unwrap();
1132 update_opencode_config(dir.path()).unwrap();
1133 assert!(dir.path().join("opencode.json").exists());
1134 assert!(!dir.path().join("opencode.jsonc").exists());
1135 }
1136
1137 #[test]
1139 fn test_find_opencode_config_prefers_jsonc() {
1140 let dir = tempfile::tempdir().unwrap();
1141 std::fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1142 std::fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1143 let found = find_opencode_config(dir.path()).unwrap();
1144 assert_eq!(
1145 found.file_name().unwrap(),
1146 "opencode.jsonc",
1147 "must prefer the .jsonc variant when both exist — the user \
1148 is maintaining .jsonc for its comment support"
1149 );
1150 }
1151
1152 #[test]
1153 fn test_find_opencode_config_returns_none_when_missing() {
1154 let dir = tempfile::tempdir().unwrap();
1155 assert!(find_opencode_config(dir.path()).is_none());
1156 }
1157
1158 #[test]
1159 fn test_opencode_config_has_comments_detects_jsonc_comments() {
1160 let dir = tempfile::tempdir().unwrap();
1161 std::fs::write(
1162 dir.path().join("opencode.jsonc"),
1163 "// a line comment\n{\"plugin\":[]}\n",
1164 )
1165 .unwrap();
1166 assert!(opencode_config_has_comments(dir.path()));
1167 }
1168
1169 #[test]
1170 fn test_opencode_config_has_comments_ignores_plain_json() {
1171 let dir = tempfile::tempdir().unwrap();
1172 std::fs::write(
1174 dir.path().join("opencode.json"),
1175 r#"{"url":"http://example.com"}"#,
1176 )
1177 .unwrap();
1178 assert!(!opencode_config_has_comments(dir.path()));
1179 }
1180
1181 #[test]
1184 fn test_strip_jsonc_comments_removes_line_comments() {
1185 let src = "{\n // leading comment\n \"a\": 1 // trailing\n}";
1186 let stripped = strip_jsonc_comments(src);
1187 assert!(!stripped.contains("leading comment"));
1188 assert!(!stripped.contains("trailing"));
1189 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1190 assert_eq!(parsed["a"], 1);
1191 }
1192
1193 #[test]
1194 fn test_strip_jsonc_comments_removes_block_comments() {
1195 let src = "{\n /* block\n comment */\n \"a\": 1\n}";
1196 let stripped = strip_jsonc_comments(src);
1197 assert!(!stripped.contains("block"));
1198 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1199 assert_eq!(parsed["a"], 1);
1200 }
1201
1202 #[test]
1203 fn test_strip_jsonc_comments_preserves_strings() {
1204 let src = r#"{"url": "http://example.com", "re": "/* not a comment */"}"#;
1209 let stripped = strip_jsonc_comments(src);
1210 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1211 assert_eq!(parsed["url"], "http://example.com");
1212 assert_eq!(parsed["re"], "/* not a comment */");
1213 }
1214
1215 #[test]
1216 fn test_strip_jsonc_comments_preserves_escaped_quote_in_string() {
1217 let src = r#"{"s": "a\"//b"}"#;
1218 let stripped = strip_jsonc_comments(src);
1219 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1220 assert_eq!(parsed["s"], r#"a"//b"#);
1221 }
1222
1223 #[test]
1224 fn test_strip_jsonc_comments_tolerates_unterminated_block() {
1225 let src = "{\"a\":1 /* never ends";
1227 let _ = strip_jsonc_comments(src); }
1229
1230 #[test]
1238 fn test_remove_sqz_preserves_other_user_config() {
1239 let dir = tempfile::tempdir().unwrap();
1240 let config = dir.path().join("opencode.json");
1241 std::fs::write(
1242 &config,
1243 r#"{
1244 "$schema": "https://opencode.ai/config.json",
1245 "model": "anthropic/claude-sonnet-4-5",
1246 "plugin": ["other-plugin", "sqz"],
1247 "mcp": {
1248 "sqz": { "type": "local", "command": ["sqz-mcp"] },
1249 "jira": { "type": "remote", "url": "https://jira.example.com/mcp" }
1250 }
1251}
1252"#,
1253 )
1254 .unwrap();
1255
1256 let (path, changed) =
1257 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1258 assert_eq!(path, config);
1259 assert!(changed, "must report that sqz entries were removed");
1260 assert!(
1261 config.exists(),
1262 "file must NOT be deleted — only sqz's entries removed"
1263 );
1264
1265 let after = std::fs::read_to_string(&config).unwrap();
1266 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1267 let plugins = parsed["plugin"].as_array().unwrap();
1268 assert!(!plugins.iter().any(|v| v.as_str() == Some("sqz")));
1269 assert!(plugins.iter().any(|v| v.as_str() == Some("other-plugin")));
1270 let mcp = parsed["mcp"].as_object().unwrap();
1271 assert!(!mcp.contains_key("sqz"), "mcp.sqz must be gone");
1272 assert!(mcp.contains_key("jira"), "mcp.jira must survive");
1273 assert_eq!(
1274 parsed["model"].as_str(),
1275 Some("anthropic/claude-sonnet-4-5"),
1276 "unrelated keys must survive"
1277 );
1278 }
1279
1280 #[test]
1284 fn test_remove_sqz_deletes_file_when_nothing_else_remains() {
1285 let dir = tempfile::tempdir().unwrap();
1286 let config = dir.path().join("opencode.json");
1287 std::fs::write(
1289 &config,
1290 r#"{
1291 "$schema": "https://opencode.ai/config.json",
1292 "mcp": {
1293 "sqz": { "type": "local", "command": ["sqz-mcp", "--transport", "stdio"] }
1294 },
1295 "plugin": ["sqz"]
1296}
1297"#,
1298 )
1299 .unwrap();
1300
1301 let (_, changed) =
1302 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1303 assert!(changed);
1304 assert!(
1305 !config.exists(),
1306 "file with only $schema + sqz entries must be removed"
1307 );
1308 }
1309
1310 #[test]
1313 fn test_remove_sqz_returns_none_when_config_missing() {
1314 let dir = tempfile::tempdir().unwrap();
1315 let result = remove_sqz_from_opencode_config(dir.path()).unwrap();
1316 assert!(result.is_none());
1317 }
1318
1319 #[test]
1322 fn test_remove_sqz_from_jsonc_drops_comments() {
1323 let dir = tempfile::tempdir().unwrap();
1324 let jsonc = dir.path().join("opencode.jsonc");
1325 std::fs::write(
1326 &jsonc,
1327 r#"{
1328 // user's comment
1329 "model": "x",
1330 "plugin": ["sqz", "other"]
1331}
1332"#,
1333 )
1334 .unwrap();
1335
1336 let (path, changed) =
1337 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1338 assert_eq!(path, jsonc);
1339 assert!(changed);
1340 assert!(path.exists(), "jsonc file kept because `model` and `other` remain");
1341
1342 let after = std::fs::read_to_string(&jsonc).unwrap();
1343 assert!(
1344 !after.contains("// user's comment"),
1345 "comments are dropped by the serde_json round-trip; \
1346 documented in update_opencode_config_detailed"
1347 );
1348 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1349 let plugins = parsed["plugin"].as_array().unwrap();
1350 assert_eq!(plugins.len(), 1);
1351 assert_eq!(plugins[0], "other");
1352 }
1353}