1use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use crate::error::CliError;
12
13pub const AGENTS_MD_CONTENT: &str = include_str!("../embedded/seshat.md");
25
26pub const SKILL_MD_CONTENT: &str = include_str!("../embedded/SKILL.md");
28
29pub const HOOK_SESSION_START: &str = include_str!("../embedded/hooks/seshat-session-start");
31
32pub const HOOK_PRE_TOOL: &str = include_str!("../embedded/hooks/seshat-pre-tool");
34
35const MARKER_START: &str = "<!-- seshat:start -->";
40const MARKER_END: &str = "<!-- seshat:end -->";
41
42#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum UpsertResult {
52 Created,
54 Appended,
56 Updated,
58 DryRun(Option<PathBuf>),
61}
62
63impl UpsertResult {
64 pub fn description(&self) -> String {
65 match self {
66 Self::Created => "created".to_string(),
67 Self::Appended => "appended".to_string(),
68 Self::Updated => "updated".to_string(),
69 Self::DryRun(Some(path)) => format!("would have written to {}", path.display()),
70 Self::DryRun(None) => "dry-run (no changes written)".to_string(),
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum SkillResult {
80 Installed,
82 DryRun(Option<PathBuf>),
85}
86
87pub fn upsert_instructions(
108 path: &Path,
109 content: &str,
110 dry_run: bool,
111) -> Result<UpsertResult, CliError> {
112 if dry_run {
113 return Ok(UpsertResult::DryRun(Some(path.to_path_buf())));
114 }
115
116 let section = format!("{MARKER_START}\n{content}\n{MARKER_END}\n");
117
118 if !path.exists() {
119 if let Some(parent) = path.parent() {
121 fs::create_dir_all(parent).map_err(|e| CliError::IoWithPath {
122 message: format!("failed to create directory: {e}"),
123 path: parent.to_path_buf(),
124 })?;
125 }
126 fs::write(path, §ion).map_err(|e| CliError::IoWithPath {
127 message: format!("failed to create instruction file: {e}"),
128 path: path.to_path_buf(),
129 })?;
130 return Ok(UpsertResult::Created);
131 }
132
133 let existing = fs::read_to_string(path).map_err(|e| CliError::IoWithPath {
134 message: format!("failed to read instruction file: {e}"),
135 path: path.to_path_buf(),
136 })?;
137
138 if let Some(start_pos) = existing.find(MARKER_START) {
139 let end_marker_pos = existing
144 .find(MARKER_END)
145 .ok_or_else(|| CliError::CommandFailed {
146 command: "seshat init".to_owned(),
147 reason: format!(
148 "{} contains `<!-- seshat:start -->` but no matching \
149 `<!-- seshat:end -->`. \
150 Fix the file manually and retry.",
151 path.display()
152 ),
153 })?;
154
155 if end_marker_pos < start_pos {
157 return Err(CliError::CommandFailed {
158 command: "seshat init".to_owned(),
159 reason: format!(
160 "{} has `<!-- seshat:end -->` before `<!-- seshat:start -->`. \
161 Fix the file manually and retry.",
162 path.display()
163 ),
164 });
165 }
166
167 let end_pos = end_marker_pos + MARKER_END.len();
168
169 let end_pos = if existing.as_bytes().get(end_pos) == Some(&b'\n') {
171 end_pos + 1
172 } else {
173 end_pos
174 };
175
176 let prefix = &existing[..start_pos];
178 let suffix = &existing[end_pos..];
179 let new_content = format!("{prefix}{section}{suffix}");
180
181 fs::write(path, new_content).map_err(|e| CliError::IoWithPath {
182 message: format!("failed to update instruction file: {e}"),
183 path: path.to_path_buf(),
184 })?;
185 Ok(UpsertResult::Updated)
186 } else {
187 let separator = if existing.ends_with('\n') || existing.is_empty() {
189 "\n"
190 } else {
191 "\n\n"
192 };
193 let new_content = format!("{existing}{separator}{section}");
194 fs::write(path, new_content).map_err(|e| CliError::IoWithPath {
195 message: format!("failed to append to instruction file: {e}"),
196 path: path.to_path_buf(),
197 })?;
198 Ok(UpsertResult::Appended)
199 }
200}
201
202pub fn install_skill(
208 target_dir: &Path,
209 content: &str,
210 dry_run: bool,
211) -> Result<SkillResult, CliError> {
212 if dry_run {
213 let skill_path = target_dir.join("SKILL.md");
214 return Ok(SkillResult::DryRun(Some(skill_path)));
215 }
216
217 fs::create_dir_all(target_dir).map_err(|e| CliError::IoWithPath {
218 message: format!("failed to create skill directory: {e}"),
219 path: target_dir.to_path_buf(),
220 })?;
221
222 let skill_path = target_dir.join("SKILL.md");
223 fs::write(&skill_path, content).map_err(|e| CliError::IoWithPath {
224 message: format!("failed to write skill file: {e}"),
225 path: skill_path,
226 })?;
227
228 Ok(SkillResult::Installed)
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum HooksResult {
238 Installed(Option<PathBuf>),
241 DryRun {
244 hooks_dir: PathBuf,
246 session_start: PathBuf,
248 pre_tool: PathBuf,
250 settings: PathBuf,
252 },
253}
254
255pub fn install_hooks_claude_code(
264 hooks_dir: &Path,
265 settings_path: &Path,
266 dry_run: bool,
267) -> Result<HooksResult, CliError> {
268 if dry_run {
269 return Ok(HooksResult::DryRun {
270 hooks_dir: hooks_dir.to_path_buf(),
271 session_start: hooks_dir.join("seshat-session-start"),
272 pre_tool: hooks_dir.join("seshat-pre-tool"),
273 settings: settings_path.to_path_buf(),
274 });
275 }
276
277 fs::create_dir_all(hooks_dir).map_err(|e| CliError::IoWithPath {
278 message: format!("failed to create hooks directory: {e}"),
279 path: hooks_dir.to_path_buf(),
280 })?;
281
282 let session_start_path = hooks_dir.join("seshat-session-start");
284 let pre_tool_path = hooks_dir.join("seshat-pre-tool");
285
286 write_executable(&session_start_path, HOOK_SESSION_START)?;
287 write_executable(&pre_tool_path, HOOK_PRE_TOOL)?;
288
289 let session_start_cmd = session_start_path.to_string_lossy().to_string();
291 let pre_tool_cmd = pre_tool_path.to_string_lossy().to_string();
292
293 let backup_path = register_claude_hooks(settings_path, &session_start_cmd, &pre_tool_cmd)?;
294
295 Ok(HooksResult::Installed(backup_path))
296}
297
298fn write_executable(path: &Path, content: &str) -> Result<(), CliError> {
304 fs::write(path, content).map_err(|e| CliError::IoWithPath {
305 message: format!("failed to write hook script: {e}"),
306 path: path.to_path_buf(),
307 })?;
308
309 #[cfg(unix)]
310 {
311 use std::os::unix::fs::PermissionsExt;
312 fs::set_permissions(path, fs::Permissions::from_mode(0o755)).map_err(|e| {
313 CliError::IoWithPath {
314 message: format!("failed to set executable permission: {e}"),
315 path: path.to_path_buf(),
316 }
317 })?;
318 }
319
320 Ok(())
321}
322
323fn register_claude_hooks(
331 settings_path: &Path,
332 session_start_cmd: &str,
333 pre_tool_cmd: &str,
334) -> Result<Option<PathBuf>, CliError> {
335 let existing = if settings_path.exists() {
337 fs::read_to_string(settings_path).map_err(|e| CliError::IoWithPath {
338 message: format!("failed to read claude settings: {e}"),
339 path: settings_path.to_path_buf(),
340 })?
341 } else {
342 String::from("{}")
343 };
344
345 let mut root: serde_json::Value =
348 serde_json::from_str(&existing).map_err(|e| CliError::CommandFailed {
349 command: "seshat init".to_owned(),
350 reason: format!(
351 "settings.json at {} is not valid JSON: {e}. \
352 Fix or remove it and retry.",
353 settings_path.display()
354 ),
355 })?;
356
357 if !root.is_object() {
359 return Err(CliError::CommandFailed {
360 command: "seshat init".to_owned(),
361 reason: format!(
362 "settings.json at {} is not a JSON object.",
363 settings_path.display()
364 ),
365 });
366 }
367
368 {
371 let hooks_entry = root
372 .as_object_mut()
373 .unwrap()
374 .entry("hooks")
375 .or_insert_with(|| serde_json::json!({}));
376 if !hooks_entry.is_object() {
377 *hooks_entry = serde_json::json!({});
378 }
379 }
380
381 let pre_tool_hook = serde_json::json!({
383 "matcher": "Grep|Glob|Read|Search",
384 "hooks": [{"type": "command", "command": pre_tool_cmd}]
385 });
386
387 {
388 let pre_tool_arr = root["hooks"]["PreToolUse"]
389 .as_array()
390 .cloned()
391 .unwrap_or_default();
392 if !hook_command_exists(&pre_tool_arr, pre_tool_cmd) {
393 let mut arr = pre_tool_arr;
394 arr.push(pre_tool_hook);
395 root["hooks"]["PreToolUse"] = serde_json::Value::Array(arr);
396 } else {
397 root["hooks"]
399 .as_object_mut()
400 .unwrap()
401 .entry("PreToolUse")
402 .or_insert_with(|| serde_json::json!([]));
403 }
404 }
405
406 let session_matchers = ["startup", "resume", "clear", "compact"];
408 {
409 let session_arr = root["hooks"]["SessionStart"]
410 .as_array()
411 .cloned()
412 .unwrap_or_default();
413 if !hook_command_exists(&session_arr, session_start_cmd) {
414 let mut arr = session_arr;
415 for matcher in session_matchers {
416 arr.push(serde_json::json!({
417 "matcher": matcher,
418 "hooks": [{"type": "command", "command": session_start_cmd}]
419 }));
420 }
421 root["hooks"]["SessionStart"] = serde_json::Value::Array(arr);
422 } else {
423 root["hooks"]
424 .as_object_mut()
425 .unwrap()
426 .entry("SessionStart")
427 .or_insert_with(|| serde_json::json!([]));
428 }
429 }
430
431 let json_str = serde_json::to_string_pretty(&root).map_err(|e| CliError::CommandFailed {
433 command: "seshat init".to_owned(),
434 reason: format!("failed to serialize settings.json: {e}"),
435 })?;
436
437 let mut backup_path = None;
438 if settings_path.exists() {
439 backup_path = Some(write_backup_for_settings(settings_path)?);
440 }
441
442 if let Some(parent) = settings_path.parent() {
443 fs::create_dir_all(parent).map_err(|e| CliError::IoWithPath {
444 message: format!("failed to create .claude directory: {e}"),
445 path: parent.to_path_buf(),
446 })?;
447 }
448
449 fs::write(settings_path, json_str).map_err(|e| CliError::IoWithPath {
450 message: format!("failed to write claude settings: {e}"),
451 path: settings_path.to_path_buf(),
452 })?;
453
454 Ok(backup_path)
455}
456
457pub fn write_backup_for_settings(path: &Path) -> Result<PathBuf, CliError> {
462 use std::process::id;
463 let pid = id();
464 let ts = SystemTime::now()
465 .duration_since(UNIX_EPOCH)
466 .map(|d| d.as_secs())
467 .unwrap_or(0);
468 let filename = path.file_name().unwrap_or_default().to_string_lossy();
469 let backup_name = format!("{filename}.seshat-backup.{pid}.{ts}");
470 let backup_path = path.with_file_name(backup_name);
471 let content = fs::read(path).map_err(|e| CliError::IoWithPath {
472 message: format!("failed to read settings for backup: {e}"),
473 path: path.to_path_buf(),
474 })?;
475 fs::write(&backup_path, content).map_err(|e| CliError::IoWithPath {
476 message: format!("failed to write settings backup: {e}"),
477 path: backup_path.clone(),
478 })?;
479 Ok(backup_path)
480}
481
482fn hook_command_exists(arr: &[serde_json::Value], cmd: &str) -> bool {
484 for entry in arr {
485 if let Some(hooks) = entry.get("hooks").and_then(|h| h.as_array()) {
486 for hook in hooks {
487 if hook.get("command").and_then(|c| c.as_str()) == Some(cmd) {
488 return true;
489 }
490 }
491 }
492 }
493 false
494}
495
496pub fn claude_home() -> Option<PathBuf> {
498 dirs::home_dir().map(|h| h.join(".claude"))
499}
500
501pub fn opencode_config_dir() -> Option<PathBuf> {
508 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
510 if !xdg.is_empty() {
511 return Some(PathBuf::from(xdg).join("opencode"));
512 }
513 }
514 dirs::home_dir().map(|h| h.join(".config").join("opencode"))
516}
517
518#[cfg(test)]
523mod tests {
524 use super::*;
525 use tempfile::TempDir;
526
527 fn tmp() -> TempDir {
528 tempfile::tempdir().expect("create temp dir")
529 }
530
531 #[test]
534 fn upsert_creates_new_file_when_absent() {
535 let dir = tmp();
536 let path = dir.path().join("AGENTS.md");
537 let result = upsert_instructions(&path, "hello world", false).unwrap();
538 assert_eq!(result, UpsertResult::Created);
539 let content = fs::read_to_string(&path).unwrap();
540 assert!(content.contains(MARKER_START));
541 assert!(content.contains("hello world"));
542 assert!(content.contains(MARKER_END));
543 }
544
545 #[test]
546 fn upsert_creates_parent_directories() {
547 let dir = tmp();
548 let path = dir.path().join("nested").join("dir").join("AGENTS.md");
549 let result = upsert_instructions(&path, "nested", false).unwrap();
550 assert_eq!(result, UpsertResult::Created);
551 assert!(path.exists());
552 }
553
554 #[test]
555 fn upsert_appends_when_no_markers() {
556 let dir = tmp();
557 let path = dir.path().join("AGENTS.md");
558 fs::write(&path, "# Existing content\n").unwrap();
559
560 let result = upsert_instructions(&path, "new section", false).unwrap();
561 assert_eq!(result, UpsertResult::Appended);
562
563 let content = fs::read_to_string(&path).unwrap();
564 assert!(content.contains("# Existing content"));
565 assert!(content.contains(MARKER_START));
566 assert!(content.contains("new section"));
567 assert!(content.contains(MARKER_END));
568 }
569
570 #[test]
571 fn upsert_replaces_between_markers() {
572 let dir = tmp();
573 let path = dir.path().join("AGENTS.md");
574 let initial = format!("# Header\n{MARKER_START}\nold content\n{MARKER_END}\n# Footer\n");
575 fs::write(&path, &initial).unwrap();
576
577 let result = upsert_instructions(&path, "new content", false).unwrap();
578 assert_eq!(result, UpsertResult::Updated);
579
580 let content = fs::read_to_string(&path).unwrap();
581 assert!(content.contains("# Header"), "header preserved");
582 assert!(content.contains("# Footer"), "footer preserved");
583 assert!(content.contains("new content"), "new content written");
584 assert!(!content.contains("old content"), "old content removed");
585 }
586
587 #[test]
588 fn upsert_idempotent_on_second_run() {
589 let dir = tmp();
590 let path = dir.path().join("AGENTS.md");
591
592 upsert_instructions(&path, "section content", false).unwrap();
593 upsert_instructions(&path, "section content", false).unwrap();
594
595 let content = fs::read_to_string(&path).unwrap();
596 let count = content.matches(MARKER_START).count();
598 assert_eq!(count, 1, "exactly one seshat section after two upserts");
599 }
600
601 #[test]
602 fn upsert_dry_run_does_not_write() {
603 let dir = tmp();
604 let path = dir.path().join("AGENTS.md");
605
606 let result = upsert_instructions(&path, "content", true).unwrap();
607 assert!(matches!(result, UpsertResult::DryRun(Some(ref p)) if p == &path));
608 assert!(!path.exists(), "file must not be created in dry-run mode");
609 }
610
611 #[test]
614 fn install_skill_creates_dir_and_file() {
615 let dir = tmp();
616 let skill_dir = dir.path().join("skills").join("seshat");
617
618 let result = install_skill(&skill_dir, "skill content", false).unwrap();
619 assert_eq!(result, SkillResult::Installed);
620
621 let skill_path = skill_dir.join("SKILL.md");
622 assert!(skill_path.exists());
623 assert_eq!(fs::read_to_string(&skill_path).unwrap(), "skill content");
624 }
625
626 #[test]
627 fn install_skill_overwrites_existing() {
628 let dir = tmp();
629 let skill_dir = dir.path().join("skills").join("seshat");
630 fs::create_dir_all(&skill_dir).unwrap();
631 fs::write(skill_dir.join("SKILL.md"), "old content").unwrap();
632
633 install_skill(&skill_dir, "new content", false).unwrap();
634
635 let content = fs::read_to_string(skill_dir.join("SKILL.md")).unwrap();
636 assert_eq!(content, "new content");
637 }
638
639 #[test]
640 fn install_skill_dry_run_does_not_write() {
641 let dir = tmp();
642 let skill_dir = dir.path().join("skills").join("seshat");
643
644 let result = install_skill(&skill_dir, "content", true).unwrap();
645 assert!(matches!(result, SkillResult::DryRun(Some(ref p)) if p.ends_with("SKILL.md")));
646 assert!(!skill_dir.exists());
647 }
648
649 #[test]
652 fn install_hooks_creates_scripts() {
653 let dir = tmp();
654 let hooks_dir = dir.path().join("hooks");
655 let settings = dir.path().join("settings.json");
656
657 install_hooks_claude_code(&hooks_dir, &settings, false).unwrap();
658
659 assert!(hooks_dir.join("seshat-session-start").exists());
660 assert!(hooks_dir.join("seshat-pre-tool").exists());
661 }
662
663 #[cfg(unix)]
664 #[test]
665 fn install_hooks_scripts_are_executable() {
666 use std::os::unix::fs::PermissionsExt;
667 let dir = tmp();
668 let hooks_dir = dir.path().join("hooks");
669 let settings = dir.path().join("settings.json");
670
671 install_hooks_claude_code(&hooks_dir, &settings, false).unwrap();
672
673 let session_meta = fs::metadata(hooks_dir.join("seshat-session-start")).unwrap();
674 assert!(
675 session_meta.permissions().mode() & 0o111 != 0,
676 "must be executable"
677 );
678
679 let pre_tool_meta = fs::metadata(hooks_dir.join("seshat-pre-tool")).unwrap();
680 assert!(
681 pre_tool_meta.permissions().mode() & 0o111 != 0,
682 "must be executable"
683 );
684 }
685
686 #[test]
687 fn install_hooks_registers_in_settings_json() {
688 let dir = tmp();
689 let hooks_dir = dir.path().join("hooks");
690 let settings = dir.path().join("settings.json");
691
692 install_hooks_claude_code(&hooks_dir, &settings, false).unwrap();
693
694 let content = fs::read_to_string(&settings).unwrap();
695 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
696 let hooks = parsed.get("hooks").expect("hooks key");
697
698 assert!(hooks.get("PreToolUse").is_some(), "PreToolUse registered");
699 assert!(
700 hooks.get("SessionStart").is_some(),
701 "SessionStart registered"
702 );
703 }
704
705 #[test]
706 fn install_hooks_idempotent_on_second_run() {
707 let dir = tmp();
708 let hooks_dir = dir.path().join("hooks");
709 let settings = dir.path().join("settings.json");
710
711 install_hooks_claude_code(&hooks_dir, &settings, false).unwrap();
712 install_hooks_claude_code(&hooks_dir, &settings, false).unwrap();
713
714 let content = fs::read_to_string(&settings).unwrap();
715 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
716 let pre_tool = parsed["hooks"]["PreToolUse"].as_array().unwrap();
717 let seshat_entries: Vec<_> = pre_tool
718 .iter()
719 .filter(|e| {
720 e.get("hooks")
721 .and_then(|h| h.as_array())
722 .map(|h| {
723 h.iter().any(|hk| {
724 hk.get("command")
725 .and_then(|c| c.as_str())
726 .map(|c| c.contains("seshat-pre-tool"))
727 .unwrap_or(false)
728 })
729 })
730 .unwrap_or(false)
731 })
732 .collect();
733 assert_eq!(seshat_entries.len(), 1, "only one seshat pre-tool entry");
734 }
735
736 #[test]
737 fn install_hooks_merges_with_existing_settings() {
738 let dir = tmp();
739 let hooks_dir = dir.path().join("hooks");
740 let settings = dir.path().join("settings.json");
741
742 fs::write(
744 &settings,
745 r#"{"hooks":{"PreToolUse":[{"matcher":".*","hooks":[{"type":"command","command":"/usr/local/bin/other-hook"}]}]}}"#,
746 )
747 .unwrap();
748
749 install_hooks_claude_code(&hooks_dir, &settings, false).unwrap();
750
751 let content = fs::read_to_string(&settings).unwrap();
752 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
753 let pre_tool = parsed["hooks"]["PreToolUse"].as_array().unwrap();
754 assert!(pre_tool.len() >= 2, "existing hooks preserved");
756 assert!(
757 content.contains("other-hook"),
758 "original hook not overwritten"
759 );
760 assert!(content.contains("seshat-pre-tool"), "seshat hook added");
761 }
762
763 #[test]
764 fn install_hooks_dry_run_does_not_write() {
765 let dir = tmp();
766 let hooks_dir = dir.path().join("hooks");
767 let settings = dir.path().join("settings.json");
768
769 let result = install_hooks_claude_code(&hooks_dir, &settings, true).unwrap();
770 assert!(
771 !hooks_dir.exists(),
772 "hooks dir must not be created in dry-run"
773 );
774 assert!(
775 !settings.exists(),
776 "settings must not be written in dry-run"
777 );
778
779 if let HooksResult::DryRun {
781 hooks_dir: hd,
782 session_start,
783 pre_tool,
784 settings: sp,
785 } = result
786 {
787 assert!(hd.ends_with("hooks"));
788 assert!(
789 session_start
790 .to_string_lossy()
791 .contains("seshat-session-start")
792 );
793 assert!(pre_tool.to_string_lossy().contains("seshat-pre-tool"));
794 assert!(sp.to_string_lossy().ends_with("settings.json"));
795 } else {
796 panic!("expected DryRun variant");
797 }
798 }
799
800 #[test]
803 fn hook_command_exists_returns_true_when_found() {
804 let arr = vec![serde_json::json!({
805 "matcher": "startup",
806 "hooks": [{"type": "command", "command": "/path/to/seshat-session-start"}]
807 })];
808 assert!(hook_command_exists(&arr, "/path/to/seshat-session-start"));
809 }
810
811 #[test]
812 fn hook_command_exists_returns_false_when_absent() {
813 let arr = vec![serde_json::json!({
814 "matcher": "startup",
815 "hooks": [{"type": "command", "command": "/other/hook"}]
816 })];
817 assert!(!hook_command_exists(&arr, "/seshat-session-start"));
818 }
819
820 #[test]
823 fn upsert_errors_on_start_without_end_marker() {
824 let dir = tmp();
825 let path = dir.path().join("AGENTS.md");
826 fs::write(
828 &path,
829 format!("# Header\n{MARKER_START}\norphaned content\n"),
830 )
831 .unwrap();
832
833 let result = upsert_instructions(&path, "new content", false);
834 assert!(result.is_err(), "must fail with unpaired start marker");
835 let err_msg = result.unwrap_err().to_string();
836 assert!(
837 err_msg.contains("seshat:end"),
838 "error must mention missing end marker; got: {err_msg}"
839 );
840 }
841
842 #[test]
843 fn upsert_errors_on_end_before_start_marker() {
844 let dir = tmp();
845 let path = dir.path().join("AGENTS.md");
846 fs::write(
848 &path,
849 format!("# Header\n{MARKER_END}\nstuff\n{MARKER_START}\ncontent\n"),
850 )
851 .unwrap();
852
853 let result = upsert_instructions(&path, "new content", false);
854 assert!(result.is_err(), "must fail with inverted markers");
855 let err_msg = result.unwrap_err().to_string();
856 assert!(
857 err_msg.contains("seshat:end") || err_msg.contains("before"),
858 "error must describe ordering issue; got: {err_msg}"
859 );
860 }
861
862 #[test]
865 fn install_hooks_errors_on_invalid_json_settings() {
866 let dir = tmp();
867 let hooks_dir = dir.path().join("hooks");
868 let settings = dir.path().join("settings.json");
869
870 fs::write(&settings, r#"{"hooks": {"bad": true,}}"#).unwrap();
872
873 let result = install_hooks_claude_code(&hooks_dir, &settings, false);
874 assert!(result.is_err(), "must fail on malformed settings.json");
875 let err_msg = result.unwrap_err().to_string();
876 assert!(
877 err_msg.contains("not valid JSON") || err_msg.contains("JSON"),
878 "error must mention JSON; got: {err_msg}"
879 );
880 }
881
882 #[test]
883 fn install_hooks_preserves_existing_non_hook_settings_keys() {
884 let dir = tmp();
885 let hooks_dir = dir.path().join("hooks");
886 let settings = dir.path().join("settings.json");
887
888 fs::write(
890 &settings,
891 r#"{
892 "theme": "dark",
893 "fontSize": 14,
894 "hooks": {
895 "SomeOtherEvent": [{"matcher": ".*", "hooks": [{"type": "command", "command": "/other/tool"}]}]
896 }
897}"#,
898 )
899 .unwrap();
900
901 install_hooks_claude_code(&hooks_dir, &settings, false).unwrap();
902
903 let content = fs::read_to_string(&settings).unwrap();
904 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
905
906 assert_eq!(parsed["theme"], "dark", "theme key preserved");
908 assert_eq!(parsed["fontSize"], 14, "fontSize key preserved");
909
910 assert!(
912 parsed["hooks"]["SomeOtherEvent"].is_array(),
913 "SomeOtherEvent hook preserved"
914 );
915 assert!(
916 content.contains("/other/tool"),
917 "other tool hook command preserved"
918 );
919
920 assert!(parsed["hooks"]["PreToolUse"].is_array(), "PreToolUse added");
922 assert!(
923 parsed["hooks"]["SessionStart"].is_array(),
924 "SessionStart added"
925 );
926 }
927
928 #[test]
931 fn upsert_result_description_created() {
932 assert_eq!(UpsertResult::Created.description(), "created");
933 }
934
935 #[test]
936 fn upsert_result_description_appended() {
937 assert_eq!(UpsertResult::Appended.description(), "appended");
938 }
939
940 #[test]
941 fn upsert_result_description_updated() {
942 assert_eq!(UpsertResult::Updated.description(), "updated");
943 }
944
945 #[test]
946 fn upsert_result_description_dry_run_some() {
947 let desc = UpsertResult::DryRun(Some(PathBuf::from("/tmp/test.md"))).description();
948 assert!(desc.contains("/tmp/test.md"));
949 assert!(desc.contains("would have written"));
950 }
951
952 #[test]
953 fn upsert_result_description_dry_run_none() {
954 let desc = UpsertResult::DryRun(None).description();
955 assert!(desc.contains("dry-run"));
956 }
957
958 #[test]
961 fn write_backup_for_settings_creates_timestamped_file() {
962 let dir = tmp();
963 let path = dir.path().join("settings.json");
964 fs::write(&path, r#"{"key":"value"}"#).unwrap();
965 let backup = write_backup_for_settings(&path).unwrap();
966 let name = backup.file_name().unwrap().to_string_lossy();
967 assert!(name.starts_with("settings.json.seshat-backup."));
968 assert!(backup.exists());
969 assert_eq!(fs::read_to_string(&backup).unwrap(), r#"{"key":"value"}"#);
970 }
971
972 #[test]
975 fn upsert_appends_with_existing_trailing_newline() {
976 let dir = tmp();
977 let path = dir.path().join("AGENTS.md");
978 fs::write(&path, "# Header\n").unwrap();
979 let result = upsert_instructions(&path, "section", false).unwrap();
980 assert_eq!(result, UpsertResult::Appended);
981 let content = fs::read_to_string(&path).unwrap();
982 let marker_count = content.matches(MARKER_START).count();
983 assert_eq!(marker_count, 1);
984 }
985
986 #[test]
989 fn upsert_appends_without_trailing_newline() {
990 let dir = tmp();
991 let path = dir.path().join("AGENTS.md");
992 fs::write(&path, "# Header").unwrap();
993 let result = upsert_instructions(&path, "section", false).unwrap();
994 assert_eq!(result, UpsertResult::Appended);
995 let content = fs::read_to_string(&path).unwrap();
996 assert!(content.contains("# Header\n\n"));
998 }
999
1000 #[test]
1003 fn claude_home_ends_with_dot_claude() {
1004 let home = claude_home().expect("home should resolve");
1005 assert!(home.ends_with(".claude"));
1006 }
1007
1008 struct EnvGuard {
1009 key: &'static str,
1010 old: Option<std::ffi::OsString>,
1011 }
1012 impl Drop for EnvGuard {
1013 fn drop(&mut self) {
1014 unsafe {
1018 match &self.old {
1019 Some(v) => std::env::set_var(self.key, v),
1020 None => std::env::remove_var(self.key),
1021 }
1022 }
1023 }
1024 }
1025
1026 #[test]
1027 fn opencode_config_dir_respects_xdg_when_set() {
1028 let _g = EnvGuard {
1029 key: "XDG_CONFIG_HOME",
1030 old: std::env::var_os("XDG_CONFIG_HOME"),
1031 };
1032 unsafe {
1034 std::env::set_var("XDG_CONFIG_HOME", "/tmp/seshat-instr-test-xdg");
1035 }
1036 let dir = opencode_config_dir().expect("should resolve");
1037 assert!(dir.ends_with("opencode"));
1038 assert!(dir.starts_with("/tmp/seshat-instr-test-xdg"));
1039 }
1040
1041 #[test]
1042 fn opencode_config_dir_empty_xdg_falls_back_to_dot_config() {
1043 let _g = EnvGuard {
1044 key: "XDG_CONFIG_HOME",
1045 old: std::env::var_os("XDG_CONFIG_HOME"),
1046 };
1047 unsafe {
1048 std::env::set_var("XDG_CONFIG_HOME", "");
1049 }
1050 if let Some(dir) = opencode_config_dir() {
1051 assert!(dir.ends_with("opencode"));
1052 assert!(dir.to_string_lossy().contains(".config"));
1053 }
1054 }
1055
1056 #[test]
1059 fn hook_command_exists_handles_entry_without_hooks_array() {
1060 let arr = vec![serde_json::json!({}), serde_json::json!({"matcher": "x"})];
1062 assert!(!hook_command_exists(&arr, "/x/seshat-pre-tool"));
1063 }
1064
1065 #[test]
1066 fn hook_command_exists_handles_hooks_entry_without_command_field() {
1067 let arr = vec![serde_json::json!({
1068 "hooks": [{"name": "no-command-field"}]
1069 })];
1070 assert!(!hook_command_exists(&arr, "/x/seshat-pre-tool"));
1071 }
1072
1073 #[test]
1074 fn hook_command_exists_matches_exact_command() {
1075 let arr = vec![serde_json::json!({
1076 "hooks": [{"command": "/x/seshat-pre-tool"}]
1077 })];
1078 assert!(hook_command_exists(&arr, "/x/seshat-pre-tool"));
1079 assert!(!hook_command_exists(&arr, "/x/seshat"));
1081 }
1082
1083 #[test]
1084 fn hook_command_exists_empty_array_returns_false() {
1085 assert!(!hook_command_exists(&[], "/x/seshat-pre-tool"));
1086 }
1087
1088 #[test]
1089 fn hook_command_exists_with_multiple_hooks_per_entry() {
1090 let arr = vec![serde_json::json!({
1091 "hooks": [
1092 {"command": "/other/tool"},
1093 {"command": "/x/seshat-session-start"},
1094 ]
1095 })];
1096 assert!(hook_command_exists(&arr, "/x/seshat-session-start"));
1097 assert!(hook_command_exists(&arr, "/other/tool"));
1098 }
1099}