1use std::io::Write;
2use std::path::Path;
3
4use humansize::{BINARY, format_size};
5
6use crate::classify::Classification;
7pub use crate::init::InitFormat;
8use crate::store::SessionMeta;
9use crate::util::{format_age, now_epoch};
10use crate::{classify, exec, help, init, learn, pattern, session, store};
11
12pub enum Action {
13 Run(Vec<String>),
14 Recall(String),
15 Forget,
16 Learn(Vec<String>),
17 Version,
18 Help(Option<String>),
19 Init(InitFormat),
20 Patterns,
21}
22
23fn parse_init_format(args: &[String]) -> InitFormat {
28 let mut iter = args.iter();
29 while let Some(arg) = iter.next() {
30 if arg == "--format" {
31 return match iter.next().map(|s| s.as_str()) {
32 Some("generic") => InitFormat::Generic,
33 Some("claude") | None => InitFormat::Claude,
34 Some(other) => {
35 eprintln!(
36 "oo: unknown --format value '{}', defaulting to claude",
37 other
38 );
39 InitFormat::Claude
40 }
41 };
42 }
43 }
44 InitFormat::Claude
45}
46
47pub fn parse_action(args: &[String]) -> Action {
48 match args.first().map(|s| s.as_str()) {
49 None => Action::Help(None),
50 Some("recall") => Action::Recall(args[1..].join(" ")),
51 Some("forget") => Action::Forget,
52 Some("learn") => Action::Learn(args[1..].to_vec()),
53 Some("version") => Action::Version,
54 Some("help") => Action::Help(args.get(1).cloned()),
56 Some("init") => Action::Init(parse_init_format(&args[1..])),
57 Some("patterns") => Action::Patterns,
58 _ => Action::Run(args.to_vec()),
59 }
60}
61
62pub fn cmd_run(args: &[String]) -> i32 {
63 if args.is_empty() {
64 eprintln!("oo: no command specified");
65 return 1;
66 }
67
68 let user_patterns = pattern::load_user_patterns(&learn::patterns_dir());
70 let builtin_patterns = pattern::builtins();
71 let mut all_patterns: Vec<&pattern::Pattern> = Vec::new();
72 for p in &user_patterns {
73 all_patterns.push(p);
74 }
75 for p in builtin_patterns {
76 all_patterns.push(p);
77 }
78
79 let output = match exec::run(args) {
81 Ok(o) => o,
82 Err(e) => {
83 eprintln!("oo: {e}");
84 return 1;
85 }
86 };
87
88 let exit_code = output.exit_code;
89 let command = args.join(" ");
90
91 let combined: Vec<&pattern::Pattern> = all_patterns;
92 let classification = classify_with_refs(&output, &command, &combined);
93
94 match &classification {
96 Classification::Failure { label, output } => {
97 println!("\u{2717} {label}\n");
98 println!("{output}");
99 }
100 Classification::Passthrough { output } => {
101 print!("{output}");
102 }
103 Classification::Success { label, summary } => {
104 if summary.is_empty() {
105 println!("\u{2713} {label}");
106 } else {
107 println!("\u{2713} {label} ({summary})");
108 }
109 }
110 Classification::Large {
111 label,
112 output,
113 size,
114 ..
115 } => {
116 let indexed = try_index(&command, output);
118 let human_size = format_size(*size, BINARY);
119 if indexed {
120 println!(
121 "\u{25CF} {label} (indexed {human_size} \u{2192} use `oo recall` to query)"
122 );
123 } else {
124 let truncated = classify::smart_truncate(output);
126 print!("{truncated}");
127 }
128 }
129 }
130
131 exit_code
132}
133
134pub fn classify_with_refs(
136 output: &exec::CommandOutput,
137 command: &str,
138 patterns: &[&pattern::Pattern],
139) -> Classification {
140 let merged = output.merged_lossy();
141 let lbl = classify::label(command);
142
143 if output.exit_code != 0 {
144 let filtered = match pattern::find_matching_ref(command, patterns) {
145 Some(pat) => {
146 if let Some(failure) = &pat.failure {
147 pattern::extract_failure(failure, &merged)
148 } else {
149 classify::smart_truncate(&merged)
150 }
151 }
152 _ => classify::smart_truncate(&merged),
153 };
154 return Classification::Failure {
155 label: lbl,
156 output: filtered,
157 };
158 }
159
160 if merged.len() <= classify::SMALL_THRESHOLD {
161 return Classification::Passthrough { output: merged };
162 }
163
164 if let Some(pat) = pattern::find_matching_ref(command, patterns) {
165 if let Some(sp) = &pat.success {
166 if let Some(summary) = pattern::extract_summary(sp, &merged) {
167 return Classification::Success {
168 label: lbl,
169 summary,
170 };
171 }
172 }
173 }
174
175 let category = classify::detect_category(command);
177 match category {
178 classify::CommandCategory::Status => {
179 Classification::Success {
181 label: lbl,
182 summary: String::new(),
183 }
184 }
185 classify::CommandCategory::Content | classify::CommandCategory::Unknown => {
186 Classification::Passthrough { output: merged }
188 }
189 classify::CommandCategory::Data => {
190 let size = merged.len();
192 Classification::Large {
193 label: lbl,
194 output: merged,
195 size,
196 }
197 }
198 }
199}
200
201pub fn try_index(command: &str, content: &str) -> bool {
202 let mut store = match store::open() {
203 Ok(s) => s,
204 Err(_) => return false,
205 };
206
207 let project_id = session::project_id();
208 let meta = SessionMeta {
209 source: "oo".into(),
210 session: session::session_id(),
211 command: command.into(),
212 timestamp: now_epoch(),
213 };
214
215 let _ = store.cleanup_stale(&project_id, 86400);
217
218 store.index(&project_id, content, &meta).is_ok()
219}
220
221pub fn cmd_recall(query: &str) -> i32 {
222 if query.is_empty() {
223 eprintln!("oo: recall requires a query");
224 return 1;
225 }
226
227 let mut store = match store::open() {
228 Ok(s) => s,
229 Err(e) => {
230 eprintln!("oo: {e}");
231 return 1;
232 }
233 };
234
235 let project_id = session::project_id();
236
237 match store.search(&project_id, query, 5) {
238 Ok(results) if results.is_empty() => {
239 println!("No results found.");
240 0
241 }
242 Ok(results) => {
243 for r in &results {
244 if let Some(meta) = &r.meta {
245 let age = format_age(meta.timestamp);
246 println!("[session] {} ({age}):", meta.command);
247 } else {
248 println!("[memory] project memory:");
249 }
250 for line in r.content.lines() {
252 println!(" {line}");
253 }
254 println!();
255 }
256 0
257 }
258 Err(e) => {
259 eprintln!("oo: {e}");
260 1
261 }
262 }
263}
264
265pub fn cmd_forget() -> i32 {
266 let mut store = match store::open() {
267 Ok(s) => s,
268 Err(e) => {
269 eprintln!("oo: {e}");
270 return 1;
271 }
272 };
273
274 let project_id = session::project_id();
275 let sid = session::session_id();
276
277 match store.delete_by_session(&project_id, &sid) {
278 Ok(count) => {
279 println!("Cleared session data ({count} entries)");
280 0
281 }
282 Err(e) => {
283 eprintln!("oo: {e}");
284 1
285 }
286 }
287}
288
289pub fn cmd_learn(args: &[String]) -> i32 {
290 if args.is_empty() {
291 eprintln!("oo: learn requires a command");
292 return 1;
293 }
294
295 let output = match exec::run(args) {
297 Ok(o) => o,
298 Err(e) => {
299 eprintln!("oo: {e}");
300 return 1;
301 }
302 };
303
304 let exit_code = output.exit_code;
305 let command = args.join(" ");
306 let merged = output.merged_lossy();
307
308 let patterns = pattern::builtins();
310 let classification = classify::classify(&output, &command, patterns);
311 match &classification {
312 Classification::Failure { label, output } => {
313 println!("\u{2717} {label}\n");
314 println!("{output}");
315 }
316 Classification::Passthrough { output } => {
317 print!("{output}");
318 }
319 Classification::Success { label, summary } => {
320 if summary.is_empty() {
321 println!("\u{2713} {label}");
322 } else {
323 println!("\u{2713} {label} ({summary})");
324 }
325 }
326 Classification::Large { label, size, .. } => {
327 let human_size = format_size(*size, BINARY);
328 println!("\u{25CF} {label} (indexed {human_size} \u{2192} use `oo recall` to query)");
329 }
330 }
331
332 let config = learn::load_learn_config().unwrap_or_else(|e| {
334 eprintln!("oo: config error: {e}");
335 learn::LearnConfig::default()
336 });
337 eprintln!(
338 " [learning pattern for \"{}\" ({})]",
339 classify::label(&command),
340 config.provider
341 );
342
343 if let Err(e) = learn::spawn_background(&command, &merged, exit_code) {
345 eprintln!("oo: learn failed: {e}");
346 }
347
348 exit_code
349}
350
351pub fn write_learn_status(
356 status_path: &Path,
357 cmd_name: &str,
358 pattern_path: &Path,
359) -> Result<(), std::io::Error> {
360 let mut file = std::fs::OpenOptions::new()
361 .create(true)
362 .append(true)
363 .open(status_path)?;
364 writeln!(
365 file,
366 "learned pattern for {} → {}",
367 cmd_name,
368 pattern_path.display()
369 )
370}
371
372pub fn write_learn_status_failure(
377 status_path: &Path,
378 cmd_name: &str,
379 error_msg: &str,
380) -> Result<(), std::io::Error> {
381 let mut file = std::fs::OpenOptions::new()
382 .create(true)
383 .append(true)
384 .open(status_path)?;
385 let first_line = error_msg.lines().next().unwrap_or(error_msg);
386 writeln!(file, "FAILED {cmd_name}: {first_line}")
387}
388
389pub fn check_and_clear_learn_status(status_path: &Path) {
392 if let Ok(content) = std::fs::read_to_string(status_path) {
393 for line in content.lines() {
394 if let Some(rest) = line.strip_prefix("FAILED ") {
395 if let Some((cmd, msg)) = rest.split_once(": ") {
397 eprintln!("oo: learn failed for {cmd} — {msg}");
398 } else {
399 eprintln!("oo: learn failed — {rest}");
400 }
401 } else {
402 eprintln!("oo: {line}");
403 }
404 }
405 let _ = std::fs::remove_file(status_path);
406 }
407}
408
409pub fn cmd_patterns_in(dir: &Path) -> i32 {
413 let entries = match std::fs::read_dir(dir) {
414 Ok(e) => e,
415 Err(_) => {
416 println!("no learned patterns yet");
417 return 0;
418 }
419 };
420
421 let mut found = false;
422 for entry in entries.flatten() {
423 let path = entry.path();
424 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
425 continue;
426 }
427 let parsed = std::fs::read_to_string(&path)
429 .ok()
430 .and_then(|s| toml::from_str::<toml::Value>(&s).ok());
431
432 let cmd_match = parsed
433 .as_ref()
434 .and_then(|v| v.get("command_match")?.as_str().map(str::to_string));
435 let has_success = parsed.as_ref().and_then(|v| v.get("success")).is_some();
436 let has_failure = parsed.as_ref().and_then(|v| v.get("failure")).is_some();
437
438 if parsed.is_none() {
440 continue;
441 }
442 found = true;
443 let cmd_match = cmd_match.unwrap_or_else(|| "(unknown)".into());
444
445 let mut flags = Vec::new();
446 if has_success {
447 flags.push("success");
448 }
449 if has_failure {
450 flags.push("failure");
451 }
452 if flags.is_empty() {
453 println!("{cmd_match}");
454 } else {
455 println!("{cmd_match} [{}]", flags.join("] ["));
456 }
457 }
458
459 if !found {
460 println!("no learned patterns yet");
461 }
462 0
463}
464
465pub fn cmd_patterns() -> i32 {
466 cmd_patterns_in(&learn::patterns_dir())
467}
468
469pub fn cmd_help(cmd: &str) -> i32 {
470 match help::lookup(cmd) {
471 Ok(text) => {
472 print!("{text}");
473 0
474 }
475 Err(e) => {
476 eprintln!("oo: {e}");
477 1
478 }
479 }
480}
481
482pub fn cmd_init(format: InitFormat) -> i32 {
483 match init::run(format) {
484 Ok(()) => 0,
485 Err(e) => {
486 eprintln!("oo: {e}");
487 1
488 }
489 }
490}
491
492#[cfg(test)]
493#[path = "commands_tests.rs"]
494mod tests;