Skip to main content

double_o/
init.rs

1use crate::error::Error;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5/// Output format for `oo init`.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum InitFormat {
8    /// Claude Code hooks.json generation (default).
9    #[default]
10    Claude,
11    /// Generic stdout-only output for non-Claude-Code agent frameworks.
12    Generic,
13}
14
15/// The AGENTS.md snippet printed to stdout on `oo init`.
16///
17/// Kept as a constant so unit tests can assert against the exact string.
18pub const AGENTS_SNIPPET: &str = "\
19## Shell commands
20Prefix all shell commands with `oo`. Use `oo recall \"<query>\"` to search large outputs.\
21";
22
23/// The generic setup section printed on `oo init --format generic`.
24///
25/// Instructs agents to use `oo` for shell commands without creating any files.
26pub const GENERIC_SETUP: &str = "\
27## Setup
28
29Add to your agent's configuration or AGENTS.md:
30
31### Shell Commands
32Prefix all shell commands with `oo`. Use `oo recall \"query\"` to search indexed output.
33Use `oo help <cmd>` for concise command reference.
34Use `oo learn <cmd>` to teach oo new output patterns.
35
36### Shell Alias (optional)
37Add to your shell profile:
38  alias o='oo'\
39";
40
41/// Minimal Claude Code PreToolUse hooks configuration.
42///
43/// Intercepts `--help`/`-h` Bash calls so agents use `oo help <cmd>` instead.
44///
45/// Schema: `hooks` is an object keyed by event name; each event maps to an array
46/// of hook configs with `matcher` (string tool name) and `hooks` (array of commands).
47/// `exit 2` in a Claude Code hook blocks the tool call and shows the message to Claude.
48/// Content filtering (`--help`/`-h`) is done inside the command script, not via a schema
49/// field, because Claude Code has no `command_pattern` key in this schema version.
50///
51/// Claude Code sends hook input as JSON on stdin (not via env vars). The script reads
52/// stdin with `cat`, extracts `.tool_input.command` with `jq`, checks for help flags,
53/// then either blocks (exit 2, message to stderr) or passes through (echo input, exit 0).
54pub const HOOKS_JSON: &str = r#"{
55  "hooks": {
56    "PreToolUse": [
57      {
58        "matcher": "Bash",
59        "hooks": [
60          {
61            "type": "command",
62            "command": "input=$(cat); cmd=$(echo \"$input\" | jq -r '.tool_input.command // \"\"' 2>/dev/null); if echo \"$cmd\" | grep -qE '\\-\\-help| -h$| -h '; then echo 'Use: oo help <cmd> for a token-efficient command reference' >&2; exit 2; fi; echo \"$input\""
63          }
64        ]
65      }
66    ]
67  }
68}
69"#;
70
71/// Resolve the directory in which to create `.claude/`.
72///
73/// Walks upward from `cwd` looking for a `.git` directory — this is the git
74/// root and the natural home for agent configuration.  Falls back to `cwd`
75/// when no git repo is found, so the command works outside repos too.
76pub fn find_root(cwd: &Path) -> PathBuf {
77    let mut dir = cwd.to_path_buf();
78    loop {
79        if dir.join(".git").exists() {
80            return dir;
81        }
82        match dir.parent() {
83            Some(parent) => dir = parent.to_path_buf(),
84            None => return cwd.to_path_buf(),
85        }
86    }
87}
88
89/// Run `oo init` with the given format.
90///
91/// - `InitFormat::Claude` (default): create `.claude/hooks.json` and print the AGENTS.md snippet.
92/// - `InitFormat::Generic`: print AGENTS.md snippet + setup instructions to stdout only (no files).
93///
94/// Uses the current working directory as the starting point for git-root detection.
95pub fn run(init_format: InitFormat) -> Result<(), Error> {
96    match init_format {
97        InitFormat::Claude => {
98            let cwd = std::env::current_dir()
99                .map_err(|e| Error::Init(format!("cannot determine working directory: {e}")))?;
100            run_in(&cwd)
101        }
102        InitFormat::Generic => run_generic(),
103    }
104}
105
106/// Claude Code variant: create `.claude/hooks.json` and print the AGENTS.md snippet.
107///
108/// Idempotent — if `hooks.json` already exists it warns and skips the write.
109pub fn run_in(cwd: &Path) -> Result<(), Error> {
110    let root = find_root(cwd);
111    let claude_dir = root.join(".claude");
112    let hooks_path = claude_dir.join("hooks.json");
113
114    // create_dir_all is idempotent — no TOCTOU guard needed.
115    fs::create_dir_all(&claude_dir)
116        .map_err(|e| Error::Init(format!("cannot create {}: {e}", claude_dir.display())))?;
117
118    if hooks_path.exists() {
119        // Warn but do NOT overwrite — caller's config is authoritative.
120        eprintln!(
121            "oo init: {} already exists — skipping (delete it to regenerate)",
122            hooks_path.display()
123        );
124    } else {
125        fs::write(&hooks_path, HOOKS_JSON)
126            .map_err(|e| Error::Init(format!("cannot write {}: {e}", hooks_path.display())))?;
127        println!("Created {}", hooks_path.display());
128    }
129
130    println!();
131    println!("Add this to your AGENTS.md:");
132    println!();
133    println!("{AGENTS_SNIPPET}");
134
135    Ok(())
136}
137
138/// Generic variant: print AGENTS.md snippet + setup instructions to stdout.
139///
140/// Does NOT create any files — suitable for non-Claude-Code agent frameworks.
141pub fn run_generic() -> Result<(), Error> {
142    println!("{AGENTS_SNIPPET}");
143    println!();
144    println!("{GENERIC_SETUP}");
145
146    Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use tempfile::TempDir;
153
154    // -----------------------------------------------------------------------
155    // AGENTS_SNIPPET content
156    // -----------------------------------------------------------------------
157
158    #[test]
159    fn snippet_contains_oo_prefix_instruction() {
160        assert!(
161            AGENTS_SNIPPET.contains("Prefix all shell commands with `oo`"),
162            "snippet must instruct agents to prefix commands with oo"
163        );
164    }
165
166    #[test]
167    fn snippet_contains_recall_instruction() {
168        assert!(
169            AGENTS_SNIPPET.contains("oo recall"),
170            "snippet must mention oo recall for large outputs"
171        );
172    }
173
174    #[test]
175    fn snippet_has_shell_commands_heading() {
176        assert!(
177            AGENTS_SNIPPET.starts_with("## Shell commands"),
178            "snippet must start with ## Shell commands heading"
179        );
180    }
181
182    // -----------------------------------------------------------------------
183    // HOOKS_JSON validity
184    // -----------------------------------------------------------------------
185
186    #[test]
187    fn hooks_json_is_valid_json() {
188        let parsed: serde_json::Value =
189            serde_json::from_str(HOOKS_JSON).expect("HOOKS_JSON must be valid JSON");
190        assert!(
191            parsed.get("hooks").is_some(),
192            "hooks.json must have a top-level 'hooks' key"
193        );
194    }
195
196    #[test]
197    fn hooks_json_has_pretooluse_event() {
198        // Schema: hooks is an object keyed by event name.
199        let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
200        let pre_tool_use = parsed["hooks"].get("PreToolUse");
201        assert!(
202            pre_tool_use.is_some(),
203            "hooks object must have a PreToolUse key"
204        );
205        assert!(
206            pre_tool_use.unwrap().as_array().is_some(),
207            "PreToolUse must be an array of hook configs"
208        );
209    }
210
211    #[test]
212    fn hooks_json_references_bash_tool() {
213        // matcher is a string tool name (not an object) in the current Claude Code schema.
214        let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
215        let configs = parsed["hooks"]["PreToolUse"].as_array().unwrap();
216        let has_bash = configs
217            .iter()
218            .any(|c| c.get("matcher").and_then(|m| m.as_str()) == Some("Bash"));
219        assert!(has_bash, "at least one PreToolUse config must target Bash");
220    }
221
222    #[test]
223    fn hooks_json_hook_command_mentions_oo_help() {
224        // Each config has a "hooks" array (plural) of command objects.
225        let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
226        let configs = parsed["hooks"]["PreToolUse"].as_array().unwrap();
227        let mentions_oo_help = configs.iter().any(|c| {
228            c.get("hooks")
229                .and_then(|hs| hs.as_array())
230                .is_some_and(|hs| {
231                    hs.iter().any(|h| {
232                        h.get("command")
233                            .and_then(|cmd| cmd.as_str())
234                            .is_some_and(|s| s.contains("oo help"))
235                    })
236                })
237        });
238        assert!(
239            mentions_oo_help,
240            "a hook command must mention 'oo help' so agents know the alternative"
241        );
242    }
243
244    #[test]
245    fn hooks_json_command_reads_stdin_not_env_var() {
246        // Claude Code sends hook input as JSON on stdin, not via $TOOL_INPUT env var.
247        // This test verifies the command uses the correct contract:
248        //   - `cat` to read stdin
249        //   - `jq` to parse JSON
250        //   - `.tool_input.command` to extract the right field
251        //   - `echo "$input"` to pass through on the allow path (exit 0)
252        let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
253        let configs = parsed["hooks"]["PreToolUse"].as_array().unwrap();
254        let command_str = configs
255            .iter()
256            .find_map(|c| {
257                c.get("hooks")
258                    .and_then(|hs| hs.as_array())
259                    .and_then(|hs| hs.first())
260                    .and_then(|h| h.get("command"))
261                    .and_then(|cmd| cmd.as_str())
262            })
263            .expect("must have at least one hook command");
264
265        assert!(
266            command_str.contains("cat"),
267            "hook must read stdin with `cat`, not rely on env vars"
268        );
269        assert!(command_str.contains("jq"), "hook must parse JSON with `jq`");
270        assert!(
271            command_str.contains("tool_input.command"),
272            "hook must extract `.tool_input.command` — the field Claude Code sends"
273        );
274        assert!(
275            command_str.contains("echo \"$input\""),
276            "hook must echo original stdin JSON on the allow path (exit 0)"
277        );
278        assert!(
279            !command_str.contains("$TOOL_INPUT"),
280            "hook must NOT use $TOOL_INPUT env var — Claude Code does not set it"
281        );
282    }
283
284    // -----------------------------------------------------------------------
285    // find_root
286    // -----------------------------------------------------------------------
287
288    #[test]
289    fn find_root_returns_git_root() {
290        let dir = TempDir::new().unwrap();
291        let git_dir = dir.path().join(".git");
292        fs::create_dir_all(&git_dir).unwrap();
293        let sub = dir.path().join("sub");
294        fs::create_dir_all(&sub).unwrap();
295
296        // find_root from subdirectory should resolve to the git root.
297        assert_eq!(find_root(&sub), dir.path());
298    }
299
300    #[test]
301    fn find_root_falls_back_to_cwd_when_no_git() {
302        let dir = TempDir::new().unwrap();
303        // No .git → cwd is returned as-is.
304        assert_eq!(find_root(dir.path()), dir.path());
305    }
306
307    // -----------------------------------------------------------------------
308    // run_in — happy path
309    // -----------------------------------------------------------------------
310
311    #[test]
312    fn run_in_creates_claude_dir_and_hooks_json() {
313        let dir = TempDir::new().unwrap();
314        run_in(dir.path()).expect("run_in must succeed in empty dir");
315
316        let hooks_path = dir.path().join(".claude").join("hooks.json");
317        assert!(hooks_path.exists(), ".claude/hooks.json must be created");
318    }
319
320    #[test]
321    fn run_in_writes_valid_json_to_hooks_file() {
322        let dir = TempDir::new().unwrap();
323        run_in(dir.path()).unwrap();
324
325        let content = fs::read_to_string(dir.path().join(".claude").join("hooks.json")).unwrap();
326        let parsed: serde_json::Value =
327            serde_json::from_str(&content).expect("written hooks.json must be valid JSON");
328        assert!(parsed.get("hooks").is_some());
329    }
330
331    // -----------------------------------------------------------------------
332    // run_in — idempotency
333    // -----------------------------------------------------------------------
334
335    #[test]
336    fn run_in_does_not_overwrite_existing_hooks_json() {
337        let dir = TempDir::new().unwrap();
338        let claude_dir = dir.path().join(".claude");
339        fs::create_dir_all(&claude_dir).unwrap();
340        let hooks_path = claude_dir.join("hooks.json");
341
342        // Pre-existing content written by a human.
343        let custom = r#"{"hooks":[],"custom":true}"#;
344        fs::write(&hooks_path, custom).unwrap();
345
346        // run_in must leave the file untouched.
347        run_in(dir.path()).unwrap();
348
349        let after = fs::read_to_string(&hooks_path).unwrap();
350        assert_eq!(
351            after, custom,
352            "pre-existing hooks.json must not be overwritten"
353        );
354    }
355
356    #[test]
357    fn run_in_is_idempotent_twice() {
358        let dir = TempDir::new().unwrap();
359        run_in(dir.path()).expect("first run must succeed");
360        run_in(dir.path()).expect("second run must also succeed without error");
361
362        // Content should be the canonical HOOKS_JSON from the first run.
363        let content = fs::read_to_string(dir.path().join(".claude").join("hooks.json")).unwrap();
364        assert_eq!(content, HOOKS_JSON);
365    }
366
367    // -----------------------------------------------------------------------
368    // InitFormat
369    // -----------------------------------------------------------------------
370
371    #[test]
372    fn init_format_default_is_claude() {
373        assert_eq!(InitFormat::default(), InitFormat::Claude);
374    }
375
376    // -----------------------------------------------------------------------
377    // GENERIC_SETUP content
378    // -----------------------------------------------------------------------
379
380    #[test]
381    fn generic_setup_contains_setup_heading() {
382        assert!(
383            GENERIC_SETUP.contains("## Setup"),
384            "generic setup must contain ## Setup heading"
385        );
386    }
387
388    #[test]
389    fn generic_setup_contains_oo_recall() {
390        assert!(
391            GENERIC_SETUP.contains("oo recall"),
392            "generic setup must mention oo recall"
393        );
394    }
395
396    #[test]
397    fn generic_setup_contains_oo_help() {
398        assert!(
399            GENERIC_SETUP.contains("oo help"),
400            "generic setup must mention oo help"
401        );
402    }
403
404    #[test]
405    fn generic_setup_contains_oo_learn() {
406        assert!(
407            GENERIC_SETUP.contains("oo learn"),
408            "generic setup must mention oo learn"
409        );
410    }
411
412    #[test]
413    fn generic_setup_contains_alias() {
414        assert!(
415            GENERIC_SETUP.contains("alias o='oo'"),
416            "generic setup must contain shell alias suggestion"
417        );
418    }
419
420    // -----------------------------------------------------------------------
421    // run_generic — does not touch the filesystem
422    // -----------------------------------------------------------------------
423
424    #[test]
425    fn run_generic_succeeds() {
426        // run_generic writes only to stdout — no file I/O, so it always succeeds.
427        run_generic().expect("run_generic must succeed without error");
428    }
429
430    #[test]
431    fn generic_setup_does_not_create_hooks_dir() {
432        // run_generic must NOT create any directories. Verify the function itself
433        // does no file I/O by checking it returns Ok without panicking.
434        // Integration tests cover the full CLI contract (no .claude dir created).
435        let result = run_generic();
436        assert!(result.is_ok(), "run_generic must return Ok");
437    }
438}