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, exec, help, init, learn, pattern, session, store};
pub enum Action {
Run(Vec<String>),
Recall { query: String, full: bool },
Forget,
Learn(Vec<String>, Option<String>),
Version,
Help(Option<String>),
Init(InitFormat),
Patterns,
}
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") => Action::Init(parse_init_format(&args[1..])),
Some("patterns") => Action::Patterns,
_ => 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 mut all_patterns = load_project_patterns();
all_patterns.extend(pattern::load_user_patterns(&learn::patterns_dir()));
all_patterns.extend_from_slice(pattern::builtins());
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();
let sid = session::session_id();
match store.delete_by_session(&project_id, &sid) {
Ok(count) => {
println!("Cleared session 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_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(format: InitFormat) -> i32 {
match init::run(format) {
Ok(()) => 0,
Err(e) => {
eprintln!("oo: {e}");
1
}
}
}
pub fn load_project_patterns() -> Vec<pattern::Pattern> {
let Ok(cwd) = std::env::current_dir() else {
return Vec::new();
};
pattern::load_user_patterns(&init::project_patterns_dir(&cwd))
}
#[cfg(test)]
#[path = "commands_tests.rs"]
mod tests;