use crate::exec::CommandOutput;
use crate::pattern::{self, Pattern};
pub const SMALL_THRESHOLD: usize = 4096;
pub const MIN_SAVINGS: usize = 4096;
pub const DISPLAY_CAP: usize = SMALL_THRESHOLD;
const TRUNCATION_THRESHOLD: usize = 80;
const MAX_LINES: usize = 120;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandCategory {
Status,
Content,
Data,
Unknown,
}
#[derive(Debug)]
pub enum Classification {
Failure {
label: String,
output: String,
},
Passthrough {
output: String,
},
Success {
label: String,
summary: String,
},
Bounded {
label: String,
output: String,
display: String,
size: usize,
},
Large {
label: String,
output: String,
size: usize,
},
}
pub fn label(command: &str) -> String {
let parts: Vec<&str> = command.split_whitespace().collect();
let Some(first) = parts.first() else {
return "command".to_string();
};
let name = match binary_index(&parts) {
Some(i) => parts[i].rsplit('/').next().unwrap_or(parts[i]),
None => first.rsplit('/').next().unwrap_or(first),
};
name.to_string()
}
fn binary_index(parts: &[&str]) -> Option<usize> {
let first = parts.first()?;
let base = first.rsplit('/').next().unwrap_or(first);
match base {
"sudo" => parts.get(1).map(|_| 1),
"env" => {
let mut i = 1;
while i < parts.len() && is_var_value(parts[i]) {
i += 1;
}
(i < parts.len()).then_some(i)
}
_ => Some(0),
}
}
fn is_var_value(token: &str) -> bool {
!token.starts_with('-') && token.contains('=')
}
pub fn detect_category(command: &str) -> CommandCategory {
let parts: Vec<&str> = command.split_whitespace().collect();
if parts.is_empty() {
return CommandCategory::Unknown;
}
let binary_idx = binary_index(&parts);
let (binary, subcommand) = match binary_idx {
Some(i) => (
parts[i].rsplit('/').next().unwrap_or(parts[i]),
parts.get(i + 1).copied().unwrap_or(""),
),
None => (
parts[0].rsplit('/').next().unwrap_or(parts[0]),
parts.get(1).copied().unwrap_or(""),
),
};
match binary {
"cargo" => match subcommand {
"test" | "clippy" | "build" | "fmt" | "check" => CommandCategory::Status,
"nextest" => {
let token_after_nextest = binary_idx.and_then(|i| parts.get(i + 2));
match token_after_nextest.copied() {
Some("run") => CommandCategory::Status,
_ => CommandCategory::Unknown,
}
}
_ => CommandCategory::Unknown,
},
"pytest" | "jest" | "vitest" | "go" | "npm" | "yarn" | "pnpm" | "bun" | "eslint"
| "ruff" | "mypy" | "tsc" | "make" | "rubocop" => CommandCategory::Status,
"git" => match subcommand {
"show" | "diff" => CommandCategory::Content,
"log" | "status" | "branch" | "tag" => CommandCategory::Data,
_ => CommandCategory::Unknown,
},
"cat" | "bat" | "less" => CommandCategory::Content,
"gh" => CommandCategory::Data,
"ls" | "find" | "grep" | "rg" => CommandCategory::Data,
_ => CommandCategory::Unknown,
}
}
pub fn classify(output: &CommandOutput, command: &str, patterns: &[Pattern]) -> Classification {
let merged = output.merged_lossy();
let lbl = label(command);
if output.exit_code != 0 {
let filtered = match pattern::find_matching(command, patterns) {
Some(pat) => {
if let Some(failure) = &pat.failure {
pattern::extract_failure(failure, &merged)
} else {
smart_truncate(&merged)
}
}
None => smart_truncate(&merged),
};
return Classification::Failure {
label: lbl,
output: filtered,
};
}
if merged.len() <= SMALL_THRESHOLD {
return Classification::Passthrough { output: merged };
}
if let Some(pat) = pattern::find_matching(command, patterns) {
if let Some(sp) = &pat.success {
if let Some(summary) = pattern::extract_summary(sp, &merged) {
return Classification::Success {
label: lbl,
summary,
};
}
}
}
let category = detect_category(command);
match category {
CommandCategory::Status => {
Classification::Success {
label: lbl,
summary: String::new(),
}
}
CommandCategory::Content | CommandCategory::Unknown => {
let size = merged.len();
let display = bounded_truncate(&merged);
Classification::Bounded {
label: lbl,
output: merged,
display,
size,
}
}
CommandCategory::Data => {
let size = merged.len();
Classification::Large {
label: lbl,
output: merged,
size,
}
}
}
}
fn floor_char_boundary(s: &str, idx: usize) -> usize {
if idx >= s.len() {
return s.len();
}
let mut i = idx;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
fn ceil_char_boundary(s: &str, idx: usize) -> usize {
if idx >= s.len() {
return s.len();
}
let mut i = idx;
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
pub fn bounded_truncate(output: &str) -> String {
if output.len() <= DISPLAY_CAP {
return output.to_string();
}
let head_budget = (DISPLAY_CAP as f64 * 0.6) as usize; let tail_budget = DISPLAY_CAP - head_budget;
let (head_end, tail_start) = cut_boundaries(output, head_budget, tail_budget);
let clamped_head = floor_char_boundary(output, head_budget).min(head_end);
let clamped_tail =
ceil_char_boundary(output, output.len().saturating_sub(tail_budget)).max(tail_start);
debug_assert!(
clamped_head + (output.len() - clamped_tail) <= DISPLAY_CAP,
"head+tail slices ({clamped_head} + {} bytes) must not exceed DISPLAY_CAP",
output.len() - clamped_tail
);
let truncated_bytes = output.len() - clamped_head - (output.len() - clamped_tail);
let marker =
format!("... [{truncated_bytes} bytes truncated → use `oo recall` to query] ...\n");
let mut result = String::with_capacity(DISPLAY_CAP + marker.len());
result.push_str(&output[..clamped_head]);
result.push_str(&marker);
result.push_str(&output[clamped_tail..]);
result
}
fn cut_boundaries(output: &str, head_budget: usize, tail_budget: usize) -> (usize, usize) {
let raw_tail = output.len().saturating_sub(tail_budget);
let mut newline_count = 0usize;
let mut first_nl_at_or_after_head: Option<usize> = None;
let mut last_nl_before_raw_tail: Option<usize> = None;
for (i, b) in output.as_bytes().iter().enumerate() {
if *b != b'\n' {
continue;
}
newline_count += 1;
if i >= raw_tail && first_nl_at_or_after_head.is_some() {
break;
}
if i >= head_budget && first_nl_at_or_after_head.is_none() {
first_nl_at_or_after_head = Some(i);
}
if i < raw_tail {
last_nl_before_raw_tail = Some(i);
}
}
if newline_count < 2 {
let head = floor_char_boundary(output, head_budget);
let tail = ceil_char_boundary(output, raw_tail);
return (head, tail);
}
let head_end = match first_nl_at_or_after_head {
Some(pos) => pos + 1, None => floor_char_boundary(output, head_budget),
};
let tail_start = match last_nl_before_raw_tail {
Some(pos) => pos + 1, None => ceil_char_boundary(output, raw_tail),
};
if head_end >= tail_start {
return (head_end, output.len());
}
(head_end, tail_start)
}
pub fn smart_truncate(output: &str) -> String {
let lines: Vec<&str> = output.lines().collect();
let total = lines.len();
if total <= TRUNCATION_THRESHOLD {
return output.to_string();
}
let budget = total.min(MAX_LINES);
let head_count = (budget as f64 * 0.6).ceil() as usize;
let tail_count = budget - head_count;
let truncated = total - head_count - tail_count;
let mut result = lines[..head_count].join("\n");
if truncated > 0 {
result.push_str(&format!("\n... [{truncated} lines truncated] ...\n"));
}
if tail_count > 0 {
result.push_str(&lines[total - tail_count..].join("\n"));
}
result
}
#[cfg(test)]
#[path = "classify_tests.rs"]
mod tests;