1use anyhow::{Context, Result};
27use serde_json::{Value, json};
28use std::path::{Path, PathBuf};
29
30use super::shared::{
31 self, McpMergeOutcome, McpRemoveOutcome, build_mcp_entry, merge_mcp_entry, remove_mcp_entry,
32};
33use super::templates::{self, opencode_hook_specs};
34use super::{
35 ClientId, DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport,
36 InstallStatus, Installer, UninstallReport, UninstallStatus, UpdateReport, UpdateStatus,
37};
38
39const HOOK_TIMEOUT_DEFAULT_MS: u64 = 5000;
41const HOOK_TIMEOUT_SESSION_END_MS: u64 = 10000;
43
44pub struct OpenCodeInstaller {
45 home_override: Option<PathBuf>,
46}
47
48impl OpenCodeInstaller {
49 pub fn new() -> Self {
50 Self {
51 home_override: None,
52 }
53 }
54
55 #[doc(hidden)]
56 pub fn with_home_root(root: PathBuf) -> Self {
57 Self {
58 home_override: Some(root),
59 }
60 }
61
62 fn home(&self) -> Result<PathBuf> {
63 match &self.home_override {
64 Some(p) => Ok(p.clone()),
65 None => shared::home_dir(),
66 }
67 }
68
69 fn opencode_dir(&self) -> Result<PathBuf> {
70 Ok(self.home()?.join(".opencode"))
71 }
72
73 fn config_json_path(&self) -> Result<PathBuf> {
74 Ok(self.opencode_dir()?.join("config.json"))
75 }
76
77 fn hooks_json_path(&self) -> Result<PathBuf> {
78 Ok(self.opencode_dir()?.join("hooks.json"))
79 }
80
81 fn hooks_dir(&self) -> Result<PathBuf> {
82 Ok(self.opencode_dir()?.join("hooks"))
83 }
84
85 fn default_binary_path(&self) -> Result<PathBuf> {
86 Ok(self.home()?.join(".cargo").join("bin").join("spool-mcp"))
87 }
88
89 fn resolve_binary_path(&self, ctx: &InstallContext) -> Result<PathBuf> {
90 match &ctx.binary_path {
91 Some(p) => Ok(p.clone()),
92 None => self.default_binary_path(),
93 }
94 }
95
96 fn validate_inputs(&self, ctx: &InstallContext, binary_path: &Path) -> Result<()> {
97 if !ctx.config_path.is_absolute() {
98 anyhow::bail!(
99 "config path must be absolute, got: {}",
100 ctx.config_path.display()
101 );
102 }
103 if !binary_path.is_absolute() {
104 anyhow::bail!(
105 "binary path must be absolute, got: {}",
106 binary_path.display()
107 );
108 }
109 Ok(())
110 }
111}
112
113impl Default for OpenCodeInstaller {
114 fn default() -> Self {
115 Self::new()
116 }
117}
118
119impl Installer for OpenCodeInstaller {
120 fn id(&self) -> ClientId {
121 ClientId::OpenCode
122 }
123
124 fn detect(&self) -> Result<bool> {
125 let opencode_dir = self.opencode_dir()?;
126 Ok(opencode_dir.exists())
127 }
128
129 fn install(&self, ctx: &InstallContext) -> Result<InstallReport> {
130 let binary_path = self.resolve_binary_path(ctx)?;
131 self.validate_inputs(ctx, &binary_path)?;
132
133 let mut planned_writes: Vec<PathBuf> = Vec::new();
134 let mut backups: Vec<PathBuf> = Vec::new();
135 let mut notes: Vec<String> = Vec::new();
136
137 let config_json = self.config_json_path()?;
139 let mut config_doc = shared::read_json_or_empty(&config_json)?;
140 let desired = build_mcp_entry(&binary_path, &ctx.config_path);
141 let mcp_outcome = merge_mcp_entry(&mut config_doc, "spool", desired, ctx.force);
142
143 if !binary_path.exists() {
144 notes.push(format!(
145 "spool-mcp binary not found at {}. Run `cargo install --path .` or pass --binary-path.",
146 binary_path.display()
147 ));
148 }
149
150 let mcp_status = match mcp_outcome {
151 McpMergeOutcome::Inserted => MergeStatus::Changed,
152 McpMergeOutcome::Unchanged => MergeStatus::Unchanged,
153 McpMergeOutcome::Conflict {
154 force_applied: true,
155 } => {
156 notes.push(format!(
157 "Existing spool mcpServers entry was overwritten (force=true). Backup at {}.",
158 config_json.display()
159 ));
160 MergeStatus::Changed
161 }
162 McpMergeOutcome::Conflict {
163 force_applied: false,
164 } => {
165 notes.push(
166 "Existing spool mcpServers entry differs. Re-run with --force to overwrite, or `spool mcp uninstall` first.".to_string(),
167 );
168 MergeStatus::Conflict
169 }
170 };
171
172 if matches!(mcp_status, MergeStatus::Conflict) {
173 return Ok(InstallReport {
174 client: ClientId::OpenCode.as_str().to_string(),
175 binary_path,
176 config_path: ctx.config_path.clone(),
177 status: InstallStatus::Conflict,
178 planned_writes,
179 backups,
180 notes,
181 });
182 }
183
184 let hooks_json = self.hooks_json_path()?;
186 let mut hooks_doc = shared::read_json_or_empty(&hooks_json)?;
187 let hooks_dir = self.hooks_dir()?;
188 let hook_specs = opencode_hook_specs();
189 let mut hooks_json_changed = false;
190 for spec in &hook_specs {
191 let target_path = hooks_dir.join(spec.file_name);
192 let target_str = target_path.to_string_lossy().into_owned();
193 let timeout = timeout_for_event(spec.hook_event);
194 match upsert_opencode_hook_entry(&mut hooks_doc, spec.hook_event, &target_str, timeout)
195 {
196 OpenCodeHookOutcome::Appended => hooks_json_changed = true,
197 OpenCodeHookOutcome::Unchanged => {}
198 }
199 }
200
201 let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);
203 let hook_files: Vec<HookFilePlan> = hook_specs
204 .iter()
205 .map(|spec| HookFilePlan {
206 target_path: hooks_dir.join(spec.file_name),
207 rendered: templates::render_hook(spec.body, &spool_bin_for_hook, &ctx.config_path),
208 })
209 .collect();
210 let hook_files_changed = hook_files
211 .iter()
212 .any(|p| !file_has_exact_contents(&p.target_path, &p.rendered));
213
214 let any_change =
216 matches!(mcp_status, MergeStatus::Changed) || hooks_json_changed || hook_files_changed;
217
218 if ctx.dry_run {
219 if matches!(mcp_status, MergeStatus::Changed) {
220 planned_writes.push(config_json.clone());
221 }
222 if hooks_json_changed {
223 planned_writes.push(hooks_json.clone());
224 }
225 for plan in &hook_files {
226 if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
227 planned_writes.push(plan.target_path.clone());
228 }
229 }
230 return Ok(InstallReport {
231 client: ClientId::OpenCode.as_str().to_string(),
232 binary_path,
233 config_path: ctx.config_path.clone(),
234 status: if any_change {
235 InstallStatus::DryRun
236 } else {
237 InstallStatus::Unchanged
238 },
239 planned_writes,
240 backups,
241 notes,
242 });
243 }
244
245 if matches!(mcp_status, MergeStatus::Changed) {
247 if let Some(b) = shared::backup_file(&config_json)
248 .with_context(|| format!("backing up {}", config_json.display()))?
249 {
250 backups.push(b);
251 }
252 shared::write_json_atomic(&config_json, &config_doc)
253 .with_context(|| format!("writing {}", config_json.display()))?;
254 planned_writes.push(config_json.clone());
255 }
256
257 if hooks_json_changed {
259 if let Some(b) = shared::backup_file(&hooks_json)
260 .with_context(|| format!("backing up {}", hooks_json.display()))?
261 {
262 backups.push(b);
263 }
264 shared::write_json_atomic(&hooks_json, &hooks_doc)
265 .with_context(|| format!("writing {}", hooks_json.display()))?;
266 planned_writes.push(hooks_json.clone());
267 }
268
269 if !hooks_dir.exists() {
271 std::fs::create_dir_all(&hooks_dir)
272 .with_context(|| format!("creating {}", hooks_dir.display()))?;
273 }
274 for plan in &hook_files {
275 if file_has_exact_contents(&plan.target_path, &plan.rendered) {
276 continue;
277 }
278 std::fs::write(&plan.target_path, &plan.rendered)
279 .with_context(|| format!("writing {}", plan.target_path.display()))?;
280 set_executable(&plan.target_path)?;
281 planned_writes.push(plan.target_path.clone());
282 }
283
284 let final_status = if any_change {
285 InstallStatus::Installed
286 } else {
287 InstallStatus::Unchanged
288 };
289
290 Ok(InstallReport {
291 client: ClientId::OpenCode.as_str().to_string(),
292 binary_path,
293 config_path: ctx.config_path.clone(),
294 status: final_status,
295 planned_writes,
296 backups,
297 notes,
298 })
299 }
300
301 fn update(&self, ctx: &InstallContext) -> Result<UpdateReport> {
302 let report = self.install(ctx)?;
303 let status = match report.status {
304 InstallStatus::Installed => UpdateStatus::Updated,
305 InstallStatus::Unchanged => UpdateStatus::Unchanged,
306 InstallStatus::DryRun => UpdateStatus::DryRun,
307 InstallStatus::Conflict => UpdateStatus::NotInstalled,
308 };
309 Ok(UpdateReport {
310 client: report.client,
311 status,
312 updated_paths: report.planned_writes,
313 notes: report.notes,
314 })
315 }
316
317 fn uninstall(&self, ctx: &InstallContext) -> Result<UninstallReport> {
318 let config_json = self.config_json_path()?;
319 let hooks_json = self.hooks_json_path()?;
320 let hooks_dir = self.hooks_dir()?;
321
322 let mut notes: Vec<String> = Vec::new();
323 let mut removed_paths: Vec<PathBuf> = Vec::new();
324 let mut backups: Vec<PathBuf> = Vec::new();
325 let mut any_change = false;
326
327 let config_doc_after_purge = if config_json.exists() {
329 let mut doc = shared::read_json_or_empty(&config_json)?;
330 match remove_mcp_entry(&mut doc, "spool") {
331 McpRemoveOutcome::Removed => {
332 any_change = true;
333 Some(doc)
334 }
335 McpRemoveOutcome::NotPresent => None,
336 }
337 } else {
338 None
339 };
340
341 let hooks_doc_after_purge = if hooks_json.exists() {
343 let mut doc = shared::read_json_or_empty(&hooks_json)?;
344 let removed = purge_opencode_hook_entries(&mut doc, "spool-");
345 if removed > 0 {
346 any_change = true;
347 Some(doc)
348 } else {
349 None
350 }
351 } else {
352 None
353 };
354
355 let hook_files: Vec<PathBuf> = opencode_hook_specs()
357 .iter()
358 .map(|s| hooks_dir.join(s.file_name))
359 .filter(|p| p.exists())
360 .collect();
361 if !hook_files.is_empty() {
362 any_change = true;
363 }
364
365 if !any_change {
366 notes.push("nothing to uninstall — no spool artifacts found.".to_string());
367 return Ok(UninstallReport {
368 client: ClientId::OpenCode.as_str().to_string(),
369 status: UninstallStatus::NotInstalled,
370 removed_paths,
371 backups,
372 notes,
373 });
374 }
375
376 if ctx.dry_run {
377 if config_doc_after_purge.is_some() {
378 removed_paths.push(config_json);
379 }
380 if hooks_doc_after_purge.is_some() {
381 removed_paths.push(hooks_json);
382 }
383 removed_paths.extend(hook_files);
384 return Ok(UninstallReport {
385 client: ClientId::OpenCode.as_str().to_string(),
386 status: UninstallStatus::DryRun,
387 removed_paths,
388 backups,
389 notes,
390 });
391 }
392
393 if let Some(doc) = config_doc_after_purge {
395 if let Some(b) = shared::backup_file(&config_json)? {
396 backups.push(b);
397 }
398 shared::write_json_atomic(&config_json, &doc)?;
399 removed_paths.push(config_json);
400 }
401
402 if let Some(doc) = hooks_doc_after_purge {
404 if let Some(b) = shared::backup_file(&hooks_json)? {
405 backups.push(b);
406 }
407 shared::write_json_atomic(&hooks_json, &doc)?;
408 removed_paths.push(hooks_json);
409 }
410
411 for p in hook_files {
413 std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
414 removed_paths.push(p);
415 }
416
417 Ok(UninstallReport {
418 client: ClientId::OpenCode.as_str().to_string(),
419 status: UninstallStatus::Removed,
420 removed_paths,
421 backups,
422 notes,
423 })
424 }
425
426 fn diagnose(&self, ctx: &InstallContext) -> Result<DiagnosticReport> {
427 let mut checks = Vec::new();
428
429 let opencode_dir = self.opencode_dir()?;
431 checks.push(DiagnosticCheck {
432 name: "opencode_dir_exists".into(),
433 status: if opencode_dir.exists() {
434 DiagnosticStatus::Ok
435 } else {
436 DiagnosticStatus::Warn
437 },
438 detail: format!("{}", opencode_dir.display()),
439 });
440
441 let config_json = self.config_json_path()?;
443 let registration_status = if config_json.exists() {
444 let doc = shared::read_json_or_empty(&config_json)?;
445 if doc.get("mcpServers").and_then(|v| v.get("spool")).is_some() {
446 DiagnosticStatus::Ok
447 } else {
448 DiagnosticStatus::Warn
449 }
450 } else {
451 DiagnosticStatus::NotApplicable
452 };
453 checks.push(DiagnosticCheck {
454 name: "mcp_servers_spool_registered".into(),
455 status: registration_status,
456 detail: "mcpServers.spool entry presence".into(),
457 });
458
459 let binary_path = self.resolve_binary_path(ctx)?;
461 checks.push(DiagnosticCheck {
462 name: "spool_mcp_binary".into(),
463 status: if binary_path.exists() {
464 DiagnosticStatus::Ok
465 } else {
466 DiagnosticStatus::Fail
467 },
468 detail: format!("{}", binary_path.display()),
469 });
470
471 checks.push(DiagnosticCheck {
473 name: "spool_config_readable".into(),
474 status: if ctx.config_path.exists() {
475 DiagnosticStatus::Ok
476 } else {
477 DiagnosticStatus::Fail
478 },
479 detail: format!("{}", ctx.config_path.display()),
480 });
481
482 let hooks_json = self.hooks_json_path()?;
484 let hooks_registered_status = if hooks_json.exists() {
485 let doc = shared::read_json_or_empty(&hooks_json)?;
486 if has_any_spool_hook_entry(&doc) {
487 DiagnosticStatus::Ok
488 } else {
489 DiagnosticStatus::Warn
490 }
491 } else {
492 DiagnosticStatus::Warn
493 };
494 checks.push(DiagnosticCheck {
495 name: "opencode_hooks_registered".into(),
496 status: hooks_registered_status,
497 detail: format!("{}", hooks_json.display()),
498 });
499
500 let hooks_dir = self.hooks_dir()?;
502 let mut missing: Vec<String> = Vec::new();
503 for spec in opencode_hook_specs() {
504 let p = hooks_dir.join(spec.file_name);
505 if !p.exists() {
506 missing.push(spec.file_name.to_string());
507 }
508 }
509 let hook_files_detail = if missing.is_empty() {
510 format!("{} (2/2 present)", hooks_dir.display())
511 } else {
512 format!("{} missing: {}", hooks_dir.display(), missing.join(", "))
513 };
514 checks.push(DiagnosticCheck {
515 name: "spool_hook_scripts".into(),
516 status: if missing.is_empty() {
517 DiagnosticStatus::Ok
518 } else {
519 DiagnosticStatus::Warn
520 },
521 detail: hook_files_detail,
522 });
523
524 Ok(DiagnosticReport {
525 client: ClientId::OpenCode.as_str().to_string(),
526 checks,
527 })
528 }
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536enum MergeStatus {
537 Changed,
538 Unchanged,
539 Conflict,
540}
541
542struct HookFilePlan {
543 target_path: PathBuf,
544 rendered: String,
545}
546
547#[derive(Debug, Clone, PartialEq, Eq)]
548enum OpenCodeHookOutcome {
549 Appended,
550 Unchanged,
551}
552
553fn timeout_for_event(event: &str) -> u64 {
554 match event {
555 "on_session_end" => HOOK_TIMEOUT_SESSION_END_MS,
556 _ => HOOK_TIMEOUT_DEFAULT_MS,
557 }
558}
559
560fn upsert_opencode_hook_entry(
563 doc: &mut Value,
564 event: &str,
565 command_path: &str,
566 timeout_ms: u64,
567) -> OpenCodeHookOutcome {
568 let root = match doc.as_object_mut() {
569 Some(obj) => obj,
570 None => {
571 *doc = json!({});
572 doc.as_object_mut().expect("just inserted")
573 }
574 };
575 let hooks = root.entry("hooks").or_insert_with(|| json!({}));
576 if !hooks.is_object() {
577 *hooks = json!({});
578 }
579 let hooks_obj = hooks.as_object_mut().expect("hooks must be object");
580 let entries = hooks_obj
581 .entry(event)
582 .or_insert_with(|| Value::Array(Vec::new()));
583 if !entries.is_array() {
584 *entries = Value::Array(Vec::new());
585 }
586 let array = entries.as_array_mut().expect("entries must be array");
587
588 for entry in array.iter() {
589 if entry.get("command").and_then(Value::as_str) == Some(command_path) {
590 return OpenCodeHookOutcome::Unchanged;
591 }
592 }
593
594 array.push(json!({
595 "command": command_path,
596 "timeout_ms": timeout_ms,
597 }));
598 OpenCodeHookOutcome::Appended
599}
600
601fn purge_opencode_hook_entries(doc: &mut Value, marker_substring: &str) -> usize {
604 let mut removed = 0usize;
605 let Some(root) = doc.as_object_mut() else {
606 return 0;
607 };
608 let Some(hooks) = root.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
609 return 0;
610 };
611 for (_event, entries) in hooks.iter_mut() {
612 let Some(array) = entries.as_array_mut() else {
613 continue;
614 };
615 let before = array.len();
616 array.retain(|entry| {
617 !entry
618 .get("command")
619 .and_then(Value::as_str)
620 .is_some_and(|c| c.contains(marker_substring))
621 });
622 removed += before - array.len();
623 }
624 hooks.retain(|_event, entries| !entries.as_array().is_some_and(|a| a.is_empty()));
625 if hooks.is_empty() {
626 root.remove("hooks");
627 }
628 removed
629}
630
631fn has_any_spool_hook_entry(doc: &Value) -> bool {
632 let Some(hooks) = doc.get("hooks").and_then(|v| v.as_object()) else {
633 return false;
634 };
635 for entries in hooks.values() {
636 let Some(arr) = entries.as_array() else {
637 continue;
638 };
639 for entry in arr {
640 if entry
641 .get("command")
642 .and_then(Value::as_str)
643 .is_some_and(|c| c.contains("spool-"))
644 {
645 return true;
646 }
647 }
648 }
649 false
650}
651
652fn file_has_exact_contents(path: &Path, expected: &str) -> bool {
653 if !path.exists() {
654 return false;
655 }
656 match std::fs::read_to_string(path) {
657 Ok(actual) => actual == expected,
658 Err(_) => false,
659 }
660}
661
662#[cfg(unix)]
663fn set_executable(path: &Path) -> Result<()> {
664 use std::os::unix::fs::PermissionsExt;
665 let mut perms = std::fs::metadata(path)
666 .with_context(|| format!("stat {}", path.display()))?
667 .permissions();
668 perms.set_mode(0o755);
669 std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?;
670 Ok(())
671}
672
673#[cfg(not(unix))]
674fn set_executable(_path: &Path) -> Result<()> {
675 Ok(())
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use std::fs;
682 use tempfile::tempdir;
683
684 fn setup() -> (tempfile::TempDir, OpenCodeInstaller, InstallContext) {
685 let temp = tempdir().unwrap();
686 let home = temp.path().to_path_buf();
687 let installer = OpenCodeInstaller::with_home_root(home.clone());
688
689 let config_path = home.join("spool.toml");
690 fs::write(&config_path, "[vault]\nroot=\"/tmp\"\n").unwrap();
691 let binary_path = home.join("fake-spool-mcp");
692 fs::write(&binary_path, "#!/bin/sh\nexit 0\n").unwrap();
693
694 let ctx = InstallContext {
695 binary_path: Some(binary_path),
696 config_path,
697 dry_run: false,
698 force: false,
699 };
700 (temp, installer, ctx)
701 }
702
703 #[test]
704 fn detect_returns_false_when_no_opencode_dir() {
705 let temp = tempdir().unwrap();
706 let installer = OpenCodeInstaller::with_home_root(temp.path().to_path_buf());
707 assert!(!installer.detect().unwrap());
708 }
709
710 #[test]
711 fn detect_returns_true_when_opencode_dir_present() {
712 let temp = tempdir().unwrap();
713 fs::create_dir_all(temp.path().join(".opencode")).unwrap();
714 let installer = OpenCodeInstaller::with_home_root(temp.path().to_path_buf());
715 assert!(installer.detect().unwrap());
716 }
717
718 #[test]
719 fn install_creates_config_and_hooks() {
720 let (temp, installer, ctx) = setup();
721 let report = installer.install(&ctx).unwrap();
722 assert_eq!(report.status, InstallStatus::Installed);
723
724 let config_json = temp.path().join(".opencode").join("config.json");
726 assert!(config_json.exists());
727 let doc: Value = serde_json::from_str(&fs::read_to_string(&config_json).unwrap()).unwrap();
728 assert!(doc["mcpServers"]["spool"].is_object());
729
730 let hooks_json = temp.path().join(".opencode").join("hooks.json");
732 assert!(hooks_json.exists());
733 let hooks_doc: Value =
734 serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap();
735 assert!(hooks_doc["hooks"]["on_session_start"].is_array());
736 assert!(hooks_doc["hooks"]["on_session_end"].is_array());
737
738 let end_entry = &hooks_doc["hooks"]["on_session_end"][0];
740 assert_eq!(end_entry["timeout_ms"], HOOK_TIMEOUT_SESSION_END_MS);
741
742 for spec in opencode_hook_specs() {
744 let p = temp
745 .path()
746 .join(".opencode")
747 .join("hooks")
748 .join(spec.file_name);
749 assert!(p.exists(), "{} missing", p.display());
750 }
751 }
752
753 #[test]
754 fn install_dry_run_does_not_write() {
755 let (temp, installer, mut ctx) = setup();
756 ctx.dry_run = true;
757 let report = installer.install(&ctx).unwrap();
758 assert_eq!(report.status, InstallStatus::DryRun);
759 assert!(!report.planned_writes.is_empty());
760 assert!(!temp.path().join(".opencode").exists());
761 }
762
763 #[test]
764 fn install_unchanged_on_repeat() {
765 let (_temp, installer, ctx) = setup();
766 let _ = installer.install(&ctx).unwrap();
767 let second = installer.install(&ctx).unwrap();
768 assert_eq!(second.status, InstallStatus::Unchanged);
769 }
770
771 #[test]
772 fn uninstall_removes_entries() {
773 let (temp, installer, ctx) = setup();
774 let _ = installer.install(&ctx).unwrap();
775
776 let report = installer.uninstall(&ctx).unwrap();
777 assert_eq!(report.status, UninstallStatus::Removed);
778
779 for spec in opencode_hook_specs() {
781 let p = temp
782 .path()
783 .join(".opencode")
784 .join("hooks")
785 .join(spec.file_name);
786 assert!(!p.exists(), "{} should be removed", p.display());
787 }
788
789 let config_json = temp.path().join(".opencode").join("config.json");
791 let doc: Value = serde_json::from_str(&fs::read_to_string(&config_json).unwrap()).unwrap();
792 assert!(doc["mcpServers"].get("spool").is_none());
793 }
794
795 #[test]
796 fn uninstall_not_installed_when_clean() {
797 let (_temp, installer, ctx) = setup();
798 let report = installer.uninstall(&ctx).unwrap();
799 assert_eq!(report.status, UninstallStatus::NotInstalled);
800 }
801
802 #[test]
803 fn diagnose_reports_full_check_set_after_install() {
804 let (_temp, installer, ctx) = setup();
805 let _ = installer.install(&ctx).unwrap();
806 let report = installer.diagnose(&ctx).unwrap();
807 let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
808 for expected in [
809 "opencode_dir_exists",
810 "mcp_servers_spool_registered",
811 "spool_mcp_binary",
812 "spool_config_readable",
813 "opencode_hooks_registered",
814 "spool_hook_scripts",
815 ] {
816 assert!(names.contains(&expected), "missing check {}", expected);
817 }
818 }
819
820 #[cfg(unix)]
821 #[test]
822 fn install_makes_hook_scripts_executable() {
823 use std::os::unix::fs::PermissionsExt;
824 let (temp, installer, ctx) = setup();
825 let _ = installer.install(&ctx).unwrap();
826 let session = temp
827 .path()
828 .join(".opencode")
829 .join("hooks")
830 .join("spool-on_session_start.sh");
831 let perms = fs::metadata(&session).unwrap().permissions();
832 assert_eq!(perms.mode() & 0o777, 0o755);
833 }
834
835 #[test]
836 fn opencode_hook_entries_use_flat_format() {
837 let mut doc = json!({});
838 upsert_opencode_hook_entry(
839 &mut doc,
840 "on_session_start",
841 "/abs/spool-on_session_start.sh",
842 5000,
843 );
844 let entries = doc["hooks"]["on_session_start"].as_array().unwrap();
845 assert_eq!(entries.len(), 1);
846 assert_eq!(entries[0]["command"], "/abs/spool-on_session_start.sh");
847 assert_eq!(entries[0]["timeout_ms"], 5000);
848 }
849
850 #[test]
851 fn upsert_opencode_hook_unchanged_on_repeat() {
852 let mut doc = json!({});
853 upsert_opencode_hook_entry(&mut doc, "on_session_start", "/abs/hook.sh", 5000);
854 let outcome =
855 upsert_opencode_hook_entry(&mut doc, "on_session_start", "/abs/hook.sh", 5000);
856 assert_eq!(outcome, OpenCodeHookOutcome::Unchanged);
857 assert_eq!(
858 doc["hooks"]["on_session_start"].as_array().unwrap().len(),
859 1
860 );
861 }
862
863 #[test]
864 fn purge_opencode_hook_entries_removes_spool_only() {
865 let mut doc = json!({
866 "hooks": {
867 "on_session_start": [
868 {"command": "/other/tool", "timeout_ms": 3000},
869 {"command": "/abs/.opencode/hooks/spool-on_session_start.sh", "timeout_ms": 5000}
870 ],
871 "on_session_end": [
872 {"command": "/abs/spool-on_session_end.sh", "timeout_ms": 10000}
873 ]
874 }
875 });
876 let removed = purge_opencode_hook_entries(&mut doc, "spool-");
877 assert_eq!(removed, 2);
878 let entries = doc["hooks"]["on_session_start"].as_array().unwrap();
879 assert_eq!(entries.len(), 1);
880 assert_eq!(entries[0]["command"], "/other/tool");
881 assert!(doc["hooks"].get("on_session_end").is_none());
883 }
884}