1use crate::error::Error;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum InitFormat {
8 #[default]
10 Claude,
11 Generic,
13}
14
15pub const AGENTS_SNIPPET: &str = "\
19## Shell commands
20Prefix all shell commands with `oo`. Use `oo recall \"<query>\"` to search large outputs.\
21";
22
23pub 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
41pub 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
71pub 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
89pub 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
106pub 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 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 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
138pub 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 #[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 #[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 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 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 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 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 #[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 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 assert_eq!(find_root(dir.path()), dir.path());
305 }
306
307 #[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 #[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 let custom = r#"{"hooks":[],"custom":true}"#;
344 fs::write(&hooks_path, custom).unwrap();
345
346 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 let content = fs::read_to_string(dir.path().join(".claude").join("hooks.json")).unwrap();
364 assert_eq!(content, HOOKS_JSON);
365 }
366
367 #[test]
372 fn init_format_default_is_claude() {
373 assert_eq!(InitFormat::default(), InitFormat::Claude);
374 }
375
376 #[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 #[test]
425 fn run_generic_succeeds() {
426 run_generic().expect("run_generic must succeed without error");
428 }
429
430 #[test]
431 fn generic_setup_does_not_create_hooks_dir() {
432 let result = run_generic();
436 assert!(result.is_ok(), "run_generic must return Ok");
437 }
438}