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() <= 4096 {
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 size = merged.len();
176    Classification::Large {
177        label: lbl,
178        output: merged,
179        size,
180    }
181}
182
183pub fn try_index(command: &str, content: &str) -> bool {
184    let mut store = match store::open() {
185        Ok(s) => s,
186        Err(_) => return false,
187    };
188
189    let project_id = session::project_id();
190    let meta = SessionMeta {
191        source: "oo".into(),
192        session: session::session_id(),
193        command: command.into(),
194        timestamp: now_epoch(),
195    };
196
197    // Lazy TTL cleanup (best-effort)
198    let _ = store.cleanup_stale(&project_id, 86400);
199
200    store.index(&project_id, content, &meta).is_ok()
201}
202
203pub fn cmd_recall(query: &str) -> i32 {
204    if query.is_empty() {
205        eprintln!("oo: recall requires a query");
206        return 1;
207    }
208
209    let mut store = match store::open() {
210        Ok(s) => s,
211        Err(e) => {
212            eprintln!("oo: {e}");
213            return 1;
214        }
215    };
216
217    let project_id = session::project_id();
218
219    match store.search(&project_id, query, 5) {
220        Ok(results) if results.is_empty() => {
221            println!("No results found.");
222            0
223        }
224        Ok(results) => {
225            for r in &results {
226                if let Some(meta) = &r.meta {
227                    let age = format_age(meta.timestamp);
228                    println!("[session] {} ({age}):", meta.command);
229                } else {
230                    println!("[memory] project memory:");
231                }
232                // Indent content
233                for line in r.content.lines() {
234                    println!("  {line}");
235                }
236                println!();
237            }
238            0
239        }
240        Err(e) => {
241            eprintln!("oo: {e}");
242            1
243        }
244    }
245}
246
247pub fn cmd_forget() -> i32 {
248    let mut store = match store::open() {
249        Ok(s) => s,
250        Err(e) => {
251            eprintln!("oo: {e}");
252            return 1;
253        }
254    };
255
256    let project_id = session::project_id();
257    let sid = session::session_id();
258
259    match store.delete_by_session(&project_id, &sid) {
260        Ok(count) => {
261            println!("Cleared session data ({count} entries)");
262            0
263        }
264        Err(e) => {
265            eprintln!("oo: {e}");
266            1
267        }
268    }
269}
270
271pub fn cmd_learn(args: &[String]) -> i32 {
272    if args.is_empty() {
273        eprintln!("oo: learn requires a command");
274        return 1;
275    }
276
277    // Run the command normally first
278    let output = match exec::run(args) {
279        Ok(o) => o,
280        Err(e) => {
281            eprintln!("oo: {e}");
282            return 1;
283        }
284    };
285
286    let exit_code = output.exit_code;
287    let command = args.join(" ");
288    let merged = output.merged_lossy();
289
290    // Show normal oo output first
291    let patterns = pattern::builtins();
292    let classification = classify::classify(&output, &command, patterns);
293    match &classification {
294        Classification::Failure { label, output } => {
295            println!("\u{2717} {label}\n");
296            println!("{output}");
297        }
298        Classification::Passthrough { output } => {
299            print!("{output}");
300        }
301        Classification::Success { label, summary } => {
302            if summary.is_empty() {
303                println!("\u{2713} {label}");
304            } else {
305                println!("\u{2713} {label} ({summary})");
306            }
307        }
308        Classification::Large { label, size, .. } => {
309            let human_size = format_size(*size, BINARY);
310            println!("\u{25CF} {label} (indexed {human_size} \u{2192} use `oo recall` to query)");
311        }
312    }
313
314    // Print provider before spawning so the user sees it in the foreground
315    let config = learn::load_learn_config().unwrap_or_else(|e| {
316        eprintln!("oo: config error: {e}");
317        learn::LearnConfig::default()
318    });
319    eprintln!(
320        "  [learning pattern for \"{}\" ({})]",
321        classify::label(&command),
322        config.provider
323    );
324
325    // Spawn background learn process
326    if let Err(e) = learn::spawn_background(&command, &merged, exit_code) {
327        eprintln!("oo: learn failed: {e}");
328    }
329
330    exit_code
331}
332
333/// Write a one-line status entry to the learn status file.
334///
335/// Called by the background process after successfully saving a pattern so
336/// the NEXT foreground invocation can display the result.
337pub fn write_learn_status(
338    status_path: &Path,
339    cmd_name: &str,
340    pattern_path: &Path,
341) -> Result<(), std::io::Error> {
342    let mut file = std::fs::OpenOptions::new()
343        .create(true)
344        .append(true)
345        .open(status_path)?;
346    writeln!(
347        file,
348        "learned pattern for {} → {}",
349        cmd_name,
350        pattern_path.display()
351    )
352}
353
354/// Check for a pending learn-status file, print its contents to stderr, then
355/// delete the file.  Called early in each foreground invocation.
356pub fn check_and_clear_learn_status(status_path: &Path) {
357    if let Ok(content) = std::fs::read_to_string(status_path) {
358        for line in content.lines() {
359            eprintln!("oo: {line}");
360        }
361        let _ = std::fs::remove_file(status_path);
362    }
363}
364
365/// List learned pattern files from the given directory.
366///
367/// Extracted for testability — callers pass in the resolved patterns dir.
368pub fn cmd_patterns_in(dir: &Path) -> i32 {
369    let entries = match std::fs::read_dir(dir) {
370        Ok(e) => e,
371        Err(_) => {
372            println!("no learned patterns yet");
373            return 0;
374        }
375    };
376
377    let mut found = false;
378    for entry in entries.flatten() {
379        let path = entry.path();
380        if path.extension().and_then(|e| e.to_str()) != Some("toml") {
381            continue;
382        }
383        // Parse the file once, extract all three fields from the single Value
384        let parsed = std::fs::read_to_string(&path)
385            .ok()
386            .and_then(|s| toml::from_str::<toml::Value>(&s).ok());
387
388        let cmd_match = parsed
389            .as_ref()
390            .and_then(|v| v.get("command_match")?.as_str().map(str::to_string));
391        let has_success = parsed.as_ref().and_then(|v| v.get("success")).is_some();
392        let has_failure = parsed.as_ref().and_then(|v| v.get("failure")).is_some();
393
394        // Only mark found after a valid parse; skip corrupt files silently
395        if parsed.is_none() {
396            continue;
397        }
398        found = true;
399        let cmd_match = cmd_match.unwrap_or_else(|| "(unknown)".into());
400
401        let mut flags = Vec::new();
402        if has_success {
403            flags.push("success");
404        }
405        if has_failure {
406            flags.push("failure");
407        }
408        if flags.is_empty() {
409            println!("{cmd_match}");
410        } else {
411            println!("{cmd_match}  [{}]", flags.join("] ["));
412        }
413    }
414
415    if !found {
416        println!("no learned patterns yet");
417    }
418    0
419}
420
421pub fn cmd_patterns() -> i32 {
422    cmd_patterns_in(&learn::patterns_dir())
423}
424
425pub fn cmd_help(cmd: &str) -> i32 {
426    match help::lookup(cmd) {
427        Ok(text) => {
428            print!("{text}");
429            0
430        }
431        Err(e) => {
432            eprintln!("oo: {e}");
433            1
434        }
435    }
436}
437
438pub fn cmd_init(format: InitFormat) -> i32 {
439    match init::run(format) {
440        Ok(()) => 0,
441        Err(e) => {
442            eprintln!("oo: {e}");
443            1
444        }
445    }
446}
447
448#[cfg(test)]
449#[path = "commands_tests.rs"]
450mod tests;