use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestFailure {
pub test_name: String,
pub test_file: String,
pub error_type: String,
pub message: String,
#[serde(default)]
pub source_file: String,
#[serde(default)]
pub source_line: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestAnalysisInput {
pub failures: Vec<TestFailure>,
#[serde(default)]
pub total: u64,
#[serde(default)]
pub passed: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct FailureCluster {
pub id: String,
pub pattern: String,
pub category: FailureCategory,
pub count: usize,
pub affected_files: Vec<String>,
pub example_tests: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggested_fix: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum FailureCategory {
MissingMethod,
MissingClass,
ReturnTypeChange,
ErrorCodeChange,
AssertionMismatch,
MockError,
FatalError,
SignatureChange,
EnvironmentError,
Other,
}
#[derive(Debug, Clone, Serialize)]
pub struct TestAnalysis {
pub component: String,
pub total_failures: usize,
pub total_tests: u64,
pub total_passed: u64,
pub clusters: Vec<FailureCluster>,
pub hints: Vec<String>,
}
pub fn analyze(component: &str, input: &TestAnalysisInput) -> TestAnalysis {
let failures = &input.failures;
if failures.is_empty() {
return TestAnalysis {
component: component.to_string(),
total_failures: 0,
total_tests: input.total,
total_passed: input.passed,
clusters: Vec::new(),
hints: vec!["All tests passing — nothing to analyze.".to_string()],
};
}
let mut cluster_map: HashMap<String, Vec<&TestFailure>> = HashMap::new();
for failure in failures {
let key = cluster_key(failure);
cluster_map.entry(key).or_default().push(failure);
}
let mut clusters: Vec<FailureCluster> = cluster_map
.into_iter()
.map(|(key, members)| {
let category = categorize(&members[0].error_type, &members[0].message);
let pattern = derive_pattern(&members);
let suggested_fix = suggest_fix(&category, &members[0].message);
let mut affected_files: Vec<String> =
members.iter().map(|f| f.test_file.clone()).collect();
affected_files.sort();
affected_files.dedup();
let example_tests: Vec<String> = members
.iter()
.take(5)
.map(|f| f.test_name.clone())
.collect();
FailureCluster {
id: key,
pattern,
category,
count: members.len(),
affected_files,
example_tests,
suggested_fix,
}
})
.collect();
clusters.sort_by(|a, b| b.count.cmp(&a.count));
let hints = generate_hints(&clusters, failures.len());
TestAnalysis {
component: component.to_string(),
total_failures: failures.len(),
total_tests: input.total,
total_passed: input.passed,
clusters,
hints,
}
}
fn cluster_key(failure: &TestFailure) -> String {
let msg = &failure.message;
if let Some(pattern) = extract_pattern(msg, "Call to undefined method ", "(") {
return format!("missing_method::{}", pattern);
}
if let Some(pattern) = extract_pattern(msg, "Class \"", "\" not found") {
return format!("missing_class::{}", pattern);
}
if let Some(pattern) = extract_pattern(msg, "Call to undefined function ", "(") {
return format!("missing_function::{}", pattern);
}
if msg.contains("Cannot redeclare") {
let fn_name = extract_between(msg, "Cannot redeclare ", "(")
.unwrap_or_else(|| extract_between(msg, "Cannot redeclare ", " ").unwrap_or("unknown"));
return format!("fatal_redeclare::{}", fn_name);
}
if msg.contains("is an instance of") {
if let Some(expected) = extract_between(msg, "instance of \"", "\"") {
return format!("wrong_type::{}", expected);
}
}
if msg.contains("Failed asserting") {
if msg.contains("is identical to") {
return format!("assertion_mismatch::identical::{}", normalize_for_key(msg));
}
if msg.contains("matches expected") {
return format!("assertion_mismatch::expected::{}", normalize_for_key(msg));
}
if msg.contains("is true") || msg.contains("is false") {
return format!("assertion_mismatch::boolean::{}", normalize_for_key(msg));
}
if msg.contains("null") {
return format!("assertion_mismatch::null::{}", normalize_for_key(msg));
}
}
if msg.contains("WP_Error") || msg.contains("wp_error") {
if let Some(code) = extract_between(msg, "code: ", ")") {
return format!("wp_error::{}", code);
}
if let Some(code) = extract_between(msg, "'", "'") {
return format!("wp_error::{}", code);
}
}
if msg.contains("must be of type") {
let key = normalize_for_key(msg);
return format!("type_error::{}", key);
}
if msg.contains("MockObject") || msg.contains("mock") || msg.contains("stub") {
return format!("mock_error::{}", normalize_for_key(msg));
}
if msg.contains("configure()") || msg.contains("getMock") {
return format!("mock_config::{}", normalize_for_key(msg));
}
format!(
"{}::{}",
failure.error_type.replace('\\', "_"),
normalize_for_key(msg)
)
}
fn categorize(error_type: &str, message: &str) -> FailureCategory {
if message.contains("Cannot redeclare")
|| message.contains("Fatal error")
|| error_type.contains("Fatal")
{
return FailureCategory::FatalError;
}
if message.contains("Call to undefined method")
|| message.contains("Call to undefined function")
{
return FailureCategory::MissingMethod;
}
if message.contains("not found") && message.contains("Class") {
return FailureCategory::MissingClass;
}
if message.contains("must be of type")
|| message.contains("Argument #")
|| message.contains("Too few arguments")
{
return FailureCategory::SignatureChange;
}
if message.contains("is an instance of") || message.contains("Return value must be") {
return FailureCategory::ReturnTypeChange;
}
if message.contains("error code")
|| message.contains("WP_Error")
|| message.contains("rest_forbidden")
|| message.contains("ability_invalid")
{
return FailureCategory::ErrorCodeChange;
}
if message.contains("MockObject")
|| message.contains("configure()")
|| message.contains("cannot be configured")
|| message.contains("getMock")
|| error_type.contains("Mock")
{
return FailureCategory::MockError;
}
if message.contains("SQLITE")
|| message.contains("MySQL")
|| message.contains("table")
|| message.contains("database")
{
return FailureCategory::EnvironmentError;
}
if message.contains("Failed asserting") {
return FailureCategory::AssertionMismatch;
}
FailureCategory::Other
}
fn derive_pattern(members: &[&TestFailure]) -> String {
let first_msg = &members[0].message;
if members.iter().all(|f| f.message == *first_msg) {
return truncate(first_msg, 120);
}
let messages: Vec<&str> = members.iter().map(|f| f.message.as_str()).collect();
let common = common_prefix(&messages);
if common.len() > 20 {
return format!("{}... ({} variants)", truncate(&common, 80), members.len());
}
truncate(first_msg, 120)
}
fn suggest_fix(category: &FailureCategory, message: &str) -> Option<String> {
match category {
FailureCategory::MissingMethod => {
if let Some(method) = extract_between(message, "::", "(") {
Some(format!(
"Method '{}' was removed or renamed — check production code for the new name",
method
))
} else {
Some("Method was removed or renamed — check production code".to_string())
}
}
FailureCategory::MissingClass => {
Some("Class was moved or renamed — update imports and references".to_string())
}
FailureCategory::FatalError => {
if message.contains("Cannot redeclare") {
Some("Function is being included twice — check bootstrap and autoloading".to_string())
} else {
Some("Fatal error in test bootstrap — fix before other tests can run".to_string())
}
}
FailureCategory::ErrorCodeChange => {
Some("Error codes changed — update assertion strings to match new API".to_string())
}
FailureCategory::ReturnTypeChange => {
Some("Return type changed — update assertions (e.g., assertIsArray → assertInstanceOf(WP_Error::class))".to_string())
}
FailureCategory::MockError => {
Some("Mock configuration broken — the mocked class/method signature changed".to_string())
}
FailureCategory::SignatureChange => {
Some("Method signature changed — update call sites with new parameter list".to_string())
}
_ => None,
}
}
fn generate_hints(clusters: &[FailureCluster], total: usize) -> Vec<String> {
let mut hints = Vec::new();
if clusters.is_empty() {
return hints;
}
let largest = &clusters[0];
hints.push(format!(
"Largest cluster: {} failure(s) — {}",
largest.count,
truncate(&largest.pattern, 80),
));
let top3_count: usize = clusters.iter().take(3).map(|c| c.count).sum();
if clusters.len() > 1 {
hints.push(format!(
"Top {} cluster(s) account for {}/{} failures ({:.0}%)",
clusters.len().min(3),
top3_count,
total,
(top3_count as f64 / total as f64) * 100.0,
));
}
let fatal_count: usize = clusters
.iter()
.filter(|c| c.category == FailureCategory::FatalError)
.map(|c| c.count)
.sum();
if fatal_count > 0 {
hints.push(format!(
"Fix fatal errors first — {} failure(s) may be blocking other tests",
fatal_count,
));
}
let auto_fixable: usize = clusters
.iter()
.filter(|c| {
matches!(
c.category,
FailureCategory::ErrorCodeChange
| FailureCategory::MissingMethod
| FailureCategory::MissingClass
)
})
.map(|c| c.count)
.sum();
if auto_fixable > 0 {
hints.push(format!(
"{} failure(s) are likely fixable with find-replace (renamed methods, changed error codes)",
auto_fixable,
));
}
hints
}
fn extract_between<'a>(s: &'a str, start: &str, end: &str) -> Option<&'a str> {
let start_idx = s.find(start)?;
let after_start = start_idx + start.len();
let end_idx = s[after_start..].find(end)?;
Some(&s[after_start..after_start + end_idx])
}
fn extract_pattern<'a>(s: &'a str, prefix: &str, end_marker: &str) -> Option<&'a str> {
if !s.contains(prefix) {
return None;
}
extract_between(s, prefix, end_marker)
}
fn normalize_for_key(msg: &str) -> String {
let mut result = msg.to_string();
result = result.split_whitespace().collect::<Vec<_>>().join(" ");
if result.len() > 80 {
result.truncate(80);
}
result
.replace(['/', '\\', '"', '\'', ':', '.'], "_")
.replace(' ', "_")
.to_lowercase()
}
fn common_prefix(strings: &[&str]) -> String {
if strings.is_empty() {
return String::new();
}
let first = strings[0];
let mut prefix_len = first.len();
for s in &strings[1..] {
prefix_len = prefix_len.min(s.len());
for (i, (a, b)) in first.bytes().zip(s.bytes()).enumerate() {
if a != b {
prefix_len = prefix_len.min(i);
break;
}
}
}
first[..prefix_len].to_string()
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max.min(s.len())])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn failure(test: &str, file: &str, error_type: &str, message: &str) -> TestFailure {
TestFailure {
test_name: test.to_string(),
test_file: file.to_string(),
error_type: error_type.to_string(),
message: message.to_string(),
source_file: String::new(),
source_line: 0,
}
}
fn input(failures: Vec<TestFailure>) -> TestAnalysisInput {
let total = failures.len() as u64 + 10; let passed = 10;
TestAnalysisInput {
failures,
total,
passed,
}
}
#[test]
fn empty_failures_produces_no_clusters() {
let result = analyze("test-component", &input(vec![]));
assert_eq!(result.total_failures, 0);
assert!(result.clusters.is_empty());
}
#[test]
fn identical_messages_cluster_together() {
let failures = vec![
failure(
"FooTest::testA",
"tests/FooTest.php",
"Error",
"Call to undefined method PluginSettings::set()",
),
failure(
"BarTest::testB",
"tests/BarTest.php",
"Error",
"Call to undefined method PluginSettings::set()",
),
failure(
"BazTest::testC",
"tests/BazTest.php",
"Error",
"Call to undefined method PluginSettings::set()",
),
];
let result = analyze("test", &input(failures));
assert_eq!(result.clusters.len(), 1);
assert_eq!(result.clusters[0].count, 3);
assert_eq!(result.clusters[0].category, FailureCategory::MissingMethod);
}
#[test]
fn different_undefined_methods_get_separate_clusters() {
let failures = vec![
failure(
"FooTest::testA",
"tests/FooTest.php",
"Error",
"Call to undefined method PluginSettings::set()",
),
failure(
"BarTest::testB",
"tests/BarTest.php",
"Error",
"Call to undefined method PluginSettings::delete()",
),
];
let result = analyze("test", &input(failures));
assert_eq!(result.clusters.len(), 2);
assert_eq!(result.clusters[0].count, 1);
assert_eq!(result.clusters[1].count, 1);
}
#[test]
fn fatal_redeclare_clusters() {
let failures = vec![
failure(
"FooTest::testA",
"tests/FooTest.php",
"Fatal",
"Cannot redeclare datamachine_get_monolog_instance()",
),
failure(
"BarTest::testB",
"tests/BarTest.php",
"Fatal",
"Cannot redeclare datamachine_get_monolog_instance()",
),
];
let result = analyze("test", &input(failures));
assert_eq!(result.clusters.len(), 1);
assert_eq!(result.clusters[0].category, FailureCategory::FatalError);
assert!(result.clusters[0].suggested_fix.is_some());
}
#[test]
fn sorted_by_count_descending() {
let failures = vec![
failure(
"A::a",
"a.php",
"Error",
"Call to undefined method X::foo()",
),
failure("B::b", "b.php", "Error", "Class \"Missing\" not found"),
failure("C::c", "c.php", "Error", "Class \"Missing\" not found"),
failure("D::d", "d.php", "Error", "Class \"Missing\" not found"),
];
let result = analyze("test", &input(failures));
assert_eq!(result.clusters[0].count, 3); assert_eq!(result.clusters[1].count, 1); }
#[test]
fn mock_errors_categorized() {
let failures = vec![failure(
"FooTest::testA",
"tests/FooTest.php",
"Error",
"Trying to configure method \"execute\" which cannot be configured because it does not exist, has not been specified, is final, or is static",
)];
let result = analyze("test", &input(failures));
assert_eq!(result.clusters[0].category, FailureCategory::MockError);
}
#[test]
fn return_type_change_detected() {
let failures = vec![failure(
"FooTest::testA",
"tests/FooTest.php",
"AssertionFailedError",
"Failed asserting that WP_Error Object (...) is an instance of \"array\"",
)];
let result = analyze("test", &input(failures));
assert_eq!(
result.clusters[0].category,
FailureCategory::ReturnTypeChange
);
}
#[test]
fn hints_include_fix_priority() {
let failures = vec![
failure("A::a", "a.php", "Fatal", "Cannot redeclare foo()"),
failure("B::b", "b.php", "Fatal", "Cannot redeclare foo()"),
failure("C::c", "c.php", "Fatal", "Cannot redeclare foo()"),
failure(
"D::d",
"d.php",
"Error",
"Call to undefined method X::bar()",
),
];
let result = analyze("test", &input(failures));
let hints_text = result.hints.join(" ");
assert!(hints_text.contains("fatal"));
}
#[test]
fn affected_files_deduplicated() {
let failures = vec![
failure(
"FooTest::testA",
"tests/FooTest.php",
"Error",
"Call to undefined method X::foo()",
),
failure(
"FooTest::testB",
"tests/FooTest.php",
"Error",
"Call to undefined method X::foo()",
),
];
let result = analyze("test", &input(failures));
assert_eq!(result.clusters[0].affected_files.len(), 1);
assert_eq!(result.clusters[0].count, 2);
}
#[test]
fn extract_between_works() {
assert_eq!(
extract_between("Class \"Foo\\Bar\" not found", "Class \"", "\" not found"),
Some("Foo\\Bar")
);
assert_eq!(extract_between("no match here", "start", "end"), None);
}
#[test]
fn common_prefix_works() {
assert_eq!(common_prefix(&["foobar", "foobaz", "fooqux"]), "foo");
assert_eq!(common_prefix(&["abc"]), "abc");
assert_eq!(common_prefix(&[]), "");
}
#[test]
fn signature_change_categorized() {
let failures = vec![failure(
"FooTest::testA",
"tests/FooTest.php",
"TypeError",
"Too few arguments to function Foo::bar(), 2 passed and exactly 3 expected",
)];
let result = analyze("test", &input(failures));
assert_eq!(
result.clusters[0].category,
FailureCategory::SignatureChange
);
}
}