use std::path::Path;
use humansize::{BINARY, format_size};
use std::io::Write;
use crate::classify::Classification;
pub use crate::init::InitFormat;
use crate::store::SessionMeta;
use crate::util::now_epoch;
use crate::{
classify, commands_patterns, error::Error, exec, help, init, init_pi, learn, pattern,
pattern_load, rewrite, session, store,
};
pub enum Action {
Run(Vec<String>),
Recall { query: String, full: bool },
Forget,
Learn(Vec<String>, Option<String>),
Version,
Help(Option<String>),
Init(InitMode),
Patterns,
Rewrite(String),
Hook(Option<String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InitMode {
Format(InitFormat),
Pi { global: bool },
ClaudeCode { global: bool },
}
pub const SUPPORTED_AGENTS: &[&str] = &["pi", "claude-code"];
pub(crate) fn parse_init_mode(args: &[String]) -> Result<InitMode, String> {
let supported = SUPPORTED_AGENTS.join(", ");
let mut agent: Option<String> = None;
let mut global = false;
let mut has_format = false;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--agent" => {
let Some(value) = iter.next().filter(|v| !v.starts_with("--")) else {
return Err(format!(
"--agent requires a value; supported agents: {supported}"
));
};
agent = Some(value.clone());
}
"--global" => global = true,
"--format" => has_format = true,
_ => {}
}
}
let Some(agent) = agent else {
if global {
return Err("--global requires --agent".to_string());
}
if has_format {
return Ok(InitMode::Format(parse_init_format(args)));
}
return Ok(InitMode::Format(InitFormat::Claude));
};
if has_format {
return Err("--agent and --format cannot be used together".to_string());
}
if !SUPPORTED_AGENTS.contains(&agent.as_str()) {
return Err(format!(
"unknown --agent value '{agent}'; supported agents: {supported}"
));
}
match agent.as_str() {
"pi" => Ok(InitMode::Pi { global }),
"claude-code" => Ok(InitMode::ClaudeCode { global }),
_ => unreachable!("agent validated against SUPPORTED_AGENTS above"),
}
}
fn parse_init_format(args: &[String]) -> InitFormat {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if arg == "--format" {
return match iter.next().map(|s| s.as_str()) {
Some("generic") => InitFormat::Generic,
Some("claude") | None => InitFormat::Claude,
Some(other) => {
eprintln!(
"oo: unknown --format value '{}', defaulting to claude",
other
);
InitFormat::Claude
}
};
}
}
InitFormat::Claude
}
fn parse_recall_action(args: &[String]) -> Action {
let query: String = args
.iter()
.filter(|a| a.as_str() != "--full")
.cloned()
.collect::<Vec<_>>()
.join(" ");
let full = args.iter().any(|a| a.as_str() == "--full");
Action::Recall { query, full }
}
fn parse_learn_action(args: &[String]) -> Action {
let mut result: Vec<String> = Vec::new();
let mut hint: Option<String> = None;
let mut iter = args.iter().peekable();
while let Some(arg) = iter.next() {
if arg == "--hint" {
if let Some(hint_text) = iter.next() {
if !hint_text.starts_with('-') {
hint = Some(hint_text.clone());
} else {
result.push(hint_text.clone());
}
}
} else {
result.push(arg.clone());
}
}
Action::Learn(result, hint)
}
pub fn parse_action(args: &[String]) -> Action {
match args.first().map(|s| s.as_str()) {
None => Action::Help(None),
Some("recall") => parse_recall_action(&args[1..]),
Some("forget") => Action::Forget,
Some("learn") => parse_learn_action(&args[1..]),
Some("version") => Action::Version,
Some("help") => Action::Help(args.get(1).cloned()),
Some("init") => match parse_init_mode(&args[1..]) {
Ok(mode) => Action::Init(mode),
Err(e) => {
eprintln!("oo: {e}");
std::process::exit(1);
}
},
Some("patterns") => Action::Patterns,
Some("rewrite") => {
let command: String = args
.get(1..)
.map(|rest| rest.join(" "))
.filter(|s| !s.is_empty())
.unwrap_or_default();
Action::Rewrite(command)
}
Some("hook") => Action::Hook(args.get(1).cloned()),
_ => Action::Run(args.to_vec()),
}
}
pub fn run_command_args(args: &[String]) -> (i32, Option<Classification>) {
if args.is_empty() {
eprintln!("oo: no command specified");
return (1, None);
}
let all_patterns = pattern_load::all_patterns();
let output = match exec::run(args) {
Ok(o) => o,
Err(e) => {
eprintln!("oo: {e}");
return (1, None);
}
};
let exit_code = output.exit_code;
let command = args.join(" ");
let merged = output.merged_lossy();
let classification = classify::classify(&output, &command, &all_patterns);
render_classification(&classification, &command, merged.len());
(exit_code, Some(classification))
}
pub fn cmd_run(args: &[String]) -> i32 {
run_command_args(args).0
}
pub fn savings_suffix(original_bytes: usize, indicator_bytes: usize) -> Option<String> {
let saved = original_bytes.saturating_sub(indicator_bytes);
(saved > classify::MIN_SAVINGS).then(|| format!(" [saved {}]", format_size(saved, BINARY)))
}
pub fn render_classification(classification: &Classification, command: &str, original_size: usize) {
match classification {
Classification::Failure { label, output } => {
let line = format!("\u{2717} {label}");
println!(
"{line}{}\n",
savings_suffix(original_size, line.len()).unwrap_or_default()
);
println!("{output}");
}
Classification::Passthrough { output } => {
print!("{output}");
}
Classification::Success { label, summary } => {
let line = if summary.is_empty() {
format!("\u{2713} {label}")
} else {
format!("\u{2713} {label} ({summary})")
};
println!(
"{line}{}",
savings_suffix(original_size, line.len()).unwrap_or_default()
);
}
Classification::Bounded {
label,
output,
display,
size,
..
} => {
let indexed = try_index(command, output);
let human_size = format_size(*size, BINARY);
if indexed {
println!(
"\u{25CF} {label} (output truncated: {human_size} total \u{2192} use `oo recall` to query)"
);
} else {
eprintln!(
"oo: warning: could not index output for recall — full output LOST (not recoverable); what follows is a truncated slice only"
);
println!(
"\u{25CF} {label} (output truncated: {human_size} total — NOT indexed, recall unavailable)"
);
}
print!("{display}");
}
Classification::Large {
label,
output,
size,
..
} => {
let indexed = try_index(command, output);
let human_size = format_size(*size, BINARY);
if indexed {
println!(
"\u{25CF} {label} (indexed {human_size} \u{2192} use `oo recall` to query)"
);
} else {
let truncated = classify::smart_truncate(output);
print!("{truncated}");
}
}
}
}
pub fn try_index(command: &str, content: &str) -> bool {
let mut store = match store::open() {
Ok(s) => s,
Err(_) => return false,
};
let project_id = session::project_id();
let meta = SessionMeta {
source: "oo".into(),
session: session::session_id(),
command: command.into(),
timestamp: now_epoch(),
};
let _ = store.cleanup_stale(&project_id, 86400);
store.index(&project_id, content, &meta).is_ok()
}
pub fn cmd_recall(query: &str, full: bool) -> i32 {
crate::recall_display::cmd_recall(query, full)
}
pub fn cmd_forget() -> i32 {
let mut store = match store::open() {
Ok(s) => s,
Err(e) => {
eprintln!("oo: {e}");
return 1;
}
};
let project_id = session::project_id();
match store.delete_project(&project_id) {
Ok(count) => {
println!("Cleared project data ({count} entries)");
0
}
Err(e) => {
eprintln!("oo: {e}");
1
}
}
}
pub fn cmd_learn(args: &[String], hint: Option<&str>) -> i32 {
if args.is_empty() {
eprintln!("oo: learn requires a command");
return 1;
}
let output = match exec::run(args) {
Ok(o) => o,
Err(e) => {
eprintln!("oo: {e}");
return 1;
}
};
let exit_code = output.exit_code;
let command = args.join(" ");
let merged = output.merged_lossy();
let original_size = merged.len();
let classification = classify::classify(&output, &command, pattern::builtins());
render_classification(&classification, &command, original_size);
let config = learn::load_learn_config().unwrap_or_else(|e| {
eprintln!("oo: config error: {e}");
learn::LearnConfig::default()
});
eprintln!(
" [learning pattern for \"{}\" ({})]",
classify::label(&command),
config.provider
);
if let Err(e) = learn::spawn_background(&command, &merged, exit_code, hint) {
eprintln!("oo: learn failed: {e}");
}
exit_code
}
pub fn write_learn_status(
status_path: &Path,
cmd_name: &str,
pattern_path: &Path,
) -> Result<(), std::io::Error> {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(status_path)?;
writeln!(
file,
"learned pattern for {} → {}",
cmd_name,
pattern_path.display()
)
}
pub fn write_learn_status_failure(
status_path: &Path,
cmd_name: &str,
error_msg: &str,
) -> Result<(), std::io::Error> {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(status_path)?;
let first_line = error_msg.lines().next().unwrap_or(error_msg);
writeln!(file, "FAILED {cmd_name}: {first_line}")
}
pub fn check_and_clear_learn_status(status_path: &Path) {
if let Ok(content) = std::fs::read_to_string(status_path) {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("FAILED ") {
if let Some((cmd, msg)) = rest.split_once(": ") {
eprintln!("oo: learn failed for {cmd} — {msg}");
} else {
eprintln!("oo: learn failed — {rest}");
}
} else {
eprintln!("oo: {line}");
}
}
let _ = std::fs::remove_file(status_path);
}
}
pub fn cmd_rewrite(command: &str) -> i32 {
match rewrite::rewrite(command, &rewrite::PATTERNS) {
Some(rewritten) => {
println!("{rewritten}");
0
}
None => 1,
}
}
pub fn cmd_patterns() -> i32 {
self::commands_patterns::cmd_patterns()
}
pub fn cmd_patterns_in(dir: &Path) -> i32 {
self::commands_patterns::cmd_patterns_in(dir)
}
pub fn list_patterns_in(dir: &Path) -> bool {
self::commands_patterns::list_patterns_in(dir)
}
pub fn cmd_help(cmd: &str) -> i32 {
match help::lookup(cmd) {
Ok(text) => {
print!("{text}");
0
}
Err(e) => {
eprintln!("oo: {e}");
1
}
}
}
pub fn cmd_init(mode: InitMode) -> i32 {
match &mode {
InitMode::Format(format) => match init::run(*format) {
Ok(()) => 0,
Err(e) => {
eprintln!("oo: {e}");
1
}
},
InitMode::Pi { global } => {
let Ok(cwd) = std::env::current_dir() else {
eprintln!("oo: cannot determine working directory");
return 1;
};
match init_pi::run(&cwd, *global) {
Ok(()) => 0,
Err(e) => {
eprintln!("oo: {e}");
1
}
}
}
InitMode::ClaudeCode { global } => match std::env::current_dir().map_err(Error::from) {
Err(e) => {
eprintln!("oo: {e}");
1
}
Ok(cwd) => match crate::hook::claude_settings_path(&cwd, *global) {
Err(e) => {
eprintln!("oo: {e}");
1
}
Ok(path) => match crate::hook::merge_oo_hook(&path) {
Ok(_) => {
println!(
"Installed `oo hook claude` PreToolUse hook in {}",
path.display()
);
println!("Uninstall: remove the `oo hook claude` entry from that file.");
0
}
Err(e) => {
eprintln!("oo: {e}");
1
}
},
},
},
}
}
pub fn cmd_hook(agent: &str) -> i32 {
match agent {
"claude" => crate::hook::cmd_hook_claude(),
other => {
eprintln!("oo: unknown hook agent '{other}'; supported hook agents: claude");
1
}
}
}
#[cfg(test)]
#[path = "commands_tests.rs"]
mod tests;