use regex::Regex;
use std::sync::LazyLock;
pub use self::builtins::builtin_patterns;
pub use self::toml::{FailureSection, PatternFile, load_user_patterns, parse_pattern_str};
pub fn builtins() -> &'static [Pattern] {
&BUILTINS
}
pub struct Pattern {
pub command_match: Regex,
pub success: Option<SuccessPattern>,
pub failure: Option<FailurePattern>,
}
pub struct SuccessPattern {
pub pattern: Regex,
pub summary: String,
}
pub struct FailurePattern {
pub strategy: FailureStrategy,
}
pub enum FailureStrategy {
Tail {
lines: usize,
},
Head {
lines: usize,
},
Grep {
pattern: Regex,
},
Between {
start: String,
end: String,
},
}
pub fn find_matching<'a>(command: &str, patterns: &'a [Pattern]) -> Option<&'a Pattern> {
patterns.iter().find(|p| p.command_match.is_match(command))
}
pub fn find_matching_ref<'a>(command: &str, patterns: &[&'a Pattern]) -> Option<&'a Pattern> {
patterns
.iter()
.find(|p| p.command_match.is_match(command))
.copied()
}
pub fn extract_summary(pat: &SuccessPattern, output: &str) -> Option<String> {
let caps = pat.pattern.captures(output)?;
let mut summary = pat.summary.clone();
for name in pat.pattern.capture_names().flatten() {
if let Some(m) = caps.name(name) {
summary = summary.replace(&format!("{{{name}}}"), m.as_str());
}
}
Some(summary)
}
pub fn extract_failure(pat: &FailurePattern, output: &str) -> String {
match &pat.strategy {
FailureStrategy::Tail { lines } => {
let all: Vec<&str> = output.lines().collect();
let start = all.len().saturating_sub(*lines);
all[start..].join("\n")
}
FailureStrategy::Head { lines } => {
let all: Vec<&str> = output.lines().collect();
let end = (*lines).min(all.len());
all[..end].join("\n")
}
FailureStrategy::Grep { pattern } => output
.lines()
.filter(|l| pattern.is_match(l))
.collect::<Vec<_>>()
.join("\n"),
FailureStrategy::Between { start, end } => {
let mut capturing = false;
let mut lines = Vec::new();
for line in output.lines() {
if !capturing && line.contains(start.as_str()) {
capturing = true;
}
if capturing {
lines.push(line);
if line.contains(end.as_str()) {
break;
}
}
}
lines.join("\n")
}
}
}
mod builtins;
mod toml;
static BUILTINS: LazyLock<Vec<Pattern>> = LazyLock::new(builtin_patterns);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builtin_pytest_success() {
let patterns = builtins();
let pat = find_matching("pytest tests/ -x", patterns).unwrap();
let output = "collected 47 items\n\
.................\n\
47 passed in 3.2s\n";
let summary = extract_summary(pat.success.as_ref().unwrap(), output).unwrap();
assert_eq!(summary, "47 passed, 3.2s");
}
#[test]
fn test_builtin_pytest_failure_tail() {
let patterns = builtins();
let pat = find_matching("pytest -x", patterns).unwrap();
let fail_pat = pat.failure.as_ref().unwrap();
let lines: String = (0..50).map(|i| format!("line {i}\n")).collect();
let result = extract_failure(fail_pat, &lines);
assert!(result.contains("line 20"));
assert!(result.contains("line 49"));
assert!(!result.contains("line 0\n"));
}
#[test]
fn test_builtin_cargo_test_success() {
let patterns = builtins();
let pat = find_matching("cargo test --release", patterns).unwrap();
let output = "running 15 tests\n\
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.45s\n";
let summary = extract_summary(pat.success.as_ref().unwrap(), output).unwrap();
assert_eq!(summary, "15 passed, 3.45s");
}
#[test]
fn test_command_matching() {
let patterns = builtins();
assert!(find_matching("pytest tests/", patterns).is_some());
assert!(find_matching("cargo test", patterns).is_some());
assert!(find_matching("cargo build", patterns).is_some());
assert!(find_matching("go test ./...", patterns).is_some());
assert!(find_matching("ruff check src/", patterns).is_some());
assert!(find_matching("eslint .", patterns).is_some());
assert!(find_matching("tsc --noEmit", patterns).is_some());
assert!(find_matching("cargo clippy", patterns).is_some());
}
#[test]
fn test_no_match_unknown_command() {
let patterns = builtins();
assert!(find_matching("curl https://example.com", patterns).is_none());
}
#[test]
fn test_summary_template_formatting() {
let pat = SuccessPattern {
pattern: Regex::new(r"(?P<a>\d+) things, (?P<b>\d+) items").unwrap(),
summary: "{a} things and {b} items".into(),
};
let result = extract_summary(&pat, "found 5 things, 3 items here").unwrap();
assert_eq!(result, "5 things and 3 items");
}
#[test]
fn test_failure_strategy_head() {
let strat = FailurePattern {
strategy: FailureStrategy::Head { lines: 3 },
};
let output = "line1\nline2\nline3\nline4\nline5\n";
let result = extract_failure(&strat, output);
assert_eq!(result, "line1\nline2\nline3");
}
#[test]
fn test_failure_strategy_grep() {
let strat = FailurePattern {
strategy: FailureStrategy::Grep {
pattern: Regex::new(r"ERROR").unwrap(),
},
};
let output = "INFO ok\nERROR bad\nINFO fine\nERROR worse\n";
let result = extract_failure(&strat, output);
assert_eq!(result, "ERROR bad\nERROR worse");
}
#[test]
fn test_failure_strategy_between() {
let strat = FailurePattern {
strategy: FailureStrategy::Between {
start: "FAILURES".into(),
end: "summary".into(),
},
};
let output = "stuff\nFAILURES\nerror 1\nerror 2\nshort test summary\nmore\n";
let result = extract_failure(&strat, output);
assert_eq!(result, "FAILURES\nerror 1\nerror 2\nshort test summary");
}
#[test]
fn test_load_pattern_from_toml() {
let toml = r#"
command_match = "^myapp test"
[success]
pattern = '(?P<count>\d+) tests passed'
summary = "{count} tests passed"
[failure]
strategy = "tail"
lines = 20
"#;
let pat = parse_pattern_str(toml).unwrap();
assert!(pat.command_match.is_match("myapp test --verbose"));
let summary = extract_summary(pat.success.as_ref().unwrap(), "42 tests passed").unwrap();
assert_eq!(summary, "42 tests passed");
}
#[test]
fn test_invalid_toml_returns_error() {
let result = parse_pattern_str("not valid toml {{{");
assert!(result.is_err());
}
#[test]
fn test_invalid_regex_returns_error() {
let toml = r#"
command_match = "[invalid"
"#;
let result = parse_pattern_str(toml);
assert!(result.is_err());
}
#[test]
fn test_user_patterns_override_builtins() {
let user_pat = parse_pattern_str(
r#"
command_match = "^pytest"
[success]
pattern = '(?P<n>\d+) ok'
summary = "{n} ok"
"#,
)
.unwrap();
let mut all = vec![user_pat];
all.extend(builtin_patterns());
let pat = find_matching("pytest -x", &all).unwrap();
let summary = extract_summary(pat.success.as_ref().unwrap(), "10 ok").unwrap();
assert_eq!(summary, "10 ok");
}
}