use crate::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InitFormat {
#[default]
Claude,
Generic,
}
pub const AGENTS_SNIPPET: &str = "\
## Shell commands
Prefix all shell commands with `oo`. Use `oo recall \"<query>\"` to search large outputs.\
";
pub const GENERIC_SETUP: &str = "\
## Setup
Add to your agent's configuration or AGENTS.md:
### Shell Commands
Prefix all shell commands with `oo`. Use `oo recall \"query\"` to search indexed output.
Use `oo help <cmd>` for concise command reference.
Use `oo learn <cmd>` to teach oo new output patterns.
### Shell Alias (optional)
Add to your shell profile:
alias o='oo'\
";
pub const HOOKS_JSON: &str = r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"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\""
}
]
}
]
}
}
"#;
pub fn project_patterns_dir(cwd: &Path) -> PathBuf {
find_root(cwd).join(".oo").join("patterns")
}
pub fn find_root(cwd: &Path) -> PathBuf {
let mut dir = cwd.to_path_buf();
loop {
if dir.join(".git").exists() {
return dir;
}
match dir.parent() {
Some(parent) => dir = parent.to_path_buf(),
None => return cwd.to_path_buf(),
}
}
}
pub fn run(init_format: InitFormat) -> Result<(), Error> {
match init_format {
InitFormat::Claude => {
let cwd = std::env::current_dir()
.map_err(|e| Error::Init(format!("cannot determine working directory: {e}")))?;
run_in(&cwd)
}
InitFormat::Generic => run_generic(),
}
}
pub fn run_in(cwd: &Path) -> Result<(), Error> {
let root = find_root(cwd);
let claude_dir = root.join(".claude");
let hooks_path = claude_dir.join("hooks.json");
fs::create_dir_all(&claude_dir)
.map_err(|e| Error::Init(format!("cannot create {}: {e}", claude_dir.display())))?;
if hooks_path.exists() {
eprintln!(
"oo init: {} already exists — skipping (delete it to regenerate)",
hooks_path.display()
);
} else {
fs::write(&hooks_path, HOOKS_JSON)
.map_err(|e| Error::Init(format!("cannot write {}: {e}", hooks_path.display())))?;
println!("Created {}", hooks_path.display());
}
println!();
println!("Add this to your AGENTS.md:");
println!();
println!("{AGENTS_SNIPPET}");
Ok(())
}
pub fn run_generic() -> Result<(), Error> {
println!("{AGENTS_SNIPPET}");
println!();
println!("{GENERIC_SETUP}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn snippet_contains_oo_prefix_instruction() {
assert!(
AGENTS_SNIPPET.contains("Prefix all shell commands with `oo`"),
"snippet must instruct agents to prefix commands with oo"
);
}
#[test]
fn snippet_contains_recall_instruction() {
assert!(
AGENTS_SNIPPET.contains("oo recall"),
"snippet must mention oo recall for large outputs"
);
}
#[test]
fn snippet_has_shell_commands_heading() {
assert!(
AGENTS_SNIPPET.starts_with("## Shell commands"),
"snippet must start with ## Shell commands heading"
);
}
#[test]
fn hooks_json_is_valid_json() {
let parsed: serde_json::Value =
serde_json::from_str(HOOKS_JSON).expect("HOOKS_JSON must be valid JSON");
assert!(
parsed.get("hooks").is_some(),
"hooks.json must have a top-level 'hooks' key"
);
}
#[test]
fn hooks_json_has_pretooluse_event() {
let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
let pre_tool_use = parsed["hooks"].get("PreToolUse");
assert!(
pre_tool_use.is_some(),
"hooks object must have a PreToolUse key"
);
assert!(
pre_tool_use.unwrap().as_array().is_some(),
"PreToolUse must be an array of hook configs"
);
}
#[test]
fn hooks_json_references_bash_tool() {
let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
let configs = parsed["hooks"]["PreToolUse"].as_array().unwrap();
let has_bash = configs
.iter()
.any(|c| c.get("matcher").and_then(|m| m.as_str()) == Some("Bash"));
assert!(has_bash, "at least one PreToolUse config must target Bash");
}
#[test]
fn hooks_json_hook_command_mentions_oo_help() {
let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
let configs = parsed["hooks"]["PreToolUse"].as_array().unwrap();
let mentions_oo_help = configs.iter().any(|c| {
c.get("hooks")
.and_then(|hs| hs.as_array())
.is_some_and(|hs| {
hs.iter().any(|h| {
h.get("command")
.and_then(|cmd| cmd.as_str())
.is_some_and(|s| s.contains("oo help"))
})
})
});
assert!(
mentions_oo_help,
"a hook command must mention 'oo help' so agents know the alternative"
);
}
#[test]
fn hooks_json_command_reads_stdin_not_env_var() {
let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
let configs = parsed["hooks"]["PreToolUse"].as_array().unwrap();
let command_str = configs
.iter()
.find_map(|c| {
c.get("hooks")
.and_then(|hs| hs.as_array())
.and_then(|hs| hs.first())
.and_then(|h| h.get("command"))
.and_then(|cmd| cmd.as_str())
})
.expect("must have at least one hook command");
assert!(
command_str.contains("cat"),
"hook must read stdin with `cat`, not rely on env vars"
);
assert!(command_str.contains("jq"), "hook must parse JSON with `jq`");
assert!(
command_str.contains("tool_input.command"),
"hook must extract `.tool_input.command` — the field Claude Code sends"
);
assert!(
command_str.contains("echo \"$input\""),
"hook must echo original stdin JSON on the allow path (exit 0)"
);
assert!(
!command_str.contains("$TOOL_INPUT"),
"hook must NOT use $TOOL_INPUT env var — Claude Code does not set it"
);
}
#[test]
fn find_root_returns_git_root() {
let dir = TempDir::new().unwrap();
let git_dir = dir.path().join(".git");
fs::create_dir_all(&git_dir).unwrap();
let sub = dir.path().join("sub");
fs::create_dir_all(&sub).unwrap();
assert_eq!(find_root(&sub), dir.path());
}
#[test]
fn find_root_falls_back_to_cwd_when_no_git() {
let dir = TempDir::new().unwrap();
assert_eq!(find_root(dir.path()), dir.path());
}
#[test]
fn project_patterns_dir_is_under_git_root() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
let sub = dir.path().join("a").join("b");
fs::create_dir_all(&sub).unwrap();
let result = project_patterns_dir(&sub);
assert_eq!(result, dir.path().join(".oo").join("patterns"));
}
#[test]
fn project_patterns_dir_no_git_uses_cwd() {
let dir = TempDir::new().unwrap();
let result = project_patterns_dir(dir.path());
assert_eq!(result, dir.path().join(".oo").join("patterns"));
}
#[test]
fn run_in_creates_claude_dir_and_hooks_json() {
let dir = TempDir::new().unwrap();
run_in(dir.path()).expect("run_in must succeed in empty dir");
let hooks_path = dir.path().join(".claude").join("hooks.json");
assert!(hooks_path.exists(), ".claude/hooks.json must be created");
}
#[test]
fn run_in_writes_valid_json_to_hooks_file() {
let dir = TempDir::new().unwrap();
run_in(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".claude").join("hooks.json")).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&content).expect("written hooks.json must be valid JSON");
assert!(parsed.get("hooks").is_some());
}
#[test]
fn run_in_does_not_overwrite_existing_hooks_json() {
let dir = TempDir::new().unwrap();
let claude_dir = dir.path().join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let hooks_path = claude_dir.join("hooks.json");
let custom = r#"{"hooks":[],"custom":true}"#;
fs::write(&hooks_path, custom).unwrap();
run_in(dir.path()).unwrap();
let after = fs::read_to_string(&hooks_path).unwrap();
assert_eq!(
after, custom,
"pre-existing hooks.json must not be overwritten"
);
}
#[test]
fn run_in_is_idempotent_twice() {
let dir = TempDir::new().unwrap();
run_in(dir.path()).expect("first run must succeed");
run_in(dir.path()).expect("second run must also succeed without error");
let content = fs::read_to_string(dir.path().join(".claude").join("hooks.json")).unwrap();
assert_eq!(content, HOOKS_JSON);
}
#[test]
fn init_format_default_is_claude() {
assert_eq!(InitFormat::default(), InitFormat::Claude);
}
#[test]
fn generic_setup_contains_setup_heading() {
assert!(
GENERIC_SETUP.contains("## Setup"),
"generic setup must contain ## Setup heading"
);
}
#[test]
fn generic_setup_contains_oo_recall() {
assert!(
GENERIC_SETUP.contains("oo recall"),
"generic setup must mention oo recall"
);
}
#[test]
fn generic_setup_contains_oo_help() {
assert!(
GENERIC_SETUP.contains("oo help"),
"generic setup must mention oo help"
);
}
#[test]
fn generic_setup_contains_oo_learn() {
assert!(
GENERIC_SETUP.contains("oo learn"),
"generic setup must mention oo learn"
);
}
#[test]
fn generic_setup_contains_alias() {
assert!(
GENERIC_SETUP.contains("alias o='oo'"),
"generic setup must contain shell alias suggestion"
);
}
#[test]
fn run_generic_succeeds() {
run_generic().expect("run_generic must succeed without error");
}
#[test]
fn generic_setup_does_not_create_hooks_dir() {
let result = run_generic();
assert!(result.is_ok(), "run_generic must return Ok");
}
}