Skip to main content

double_o/
commands.rs

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
23/// Parse `--format <value>` from the remaining init args.
24///
25/// Recognised values: `claude` (default), `generic`.
26/// Unknown values emit a warning to stderr and fall back to Claude.
27fn 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        // `oo help <cmd>` — look up cheat sheet; `oo help` alone shows usage
55        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    // Load patterns: user patterns first (override), then builtins
69    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    // Run command
80    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    // Print result
95    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            // Index into store
117            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                // Couldn't index, show truncated output instead
125                let truncated = classify::smart_truncate(output);
126                print!("{truncated}");
127            }
128        }
129    }
130
131    exit_code
132}
133
134/// Classify using a slice of pattern references.
135pub 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    // Large, no pattern match — use category to determine behavior
176    let category = classify::detect_category(command);
177    match category {
178        classify::CommandCategory::Status => {
179            // Status commands: quiet success (empty summary)
180            Classification::Success {
181                label: lbl,
182                summary: String::new(),
183            }
184        }
185        classify::CommandCategory::Content | classify::CommandCategory::Unknown => {
186            // Content and Unknown: always passthrough (never index)
187            Classification::Passthrough { output: merged }
188        }
189        classify::CommandCategory::Data => {
190            // Data: index for recall
191            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    // Lazy TTL cleanup (best-effort)
216    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                // Indent content
251                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    // Run the command normally first
296    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    // Show normal oo output first
309    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    // Print provider before spawning so the user sees it in the foreground
333    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    // Spawn background learn process
344    if let Err(e) = learn::spawn_background(&command, &merged, exit_code) {
345        eprintln!("oo: learn failed: {e}");
346    }
347
348    exit_code
349}
350
351/// Write a one-line status entry to the learn status file.
352///
353/// Called by the background process after successfully saving a pattern so
354/// the NEXT foreground invocation can display the result.
355pub 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
372/// Write a one-line failure entry to the learn status file.
373///
374/// Called by the background process when `run_learn` returns `Err`, so the
375/// NEXT foreground invocation can display the error.
376pub 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
389/// Check for a pending learn-status file, print its contents to stderr, then
390/// delete the file.  Called early in each foreground invocation.
391pub 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                // Format: "FAILED cmd-name: error message"
396                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
409/// List learned pattern files from the given directory.
410///
411/// Extracted for testability — callers pass in the resolved patterns dir.
412pub 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        // Parse the file once, extract all three fields from the single Value
428        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        // Only mark found after a valid parse; skip corrupt files silently
439        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;