use regex::Regex;
use serde_json::{json, Value};
use std::sync::OnceLock;
pub(crate) const BUFFER_QUERY_INLINE_CAP: usize = 100;
const HEAD_LINES: usize = 20;
const TAIL_LINES: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandType {
Test,
Build,
Generic,
}
fn test_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?x)
(?:^|\s|/)
(?:
cargo\s+test
| pytest
| npm\s+test
| npx\s+jest
| jest
| go\s+test
| mvn\s+test
| gradle\s+test
)
(?:\s|$)",
)
.expect("test regex")
})
}
fn build_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?x)
(?:^|\s|/)
(?:
cargo\s+(?:build|clippy|check)
| npm\s+run\s+build
| make(?:\s|$)
| tsc(?:\s|$)
| gcc(?:\s|$)
| g\+\+(?:\s|$)
| clang(?:\s|$)
| javac(?:\s|$)
| go\s+build
)",
)
.expect("build regex")
})
}
fn cargo_test_result_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(\d+)\s+passed;\s+(\d+)\s+failed;\s+(\d+)\s+ignored")
.expect("cargo test regex")
})
}
fn rust_error_code_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^error\[E\d+\]").expect("rust error regex"))
}
fn warning_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^warning(\[.+\])?:").expect("warning regex"))
}
pub fn detect_command_type(command: &str) -> CommandType {
if test_re().is_match(command) {
CommandType::Test
} else if build_re().is_match(command) {
CommandType::Build
} else {
CommandType::Generic
}
}
pub fn detect_terminal_filter(cmd: &str) -> Option<usize> {
const TERMINAL_FILTERS: &[&str] = &[
"grep", "egrep", "fgrep", "rg", "head", "tail", "sed", "awk", "cut", "wc", "sort", "uniq",
"tr",
];
let mut last_pipe: Option<usize> = None;
let mut in_single = false;
let mut in_double = false;
let mut escape_next = false;
for (i, ch) in cmd.char_indices() {
if escape_next {
escape_next = false;
continue;
}
match ch {
'\\' if !in_single => escape_next = true,
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'|' if !in_single && !in_double => {
let next_char = cmd[i + 1..].chars().next();
let prev_char = if i > 0 { cmd[..i].chars().last() } else { None };
if next_char != Some('|') && prev_char != Some('|') {
last_pipe = Some(i);
}
}
_ => {}
}
}
let pipe_pos = last_pipe?;
let after_pipe = cmd[pipe_pos + 1..].trim_start();
let token = after_pipe
.split(|c: char| c.is_whitespace())
.next()
.unwrap_or("");
let name = token.rsplit('/').next().unwrap_or(token);
if TERMINAL_FILTERS.contains(&name) {
Some(pipe_pos)
} else {
None
}
}
pub fn needs_summary(stdout: &str, stderr: &str) -> bool {
(stdout.len() + stderr.len()) / 4 > crate::tools::MAX_INLINE_TOKENS
}
pub fn summarize_test_output(stdout: &str, stderr: &str, exit_code: i32) -> Value {
let mut passed: u64 = 0;
let mut failed: u64 = 0;
let mut ignored: u64 = 0;
let combined = if stderr.is_empty() {
stdout.to_string()
} else {
format!("{stdout}\n{stderr}")
};
let re = cargo_test_result_re();
for line in combined.lines() {
if let Some(caps) = re.captures(line) {
passed += caps[1].parse::<u64>().unwrap_or(0);
failed += caps[2].parse::<u64>().unwrap_or(0);
ignored += caps[3].parse::<u64>().unwrap_or(0);
}
}
let failures = extract_test_failures(&combined);
let mut result = json!({
"type": "test",
"exit_code": exit_code,
"passed": passed,
});
if failed > 0 {
result["failed"] = json!(failed);
}
if ignored > 0 {
result["ignored"] = json!(ignored);
}
if let Some(f) = failures {
result["failures"] = Value::String(f);
}
result
}
pub fn summarize_build_output(stdout: &str, stderr: &str, exit_code: i32) -> Value {
let combined = if stderr.is_empty() {
stdout.to_string()
} else if stdout.is_empty() {
stderr.to_string()
} else {
format!("{stdout}\n{stderr}")
};
let mut errors: u64 = 0;
let mut warnings: u64 = 0;
let mut first_error: Option<String> = None;
let err_re = rust_error_code_re();
let warn_re = warning_re();
let lines: Vec<&str> = combined.lines().collect();
for (i, line) in lines.iter().enumerate() {
if err_re.is_match(line) {
errors += 1;
if first_error.is_none() {
first_error = Some(extract_error_block(&lines, i));
}
} else if warn_re.is_match(line) {
warnings += 1;
}
}
let mut result = json!({
"type": "build",
"exit_code": exit_code,
});
if errors > 0 {
result["errors"] = json!(errors);
}
if warnings > 0 {
result["warnings"] = json!(warnings);
}
if let Some(err) = first_error {
result["first_error"] = Value::String(err);
}
result
}
pub fn summarize_generic(stdout: &str, stderr: &str, exit_code: i32) -> Value {
let stdout_lines: Vec<&str> = stdout.lines().collect();
let total_stdout_lines = stdout_lines.len();
let summarized_stdout = if total_stdout_lines > HEAD_LINES + TAIL_LINES {
let head: Vec<&str> = stdout_lines[..HEAD_LINES].to_vec();
let tail: Vec<&str> = stdout_lines[total_stdout_lines - TAIL_LINES..].to_vec();
let omitted = total_stdout_lines - HEAD_LINES - TAIL_LINES;
format!(
"{}\n--- {} lines omitted ---\n{}",
head.join("\n"),
omitted,
tail.join("\n")
)
} else {
stdout.to_string()
};
let mut result = json!({
"type": "generic",
"exit_code": exit_code,
});
if !summarized_stdout.is_empty() {
result["stdout"] = Value::String(summarized_stdout);
}
if !stderr.is_empty() {
result["stderr"] = Value::String(stderr.to_string());
}
result
}
pub fn count_lines(s: &str) -> usize {
if s.is_empty() {
0
} else {
s.lines().count()
}
}
#[allow(dead_code)]
pub(crate) fn truncate_lines(text: &str, max_lines: usize) -> (String, usize, usize) {
let total = count_lines(text);
if total <= max_lines {
return (text.to_string(), total, total);
}
let truncated = text.lines().take(max_lines).collect::<Vec<_>>().join("\n");
(truncated, max_lines, total)
}
pub(crate) fn truncate_lines_and_bytes(
text: &str,
max_lines: usize,
max_bytes: usize,
) -> (String, usize, usize) {
let total = count_lines(text);
let mut result = String::new();
let mut lines_shown = 0;
for line in text.lines().take(max_lines) {
let needed = if result.is_empty() {
line.len()
} else {
line.len() + 1 };
if result.len() + needed > max_bytes {
break;
}
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
lines_shown += 1;
}
(result, lines_shown, total)
}
fn extract_test_failures(output: &str) -> Option<String> {
let lines: Vec<&str> = output.lines().collect();
let mut start = None;
let mut end = None;
for (i, line) in lines.iter().enumerate() {
if line.trim() == "failures:" {
if start.is_none() {
start = Some(i);
} else {
end = Some(i);
break;
}
}
}
if let Some(s) = start {
let e = end.unwrap_or(lines.len());
let section: Vec<&str> = if let Some(end_idx) = end {
let block_end = lines[end_idx..]
.iter()
.position(|l| l.starts_with("test result:"))
.map(|p| end_idx + p)
.unwrap_or(lines.len());
lines[s..block_end].to_vec()
} else {
lines[s..e].to_vec()
};
let text = section.join("\n").trim().to_string();
if text.is_empty() {
None
} else {
Some(text)
}
} else {
None
}
}
fn extract_error_block(lines: &[&str], start: usize) -> String {
let err_re = rust_error_code_re();
let warn_re = warning_re();
let mut block = vec![lines[start]];
for line in &lines[start + 1..] {
if line.is_empty()
|| err_re.is_match(line)
|| warn_re.is_match(line)
|| line.starts_with("error:")
{
break;
}
block.push(line);
}
block.join("\n")
}
pub(crate) fn strip_ansi_codes(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
match chars.peek().copied() {
Some('[') => {
chars.next(); for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break; }
}
}
_ => {
result.push(ch);
}
}
} else {
result.push(ch);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_ansi_codes_removes_color_sequences() {
let input = "\x1b[32mINFO\x1b[0m some message";
assert_eq!(strip_ansi_codes(input), "INFO some message");
}
#[test]
fn strip_ansi_codes_removes_256_color_sequences() {
let input = "\x1b[1;38;5;220mWARN\x1b[0m something";
assert_eq!(strip_ansi_codes(input), "WARN something");
}
#[test]
fn strip_ansi_codes_plain_text_unchanged() {
let input = "no escape codes here\nline two";
assert_eq!(strip_ansi_codes(input), input);
}
#[test]
fn strip_ansi_codes_preserves_newlines() {
let input = "\x1b[32mline1\x1b[0m\nline2\n\x1b[31mline3\x1b[0m";
assert_eq!(strip_ansi_codes(input), "line1\nline2\nline3");
}
#[test]
fn strip_ansi_codes_non_csi_escape_preserved() {
let input = "\x1b=hello";
assert_eq!(strip_ansi_codes(input), "\x1b=hello");
}
#[test]
fn strip_ansi_codes_empty_string() {
assert_eq!(strip_ansi_codes(""), "");
}
#[test]
fn detect_test_command() {
assert_eq!(detect_command_type("cargo test"), CommandType::Test);
assert_eq!(
detect_command_type("cargo test --release"),
CommandType::Test
);
assert_eq!(detect_command_type("pytest tests/"), CommandType::Test);
assert_eq!(detect_command_type("npm test"), CommandType::Test);
assert_eq!(detect_command_type("npx jest"), CommandType::Test);
assert_eq!(detect_command_type("go test ./..."), CommandType::Test);
}
#[test]
fn detect_build_command() {
assert_eq!(detect_command_type("cargo build"), CommandType::Build);
assert_eq!(
detect_command_type("cargo clippy -- -D warnings"),
CommandType::Build
);
assert_eq!(detect_command_type("npm run build"), CommandType::Build);
assert_eq!(detect_command_type("make"), CommandType::Build);
assert_eq!(detect_command_type("tsc"), CommandType::Build);
assert_eq!(detect_command_type("gcc main.c"), CommandType::Build);
}
#[test]
fn detect_generic_command() {
assert_eq!(detect_command_type("echo hello"), CommandType::Generic);
assert_eq!(detect_command_type("ls -la"), CommandType::Generic);
assert_eq!(detect_command_type("cat file.txt"), CommandType::Generic);
}
#[test]
fn short_output_not_summarized() {
assert!(!needs_summary("hello\nworld\n", ""));
}
#[test]
fn long_output_needs_summary() {
let stdout: String = (1..=3000).map(|i| format!("line {}\n", i)).collect();
assert!(needs_summary(&stdout, ""));
}
#[test]
fn summarize_cargo_test_all_pass() {
let stdout = "running 5 tests\ntest a ... ok\ntest b ... ok\ntest c ... ok\ntest d ... ok\ntest e ... ok\n\ntest result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s\n";
let summary = summarize_test_output(stdout, "", 0);
assert_eq!(summary["passed"], 5);
assert!(
summary.get("failed").is_none(),
"failed:0 should be omitted"
);
assert!(
summary.get("ignored").is_none(),
"ignored:0 should be omitted"
);
assert!(summary.get("failures").is_none() || summary["failures"].is_null());
}
#[test]
fn summarize_cargo_test_with_failures() {
let stdout = "running 3 tests\ntest ok_test ... ok\ntest failing_test ... FAILED\ntest another ... ok\n\nfailures:\n\n---- failing_test stdout ----\nthread 'failing_test' panicked at 'assertion failed'\n\nfailures:\n failing_test\n\ntest result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out\n";
let summary = summarize_test_output(stdout, "", 1);
assert_eq!(summary["passed"], 2);
assert_eq!(summary["failed"], 1);
let failures = summary["failures"].as_str().unwrap();
assert!(failures.contains("failing_test"));
}
#[test]
fn summarize_cargo_test_multiple_binaries() {
let stdout = "\
running 3 tests
test a ... ok
test b ... ok
test c ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
running 2 tests
test d ... ok
test e ... FAILED
failures:
---- e stdout ----
assertion failed
failures:
e
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
";
let summary = summarize_test_output(stdout, "", 1);
assert_eq!(summary["passed"], 4);
assert_eq!(summary["failed"], 1);
}
#[test]
fn summarize_build_errors() {
let stderr = "error[E0308]: mismatched types\n --> src/main.rs:5:20\n |\n5 | let x: String = 42;\n | ^^ expected `String`, found integer\n\nwarning: unused variable: `y`\n --> src/main.rs:3:9\n |\n3 | let y = 1;\n | ^ help: consider prefixing with an underscore: `_y`\n\nerror: aborting due to 1 previous error; 1 warning emitted\n";
let summary = summarize_build_output("", stderr, 1);
assert_eq!(summary["errors"], 1); assert_eq!(summary["warnings"], 1);
assert!(summary["first_error"].as_str().unwrap().contains("E0308"));
}
#[test]
fn summarize_build_no_errors() {
let stderr = "warning: unused variable: `x`\n --> src/main.rs:2:9\n";
let summary = summarize_build_output("", stderr, 0);
assert!(
summary.get("errors").is_none(),
"errors:0 should be omitted"
);
assert_eq!(summary["warnings"], 1);
assert!(summary.get("first_error").is_none() || summary["first_error"].is_null());
}
#[test]
fn summarize_generic_head_tail() {
let lines: String = (1..=100).map(|i| format!("line {}\n", i)).collect();
let summary = summarize_generic(&lines, "", 0);
let output = summary["stdout"].as_str().unwrap();
assert!(output.contains("line 1"));
assert!(output.contains("line 20"));
assert!(output.contains("lines omitted"));
assert!(output.contains("line 100"));
}
#[test]
fn summarize_generic_short_output_verbatim() {
let stdout = "line 1\nline 2\nline 3\n";
let summary = summarize_generic(stdout, "", 0);
let output = summary["stdout"].as_str().unwrap();
assert_eq!(output, stdout);
assert!(!output.contains("omitted"));
}
#[test]
fn summarize_generic_includes_stderr() {
let summary = summarize_generic("out\n", "err\n", 1);
assert!(summary.get("stderr").is_some());
assert_eq!(summary["stderr"].as_str().unwrap(), "err\n");
}
#[test]
fn summarize_generic_omits_empty_stderr() {
let summary = summarize_generic("out\n", "", 0);
assert!(summary.get("stderr").is_none() || summary["stderr"].is_null());
}
#[test]
fn summarize_generic_omits_empty_stdout() {
let summary = summarize_generic("", "err\n", 1);
assert!(summary.get("stdout").is_none() || summary["stdout"].is_null());
assert_eq!(summary["stderr"].as_str().unwrap(), "err\n");
}
#[test]
fn count_lines_empty() {
assert_eq!(count_lines(""), 0);
}
#[test]
fn count_lines_normal() {
assert_eq!(count_lines("a\nb\nc"), 3);
}
#[test]
fn truncate_lines_short_returns_unchanged() {
let text = "a\nb\nc";
let (out, shown, total) = truncate_lines(text, 10);
assert_eq!(out, text);
assert_eq!(shown, 3);
assert_eq!(total, 3);
}
#[test]
fn truncate_lines_exact_limit_not_truncated() {
let text: String = (1..=5)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let (out, shown, total) = truncate_lines(&text, 5);
assert_eq!(shown, 5);
assert_eq!(total, 5);
assert_eq!(out, text);
}
#[test]
fn truncate_lines_long_truncates_correctly() {
let text: String = (1..=10)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let (out, shown, total) = truncate_lines(&text, 3);
assert_eq!(shown, 3);
assert_eq!(total, 10);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "line 1");
assert_eq!(lines[2], "line 3");
}
#[test]
fn truncate_lines_empty_string() {
let (out, shown, total) = truncate_lines("", 10);
assert_eq!(out, "");
assert_eq!(shown, 0);
assert_eq!(total, 0);
}
#[test]
fn truncate_lines_and_bytes_line_limit_wins() {
let text = "aaa\nbbb\nccc";
let (out, shown, total) = truncate_lines_and_bytes(text, 2, 1000);
assert_eq!(out, "aaa\nbbb");
assert_eq!(shown, 2);
assert_eq!(total, 3);
}
#[test]
fn truncate_lines_and_bytes_byte_limit_wins() {
let text = "aaa\nbbb\nccc\nddd\neee";
let (out, shown, total) = truncate_lines_and_bytes(text, 10, 8);
assert_eq!(out, "aaa\nbbb");
assert_eq!(shown, 2);
assert_eq!(total, 5);
}
#[test]
fn truncate_lines_and_bytes_both_fit() {
let text = "a\nb\nc";
let (out, shown, total) = truncate_lines_and_bytes(text, 10, 1000);
assert_eq!(out, text);
assert_eq!(shown, 3);
assert_eq!(total, 3);
}
#[test]
fn truncate_lines_and_bytes_long_lines_stay_under_byte_budget() {
let long_line = "x".repeat(200);
let text: String = (0..100).map(|_| format!("{long_line}\n")).collect();
let (out, shown, _total) = truncate_lines_and_bytes(&text, 100, 10_000);
assert!(
out.len() <= 10_000,
"output {} bytes exceeds budget",
out.len()
);
assert!(shown < 100, "expected byte truncation, shown={shown}");
}
#[test]
fn terminal_filter_grep() {
let pos = detect_terminal_filter("cargo build 2>&1 | grep error");
assert!(pos.is_some());
}
#[test]
fn terminal_filter_head() {
let pos = detect_terminal_filter("cat big_file.log | head -20");
assert!(pos.is_some());
}
#[test]
fn terminal_filter_tail() {
let pos = detect_terminal_filter("journalctl | tail -100");
assert!(pos.is_some());
}
#[test]
fn terminal_filter_no_pipe() {
assert!(detect_terminal_filter("cargo build").is_none());
}
#[test]
fn terminal_filter_non_filter_pipe() {
assert!(detect_terminal_filter("cat file | cargo install").is_none());
}
#[test]
fn terminal_filter_quoted_pipe_ignored() {
assert!(detect_terminal_filter("echo 'foo | bar'").is_none());
}
#[test]
fn terminal_filter_nested_filters_last_wins() {
let cmd = "cat file | sed 's/x/y/' | grep foo";
let pos = detect_terminal_filter(cmd);
assert!(pos.is_some());
let pipe_pos = pos.unwrap();
assert!(cmd[pipe_pos + 1..].trim_start().starts_with("grep"));
}
#[test]
fn terminal_filter_returns_pipe_position() {
let cmd = "cargo build | grep error";
let pos = detect_terminal_filter(cmd).unwrap();
assert_eq!(&cmd[pos..pos + 1], "|");
}
#[test]
fn terminal_filter_logical_or_not_a_pipe() {
assert!(detect_terminal_filter("ls || grep foo").is_none());
}
#[test]
fn terminal_filter_path_prefixed_filter() {
let pos = detect_terminal_filter("ls | /usr/bin/grep foo");
assert!(pos.is_some());
}
#[test]
fn bash_stderr_pipe_not_treated_as_filter() {
assert!(detect_terminal_filter("cmd |& grep foo").is_none());
}
}