#[cfg(test)]
use crate::classify_bounded::cut_boundaries;
pub use crate::classify_bounded::{bounded_truncate, bounded_truncate_indexed};
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 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,
full_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,
full_output: merged,
};
}
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,
}
}
}
}
pub fn smart_truncate(output: &str) -> String {
let lines: Vec<&str> = output.lines().collect();
let total = lines.len();
if total <= MAX_LINES {
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;
#[cfg(test)]
#[path = "classify_trunc_tests.rs"]
mod trunc_tests;