1use std::path::{Path, PathBuf};
18
19use crate::error::Result;
20
21pub fn generate_opencode_plugin(sqz_path: &str) -> String {
55 let sqz_path = crate::tool_hooks::json_escape_string_value(sqz_path);
59 format!(
60 r#"/**
61 * sqz — OpenCode plugin for transparent context compression.
62 *
63 * Intercepts shell commands and pipes output through sqz for token savings.
64 * Install: copy to ~/.config/opencode/plugins/sqz.ts
65 * Discovery is automatic — no opencode.json entry needed (and in fact
66 * including one causes the plugin to load twice, per issue #10).
67 */
68
69const SqzPluginFactory = async (ctx: any) => {{
70 const SQZ_PATH = "{sqz_path}";
71
72 // Commands that should not be intercepted.
73 const INTERACTIVE = new Set([
74 "vim", "vi", "nano", "emacs", "less", "more", "top", "htop",
75 "ssh", "python", "python3", "node", "irb", "ghci",
76 "psql", "mysql", "sqlite3", "mongo", "redis-cli",
77 ]);
78
79 function isInteractive(cmd: string): boolean {{
80 const base = cmd.split(/\s+/)[0]?.split("/").pop() ?? "";
81 if (INTERACTIVE.has(base)) return true;
82 if (cmd.includes("--watch") || cmd.includes("run dev") ||
83 cmd.includes("run start") || cmd.includes("run serve")) return true;
84 return false;
85 }}
86
87 // Don't rewrite commands containing shell operators. Appending
88 // `2>&1 | sqz compress` to a heredoc, compound command, existing
89 // pipe, or redirect corrupts the command. Issue #22: heredoc
90 // terminators like `EOF 2>&1 | sqz compress --cmd git` are not
91 // valid delimiters.
92 function hasShellOperators(cmd: string): boolean {{
93 if (cmd.includes("&&") || cmd.includes("||") || cmd.includes(";")) return true;
94 if (cmd.includes(">") || cmd.includes("<")) return true;
95 if (cmd.includes("|")) return true;
96 if (cmd.includes("<<")) return true;
97 if (cmd.includes("$(") || cmd.includes("`")) return true;
98 return false;
99 }}
100
101 function shouldIntercept(tool: string): boolean {{
102 return ["bash", "shell", "terminal", "run_shell_command"].includes(tool.toLowerCase());
103 }}
104
105 // Detect that a command has already been wrapped by sqz. Before this
106 // guard was in place OpenCode could call the hook twice on the same
107 // command (for retried tool calls, or when a previous rewrite was
108 // echoed back to the agent and the agent re-submitted it) and each
109 // pass would prepend another `SQZ_CMD=$base` prefix, producing monsters
110 // like `SQZ_CMD=SQZ_CMD=ddev SQZ_CMD=ddev ddev exec ...` (reported as
111 // a follow-up to issue #5). We skip if any of these markers appear:
112 // * the case-insensitive substring "sqz_cmd=" or "sqz compress"
113 // (covers the tail of prior wraps regardless of case; SQZ_CMD= is
114 // legacy pre-issue-#10 but still valid in POSIX shell hooks)
115 // * a leading `VAR=` assignment that starts with SQZ_
116 // (defensive catch-all for exotic wrap variants)
117 // * the base command itself is sqz or sqz-mcp (running sqz directly
118 // — compressing sqz's own output is pointless and causes loops)
119 function isAlreadyWrapped(cmd: string): boolean {{
120 const lowered = cmd.toLowerCase();
121 if (lowered.includes("sqz_cmd=")) return true;
122 if (lowered.includes("sqz compress")) return true;
123 if (lowered.includes("| sqz ") || lowered.includes("| sqz\t")) return true;
124 if (/^\s*SQZ_[A-Z0-9_]+=/.test(cmd)) return true;
125 const base = extractBaseCmd(cmd);
126 if (base === "sqz" || base === "sqz-mcp" || base === "sqz.exe") return true;
127 return false;
128 }}
129
130 // Extract the base command name defensively. If the command has
131 // leading env-var assignments (VAR=val VAR2=val2 actual_cmd arg1),
132 // skip past them so the base is `actual_cmd` — not `VAR=val`.
133 function extractBaseCmd(cmd: string): string {{
134 const tokens = cmd.split(/\s+/).filter(t => t.length > 0);
135 for (const tok of tokens) {{
136 // A token is an env assignment if it matches NAME=VALUE where NAME
137 // is a valid env var identifier. Skip it and keep looking.
138 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) continue;
139 return tok.split("/").pop() ?? "unknown";
140 }}
141 return "unknown";
142 }}
143
144 // Shell-escape a command-name label so it's safe to inline into the
145 // rewritten shell command. Agents occasionally invoke commands via
146 // paths with spaces (`"/my tools/foo" --arg`) and in the LLM
147 // roundtrip that can survive to `extractBaseCmd`'s output. Quote the
148 // label unless it's pure ASCII alphanumeric.
149 function shellEscapeLabel(s: string): string {{
150 if (/^[A-Za-z0-9_.-]+$/.test(s)) return s;
151 return "'" + s.replace(/'/g, "'\\''") + "'";
152 }}
153
154 return {{
155 "tool.execute.before": async (input: any, output: any) => {{
156 const tool = input.tool ?? "";
157 if (!shouldIntercept(tool)) return;
158
159 const cmd = output.args?.command ?? "";
160 if (!cmd || isAlreadyWrapped(cmd) || isInteractive(cmd) || hasShellOperators(cmd)) return;
161
162 // Rewrite: pipe through `sqz compress --cmd <base>`.
163 //
164 // Issue #10: the previous form was `SQZ_CMD=<base> <cmd> 2>&1 |
165 // <sqz> compress`, which uses sh-specific inline env-var syntax.
166 // On Windows, OpenCode Desktop routes bash-tool commands through
167 // PowerShell (or cmd.exe when $SHELL is unset), and both parse
168 // `SQZ_CMD=cmd` as a command name — raising CommandNotFoundException
169 // and producing zero compression. `--cmd NAME` is a normal CLI
170 // argument, shell-neutral, works in POSIX sh, zsh, fish, PowerShell,
171 // and cmd.exe.
172 const base = extractBaseCmd(cmd);
173 const label = shellEscapeLabel(base);
174 output.args.command = `${{cmd}} 2>&1 | ${{SQZ_PATH}} compress --cmd ${{label}}`;
175 }},
176 }};
177}};
178
179// V1 default export — modern OpenCode (post-V1 loader) reads `id` here
180// and displays "sqz" in the plugin list. Without this, OpenCode falls
181// back to the raw `file:///...` spec as the plugin name (@itguy327 on
182// issue #10). `readV1Plugin` in OpenCode's plugin/shared.ts requires
183// file-source plugins to declare an id — otherwise `resolvePluginId`
184// throws.
185export default {{
186 id: "sqz",
187 server: SqzPluginFactory,
188}};
189
190// Legacy named export — pre-V1 OpenCode versions walk Object.values(mod)
191// looking for factory functions. Assigning the same reference as the
192// default export's `.server` means the legacy `seen` Set dedups via
193// identity, so the factory fires exactly once either way. Kept for
194// backward compatibility with OpenCode versions that predate the V1
195// loader (roughly anything before mid-2025).
196export const SqzPlugin = SqzPluginFactory;
197"#
198 )
199}
200
201pub fn opencode_plugin_path() -> PathBuf {
203 let home = std::env::var("HOME")
204 .or_else(|_| std::env::var("USERPROFILE"))
205 .map(PathBuf::from)
206 .unwrap_or_else(|_| PathBuf::from("."));
207 home.join(".config")
208 .join("opencode")
209 .join("plugins")
210 .join("sqz.ts")
211}
212
213pub fn install_opencode_plugin(sqz_path: &str) -> Result<bool> {
225 let plugin_path = opencode_plugin_path();
226 let new_content = generate_opencode_plugin(sqz_path);
227
228 if plugin_path.exists() {
230 if let Ok(existing) = std::fs::read_to_string(&plugin_path) {
231 if existing == new_content {
232 return Ok(false);
233 }
234 }
235 }
236
237 if let Some(parent) = plugin_path.parent() {
238 std::fs::create_dir_all(parent).map_err(|e| {
239 crate::error::SqzError::Other(format!(
240 "failed to create OpenCode plugins dir {}: {e}",
241 parent.display()
242 ))
243 })?;
244 }
245
246 let content = generate_opencode_plugin(sqz_path);
247 std::fs::write(&plugin_path, &content).map_err(|e| {
248 crate::error::SqzError::Other(format!(
249 "failed to write OpenCode plugin to {}: {e}",
250 plugin_path.display()
251 ))
252 })?;
253
254 Ok(true)
255}
256
257pub fn find_opencode_config(project_dir: &Path) -> Option<PathBuf> {
264 let jsonc = project_dir.join("opencode.jsonc");
265 if jsonc.exists() {
266 return Some(jsonc);
267 }
268 let json = project_dir.join("opencode.json");
269 if json.exists() {
270 return Some(json);
271 }
272 None
273}
274
275pub fn opencode_config_has_comments(project_dir: &Path) -> bool {
280 let path = match find_opencode_config(project_dir) {
281 Some(p) => p,
282 None => return false,
283 };
284 if path.extension().map(|e| e != "jsonc").unwrap_or(true) {
285 return false;
286 }
287 let content = match std::fs::read_to_string(&path) {
288 Ok(s) => s,
289 Err(_) => return false,
290 };
291 strip_jsonc_comments(&content) != content
292}
293
294pub fn strip_jsonc_comments(src: &str) -> String {
304 let mut out = String::with_capacity(src.len());
305 let bytes = src.as_bytes();
306 let mut i = 0;
307 let len = bytes.len();
308
309 while i < len {
310 let b = bytes[i];
311
312 if b == b'"' {
315 out.push('"');
316 i += 1;
317 while i < len {
318 let c = bytes[i];
319 out.push(c as char);
320 if c == b'\\' && i + 1 < len {
321 out.push(bytes[i + 1] as char);
323 i += 2;
324 continue;
325 }
326 i += 1;
327 if c == b'"' {
328 break;
329 }
330 }
331 continue;
332 }
333
334 if b == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
337 i += 2;
338 while i < len && bytes[i] != b'\n' {
339 i += 1;
340 }
341 continue;
342 }
343
344 if b == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
346 i += 2;
347 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
348 if bytes[i] == b'\n' {
350 out.push('\n');
351 }
352 i += 1;
353 }
354 if i + 1 < len {
357 i += 2;
358 }
359 continue;
360 }
361
362 out.push(b as char);
363 i += 1;
364 }
365
366 strip_trailing_commas(&out)
372}
373
374fn strip_trailing_commas(src: &str) -> String {
379 let mut out = String::with_capacity(src.len());
380 let bytes = src.as_bytes();
381 let mut i = 0;
382 let len = bytes.len();
383
384 while i < len {
385 let b = bytes[i];
386
387 if b == b'"' {
389 out.push('"');
390 i += 1;
391 while i < len {
392 let c = bytes[i];
393 out.push(c as char);
394 if c == b'\\' && i + 1 < len {
395 out.push(bytes[i + 1] as char);
396 i += 2;
397 continue;
398 }
399 i += 1;
400 if c == b'"' {
401 break;
402 }
403 }
404 continue;
405 }
406
407 if b == b',' {
411 let mut j = i + 1;
412 while j < len && bytes[j].is_ascii_whitespace() {
413 j += 1;
414 }
415 if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
416 for k in (i + 1)..j {
419 out.push(bytes[k] as char);
420 }
421 i = j;
422 continue;
423 }
424 }
425
426 out.push(b as char);
427 i += 1;
428 }
429
430 out
431}
432
433pub fn update_opencode_config(project_dir: &Path) -> Result<bool> {
449 let (updated, _) = update_opencode_config_detailed(project_dir)?;
450 Ok(updated)
451}
452
453pub fn update_opencode_config_detailed(project_dir: &Path) -> Result<(bool, bool)> {
457 let planned = plan_opencode_config_change(project_dir)?;
458 if !planned.will_change {
459 return Ok((false, false));
460 }
461 apply_opencode_config_change(project_dir, &planned)
463}
464
465pub fn plan_opencode_config_change(project_dir: &Path) -> Result<PlannedOpencodeChange> {
478 compute_opencode_change(project_dir, false).map(|r| r.0)
479}
480
481#[derive(Debug, Clone, PartialEq, Eq)]
483pub struct PlannedOpencodeChange {
484 pub target_path: PathBuf,
486 pub will_change: bool,
489 pub comments_lost: bool,
492}
493
494fn apply_opencode_config_change(
495 project_dir: &Path,
496 _planned: &PlannedOpencodeChange,
497) -> Result<(bool, bool)> {
498 let (planned, _) = compute_opencode_change(project_dir, true)?;
499 Ok((planned.will_change, planned.comments_lost))
500}
501
502fn compute_opencode_change(
508 project_dir: &Path,
509 apply: bool,
510) -> Result<(PlannedOpencodeChange, ())> {
511 fn sqz_mcp_value() -> serde_json::Value {
512 serde_json::json!({
513 "type": "local",
514 "command": ["sqz-mcp", "--transport", "stdio"],
515 "enabled": true
516 })
517 }
518
519 if let Some(existing_path) = find_opencode_config(project_dir) {
520 let is_jsonc = existing_path
521 .extension()
522 .map(|e| e == "jsonc")
523 .unwrap_or(false);
524 let content = std::fs::read_to_string(&existing_path).map_err(|e| {
525 crate::error::SqzError::Other(format!(
526 "failed to read {}: {e}",
527 existing_path.display()
528 ))
529 })?;
530 let parseable = if is_jsonc {
531 strip_jsonc_comments(&content)
532 } else {
533 content.clone()
534 };
535 let had_comments = is_jsonc && parseable != content;
536
537 let mut config: serde_json::Value = serde_json::from_str(&parseable).map_err(|e| {
538 crate::error::SqzError::Other(format!(
539 "failed to parse {}: {e}",
540 existing_path.display()
541 ))
542 })?;
543 let obj = config.as_object_mut().ok_or_else(|| {
544 crate::error::SqzError::Other(format!(
545 "{} root is not a JSON object",
546 existing_path.display()
547 ))
548 })?;
549
550 let mut changed = false;
551
552 if let Some(arr) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
553 let before = arr.len();
554 arr.retain(|v| v.as_str() != Some("sqz"));
555 if arr.len() != before {
556 changed = true;
557 }
558 if arr.is_empty() {
559 obj.remove("plugin");
560 changed = true;
561 }
562 }
563
564 let mcp_entry = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
565 if let Some(mcp_obj) = mcp_entry.as_object_mut() {
566 if !mcp_obj.contains_key("sqz") {
567 mcp_obj.insert("sqz".to_string(), sqz_mcp_value());
568 changed = true;
569 } else if let Some(sqz_entry) = mcp_obj.get_mut("sqz").and_then(|v| v.as_object_mut()) {
570 if !sqz_entry.contains_key("enabled") {
571 sqz_entry.insert("enabled".to_string(), serde_json::json!(true));
572 changed = true;
573 }
574 }
575 } else {
576 return Err(crate::error::SqzError::Other(format!(
577 "{} has an `mcp` field that is not an object; \
578 refusing to modify it automatically",
579 existing_path.display()
580 )));
581 }
582
583 let planned = PlannedOpencodeChange {
584 target_path: existing_path.clone(),
585 will_change: changed,
586 comments_lost: changed && had_comments,
587 };
588
589 if apply && changed {
590 let updated = serde_json::to_string_pretty(&config).map_err(|e| {
591 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
592 })?;
593 std::fs::write(&existing_path, format!("{updated}\n")).map_err(|e| {
594 crate::error::SqzError::Other(format!(
595 "failed to write {}: {e}",
596 existing_path.display()
597 ))
598 })?;
599 }
600
601 Ok((planned, ()))
602 } else {
603 let target = project_dir.join("opencode.json");
605 let planned = PlannedOpencodeChange {
606 target_path: target.clone(),
607 will_change: true,
608 comments_lost: false,
609 };
610
611 if apply {
612 let config = serde_json::json!({
613 "$schema": "https://opencode.ai/config.json",
614 "mcp": {
615 "sqz": sqz_mcp_value()
616 }
617 });
618 let content = serde_json::to_string_pretty(&config).map_err(|e| {
619 crate::error::SqzError::Other(format!("failed to serialize opencode.json: {e}"))
620 })?;
621 std::fs::write(&target, format!("{content}\n")).map_err(|e| {
622 crate::error::SqzError::Other(format!("failed to write opencode.json: {e}"))
623 })?;
624 }
625
626 Ok((planned, ()))
627 }
628}
629
630pub fn remove_sqz_from_opencode_config(project_dir: &Path) -> Result<Option<(PathBuf, bool)>> {
643 let path = match find_opencode_config(project_dir) {
644 Some(p) => p,
645 None => return Ok(None),
646 };
647 let is_jsonc = path.extension().map(|e| e == "jsonc").unwrap_or(false);
648 let raw = std::fs::read_to_string(&path).map_err(|e| {
649 crate::error::SqzError::Other(format!("failed to read {}: {e}", path.display()))
650 })?;
651 let parseable = if is_jsonc {
652 strip_jsonc_comments(&raw)
653 } else {
654 raw.clone()
655 };
656 let mut config: serde_json::Value = match serde_json::from_str(&parseable) {
657 Ok(v) => v,
658 Err(_) => {
659 return Ok(Some((path, false)));
661 }
662 };
663
664 let mut changed = false;
665
666 if let Some(obj) = config.as_object_mut() {
667 if let Some(plugin) = obj.get_mut("plugin").and_then(|v| v.as_array_mut()) {
669 let before = plugin.len();
670 plugin.retain(|v| v.as_str() != Some("sqz"));
671 if plugin.len() != before {
672 changed = true;
673 }
674 if plugin.is_empty() {
676 obj.remove("plugin");
677 }
678 }
679
680 if let Some(mcp) = obj.get_mut("mcp").and_then(|v| v.as_object_mut()) {
682 if mcp.remove("sqz").is_some() {
683 changed = true;
684 }
685 if mcp.is_empty() {
686 obj.remove("mcp");
687 }
688 }
689 }
690
691 if !changed {
692 return Ok(Some((path, false)));
693 }
694
695 let essentially_empty = match config.as_object() {
700 Some(obj) => {
701 obj.is_empty()
702 || (obj.len() == 1
703 && obj.get("$schema").and_then(|v| v.as_str())
704 == Some("https://opencode.ai/config.json"))
705 }
706 None => false,
707 };
708
709 if essentially_empty {
710 std::fs::remove_file(&path).map_err(|e| {
711 crate::error::SqzError::Other(format!(
712 "failed to remove {}: {e}",
713 path.display()
714 ))
715 })?;
716 return Ok(Some((path, true)));
717 }
718
719 let updated = serde_json::to_string_pretty(&config).map_err(|e| {
722 crate::error::SqzError::Other(format!("failed to serialize config: {e}"))
723 })?;
724 std::fs::write(&path, format!("{updated}\n")).map_err(|e| {
725 crate::error::SqzError::Other(format!(
726 "failed to write {}: {e}",
727 path.display()
728 ))
729 })?;
730 Ok(Some((path, true)))
731}
732
733fn is_already_wrapped(command: &str) -> bool {
745 let lowered = command.to_ascii_lowercase();
746 if lowered.contains("sqz_cmd=") {
747 return true;
748 }
749 if lowered.contains("sqz compress") {
750 return true;
751 }
752 if lowered.contains("| sqz ") || lowered.contains("| sqz\t") {
753 return true;
754 }
755 let trimmed = command.trim_start();
757 if let Some(eq_idx) = trimmed.find('=') {
758 let name = &trimmed[..eq_idx];
759 if name.starts_with("SQZ_")
760 && !name.is_empty()
761 && name
762 .chars()
763 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
764 {
765 return true;
766 }
767 }
768 let base = extract_base_cmd(command);
770 if base == "sqz" || base == "sqz-mcp" || base == "sqz.exe" {
771 return true;
772 }
773 false
774}
775
776fn extract_base_cmd(command: &str) -> &str {
783 for tok in command.split_whitespace() {
784 if is_env_assignment(tok) {
785 continue;
786 }
787 return tok.rsplit('/').next().unwrap_or("unknown");
788 }
789 "unknown"
790}
791
792fn is_env_assignment(token: &str) -> bool {
796 let eq = match token.find('=') {
797 Some(i) => i,
798 None => return false,
799 };
800 if eq == 0 {
801 return false;
802 }
803 let name = &token[..eq];
804 let mut chars = name.chars();
805 match chars.next() {
806 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
807 _ => return false,
808 }
809 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
810}
811
812pub fn process_opencode_hook(input: &str) -> Result<String> {
822 let parsed: serde_json::Value = serde_json::from_str(input)
823 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: invalid JSON: {e}")))?;
824
825 let tool = parsed
826 .get("tool")
827 .or_else(|| parsed.get("toolName"))
828 .or_else(|| parsed.get("tool_name"))
829 .and_then(|v| v.as_str())
830 .unwrap_or("");
831
832 if !matches!(
834 tool.to_lowercase().as_str(),
835 "bash" | "shell" | "terminal" | "run_shell_command"
836 ) {
837 return Ok(input.to_string());
838 }
839
840 let command = parsed
842 .get("args")
843 .or_else(|| parsed.get("toolCall"))
844 .or_else(|| parsed.get("tool_input"))
845 .and_then(|v| v.get("command"))
846 .and_then(|v| v.as_str())
847 .unwrap_or("");
848
849 if command.is_empty() || is_already_wrapped(command) {
850 return Ok(input.to_string());
851 }
852
853 if crate::tool_hooks::has_shell_operators(command) {
857 return Ok(input.to_string());
858 }
859
860 let base = extract_base_cmd(command);
864
865 if matches!(
866 base,
867 "vim" | "vi" | "nano" | "emacs" | "less" | "more" | "top" | "htop"
868 | "ssh" | "python" | "python3" | "node" | "irb" | "ghci"
869 | "psql" | "mysql" | "sqlite3" | "mongo" | "redis-cli"
870 ) || command.contains("--watch")
871 || command.contains("run dev")
872 || command.contains("run start")
873 || command.contains("run serve")
874 {
875 return Ok(input.to_string());
876 }
877
878 let base_cmd = base;
880
881 let escaped_base = if base_cmd
882 .chars()
883 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
884 {
885 base_cmd.to_string()
886 } else {
887 format!("'{}'", base_cmd.replace('\'', "'\\''"))
888 };
889
890 let rewritten = format!(
895 "{} 2>&1 | sqz compress --cmd {}",
896 command, escaped_base,
897 );
898
899 let output = serde_json::json!({
901 "decision": "approve",
902 "reason": "sqz: command output will be compressed for token savings",
903 "updatedInput": {
904 "command": rewritten
905 },
906 "args": {
907 "command": rewritten
908 }
909 });
910
911 serde_json::to_string(&output)
912 .map_err(|e| crate::error::SqzError::Other(format!("opencode hook: serialize error: {e}")))
913}
914
915#[cfg(test)]
918mod tests {
919 use super::*;
920
921 #[test]
922 fn test_generate_opencode_plugin_contains_sqz_path() {
923 let content = generate_opencode_plugin("/usr/local/bin/sqz");
924 assert!(content.contains("/usr/local/bin/sqz"));
925 assert!(content.contains("SqzPlugin"));
926 assert!(content.contains("tool.execute.before"));
927 }
928
929 #[test]
930 fn test_generate_opencode_plugin_windows_path_escaped() {
931 let windows_path = r"C:\Users\SqzUser\.cargo\bin\sqz.exe";
936 let content = generate_opencode_plugin(windows_path);
937 assert!(
941 content.contains(r#"const SQZ_PATH = "C:\\Users\\SqzUser\\.cargo\\bin\\sqz.exe""#),
942 "expected JS-escaped path in plugin — got:\n{content}"
943 );
944 assert!(
947 !content.contains(r#"const SQZ_PATH = "C:\U"#),
948 "plugin must not contain unescaped backslashes in the string literal"
949 );
950 }
951
952 #[test]
953 fn test_generate_opencode_plugin_has_interactive_check() {
954 let content = generate_opencode_plugin("sqz");
955 assert!(content.contains("isInteractive"));
956 assert!(content.contains("vim"));
957 assert!(content.contains("--watch"));
958 }
959
960 #[test]
963 fn test_generate_opencode_plugin_has_shell_operator_guard() {
964 let content = generate_opencode_plugin("sqz");
965 assert!(
966 content.contains("function hasShellOperators(cmd: string): boolean"),
967 "plugin must define hasShellOperators guard (issue #22)"
968 );
969 assert!(
970 content.contains("hasShellOperators(cmd)"),
971 "plugin hook body must call hasShellOperators (issue #22)"
972 );
973 assert!(
974 content.contains("<<"),
975 "hasShellOperators must check for heredoc operator"
976 );
977 }
978
979 #[test]
995 fn test_generate_opencode_plugin_declares_v1_id() {
996 let content = generate_opencode_plugin("sqz");
997 assert!(
998 content.contains("id: \"sqz\""),
999 "plugin must default-export `id: \"sqz\"` so OpenCode's \
1000 V1 loader (shared.ts readV1Plugin/resolvePluginId) \
1001 displays \"sqz\" in the UI instead of the file path; \
1002 got:\n{content}"
1003 );
1004 assert!(
1005 content.contains("server: SqzPluginFactory"),
1006 "plugin must default-export `server: <factory>` for V1 \
1007 loader compliance; got:\n{content}"
1008 );
1009 assert!(
1010 content.contains("export default {"),
1011 "plugin must have a default export per OpenCode V1 shape; \
1012 got:\n{content}"
1013 );
1014 }
1015
1016 #[test]
1027 fn test_generate_opencode_plugin_legacy_named_export_preserved() {
1028 let content = generate_opencode_plugin("sqz");
1029 assert!(
1030 content.contains("export const SqzPlugin = SqzPluginFactory"),
1031 "legacy named export must alias the same factory reference \
1032 as the V1 default export — otherwise old OpenCode versions \
1033 would see two distinct factories in `Object.values(mod)` \
1034 and fire the hook twice; got:\n{content}"
1035 );
1036 }
1037
1038 #[test]
1045 fn test_process_opencode_hook_rewrites_bash() {
1046 let input = r#"{"tool":"bash","args":{"command":"git status"}}"#;
1047 let result = process_opencode_hook(input).unwrap();
1048 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1049 assert_eq!(parsed["decision"].as_str().unwrap(), "approve");
1050 let cmd = parsed["args"]["command"].as_str().unwrap();
1051 assert!(cmd.contains("sqz compress"), "should pipe through sqz: {cmd}");
1052 assert!(cmd.contains("git status"), "should preserve original: {cmd}");
1053 assert!(cmd.contains("--cmd git"), "should pass base command via --cmd: {cmd}");
1057 assert!(
1058 !cmd.contains("SQZ_CMD="),
1059 "must not emit legacy sh-style env prefix: {cmd}"
1060 );
1061 }
1062
1063 #[test]
1064 fn test_process_opencode_hook_passes_non_shell() {
1065 let input = r#"{"tool":"read_file","args":{"path":"file.txt"}}"#;
1066 let result = process_opencode_hook(input).unwrap();
1067 assert_eq!(result, input, "non-shell tools should pass through");
1068 }
1069
1070 #[test]
1071 fn test_process_opencode_hook_skips_sqz_commands() {
1072 let input = r#"{"tool":"bash","args":{"command":"sqz stats"}}"#;
1073 let result = process_opencode_hook(input).unwrap();
1074 assert_eq!(result, input, "sqz commands should not be double-wrapped");
1075 }
1076
1077 #[test]
1078 fn test_process_opencode_hook_skips_interactive() {
1079 let input = r#"{"tool":"bash","args":{"command":"vim file.txt"}}"#;
1080 let result = process_opencode_hook(input).unwrap();
1081 assert_eq!(result, input, "interactive commands should pass through");
1082 }
1083
1084 #[test]
1085 fn test_process_opencode_hook_skips_watch() {
1086 let input = r#"{"tool":"bash","args":{"command":"npm run dev --watch"}}"#;
1087 let result = process_opencode_hook(input).unwrap();
1088 assert_eq!(result, input, "watch mode should pass through");
1089 }
1090
1091 #[test]
1095 fn test_process_opencode_hook_skips_shell_operators() {
1096 let heredoc = r#"{"tool":"bash","args":{"command":"git commit -F- <<'EOF'\nfeat: subject\n\nbody\nEOF"}}"#;
1098 let result = process_opencode_hook(heredoc).unwrap();
1099 assert_eq!(result, heredoc, "heredoc must not be rewritten (issue #22)");
1100
1101 let compound = r#"{"tool":"bash","args":{"command":"cargo build && cargo test"}}"#;
1103 let result = process_opencode_hook(compound).unwrap();
1104 assert_eq!(result, compound, "compound commands must pass through");
1105
1106 let pipe = r#"{"tool":"bash","args":{"command":"cat file.txt | grep foo"}}"#;
1108 let result = process_opencode_hook(pipe).unwrap();
1109 assert_eq!(result, pipe, "existing pipes must pass through");
1110
1111 let redirect = r#"{"tool":"bash","args":{"command":"echo hello > output.txt"}}"#;
1113 let result = process_opencode_hook(redirect).unwrap();
1114 assert_eq!(result, redirect, "redirects must pass through");
1115 }
1116
1117 #[test]
1118 fn test_process_opencode_hook_invalid_json() {
1119 let result = process_opencode_hook("not json");
1120 assert!(result.is_err());
1121 }
1122
1123 #[test]
1124 fn test_process_opencode_hook_empty_command() {
1125 let input = r#"{"tool":"bash","args":{"command":""}}"#;
1126 let result = process_opencode_hook(input).unwrap();
1127 assert_eq!(result, input);
1128 }
1129
1130 #[test]
1131 fn test_process_opencode_hook_run_shell_command() {
1132 let input = r#"{"tool":"run_shell_command","args":{"command":"ls -la"}}"#;
1133 let result = process_opencode_hook(input).unwrap();
1134 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1135 let cmd = parsed["args"]["command"].as_str().unwrap();
1136 assert!(cmd.contains("sqz compress"));
1137 }
1138
1139 #[test]
1140 fn test_install_opencode_plugin_creates_file() {
1141 let dir = tempfile::tempdir().unwrap();
1142 std::env::set_var("HOME", dir.path());
1144 let result = install_opencode_plugin("sqz");
1145 assert!(result.is_ok());
1146 let plugin_path = dir
1148 .path()
1149 .join(".config/opencode/plugins/sqz.ts");
1150 assert!(plugin_path.exists(), "plugin file should exist");
1151 let content = std::fs::read_to_string(&plugin_path).unwrap();
1152 assert!(content.contains("SqzPlugin"));
1153 }
1154
1155 #[test]
1156 fn test_update_opencode_config_creates_new() {
1157 let dir = tempfile::tempdir().unwrap();
1158 let result = update_opencode_config(dir.path()).unwrap();
1159 assert!(result, "should create new config");
1160 let config_path = dir.path().join("opencode.json");
1161 assert!(config_path.exists());
1162 let content = std::fs::read_to_string(&config_path).unwrap();
1163 assert!(content.contains("\"sqz\""));
1164 assert!(content.contains("sqz-mcp"));
1165
1166 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1173 assert!(
1174 parsed.get("plugin").is_none(),
1175 "fresh-install opencode.json must not include `plugin`; got: {content}"
1176 );
1177 assert_eq!(
1178 parsed["mcp"]["sqz"]["type"].as_str(),
1179 Some("local"),
1180 "mcp.sqz must be present"
1181 );
1182 }
1183
1184 #[test]
1185 fn test_update_opencode_config_adds_to_existing() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let config_path = dir.path().join("opencode.json");
1188 std::fs::write(
1189 &config_path,
1190 r#"{"$schema":"https://opencode.ai/config.json","plugin":["other"]}"#,
1191 )
1192 .unwrap();
1193
1194 let result = update_opencode_config(dir.path()).unwrap();
1195 assert!(result, "should update existing config");
1196 let content = std::fs::read_to_string(&config_path).unwrap();
1197 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1198 let plugins = parsed["plugin"].as_array().unwrap();
1203 assert!(
1204 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1205 "issue #10: sqz must NOT be registered as a config-level plugin \
1206 (the local plugin file at ~/.config/opencode/plugins/sqz.ts \
1207 already loads it; double-registering causes double hook firing)"
1208 );
1209 assert!(
1210 plugins.iter().any(|v| v.as_str() == Some("other")),
1211 "pre-existing plugin entries from OTHER plugins must be preserved"
1212 );
1213 assert_eq!(
1216 parsed["mcp"]["sqz"]["type"].as_str(),
1217 Some("local"),
1218 "mcp.sqz must be added"
1219 );
1220 }
1221
1222 #[test]
1227 fn test_update_opencode_config_removes_legacy_sqz_plugin_entry() {
1228 let dir = tempfile::tempdir().unwrap();
1229 let config_path = dir.path().join("opencode.json");
1230 std::fs::write(
1231 &config_path,
1232 r#"{"plugin":["other","sqz"]}"#,
1233 )
1234 .unwrap();
1235
1236 let changed = update_opencode_config(dir.path()).unwrap();
1237 assert!(changed, "must report that the legacy plugin entry was stripped");
1238
1239 let after = std::fs::read_to_string(&config_path).unwrap();
1240 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1241 let plugins = parsed["plugin"].as_array().unwrap();
1242 assert!(
1243 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1244 "legacy sqz plugin entry must be stripped on re-init"
1245 );
1246 assert!(
1247 plugins.iter().any(|v| v.as_str() == Some("other")),
1248 "other plugin entries must survive the cleanup"
1249 );
1250 }
1251
1252 #[test]
1256 fn test_update_opencode_config_drops_empty_plugin_array_after_cleanup() {
1257 let dir = tempfile::tempdir().unwrap();
1258 let config_path = dir.path().join("opencode.json");
1259 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1260
1261 update_opencode_config(dir.path()).unwrap();
1262
1263 let after = std::fs::read_to_string(&config_path).unwrap();
1264 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1265 assert!(
1266 parsed.get("plugin").is_none(),
1267 "empty plugin array should be dropped entirely, got: {after}"
1268 );
1269 }
1270
1271 #[test]
1272 fn test_update_opencode_config_skips_if_present() {
1273 let dir = tempfile::tempdir().unwrap();
1274 let config_path = dir.path().join("opencode.json");
1275 std::fs::write(
1276 &config_path,
1277 r#"{
1278 "mcp": {
1279 "sqz": {
1280 "type": "local",
1281 "command": ["sqz-mcp", "--transport", "stdio"],
1282 "enabled": true
1283 }
1284 }
1285}"#,
1286 )
1287 .unwrap();
1288
1289 let result = update_opencode_config(dir.path()).unwrap();
1290 assert!(
1291 !result,
1292 "a config with mcp.sqz including enabled:true must be idempotent"
1293 );
1294 }
1295
1296 #[test]
1301 fn test_update_opencode_config_adds_missing_mcp_entry() {
1302 let dir = tempfile::tempdir().unwrap();
1303 let config_path = dir.path().join("opencode.json");
1304 std::fs::write(&config_path, r#"{"plugin":["sqz"]}"#).unwrap();
1305
1306 let changed = update_opencode_config(dir.path()).unwrap();
1307 assert!(changed, "must report that mcp.sqz was added");
1308
1309 let after = std::fs::read_to_string(&config_path).unwrap();
1310 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1311 assert_eq!(
1312 parsed["mcp"]["sqz"]["type"].as_str(),
1313 Some("local"),
1314 "mcp.sqz must be populated with the default server entry"
1315 );
1316 }
1317
1318 #[test]
1329 fn test_process_opencode_hook_skips_already_wrapped_sqz_cmd_prefix() {
1330 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"}}"#;
1331 let result = process_opencode_hook(input).unwrap();
1332 assert_eq!(
1333 result, input,
1334 "already-wrapped command must pass through unchanged; \
1335 otherwise each pass accumulates another SQZ_CMD= prefix"
1336 );
1337 }
1338
1339 #[test]
1342 fn test_process_opencode_hook_guard_is_case_insensitive() {
1343 let input = r#"{"tool":"bash","args":{"command":"SQZ_CMD=git git status"}}"#;
1344 let result = process_opencode_hook(input).unwrap();
1345 assert_eq!(
1346 result, input,
1347 "uppercase SQZ_CMD= prefix must short-circuit the wrap"
1348 );
1349 }
1350
1351 #[test]
1357 fn test_process_opencode_hook_skips_leading_env_assignments_for_base() {
1358 let input = r#"{"tool":"bash","args":{"command":"FOO=bar BAZ=qux make test"}}"#;
1359 let result = process_opencode_hook(input).unwrap();
1360 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1361 let cmd = parsed["args"]["command"].as_str().unwrap();
1362 assert!(
1363 cmd.contains("--cmd make"),
1364 "base command must be `make`, not `FOO=bar`; got: {cmd}"
1365 );
1366 assert!(
1367 cmd.contains("FOO=bar BAZ=qux make test"),
1368 "original command must be preserved: {cmd}"
1369 );
1370 }
1371
1372 #[test]
1374 fn test_process_opencode_hook_skips_bare_sqz_invocation() {
1375 for cmd in ["sqz stats", "sqz gain", "/usr/local/bin/sqz compress"] {
1376 let input = format!(
1377 r#"{{"tool":"bash","args":{{"command":"{cmd}"}}}}"#
1378 );
1379 let result = process_opencode_hook(&input).unwrap();
1380 assert_eq!(
1381 result, input,
1382 "sqz-invoking command `{cmd}` must not be rewrapped"
1383 );
1384 }
1385 }
1386
1387 #[test]
1391 fn test_generate_opencode_plugin_has_double_wrap_guard() {
1392 let content = generate_opencode_plugin("sqz");
1393 assert!(
1394 content.contains("function isAlreadyWrapped(cmd: string): boolean"),
1395 "generated plugin must define isAlreadyWrapped helper"
1396 );
1397 assert!(
1398 content.contains(r#"lowered.includes("sqz_cmd=")"#),
1399 "plugin must check for the SQZ_CMD= prior-wrap prefix"
1400 );
1401 assert!(
1402 content.contains(r#"lowered.includes("sqz compress")"#),
1403 "plugin must check for the `sqz compress` prior-wrap tail"
1404 );
1405 assert!(
1406 content.contains("isAlreadyWrapped(cmd)"),
1407 "plugin hook body must call isAlreadyWrapped on the command"
1408 );
1409 assert!(
1410 content.contains("function extractBaseCmd(cmd: string): string"),
1411 "plugin must define extractBaseCmd that skips env assignments"
1412 );
1413 assert!(
1414 content.contains("extractBaseCmd(cmd)"),
1415 "plugin hook body must use extractBaseCmd, not raw split"
1416 );
1417 }
1418
1419 #[test]
1422 fn test_is_already_wrapped_detects_all_marker_shapes() {
1423 assert!(is_already_wrapped("SQZ_CMD=git git status"));
1424 assert!(is_already_wrapped("sqz_cmd=git git status"));
1425 assert!(is_already_wrapped("git status | sqz compress"));
1426 assert!(is_already_wrapped("git status 2>&1 | /path/sqz compress"));
1427 assert!(is_already_wrapped("ls -la | sqz compress-stream"));
1428 assert!(is_already_wrapped("sqz stats"));
1429 assert!(is_already_wrapped("/usr/local/bin/sqz gain"));
1430 assert!(is_already_wrapped("SQZ_FOO=bar cmd"));
1431 assert!(!is_already_wrapped("git status"));
1432 assert!(!is_already_wrapped("grep sqz logfile.txt"));
1433 assert!(!is_already_wrapped("cargo test --package my-sqz-crate"));
1434 }
1435
1436 #[test]
1437 fn test_extract_base_cmd_skips_env_assignments() {
1438 assert_eq!(extract_base_cmd("make test"), "make");
1439 assert_eq!(extract_base_cmd("FOO=bar make test"), "make");
1440 assert_eq!(extract_base_cmd("FOO=bar BAZ=qux make test"), "make");
1441 assert_eq!(extract_base_cmd("/usr/bin/git status"), "git");
1442 assert_eq!(extract_base_cmd(""), "unknown");
1443 assert_eq!(extract_base_cmd("FOO=bar"), "unknown");
1444 }
1445
1446 #[test]
1447 fn test_is_env_assignment() {
1448 assert!(is_env_assignment("FOO=bar"));
1449 assert!(is_env_assignment("FOO="));
1450 assert!(is_env_assignment("_underscore=1"));
1451 assert!(is_env_assignment("MixedCase_1=x"));
1452 assert!(!is_env_assignment("=bar"));
1453 assert!(!is_env_assignment("FOO"));
1454 assert!(!is_env_assignment("--flag=value"));
1455 assert!(!is_env_assignment("123=value"));
1456 assert!(!is_env_assignment("FOO BAR=baz"));
1457 }
1458
1459 #[test]
1468 fn test_update_merges_into_existing_jsonc() {
1469 let dir = tempfile::tempdir().unwrap();
1470 let jsonc = dir.path().join("opencode.jsonc");
1471 std::fs::write(
1472 &jsonc,
1473 r#"{
1474 // user's own config with a comment
1475 "$schema": "https://opencode.ai/config.json",
1476 "model": "anthropic/claude-sonnet-4-5",
1477 /* another comment */
1478 "plugin": ["other-plugin"]
1479}
1480"#,
1481 )
1482 .unwrap();
1483
1484 let changed = update_opencode_config(dir.path()).unwrap();
1485 assert!(changed, "must merge sqz entries into the existing .jsonc");
1486
1487 assert!(jsonc.exists(), "original .jsonc must still exist");
1489 assert!(
1490 !dir.path().join("opencode.json").exists(),
1491 "must not create a parallel opencode.json alongside .jsonc \
1492 (that's the issue #6 bug)"
1493 );
1494
1495 let after = std::fs::read_to_string(&jsonc).unwrap();
1496 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1497 let plugins = parsed["plugin"].as_array().unwrap();
1498 assert!(
1502 !plugins.iter().any(|v| v.as_str() == Some("sqz")),
1503 "issue #10: sqz must NOT be added to plugin[]"
1504 );
1505 assert!(
1506 plugins.iter().any(|v| v.as_str() == Some("other-plugin")),
1507 "pre-existing plugin entries must be preserved"
1508 );
1509 assert_eq!(
1510 parsed["model"].as_str(),
1511 Some("anthropic/claude-sonnet-4-5"),
1512 "unrelated user keys must survive the merge"
1513 );
1514 assert_eq!(
1515 parsed["mcp"]["sqz"]["type"].as_str(),
1516 Some("local"),
1517 "mcp.sqz must be registered"
1518 );
1519 }
1520
1521 #[test]
1525 fn test_update_opencode_config_detailed_reports_comments_lost() {
1526 let dir = tempfile::tempdir().unwrap();
1527 let jsonc = dir.path().join("opencode.jsonc");
1528 std::fs::write(
1529 &jsonc,
1530 r#"{
1531 // comment to be dropped
1532 "plugin": ["other"]
1533}
1534"#,
1535 )
1536 .unwrap();
1537
1538 let (changed, comments_lost) =
1539 update_opencode_config_detailed(dir.path()).unwrap();
1540 assert!(changed);
1541 assert!(
1542 comments_lost,
1543 "merger must report that comments were dropped from .jsonc"
1544 );
1545 }
1546
1547 #[test]
1551 fn plan_opencode_reports_no_change_when_already_configured() {
1552 let dir = tempfile::tempdir().unwrap();
1553 update_opencode_config(dir.path()).unwrap();
1555 let planned = plan_opencode_config_change(dir.path()).unwrap();
1557 assert!(
1558 !planned.will_change,
1559 "re-running against a fully configured file must be a no-op"
1560 );
1561 assert!(!planned.comments_lost);
1562 }
1563
1564 #[test]
1567 fn plan_opencode_reports_change_without_writing() {
1568 let dir = tempfile::tempdir().unwrap();
1569 let path = dir.path().join("opencode.json");
1570 std::fs::write(&path, r#"{"plugin":["other"]}"#).unwrap();
1571 let before = std::fs::read_to_string(&path).unwrap();
1572
1573 let planned = plan_opencode_config_change(dir.path()).unwrap();
1574 assert!(planned.will_change);
1575 assert_eq!(planned.target_path, path);
1576
1577 let after = std::fs::read_to_string(&path).unwrap();
1578 assert_eq!(before, after, "dry-run must not modify the file");
1579 }
1580
1581 #[test]
1584 fn plan_opencode_reports_fresh_create() {
1585 let dir = tempfile::tempdir().unwrap();
1586 let planned = plan_opencode_config_change(dir.path()).unwrap();
1587 assert!(planned.will_change);
1588 assert_eq!(planned.target_path, dir.path().join("opencode.json"));
1589 assert!(!dir.path().join("opencode.json").exists(),
1590 "dry-run must not create the file");
1591 }
1592
1593 #[test]
1597 fn test_update_creates_plain_json_when_nothing_exists() {
1598 let dir = tempfile::tempdir().unwrap();
1599 update_opencode_config(dir.path()).unwrap();
1600 assert!(dir.path().join("opencode.json").exists());
1601 assert!(!dir.path().join("opencode.jsonc").exists());
1602 }
1603
1604 #[test]
1606 fn test_find_opencode_config_prefers_jsonc() {
1607 let dir = tempfile::tempdir().unwrap();
1608 std::fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1609 std::fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1610 let found = find_opencode_config(dir.path()).unwrap();
1611 assert_eq!(
1612 found.file_name().unwrap(),
1613 "opencode.jsonc",
1614 "must prefer the .jsonc variant when both exist — the user \
1615 is maintaining .jsonc for its comment support"
1616 );
1617 }
1618
1619 #[test]
1620 fn test_find_opencode_config_returns_none_when_missing() {
1621 let dir = tempfile::tempdir().unwrap();
1622 assert!(find_opencode_config(dir.path()).is_none());
1623 }
1624
1625 #[test]
1626 fn test_opencode_config_has_comments_detects_jsonc_comments() {
1627 let dir = tempfile::tempdir().unwrap();
1628 std::fs::write(
1629 dir.path().join("opencode.jsonc"),
1630 "// a line comment\n{\"plugin\":[]}\n",
1631 )
1632 .unwrap();
1633 assert!(opencode_config_has_comments(dir.path()));
1634 }
1635
1636 #[test]
1637 fn test_opencode_config_has_comments_ignores_plain_json() {
1638 let dir = tempfile::tempdir().unwrap();
1639 std::fs::write(
1641 dir.path().join("opencode.json"),
1642 r#"{"url":"http://example.com"}"#,
1643 )
1644 .unwrap();
1645 assert!(!opencode_config_has_comments(dir.path()));
1646 }
1647
1648 #[test]
1651 fn test_strip_jsonc_comments_removes_line_comments() {
1652 let src = "{\n // leading comment\n \"a\": 1 // trailing\n}";
1653 let stripped = strip_jsonc_comments(src);
1654 assert!(!stripped.contains("leading comment"));
1655 assert!(!stripped.contains("trailing"));
1656 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1657 assert_eq!(parsed["a"], 1);
1658 }
1659
1660 #[test]
1661 fn test_strip_jsonc_comments_removes_block_comments() {
1662 let src = "{\n /* block\n comment */\n \"a\": 1\n}";
1663 let stripped = strip_jsonc_comments(src);
1664 assert!(!stripped.contains("block"));
1665 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1666 assert_eq!(parsed["a"], 1);
1667 }
1668
1669 #[test]
1670 fn test_strip_jsonc_comments_preserves_strings() {
1671 let src = r#"{"url": "http://example.com", "re": "/* not a comment */"}"#;
1676 let stripped = strip_jsonc_comments(src);
1677 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1678 assert_eq!(parsed["url"], "http://example.com");
1679 assert_eq!(parsed["re"], "/* not a comment */");
1680 }
1681
1682 #[test]
1683 fn test_strip_jsonc_comments_preserves_escaped_quote_in_string() {
1684 let src = r#"{"s": "a\"//b"}"#;
1685 let stripped = strip_jsonc_comments(src);
1686 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1687 assert_eq!(parsed["s"], r#"a"//b"#);
1688 }
1689
1690 #[test]
1691 fn test_strip_jsonc_comments_tolerates_unterminated_block() {
1692 let src = "{\"a\":1 /* never ends";
1694 let _ = strip_jsonc_comments(src); }
1696
1697 #[test]
1698 fn test_strip_jsonc_comments_removes_trailing_commas() {
1699 let src = r#"{
1702 "a": [1, 2, 3,],
1703 "b": {"x": 1, "y": 2,},
1704}"#;
1705 let stripped = strip_jsonc_comments(src);
1706 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1707 assert_eq!(parsed["a"], serde_json::json!([1, 2, 3]));
1708 assert_eq!(parsed["b"]["x"], 1);
1709 assert_eq!(parsed["b"]["y"], 2);
1710 }
1711
1712 #[test]
1713 fn test_strip_jsonc_comments_trailing_comma_in_string_preserved() {
1714 let src = r#"{"s": "a,}"}"#;
1716 let stripped = strip_jsonc_comments(src);
1717 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1718 assert_eq!(parsed["s"], "a,}");
1719 }
1720
1721 #[test]
1722 fn test_strip_jsonc_full_opencode_jsonc_with_comments_and_trailing_commas() {
1723 let src = r#"{
1726 // User's OpenCode config
1727 "$schema": "https://opencode.ai/config.json",
1728 "mcp": {
1729 // MCP servers
1730 "dart": {
1731 "type": "local",
1732 "command": ["dart", "mcp-server"],
1733 },
1734 },
1735}"#;
1736 let stripped = strip_jsonc_comments(src);
1737 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
1738 assert_eq!(
1739 parsed["mcp"]["dart"]["type"], "local",
1740 "must parse after stripping comments + trailing commas"
1741 );
1742 }
1743
1744 #[test]
1752 fn test_remove_sqz_preserves_other_user_config() {
1753 let dir = tempfile::tempdir().unwrap();
1754 let config = dir.path().join("opencode.json");
1755 std::fs::write(
1756 &config,
1757 r#"{
1758 "$schema": "https://opencode.ai/config.json",
1759 "model": "anthropic/claude-sonnet-4-5",
1760 "plugin": ["other-plugin", "sqz"],
1761 "mcp": {
1762 "sqz": { "type": "local", "command": ["sqz-mcp"] },
1763 "jira": { "type": "remote", "url": "https://jira.example.com/mcp" }
1764 }
1765}
1766"#,
1767 )
1768 .unwrap();
1769
1770 let (path, changed) =
1771 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1772 assert_eq!(path, config);
1773 assert!(changed, "must report that sqz entries were removed");
1774 assert!(
1775 config.exists(),
1776 "file must NOT be deleted — only sqz's entries removed"
1777 );
1778
1779 let after = std::fs::read_to_string(&config).unwrap();
1780 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1781 let plugins = parsed["plugin"].as_array().unwrap();
1782 assert!(!plugins.iter().any(|v| v.as_str() == Some("sqz")));
1783 assert!(plugins.iter().any(|v| v.as_str() == Some("other-plugin")));
1784 let mcp = parsed["mcp"].as_object().unwrap();
1785 assert!(!mcp.contains_key("sqz"), "mcp.sqz must be gone");
1786 assert!(mcp.contains_key("jira"), "mcp.jira must survive");
1787 assert_eq!(
1788 parsed["model"].as_str(),
1789 Some("anthropic/claude-sonnet-4-5"),
1790 "unrelated keys must survive"
1791 );
1792 }
1793
1794 #[test]
1798 fn test_remove_sqz_deletes_file_when_nothing_else_remains() {
1799 let dir = tempfile::tempdir().unwrap();
1800 let config = dir.path().join("opencode.json");
1801 std::fs::write(
1803 &config,
1804 r#"{
1805 "$schema": "https://opencode.ai/config.json",
1806 "mcp": {
1807 "sqz": { "type": "local", "command": ["sqz-mcp", "--transport", "stdio"] }
1808 },
1809 "plugin": ["sqz"]
1810}
1811"#,
1812 )
1813 .unwrap();
1814
1815 let (_, changed) =
1816 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1817 assert!(changed);
1818 assert!(
1819 !config.exists(),
1820 "file with only $schema + sqz entries must be removed"
1821 );
1822 }
1823
1824 #[test]
1827 fn test_remove_sqz_returns_none_when_config_missing() {
1828 let dir = tempfile::tempdir().unwrap();
1829 let result = remove_sqz_from_opencode_config(dir.path()).unwrap();
1830 assert!(result.is_none());
1831 }
1832
1833 #[test]
1836 fn test_remove_sqz_from_jsonc_drops_comments() {
1837 let dir = tempfile::tempdir().unwrap();
1838 let jsonc = dir.path().join("opencode.jsonc");
1839 std::fs::write(
1840 &jsonc,
1841 r#"{
1842 // user's comment
1843 "model": "x",
1844 "plugin": ["sqz", "other"]
1845}
1846"#,
1847 )
1848 .unwrap();
1849
1850 let (path, changed) =
1851 remove_sqz_from_opencode_config(dir.path()).unwrap().unwrap();
1852 assert_eq!(path, jsonc);
1853 assert!(changed);
1854 assert!(path.exists(), "jsonc file kept because `model` and `other` remain");
1855
1856 let after = std::fs::read_to_string(&jsonc).unwrap();
1857 assert!(
1858 !after.contains("// user's comment"),
1859 "comments are dropped by the serde_json round-trip; \
1860 documented in update_opencode_config_detailed"
1861 );
1862 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1863 let plugins = parsed["plugin"].as_array().unwrap();
1864 assert_eq!(plugins.len(), 1);
1865 assert_eq!(plugins[0], "other");
1866 }
1867
1868 #[test]
1878 fn issue_10_opencode_rewrite_works_in_powershell_syntax() {
1879 let input = r#"{"tool":"bash","args":{"command":"dotnet build NewNeonCheckers3.sln"}}"#;
1880 let result = process_opencode_hook(input).unwrap();
1881 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1882 let cmd = parsed["args"]["command"].as_str().unwrap();
1883
1884 assert!(
1888 !cmd.contains("SQZ_CMD="),
1889 "issue #10: rewrite must not emit `SQZ_CMD=` (breaks on \
1890 PowerShell/cmd.exe); got: {cmd}"
1891 );
1892 assert!(
1894 cmd.contains("--cmd dotnet"),
1895 "rewrite must pass label via --cmd; got: {cmd}"
1896 );
1897 let first_token = cmd.split_whitespace().next().unwrap_or("");
1902 assert_eq!(
1903 first_token, "dotnet",
1904 "first token of the rewritten command must be the user's \
1905 command itself, not an env-var assignment; got: {cmd}"
1906 );
1907 }
1908
1909 #[test]
1913 fn issue_10_ts_plugin_emits_cmd_flag_not_env_prefix() {
1914 let content = generate_opencode_plugin("sqz");
1915 assert!(
1919 content.contains("compress --cmd"),
1920 "TS plugin must build rewrite with `compress --cmd ${{base}}`"
1921 );
1922 assert!(
1928 !content.contains("SQZ_CMD=${base}"),
1929 "TS plugin must not emit the legacy `SQZ_CMD=${{base}}` prefix"
1930 );
1931 }
1932
1933 #[test]
1948 fn issue_10_fresh_opencode_config_has_no_plugin_entry() {
1949 let dir = tempfile::tempdir().unwrap();
1950 update_opencode_config(dir.path()).unwrap();
1951 let content = std::fs::read_to_string(dir.path().join("opencode.json")).unwrap();
1952 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1953
1954 assert!(
1956 parsed.get("plugin").is_none(),
1957 "issue #10: fresh opencode.json must not include `plugin` key; got: {content}"
1958 );
1959
1960 assert_eq!(
1963 parsed["mcp"]["sqz"]["type"].as_str(),
1964 Some("local"),
1965 "mcp.sqz is the one sqz-authored entry that belongs in \
1966 opencode.json; must still be registered"
1967 );
1968 }
1969
1970 #[test]
1975 fn issue_10_reinit_strips_legacy_plugin_entry() {
1976 let dir = tempfile::tempdir().unwrap();
1977 let config = dir.path().join("opencode.json");
1978 std::fs::write(
1979 &config,
1980 r#"{"$schema":"https://opencode.ai/config.json","mcp":{"sqz":{"type":"local","command":["sqz-mcp","--transport","stdio"]}},"plugin":["sqz"]}"#,
1982 )
1983 .unwrap();
1984
1985 let changed = update_opencode_config(dir.path()).unwrap();
1986 assert!(changed, "re-init must report a change (the legacy entry was stripped)");
1987
1988 let after = std::fs::read_to_string(&config).unwrap();
1989 let parsed: serde_json::Value = serde_json::from_str(&after).unwrap();
1990 assert!(
1991 parsed.get("plugin").is_none(),
1992 "legacy `plugin: [\"sqz\"]` must be stripped on re-init; got: {after}"
1993 );
1994 assert_eq!(
1996 parsed["mcp"]["sqz"]["type"].as_str(),
1997 Some("local"),
1998 "mcp.sqz must survive cleanup of the plugin entry"
1999 );
2000 }
2001}