Skip to main content

seshat_cli/
instructions.rs

1//! Agent instruction file management for `seshat init`.
2//!
3//! Writes and maintains Seshat usage instructions in AI agent config files
4//! (AGENTS.md, CLAUDE.md), installs the Seshat skill file, and registers
5//! Claude Code hooks — all idempotently using HTML comment markers.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use crate::error::CliError;
12
13// ---------------------------------------------------------------------------
14// Embedded source files (compiled into the binary at build time)
15// ---------------------------------------------------------------------------
16
17// Paths below resolve through symlinks under `crates/seshat-cli/embedded/`,
18// which point at the real files in the workspace root (`rules/`, `skills/`).
19// `cargo publish` follows the symlinks and packages the *contents*, so the
20// uploaded tarball is self-contained — no out-of-crate paths leak in.
21
22/// Compact instructions for AGENTS.md / CLAUDE.md.
23/// Contains idempotency markers `<!-- seshat:start -->` / `<!-- seshat:end -->`.
24pub const AGENTS_MD_CONTENT: &str = include_str!("../embedded/seshat.md");
25
26/// Full reference skill for on-demand loading by Claude Code / OpenCode.
27pub const SKILL_MD_CONTENT: &str = include_str!("../embedded/SKILL.md");
28
29/// Soft SessionStart hook — prints a reminder at session start (exit 0).
30pub const HOOK_SESSION_START: &str = include_str!("../embedded/hooks/seshat-session-start");
31
32/// Soft PreToolUse hook — one nudge per session before Grep/Glob/Read (exit 0).
33pub const HOOK_PRE_TOOL: &str = include_str!("../embedded/hooks/seshat-pre-tool");
34
35// ---------------------------------------------------------------------------
36// Marker constants
37// ---------------------------------------------------------------------------
38
39const MARKER_START: &str = "<!-- seshat:start -->";
40const MARKER_END: &str = "<!-- seshat:end -->";
41
42// ---------------------------------------------------------------------------
43// Public result types
44// ---------------------------------------------------------------------------
45
46/// Outcome of an `upsert_instructions` call.
47///
48/// `DryRun(Some(path))` contains the path that would have been written.
49/// The path is the same `Path` passed as `path` — it may be absolute or relative.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum UpsertResult {
52    /// File did not exist — created with seshat section.
53    Created,
54    /// File existed, no markers found — section appended.
55    Appended,
56    /// File existed, markers found — section replaced.
57    Updated,
58    /// `dry_run = true` — no file was written.
59    /// Contains the path that would have been written.
60    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/// Outcome of `install_skill`.
76///
77/// `DryRun(Some(path))` contains the path of the SKILL.md that would have been written.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum SkillResult {
80    /// Skill file written (created or overwritten).
81    Installed,
82    /// `dry_run = true` — no file was written.
83    /// Contains the path that would have been written.
84    DryRun(Option<PathBuf>),
85}
86
87// ---------------------------------------------------------------------------
88// Core functions
89// ---------------------------------------------------------------------------
90
91/// Write or update the Seshat instruction section in an agent instruction file.
92///
93/// The section is wrapped with HTML comment markers:
94/// ```text
95/// <!-- seshat:start -->
96/// …content…
97/// <!-- seshat:end -->
98/// ```
99///
100/// Algorithm:
101/// 1. File absent → create with the seshat section.
102/// 2. File present, no markers → append the section.
103/// 3. File present, markers found → replace content between markers.
104///
105/// `content` is the raw text to embed (should NOT include the markers themselves —
106/// they are added by this function). Pass [`AGENTS_MD_CONTENT`] for standard use.
107pub 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        // Case 1: file does not exist — create it.
120        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, &section).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        // Case 3: markers present — replace between them (inclusive).
140        // Guard: MARKER_END must follow MARKER_START; if absent the file is
141        // corrupted (e.g. interrupted write). Fail explicitly instead of
142        // silently truncating the suffix.
143        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        // Verify ordering: end marker must come after start marker.
156        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        // Consume a trailing newline if present after the end marker.
170        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        // Preserve leading newline before marker if the file doesn't start with it.
177        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        // Case 2: no markers — append section.
188        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
202/// Install the Seshat skill file into an agent's skills directory.
203///
204/// `target_dir` should be e.g. `~/.claude/skills/seshat/` or
205/// `~/.config/opencode/skills/seshat/`. The function creates the directory if
206/// absent and always overwrites `SKILL.md` (versioned via binary release).
207pub 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/// Outcome of `install_hooks_claude_code`.
232///
233/// `Installed(None)` means settings.json was newly created (no backup needed).
234/// `Installed(Some(path))` means settings.json existed and was backed up to `path`.
235/// `DryRun` contains the paths that would have been created/modified.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum HooksResult {
238    /// Hooks installed and registered.
239    /// Contains the backup path if settings.json was updated.
240    Installed(Option<PathBuf>),
241    /// `dry_run = true` — no files were written.
242    /// Contains the paths that would have been created/modified.
243    DryRun {
244        /// Directory where hook scripts would be written.
245        hooks_dir: PathBuf,
246        /// Path for the session-start hook script.
247        session_start: PathBuf,
248        /// Path for the pre-tool hook script.
249        pre_tool: PathBuf,
250        /// Path for the settings.json file.
251        settings: PathBuf,
252    },
253}
254
255/// Install Seshat hooks for Claude Code and register them in `settings.json`.
256///
257/// Writes two executable scripts to `hooks_dir`:
258/// - `seshat-session-start` — soft SessionStart reminder
259/// - `seshat-pre-tool` — soft PreToolUse nudge (1 per session)
260///
261/// Registers both in `settings_path` (typically `~/.claude/settings.json`)
262/// under the `"hooks"` key. Idempotent: skips entries already present.
263pub 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    // Write hook scripts.
283    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    // Register in settings.json.
290    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
298// ---------------------------------------------------------------------------
299// Private helpers
300// ---------------------------------------------------------------------------
301
302/// Write `content` to `path` and set executable permissions (Unix only).
303fn 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
323/// Idempotently register Seshat hooks in `~/.claude/settings.json`.
324///
325/// Merges into the existing `"hooks"` object without touching other entries.
326/// Uses the hook command path as the idempotency key.
327///
328/// Returns `Some(PathBuf)` with the backup path if settings.json existed and was written,
329/// `None` if the file was new (no backup needed).
330fn register_claude_hooks(
331    settings_path: &Path,
332    session_start_cmd: &str,
333    pre_tool_cmd: &str,
334) -> Result<Option<PathBuf>, CliError> {
335    // Read existing settings (or start with empty object).
336    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    // Fail explicitly if the file exists but is not valid JSON — we must not
346    // silently overwrite user settings.
347    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    // Ensure root is an object; if it isn't (e.g. bare array/string), fail.
358    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    // Work directly on root to avoid clone-and-reinsert losing unknown keys.
369    // Ensure root["hooks"] is an object.
370    {
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    // --- PreToolUse ---
382    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            // Ensure the key exists even if we didn't push.
398            root["hooks"]
399                .as_object_mut()
400                .unwrap()
401                .entry("PreToolUse")
402                .or_insert_with(|| serde_json::json!([]));
403        }
404    }
405
406    // --- SessionStart ---
407    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    // Write back with backup.
432    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
457/// Write a timestamped backup of settings.json for hook installation.
458///
459/// Uses PID + timestamp to avoid collisions across processes. Reads content
460/// via `fs::read` + `fs::write` (not `fs::copy`) to avoid following symlinks.
461pub 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
482/// Check if any hook entry in `arr` already contains `cmd` as a command value.
483fn 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
496/// Resolve the Claude home directory (`~/.claude`).
497pub fn claude_home() -> Option<PathBuf> {
498    dirs::home_dir().map(|h| h.join(".claude"))
499}
500
501/// Resolve the OpenCode global config directory.
502///
503/// OpenCode follows XDG conventions on all platforms: it reads
504/// `$XDG_CONFIG_HOME/opencode` when the env var is set, and falls back to
505/// `~/.config/opencode` otherwise — including on macOS where
506/// `dirs::config_dir()` would incorrectly return `~/Library/Application Support/`.
507pub fn opencode_config_dir() -> Option<PathBuf> {
508    // Respect $XDG_CONFIG_HOME if set and non-empty.
509    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    // Default XDG fallback: ~/.config/opencode
515    dirs::home_dir().map(|h| h.join(".config").join("opencode"))
516}
517
518// ---------------------------------------------------------------------------
519// Tests
520// ---------------------------------------------------------------------------
521
522#[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    // ── upsert_instructions ──────────────────────────────────────────────
532
533    #[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        // Only one start marker should be present
597        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    // ── install_skill ────────────────────────────────────────────────────
612
613    #[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    // ── install_hooks_claude_code ────────────────────────────────────────
650
651    #[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        // Pre-populate with an unrelated hook
743        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        // Both original and seshat entries must be present
755        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        // Verify dry-run result contains paths
780        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    // ── hook_command_exists ──────────────────────────────────────────────
801
802    #[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    // ── P2: unpaired markers ─────────────────────────────────────────────
821
822    #[test]
823    fn upsert_errors_on_start_without_end_marker() {
824        let dir = tmp();
825        let path = dir.path().join("AGENTS.md");
826        // File with only the start marker — no end marker.
827        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        // Inverted marker order.
847        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    // ── P3: malformed settings.json ──────────────────────────────────────
863
864    #[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        // Write invalid JSON (trailing comma).
871        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        // Pre-populate with unrelated top-level keys AND a hook from another tool.
889        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        // Non-hook top-level keys must survive.
907        assert_eq!(parsed["theme"], "dark", "theme key preserved");
908        assert_eq!(parsed["fontSize"], 14, "fontSize key preserved");
909
910        // Pre-existing hook event must survive.
911        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        // Seshat hooks must be present.
921        assert!(parsed["hooks"]["PreToolUse"].is_array(), "PreToolUse added");
922        assert!(
923            parsed["hooks"]["SessionStart"].is_array(),
924            "SessionStart added"
925        );
926    }
927
928    // ── UpsertResult::description ───────────────────────────────────────
929
930    #[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    // ── write_backup_for_settings ───────────────────────────────────────
959
960    #[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    // ── upsert Appended with existing trailing newline ───────────────────
973
974    #[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    // ── upsert Appended without trailing newline ─────────────────────────
987
988    #[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        // Should have double newline between header and section
997        assert!(content.contains("# Header\n\n"));
998    }
999
1000    // ── claude_home / opencode_config_dir ───────────────────────────
1001
1002    #[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            // SAFETY: process-global env mutation; only safe in single-threaded
1015            // tests. We restore the original value before drop so subsequent
1016            // tests see the same environment.
1017            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        // SAFETY: same single-threaded test scope; restored on guard drop.
1033        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    // ── hook_command_exists edge cases ──────────────────────────────
1057
1058    #[test]
1059    fn hook_command_exists_handles_entry_without_hooks_array() {
1060        // Entry shape may be missing the inner "hooks" array.
1061        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        // Substring match must NOT trigger.
1080        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}