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/// Write a one-line failure entry to the learn status file.
355///
356/// Called by the background process when `run_learn` returns `Err`, so the
357/// NEXT foreground invocation can display the error.
358pub fn write_learn_status_failure(
359    status_path: &Path,
360    cmd_name: &str,
361    error_msg: &str,
362) -> Result<(), std::io::Error> {
363    let mut file = std::fs::OpenOptions::new()
364        .create(true)
365        .append(true)
366        .open(status_path)?;
367    let first_line = error_msg.lines().next().unwrap_or(error_msg);
368    writeln!(file, "FAILED {cmd_name}: {first_line}")
369}
370
371/// Check for a pending learn-status file, print its contents to stderr, then
372/// delete the file.  Called early in each foreground invocation.
373pub fn check_and_clear_learn_status(status_path: &Path) {
374    if let Ok(content) = std::fs::read_to_string(status_path) {
375        for line in content.lines() {
376            if let Some(rest) = line.strip_prefix("FAILED ") {
377                // Format: "FAILED cmd-name: error message"
378                if let Some((cmd, msg)) = rest.split_once(": ") {
379                    eprintln!("oo: learn failed for {cmd} — {msg}");
380                } else {
381                    eprintln!("oo: learn failed — {rest}");
382                }
383            } else {
384                eprintln!("oo: {line}");
385            }
386        }
387        let _ = std::fs::remove_file(status_path);
388    }
389}
390
391/// List learned pattern files from the given directory.
392///
393/// Extracted for testability — callers pass in the resolved patterns dir.
394pub fn cmd_patterns_in(dir: &Path) -> i32 {
395    let entries = match std::fs::read_dir(dir) {
396        Ok(e) => e,
397        Err(_) => {
398            println!("no learned patterns yet");
399            return 0;
400        }
401    };
402
403    let mut found = false;
404    for entry in entries.flatten() {
405        let path = entry.path();
406        if path.extension().and_then(|e| e.to_str()) != Some("toml") {
407            continue;
408        }
409        // Parse the file once, extract all three fields from the single Value
410        let parsed = std::fs::read_to_string(&path)
411            .ok()
412            .and_then(|s| toml::from_str::<toml::Value>(&s).ok());
413
414        let cmd_match = parsed
415            .as_ref()
416            .and_then(|v| v.get("command_match")?.as_str().map(str::to_string));
417        let has_success = parsed.as_ref().and_then(|v| v.get("success")).is_some();
418        let has_failure = parsed.as_ref().and_then(|v| v.get("failure")).is_some();
419
420        // Only mark found after a valid parse; skip corrupt files silently
421        if parsed.is_none() {
422            continue;
423        }
424        found = true;
425        let cmd_match = cmd_match.unwrap_or_else(|| "(unknown)".into());
426
427        let mut flags = Vec::new();
428        if has_success {
429            flags.push("success");
430        }
431        if has_failure {
432            flags.push("failure");
433        }
434        if flags.is_empty() {
435            println!("{cmd_match}");
436        } else {
437            println!("{cmd_match}  [{}]", flags.join("] ["));
438        }
439    }
440
441    if !found {
442        println!("no learned patterns yet");
443    }
444    0
445}
446
447pub fn cmd_patterns() -> i32 {
448    cmd_patterns_in(&learn::patterns_dir())
449}
450
451pub fn cmd_help(cmd: &str) -> i32 {
452    match help::lookup(cmd) {
453        Ok(text) => {
454            print!("{text}");
455            0
456        }
457        Err(e) => {
458            eprintln!("oo: {e}");
459            1
460        }
461    }
462}
463
464pub fn cmd_init(format: InitFormat) -> i32 {
465    match init::run(format) {
466        Ok(()) => 0,
467        Err(e) => {
468            eprintln!("oo: {e}");
469            1
470        }
471    }
472}
473
474#[cfg(test)]
475#[path = "commands_tests.rs"]
476mod tests;