use once_cell::sync::Lazy;
use regex::Regex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutKind {
Log,
Diff,
Grep,
}
static GREP_LINE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^(?:[A-Za-z]:)?[^:\n]*[A-Za-z./\\][^:\n]*:\d+:").unwrap());
const MIN_LINES: usize = 3;
const MIN_LOG_LINES: usize = 8;
pub fn detect(text: &str) -> Option<OutKind> {
if is_diff(text.trim_start()) {
return Some(OutKind::Diff);
}
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.len() < MIN_LINES {
return None;
}
if is_grep(&lines) {
return Some(OutKind::Grep);
}
if is_log(&lines) {
return Some(OutKind::Log);
}
None
}
fn is_diff(t: &str) -> bool {
if t.starts_with("diff --git ") {
return true;
}
let has_hunk = t.starts_with("@@ ") || t.contains("\n@@ ");
let has_file = t.starts_with("--- ") || t.contains("\n--- ") || t.contains("\n+++ ");
has_hunk && has_file
}
fn is_grep(lines: &[&str]) -> bool {
let matches = lines.iter().filter(|l| GREP_LINE.is_match(l)).count();
matches >= MIN_LINES && matches * 4 >= lines.len() * 3
}
fn is_log(lines: &[&str]) -> bool {
if lines.len() < MIN_LOG_LINES {
return false;
}
let level = lines
.iter()
.filter(|l| super::signals::LEVEL.is_match(l))
.count();
if level * 100 >= lines.len() * 30 {
return true;
}
let strong = lines
.iter()
.filter(|l| super::signals::STRONG.is_match(l))
.count();
strong >= 2 && strong * 10 >= lines.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_git_diff() {
let d = "diff --git a/x.rs b/x.rs\n--- a/x.rs\n+++ b/x.rs\n@@ -1 +1 @@\n-old\n+new";
assert_eq!(detect(d), Some(OutKind::Diff));
}
#[test]
fn detects_plain_unified_diff() {
let d = "--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n line\n-old\n+new";
assert_eq!(detect(d), Some(OutKind::Diff));
}
#[test]
fn detects_grep_output() {
let g = "src/main.rs:10: let x = 1;\n\
src/main.rs:42: foo(x);\n\
src/lib.rs:7:pub fn foo() {}";
assert_eq!(detect(g), Some(OutKind::Grep));
}
#[test]
fn clock_times_are_not_grep() {
let log = "12:00:01 service started ok\n\
12:00:02 handling request fine\n\
12:00:03 all good here now";
assert_ne!(detect(log), Some(OutKind::Grep));
}
#[test]
fn detects_log_with_failures() {
let log = "INFO build started\n\
INFO compiling module a\n\
INFO compiling module b\n\
ERROR failed to resolve symbol foo\n\
INFO compiling module c\n\
ERROR type mismatch in bar\n\
INFO compiling module d\n\
INFO done with warnings";
assert_eq!(detect(log), Some(OutKind::Log));
}
#[test]
fn long_prose_mentioning_failures_is_not_log() {
let prose: Vec<String> = (0..104)
.map(|i| format!("Step {i}: describe the section thoroughly, capturing every detail of the request in flowing prose."))
.chain([
"Errors and fixes: list all errors that you ran into, and how you fixed them.".to_string(),
"Tool calls will be rejected and you will fail the task entirely.".to_string(),
])
.collect();
assert_eq!(detect(&prose.join("\n")), None);
}
#[test]
fn real_compaction_prompt_is_not_log() {
let t = include_str!("../../../tests/fixtures/compaction_prompt.txt");
assert_eq!(detect(t), None);
}
#[test]
fn long_level_heavy_log_with_few_errors_still_detects() {
let mut lines: Vec<String> = (0..100)
.map(|i| format!("INFO compiling module {i}"))
.collect();
lines.push("ERROR failed to resolve symbol foo".to_string());
lines.push("ERROR type mismatch in bar".to_string());
assert_eq!(detect(&lines.join("\n")), Some(OutKind::Log));
}
#[test]
fn plain_prose_is_not_tool_output() {
let prose = "The quarterly report covers revenue and costs.\n\
Margins improved across every region this year.\n\
The board approved the new budget unanimously.";
assert_eq!(detect(prose), None);
}
}