double-o 0.9.0

Context-efficient command runner for AI coding agents
Documentation
use std::path::Path;

use humansize::{BINARY, format_size};

use crate::classify::Classification;
pub use crate::display::savings_suffix;
use crate::indexing::index_or_warn;
pub use crate::indexing::try_index;
pub use crate::init::InitFormat;
pub(crate) use crate::init_cmd::parse_init_mode;
pub use crate::init_cmd::{InitMode, cmd_init};
use crate::{
    classify, commands_patterns, exec, failure_display, help, learn, pattern, pattern_load,
    rewrite, session, store, usage,
};

pub enum Action {
    /// Print a reserved subcommand's usage text and exit 0. The subcommand does not run,
    /// but a pending learn-status message is printed and cleared before this action.
    Usage(&'static str),
    Run(Vec<String>),
    Recall {
        query: String,
        full: bool,
    },
    Forget,
    Learn(Vec<String>, Option<String>),
    Version,
    Help(Option<String>),
    Init(InitMode),
    Patterns,
    Rewrite(String),
    Hook(Option<String>),
}

/// Parse `oo recall` arguments, extracting optional `--full` flag.
///
/// `--full` is stripped from ANY position in the trailing args (consistent
/// with `parse_learn_action`'s `--hint` handling). Searching for the literal
/// string `--full` in a query is unsupported; document that in help.
fn parse_recall_action(args: &[String]) -> Action {
    let query: String = args
        .iter()
        .filter(|a| a.as_str() != "--full")
        .cloned()
        .collect::<Vec<_>>()
        .join(" ");
    let full = args.iter().any(|a| a.as_str() == "--full");
    Action::Recall { query, full }
}

/// Parse `oo learn` arguments, extracting optional `--hint <text>` flag.
///
/// Returns (args_without_hint, hint_text). The hint text is removed from the
/// command args so it doesn't interfere with the actual command being run.
fn parse_learn_action(args: &[String]) -> Action {
    let mut result: Vec<String> = Vec::new();
    let mut hint: Option<String> = None;
    let mut iter = args.iter().peekable();

    while let Some(arg) = iter.next() {
        if arg == "--hint" {
            // Take the next argument as the hint text, but only if it's not a flag
            // If the next arg starts with '-', treat it as a command argument, not hint text
            if let Some(hint_text) = iter.next() {
                if !hint_text.starts_with('-') {
                    hint = Some(hint_text.clone());
                } else {
                    // The next arg is a flag, treat it as part of the command
                    result.push(hint_text.clone());
                }
            }
            // If no hint text after --hint, emit a warning and treat as no hint
        } else {
            result.push(arg.clone());
        }
    }

    Action::Learn(result, hint)
}

pub fn parse_action(args: &[String]) -> Action {
    // `oo <reserved> --help|-h`: the flag must be the first argument after the
    // subcommand. Intercepted before any side-effecting arm runs.
    if args.get(1).is_some_and(|a| a == "--help" || a == "-h") {
        if let Some(text) = args.first().and_then(|s| usage::for_subcommand(s)) {
            return Action::Usage(text);
        }
    }
    match args.first().map(|s| s.as_str()) {
        None => Action::Help(None),
        Some("recall") => parse_recall_action(&args[1..]),
        Some("forget") => Action::Forget,
        Some("learn") => parse_learn_action(&args[1..]),
        Some("version") => Action::Version,
        // `oo help <cmd>` — look up cheat sheet; `oo help` alone shows usage
        Some("help") => Action::Help(args.get(1).cloned()),
        Some("init") => match parse_init_mode(&args[1..]) {
            Ok(mode) => Action::Init(mode),
            Err(e) => {
                // Errors already carry their full prefix when they need one
                // (`oo init: unsupported format ...`); the rest keep `oo: `.
                if e.starts_with("oo init: ") {
                    eprintln!("{e}");
                } else {
                    eprintln!("oo: {e}");
                }
                std::process::exit(1);
            }
        },
        Some("patterns") => Action::Patterns,
        Some("rewrite") => {
            let command: String = args
                .get(1..)
                .map(|rest| rest.join(" "))
                .filter(|s| !s.is_empty())
                .unwrap_or_default();
            Action::Rewrite(command)
        }
        // `oo hook claude` — stdin-JSON PreToolUse processor (issue #172).
        // Must be recognised before the `Run` fall-through, else `hook` would
        // be treated as a shell command to spawn.
        Some("hook") => Action::Hook(args.get(1).cloned()),
        _ => Action::Run(args.to_vec()),
    }
}

/// Run a command via [`exec::run`], load the pattern set used by `cmd_run`, classify the output,
/// and render it. Returns the exit code and the classification so tests can inspect the
/// classification directly (rendering happens here so the normal `cmd_run` path is exercised).
///
/// The classification is `None` on the two pre-classification error paths
/// (no command given, or exec failure) — there is no output to classify, so
/// no `Classification` value is fabricated.
pub fn run_command_args(args: &[String]) -> (i32, Option<Classification>) {
    if args.is_empty() {
        eprintln!("oo: no command specified");
        return (1, None);
    }

    // Load patterns (first match wins: project overrides user overrides builtins).
    let all_patterns = pattern_load::all_patterns();

    // Run command
    let output = match exec::run(args) {
        Ok(o) => o,
        Err(e) => {
            eprintln!("oo: {e}");
            return (1, None);
        }
    };

    let exit_code = output.exit_code;
    let command = args.join(" ");

    // Classify
    let merged = output.merged_lossy();
    let classification = classify::classify(&output, &command, &all_patterns);

    // Print result
    render_classification(&classification, &command, merged.len());

    (exit_code, Some(classification))
}

/// Run a command via [`exec::run`], classify the output, and render it. Returns the exit code.
pub fn cmd_run(args: &[String]) -> i32 {
    run_command_args(args).0
}

/// Print a [`Classification`] to stdout using the shared display path.
///
/// `original_size` is the merged output size in bytes, used to append the
/// savings suffix to the Success and Failure indicator lines via
/// [`savings_suffix`]. The Large tier indexes the output via [`try_index`]
/// and, on indexing failure, prints a NOT-indexed header and the stderr warning
/// before the [`classify::smart_truncate`] view.
/// Both `cmd_run` and `cmd_learn` use this so the arms cannot drift apart.
pub fn render_classification(classification: &Classification, command: &str, original_size: usize) {
    match classification {
        Classification::Failure {
            label,
            output,
            full_output,
        } => failure_display::render(label, command, original_size, output, full_output),
        Classification::Passthrough { output } => {
            print!("{output}");
        }
        Classification::Success { label, summary } => {
            let line = if summary.is_empty() {
                format!("\u{2713} {label}")
            } else {
                format!("\u{2713} {label} ({summary})")
            };
            println!(
                "{line}{}",
                savings_suffix(original_size, line.len()).unwrap_or_default()
            );
        }
        Classification::Bounded {
            label,
            output,
            display,
            size,
            ..
        } => {
            // SECURITY (residual risk, pre-existing): the framing line printed
            // below (`● {label} (output truncated: ...)`) is byte-predictable
            // and is followed by attacker-controlled `display`, which could
            // embed a byte-identical copy of it — so "the framing line comes
            // first" is a convention, not an enforceable guarantee, and an
            // agent that greps for the framing pattern can't distinguish the
            // host-authored line from a forged one. The Large arm's
            // `● ... (indexed ...)` line has the same property. NOT fixed
            // here (would require changing the output format); document the
            // residual risk at the point of use instead.
            //
            // Best-effort index the full output for recall; the display is
            // always printed — the byte-bounded head+tail slice IS the point
            // of this arm (issue #148). The truncation marker in `display`
            // makes it detectable as bounded output, but it lives inside
            // attacker-controlled output, so the host also prints its own
            // framing line carrying the authoritative size (mirrors the Large
            // arm) — an agent must never be told to recall data that was not
            // indexed.
            let human_size = format_size(*size, BINARY);
            // Indexing failed: do NOT advertise `oo recall` — neither in the
            // framing line nor in the slice marker, so the slice is rebuilt with
            // the unindexed marker. `index_or_warn` keeps the failure visible.
            if index_or_warn(command, output) {
                println!(
                    "\u{25CF} {label} (output truncated: {human_size} total \u{2192} use `oo recall` to query)"
                );
                print!("{display}");
            } else {
                println!(
                    "\u{25CF} {label} (output truncated: {human_size} total — NOT indexed, recall unavailable)"
                );
                print!("{}", classify::bounded_truncate_indexed(output, false));
            }
        }
        Classification::Large {
            label,
            output,
            size,
            ..
        } => {
            // Index into store; on failure the full output is LOST, so the
            // header says so and the stderr warning is printed (as in the Bounded arm).
            let indexed = index_or_warn(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 {
                println!("\u{25CF} {label} ({human_size} total — NOT indexed, recall unavailable)");
                let truncated = classify::smart_truncate(output);
                print!("{truncated}");
            }
        }
    }
}

pub fn cmd_recall(query: &str, full: bool) -> i32 {
    crate::recall_display::cmd_recall(query, full)
}

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();

    match store.delete_project(&project_id) {
        Ok(count) => {
            println!("Cleared project data ({count} entries)");
            0
        }
        Err(e) => {
            eprintln!("oo: {e}");
            1
        }
    }
}

pub fn cmd_learn(args: &[String], hint: Option<&str>) -> i32 {
    if args.is_empty() {
        eprintln!("oo: learn requires a command");
        return 1;
    }

    // Run the command normally first
    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 original_size = merged.len();

    // Show normal oo output first (builtins only — project/user patterns are
    // intentionally not consulted here; see issue #151)
    let classification = classify::classify(&output, &command, pattern::builtins());
    render_classification(&classification, &command, original_size);

    // Print provider before spawning so the user sees it in the foreground
    let config = learn::load_learn_config().unwrap_or_else(|e| {
        eprintln!("oo: {e}");
        learn::LearnConfig::default()
    });
    eprintln!(
        "  [learning pattern for \"{}\" ({})]",
        classify::label(&command),
        config.provider
    );

    // Spawn background learn process
    if let Err(e) = learn::spawn_background(&command, &merged, exit_code, hint) {
        eprintln!("oo: learn failed: {e}");
    }

    exit_code
}

/// Rewrite a command via `oo rewrite` for agent hooks: print the `oo`-prefixed
/// form on stdout (exit 0) when at least one segment has an oo pattern, or
/// print nothing and return 1 otherwise. Never executes the command.
pub fn cmd_rewrite(command: &str) -> i32 {
    match rewrite::rewrite(command, &rewrite::PATTERNS) {
        Some(rewritten) => {
            println!("{rewritten}");
            0
        }
        None => 1,
    }
}

/// List patterns from both project-local and user config directories.
pub fn cmd_patterns() -> i32 {
    self::commands_patterns::cmd_patterns()
}

/// List learned pattern files from a single directory (legacy test helper).
pub fn cmd_patterns_in(dir: &Path) -> i32 {
    self::commands_patterns::cmd_patterns_in(dir)
}

/// Print pattern entries from a single directory, returning true if any were found.
///
/// Each line is printed with a two-space indent so callers can add section headers.
pub fn list_patterns_in(dir: &Path) -> bool {
    self::commands_patterns::list_patterns_in(dir)
}

pub fn cmd_help(cmd: &str) -> i32 {
    if let Some(text) = usage::for_subcommand(cmd) {
        print!("{text}");
        return 0;
    }
    match help::lookup(cmd) {
        Ok(text) => {
            print!("{text}");
            0
        }
        Err(e) => {
            eprintln!("oo: {e}");
            1
        }
    }
}

/// Entry point for `oo hook <agent>`: dispatch to the agent-specific stdin
/// processor. `claude` reads the PreToolUse JSON on stdin and rewrites Bash
/// commands (issue #172); anything else errors. The claude processor is
/// fail-open and returns 0.
pub fn cmd_hook(agent: &str) -> i32 {
    match agent {
        "claude" => crate::hook::cmd_hook_claude(),
        other => {
            eprintln!("oo: unknown hook agent '{other}'; supported hook agents: claude");
            1
        }
    }
}

#[cfg(test)]
#[path = "commands_tests.rs"]
mod tests;