use std::io::Write;
use std::path::Path;
use humansize::{BINARY, format_size};
use crate::classify::Classification;
pub use crate::init::InitFormat;
use crate::store::SessionMeta;
use crate::util::{format_age, now_epoch};
use crate::{classify, exec, help, init, learn, pattern, session, store};
pub enum Action {
Run(Vec<String>),
Recall(String),
Forget,
Learn(Vec<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
}
pub fn parse_action(args: &[String]) -> Action {
match args.first().map(|s| s.as_str()) {
None => Action::Help(None),
Some("recall") => Action::Recall(args[1..].join(" ")),
Some("forget") => Action::Forget,
Some("learn") => Action::Learn(args[1..].to_vec()),
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 cmd_run(args: &[String]) -> i32 {
if args.is_empty() {
eprintln!("oo: no command specified");
return 1;
}
let project_patterns = load_project_patterns();
let user_patterns = pattern::load_user_patterns(&learn::patterns_dir());
let builtin_patterns = pattern::builtins();
let mut all_patterns: Vec<&pattern::Pattern> = Vec::new();
for p in &project_patterns {
all_patterns.push(p);
}
for p in &user_patterns {
all_patterns.push(p);
}
for p in builtin_patterns {
all_patterns.push(p);
}
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 combined: Vec<&pattern::Pattern> = all_patterns;
let classification = classify_with_refs(&output, &command, &combined);
match &classification {
Classification::Failure { label, output } => {
println!("\u{2717} {label}\n");
println!("{output}");
}
Classification::Passthrough { output } => {
print!("{output}");
}
Classification::Success { label, summary } => {
if summary.is_empty() {
println!("\u{2713} {label}");
} else {
println!("\u{2713} {label} ({summary})");
}
}
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}");
}
}
}
exit_code
}
pub fn classify_with_refs(
output: &exec::CommandOutput,
command: &str,
patterns: &[&pattern::Pattern],
) -> Classification {
let merged = output.merged_lossy();
let lbl = classify::label(command);
if output.exit_code != 0 {
let filtered = match pattern::find_matching_ref(command, patterns) {
Some(pat) => {
if let Some(failure) = &pat.failure {
pattern::extract_failure(failure, &merged)
} else {
classify::smart_truncate(&merged)
}
}
_ => classify::smart_truncate(&merged),
};
return Classification::Failure {
label: lbl,
output: filtered,
};
}
if merged.len() <= classify::SMALL_THRESHOLD {
return Classification::Passthrough { output: merged };
}
if let Some(pat) = pattern::find_matching_ref(command, patterns) {
if let Some(sp) = &pat.success {
if let Some(summary) = pattern::extract_summary(sp, &merged) {
return Classification::Success {
label: lbl,
summary,
};
}
}
}
let category = classify::detect_category(command);
match category {
classify::CommandCategory::Status => {
Classification::Success {
label: lbl,
summary: String::new(),
}
}
classify::CommandCategory::Content | classify::CommandCategory::Unknown => {
Classification::Passthrough { output: merged }
}
classify::CommandCategory::Data => {
let size = merged.len();
Classification::Large {
label: lbl,
output: merged,
size,
}
}
}
}
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) -> i32 {
if query.is_empty() {
eprintln!("oo: recall requires a query");
return 1;
}
let mut store = match store::open() {
Ok(s) => s,
Err(e) => {
eprintln!("oo: {e}");
return 1;
}
};
let project_id = session::project_id();
match store.search(&project_id, query, 5) {
Ok(results) if results.is_empty() => {
println!("No results found.");
0
}
Ok(results) => {
for r in &results {
if let Some(meta) = &r.meta {
let age = format_age(meta.timestamp);
println!("[session] {} ({age}):", meta.command);
} else {
println!("[memory] project memory:");
}
for line in r.content.lines() {
println!(" {line}");
}
println!();
}
0
}
Err(e) => {
eprintln!("oo: {e}");
1
}
}
}
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]) -> 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 patterns = pattern::builtins();
let classification = classify::classify(&output, &command, patterns);
match &classification {
Classification::Failure { label, output } => {
println!("\u{2717} {label}\n");
println!("{output}");
}
Classification::Passthrough { output } => {
print!("{output}");
}
Classification::Success { label, summary } => {
if summary.is_empty() {
println!("\u{2713} {label}");
} else {
println!("\u{2713} {label} ({summary})");
}
}
Classification::Large { label, size, .. } => {
let human_size = format_size(*size, BINARY);
println!("\u{25CF} {label} (indexed {human_size} \u{2192} use `oo recall` to query)");
}
}
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) {
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 list_patterns_in(dir: &Path) -> bool {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return false,
};
let mut found = false;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let parsed = std::fs::read_to_string(&path)
.ok()
.and_then(|s| toml::from_str::<toml::Value>(&s).ok());
let cmd_match = parsed
.as_ref()
.and_then(|v| v.get("command_match")?.as_str().map(str::to_string));
let has_success = parsed.as_ref().and_then(|v| v.get("success")).is_some();
let has_failure = parsed.as_ref().and_then(|v| v.get("failure")).is_some();
if parsed.is_none() {
continue;
}
found = true;
let cmd_match = cmd_match.unwrap_or_else(|| "(unknown)".into());
let mut flags = Vec::new();
if has_success {
flags.push("success");
}
if has_failure {
flags.push("failure");
}
if flags.is_empty() {
println!(" {cmd_match}");
} else {
println!(" {cmd_match} [{}]", flags.join("] ["));
}
}
found
}
pub fn cmd_patterns_in(dir: &Path) -> i32 {
if !list_patterns_in(dir) {
println!("no learned patterns yet");
}
0
}
pub fn cmd_patterns() -> i32 {
let project_dir = std::env::current_dir()
.map(|cwd| init::project_patterns_dir(&cwd))
.ok();
let user_dir = learn::patterns_dir();
let mut total_found = false;
if let Some(ref pdir) = project_dir {
if pdir.exists() {
println!("Project ({}):", pdir.display());
if list_patterns_in(pdir) {
total_found = true;
}
println!();
}
}
println!("User ({}):", user_dir.display());
if list_patterns_in(&user_dir) {
total_found = true;
}
if !total_found {
println!("no patterns yet");
}
0
}
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;