use std::collections::{BTreeMap, HashMap};
use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct TestInfo {
pub number: Option<String>,
pub function_name: String,
pub description: String,
pub file_path: PathBuf,
pub line_number: usize,
}
impl TestInfo {
fn is_numbered(&self) -> bool {
self.number.is_some()
}
fn description_number(&self) -> Option<String> {
let trimmed = self.description.trim();
let re = regex::Regex::new(r"^TEST(\d+):").unwrap();
re.captures(trimmed).map(|c| c[1].to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectType {
Rust,
Go,
Python,
Js,
Swift,
}
impl ProjectType {
pub fn detect(root: &Path) -> Result<Self, String> {
if root.join("Cargo.toml").exists() {
return Ok(ProjectType::Rust);
}
if root.join("go.mod").exists() {
return Ok(ProjectType::Go);
}
if root.join("pyproject.toml").exists() {
return Ok(ProjectType::Python);
}
if root.join("package.json").exists() {
return Ok(ProjectType::Js);
}
if root.join("Package.swift").exists() {
return Ok(ProjectType::Swift);
}
Err(format!(
"Cannot detect project type in {} — no Cargo.toml, go.mod, \
pyproject.toml, package.json, or Package.swift found",
root.display()
))
}
pub fn label(self) -> &'static str {
match self {
ProjectType::Rust => "Rust",
ProjectType::Go => "Go",
ProjectType::Python => "Python",
ProjectType::Js => "JS",
ProjectType::Swift => "Swift/ObjC",
}
}
}
fn extract_comments_above(lines: &[&str], fn_line_idx: usize, comment_prefix: &str) -> String {
if fn_line_idx == 0 {
return String::new();
}
let mut j = fn_line_idx as isize - 1;
while j >= 0
&& (lines[j as usize].trim().is_empty()
|| lines[j as usize].trim().starts_with("#["))
{
j -= 1;
}
let mut parts: Vec<String> = Vec::new();
while j >= 0 && lines[j as usize].trim().starts_with(comment_prefix) {
let raw = lines[j as usize].trim();
let text = raw[comment_prefix.len()..].trim().to_string();
parts.insert(0, text);
j -= 1;
}
let test_re = regex::Regex::new(r"^TEST\d+:").unwrap();
if let Some(numbered_idx) = parts.iter().position(|p| test_re.is_match(p)) {
parts = parts[numbered_idx..].to_vec();
}
let sep_re = regex::Regex::new(r"^[=\-#]{3,}$").unwrap();
parts.retain(|p| !p.is_empty() && !sep_re.is_match(p));
if parts.len() > 1 {
let continuation = parts[1..].join(" ");
if !continuation.is_empty() && parts[0].contains(&continuation) {
parts.truncate(1);
}
}
parts.join(" ")
}
fn extract_python_docstring(lines: &[&str], fn_line_idx: usize) -> String {
let mut i = fn_line_idx + 1;
while i < lines.len() && lines[i].trim().is_empty() {
i += 1;
}
if i >= lines.len() {
return String::new();
}
let stripped = lines[i].trim();
let test_re = regex::Regex::new(r"^TEST\d+:").unwrap();
for quote in &[r#"""""#, "'''"] {
if let Some(after) = stripped.strip_prefix(quote) {
if let Some(end) = after.find(quote) {
let content = after[..end].trim();
if test_re.is_match(content) {
return content.to_string();
}
}
}
}
String::new()
}
fn parse_number(function_name: &str, pattern: ®ex::Regex) -> Option<String> {
pattern.captures(function_name).map(|c| c[1].to_string())
}
fn assert_valid_test_function(
file_path: &Path,
line_number: usize,
function_name: &str,
required_prefix: &str,
) -> Result<(), String> {
if !function_name.starts_with(required_prefix) {
return Err(format!(
"{}:{}: test declaration resolves to function '{}', expected prefix '{}'",
file_path.display(),
line_number,
function_name,
required_prefix
));
}
Ok(())
}
pub fn extract_rust(file_path: &Path) -> Result<Vec<TestInfo>, String> {
let content = fs::read_to_string(file_path)
.map_err(|e| format!("Could not read {}: {}", file_path.display(), e))?;
let raw_lines: Vec<&str> = content.lines().collect();
let test_attr_re =
regex::Regex::new(r"^\s*#\[(?:tokio::)?test(?:\([^\]]*\))?\]\s*(?://.*)?$").unwrap();
let any_attr_re = regex::Regex::new(r"^\s*#\[[^\]]+\]\s*(?://.*)?$").unwrap();
let fn_re =
regex::Regex::new(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
.unwrap();
let number_re = regex::Regex::new(r"^test(\d+)_").unwrap();
let mut tests = Vec::new();
let mut i = 0;
while i < raw_lines.len() {
if !test_attr_re.is_match(raw_lines[i]) {
i += 1;
continue;
}
let attr_line = i + 1; let mut j = i + 1;
while j < raw_lines.len()
&& (raw_lines[j].trim().is_empty()
|| raw_lines[j].trim().starts_with("//")
|| any_attr_re.is_match(raw_lines[j]))
{
j += 1;
}
if j >= raw_lines.len() {
return Err(format!(
"{}:{}: test attribute is not followed by a function",
file_path.display(),
attr_line
));
}
let fn_match = fn_re.captures(raw_lines[j]).ok_or_else(|| {
format!(
"{}:{}: test attribute is followed by a non-function line: {:?}",
file_path.display(),
attr_line,
raw_lines[j]
)
})?;
let function_name = fn_match[1].to_string();
assert_valid_test_function(file_path, attr_line, &function_name, "test")?;
tests.push(TestInfo {
number: parse_number(&function_name, &number_re),
description: extract_comments_above(&raw_lines, j, "//"),
file_path: file_path.to_path_buf(),
line_number: j + 1,
function_name,
});
i = j + 1;
}
Ok(tests)
}
pub fn extract_go(file_path: &Path) -> Result<Vec<TestInfo>, String> {
let content = fs::read_to_string(file_path)
.map_err(|e| format!("Could not read {}: {}", file_path.display(), e))?;
let raw_lines: Vec<&str> = content.lines().collect();
let fn_re = regex::Regex::new(r"^\s*func\s+(Test[A-Za-z0-9_]+)\s*\(").unwrap();
let number_re = regex::Regex::new(r"^Test(\d+)_").unwrap();
let mut tests = Vec::new();
for (i, &line) in raw_lines.iter().enumerate() {
if let Some(cap) = fn_re.captures(line) {
let function_name = cap[1].to_string();
tests.push(TestInfo {
number: parse_number(&function_name, &number_re),
description: extract_comments_above(&raw_lines, i, "//"),
file_path: file_path.to_path_buf(),
line_number: i + 1,
function_name,
});
}
}
Ok(tests)
}
pub fn extract_python(file_path: &Path) -> Result<Vec<TestInfo>, String> {
let content = fs::read_to_string(file_path)
.map_err(|e| format!("Could not read {}: {}", file_path.display(), e))?;
let raw_lines: Vec<&str> = content.lines().collect();
let fn_re =
regex::Regex::new(r"^\s*(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\(").unwrap();
let number_re = regex::Regex::new(r"^test_(\d+)_").unwrap();
let decorator_re = regex::Regex::new(r"^\s*@").unwrap();
let mut tests = Vec::new();
for (i, &line) in raw_lines.iter().enumerate() {
if let Some(cap) = fn_re.captures(line) {
let function_name = cap[1].to_string();
let mut comment_idx = i;
while comment_idx > 0
&& (raw_lines[comment_idx - 1].trim().is_empty()
|| decorator_re.is_match(raw_lines[comment_idx - 1]))
{
comment_idx -= 1;
}
let description = {
let ds = extract_python_docstring(&raw_lines, i);
if ds.is_empty() {
extract_comments_above(&raw_lines, comment_idx, "#")
} else {
ds
}
};
tests.push(TestInfo {
number: parse_number(&function_name, &number_re),
description,
file_path: file_path.to_path_buf(),
line_number: i + 1,
function_name,
});
}
}
Ok(tests)
}
pub fn extract_js(file_path: &Path) -> Result<Vec<TestInfo>, String> {
let content = fs::read_to_string(file_path)
.map_err(|e| format!("Could not read {}: {}", file_path.display(), e))?;
let raw_lines: Vec<&str> = content.lines().collect();
let fn_re = regex::Regex::new(r"^\s*function\s+(test[A-Za-z0-9_]+)\s*\(").unwrap();
let number_re = regex::Regex::new(r"^test(\d+)_").unwrap();
let mut tests = Vec::new();
for (i, &line) in raw_lines.iter().enumerate() {
if let Some(cap) = fn_re.captures(line) {
let function_name = cap[1].to_string();
tests.push(TestInfo {
number: parse_number(&function_name, &number_re),
description: extract_comments_above(&raw_lines, i, "//"),
file_path: file_path.to_path_buf(),
line_number: i + 1,
function_name,
});
}
}
Ok(tests)
}
pub fn extract_swift(file_path: &Path) -> Result<Vec<TestInfo>, String> {
let content = fs::read_to_string(file_path)
.map_err(|e| format!("Could not read {}: {}", file_path.display(), e))?;
let raw_lines: Vec<&str> = content.lines().collect();
let fn_re = regex::Regex::new(r"^\s*func\s+(test[A-Za-z0-9_]+)\s*\(").unwrap();
let number_re = regex::Regex::new(r"^test(\d+)_").unwrap();
let mut tests = Vec::new();
for (i, &line) in raw_lines.iter().enumerate() {
if let Some(cap) = fn_re.captures(line) {
let function_name = cap[1].to_string();
tests.push(TestInfo {
number: parse_number(&function_name, &number_re),
description: extract_comments_above(&raw_lines, i, "//"),
file_path: file_path.to_path_buf(),
line_number: i + 1,
function_name,
});
}
}
Ok(tests)
}
pub fn extract_objc(file_path: &Path) -> Result<Vec<TestInfo>, String> {
let content = fs::read_to_string(file_path)
.map_err(|e| format!("Could not read {}: {}", file_path.display(), e))?;
let raw_lines: Vec<&str> = content.lines().collect();
let fn_re =
regex::Regex::new(r"^\s*-\s*\(void\)\s*(test[A-Za-z0-9_]+)\s*\{").unwrap();
let number_re = regex::Regex::new(r"^test(\d+)_").unwrap();
let mut tests = Vec::new();
for (i, &line) in raw_lines.iter().enumerate() {
if let Some(cap) = fn_re.captures(line) {
let function_name = cap[1].to_string();
tests.push(TestInfo {
number: parse_number(&function_name, &number_re),
description: extract_comments_above(&raw_lines, i, "//"),
file_path: file_path.to_path_buf(),
line_number: i + 1,
function_name,
});
}
}
Ok(tests)
}
pub fn scan_project(root: &Path, project_type: ProjectType) -> Result<Vec<TestInfo>, String> {
let mut all_tests = Vec::new();
match project_type {
ProjectType::Rust => {
for entry in walkdir::WalkDir::new(root)
.into_iter()
.filter_entry(|e| e.file_name() != "target" && e.file_name() != ".scrap")
{
let entry = entry.map_err(|e| e.to_string())?;
if entry.path().extension().map(|x| x == "rs").unwrap_or(false) {
all_tests.extend(extract_rust(entry.path())?);
}
}
}
ProjectType::Go => {
for entry in walkdir::WalkDir::new(root).into_iter() {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy();
if name.ends_with("_test.go") {
all_tests.extend(extract_go(entry.path())?);
}
}
}
ProjectType::Python => {
for entry in walkdir::WalkDir::new(root).into_iter() {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy();
if name.starts_with("test_") && name.ends_with(".py") {
all_tests.extend(extract_python(entry.path())?);
}
}
}
ProjectType::Js => {
for entry in walkdir::WalkDir::new(root)
.into_iter()
.filter_entry(|e| e.file_name() != "node_modules")
{
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy();
if name.ends_with(".test.js") {
all_tests.extend(extract_js(entry.path())?);
}
}
}
ProjectType::Swift => {
for entry in walkdir::WalkDir::new(root)
.into_iter()
.filter_entry(|e| e.file_name() != ".build" && e.file_name() != ".scrap")
{
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy();
if name.ends_with(".swift") {
all_tests.extend(extract_swift(entry.path())?);
} else if name.ends_with(".m") {
all_tests.extend(extract_objc(entry.path())?);
}
}
}
}
Ok(all_tests)
}
fn find_duplicates(tests: &[TestInfo]) -> BTreeMap<String, Vec<&TestInfo>> {
let mut by_number: BTreeMap<String, Vec<&TestInfo>> = BTreeMap::new();
for test in tests {
if let Some(ref n) = test.number {
by_number.entry(n.clone()).or_default().push(test);
}
}
by_number.retain(|_, v| v.len() > 1);
by_number
}
fn suggest_free_numbers(
duplicates: &BTreeMap<String, Vec<&TestInfo>>,
all_tests: &[TestInfo],
) -> HashMap<String, Vec<u64>> {
let taken: std::collections::HashSet<u64> = all_tests
.iter()
.filter_map(|t| t.number.as_ref())
.filter_map(|n| n.parse::<u64>().ok())
.collect();
let mut reserved: std::collections::HashSet<u64> = std::collections::HashSet::new();
let mut suggestions: HashMap<String, Vec<u64>> = HashMap::new();
let mut sorted_keys: Vec<(&String, u64)> = duplicates
.keys()
.filter_map(|k| k.parse::<u64>().ok().map(|n| (k, n)))
.collect();
sorted_keys.sort_by_key(|(_, n)| *n);
for (number, n) in sorted_keys {
let needed = duplicates[number].len() - 1;
let mut found: Vec<u64> = Vec::new();
let mut delta: u64 = 1;
while found.len() < needed {
for candidate in [n + delta, n.saturating_sub(delta)] {
if candidate == 0 {
continue;
}
if taken.contains(&candidate) || reserved.contains(&candidate) {
continue;
}
found.push(candidate);
reserved.insert(candidate);
if found.len() == needed {
break;
}
}
delta += 1;
}
suggestions.insert(number.clone(), found);
}
suggestions
}
fn find_numbering_mismatches(tests: &[TestInfo]) -> Vec<&TestInfo> {
let mut mismatches: Vec<&TestInfo> = tests
.iter()
.filter(|t| {
t.description_number()
.map(|dn| Some(dn) != t.number)
.unwrap_or(false)
})
.collect();
mismatches.sort_by(|a, b| {
let an = a.number.as_ref().and_then(|n| n.parse::<u64>().ok()).unwrap_or(u64::MAX);
let bn = b.number.as_ref().and_then(|n| n.parse::<u64>().ok()).unwrap_or(u64::MAX);
an.cmp(&bn)
.then(a.function_name.cmp(&b.function_name))
});
mismatches
}
fn rel_location(test: &TestInfo, proj_root: &Path) -> Result<String, String> {
let rel = test.file_path.strip_prefix(proj_root).map_err(|_| {
format!(
"Test path {} is outside the project root {}",
test.file_path.display(),
proj_root.display()
)
})?;
Ok(format!("{}:{}", rel.display(), test.line_number))
}
pub fn generate_markdown(
tests: &[TestInfo],
project_type: ProjectType,
proj_root: &Path,
) -> Result<String, String> {
let duplicates = find_duplicates(tests);
let duplicate_numbers: std::collections::HashSet<&String> = duplicates.keys().collect();
let suggestions = if duplicates.is_empty() {
HashMap::new()
} else {
suggest_free_numbers(&duplicates, tests)
};
let numbered_tests: Vec<&TestInfo> = tests.iter().filter(|t| t.is_numbered()).collect();
let unnumbered_tests: Vec<&TestInfo> = tests.iter().filter(|t| !t.is_numbered()).collect();
let numbered_missing_desc: Vec<&TestInfo> = numbered_tests
.iter()
.filter(|t| t.description.trim().is_empty())
.copied()
.collect();
let numbering_mismatches = find_numbering_mismatches(tests);
let mut unique_numbered: Vec<&TestInfo> = numbered_tests
.iter()
.filter(|t| !duplicate_numbers.contains(t.number.as_ref().unwrap()))
.copied()
.collect();
unique_numbered.sort_by(|a, b| {
let an = a.number.as_ref().unwrap().parse::<u64>().unwrap_or(u64::MAX);
let bn = b.number.as_ref().unwrap().parse::<u64>().unwrap_or(u64::MAX);
an.cmp(&bn).then(a.function_name.cmp(&b.function_name))
});
let mut duplicate_entries: Vec<&TestInfo> = numbered_tests
.iter()
.filter(|t| duplicate_numbers.contains(t.number.as_ref().unwrap()))
.copied()
.collect();
duplicate_entries.sort_by(|a, b| {
let an = a.number.as_ref().unwrap().parse::<u64>().unwrap_or(u64::MAX);
let bn = b.number.as_ref().unwrap().parse::<u64>().unwrap_or(u64::MAX);
an.cmp(&bn).then(a.function_name.cmp(&b.function_name))
});
let mut sorted_unnumbered: Vec<&TestInfo> = unnumbered_tests.clone();
sorted_unnumbered.sort_by(|a, b| {
a.function_name
.cmp(&b.function_name)
.then(a.file_path.cmp(&b.file_path))
.then(a.line_number.cmp(&b.line_number))
});
let label = project_type.label();
let mut out = String::new();
writeln!(out, "# {} Test Catalog\n", label).unwrap();
writeln!(out, "**Total Tests:** {}\n", tests.len()).unwrap();
writeln!(out, "**Numbered Tests:** {}\n", numbered_tests.len()).unwrap();
writeln!(out, "**Unnumbered Tests:** {}\n", unnumbered_tests.len()).unwrap();
writeln!(
out,
"**Numbered Tests Missing Descriptions:** {}\n",
numbered_missing_desc.len()
)
.unwrap();
writeln!(
out,
"**Numbering Mismatches:** {}\n",
numbering_mismatches.len()
)
.unwrap();
if !duplicates.is_empty() {
writeln!(
out,
"**⚠ Duplicate test numbers detected: {} number(s) used more than once.**",
duplicates.len()
)
.unwrap();
writeln!(
out,
"Unique numbered tests are listed first. Duplicate-number entries are grouped \
after them and marked with ⚠. Unnumbered tests are listed in their own group.\n"
)
.unwrap();
} else {
writeln!(out, "All numbered test numbers are unique.\n").unwrap();
}
writeln!(out, "This catalog lists all tests in the {} codebase.\n", label).unwrap();
writeln!(out, "| Test # | Function Name | Description | File |").unwrap();
writeln!(out, "|--------|---------------|-------------|------|").unwrap();
let write_row = |out: &mut String, test: &TestInfo, flag: &str| -> Result<(), String> {
let description = test.description.replace('|', "\\|");
let location = rel_location(test, proj_root)?.replace('|', "\\|");
let test_label = match &test.number {
Some(n) => format!("test{}", n),
None => "unnumbered".to_string(),
};
writeln!(
out,
"| {}{} | `{}` | {} | {} |",
test_label, flag, test.function_name, description, location
)
.unwrap();
Ok(())
};
for test in &unique_numbered {
write_row(&mut out, test, "")?;
}
if !duplicate_entries.is_empty() {
writeln!(out, "| | | | |").unwrap();
for test in &duplicate_entries {
write_row(&mut out, test, " ⚠")?;
}
}
if !sorted_unnumbered.is_empty() {
writeln!(out, "| | | | |").unwrap();
for test in &sorted_unnumbered {
write_row(&mut out, test, "")?;
}
}
if !duplicates.is_empty() {
writeln!(out, "\n---\n\n## ⚠ Duplicate Test Numbers\n").unwrap();
writeln!(
out,
"The following test numbers are assigned to more than one function. \
Keep the first occurrence at the existing number and renumber the rest \
using the suggested free numbers below.\n"
)
.unwrap();
let mut sorted_dup_keys: Vec<&String> = duplicates.keys().collect();
sorted_dup_keys.sort_by_key(|k| k.parse::<u64>().unwrap_or(u64::MAX));
for number in sorted_dup_keys {
let mut entries: Vec<&&TestInfo> = duplicates[number].iter().collect();
entries.sort_by_key(|t| &t.function_name);
let free = &suggestions[number];
writeln!(out, "### test{} ({} occurrences)\n", number, entries.len()).unwrap();
for entry in &entries {
writeln!(
out,
"- `{}` — {}",
entry.function_name,
rel_location(entry, proj_root)?
)
.unwrap();
}
let suggested_str: Vec<String> = free.iter().map(|n| format!("test{}", n)).collect();
writeln!(out, "\n**Suggested free number(s):** {}\n", suggested_str.join(", ")).unwrap();
}
}
if !sorted_unnumbered.is_empty() {
writeln!(out, "---\n\n## Unnumbered Tests\n").unwrap();
writeln!(
out,
"The following tests are cataloged but do not currently participate in numeric \
test indexing.\n"
)
.unwrap();
for test in &sorted_unnumbered {
writeln!(
out,
"- `{}` — {}",
test.function_name,
rel_location(test, proj_root)?
)
.unwrap();
}
writeln!(out).unwrap();
}
if !numbered_missing_desc.is_empty() {
writeln!(out, "---\n\n## Numbered Tests Missing Descriptions\n").unwrap();
writeln!(
out,
"These tests still participate in numeric indexing, but the cataloger did not \
find an authoritative immediate comment/docstring description for them. \
This is reported explicitly so intentional blank-description parity and \
accidental comment drift are both visible.\n"
)
.unwrap();
let mut sorted_missing = numbered_missing_desc.clone();
sorted_missing.sort_by(|a, b| {
let an = a.number.as_ref().unwrap().parse::<u64>().unwrap_or(u64::MAX);
let bn = b.number.as_ref().unwrap().parse::<u64>().unwrap_or(u64::MAX);
an.cmp(&bn).then(a.function_name.cmp(&b.function_name))
});
for test in sorted_missing {
writeln!(
out,
"- `test{}` / `{}` — {}",
test.number.as_ref().unwrap(),
test.function_name,
rel_location(test, proj_root)?
)
.unwrap();
}
writeln!(out).unwrap();
}
if !numbering_mismatches.is_empty() {
writeln!(out, "---\n\n## Numbering Mismatches\n").unwrap();
writeln!(
out,
"These tests have a numbering disagreement between the function name and the \
authoritative immediate TEST comment/docstring above the test. This is reported \
explicitly so comment sync does not silently overwrite a misnumbered test.\n"
)
.unwrap();
for test in &numbering_mismatches {
let function_number = test
.number
.as_ref()
.map(|n| format!("test{}", n))
.unwrap_or_else(|| "unnumbered".to_string());
let description_number = format!("test{}", test.description_number().unwrap());
writeln!(
out,
"- `{}` / `{}` / `{}` — {}",
function_number,
description_number,
test.function_name,
rel_location(test, proj_root)?
)
.unwrap();
}
writeln!(out).unwrap();
}
writeln!(out, "---\n").unwrap();
writeln!(out, "*Generated from {} source tree*", label).unwrap();
writeln!(out, "*Total tests: {}*", tests.len()).unwrap();
writeln!(out, "*Total numbered tests: {}*", numbered_tests.len()).unwrap();
writeln!(out, "*Total unnumbered tests: {}*", unnumbered_tests.len()).unwrap();
writeln!(
out,
"*Total numbered tests missing descriptions: {}*",
numbered_missing_desc.len()
)
.unwrap();
writeln!(
out,
"*Total numbering mismatches: {}*",
numbering_mismatches.len()
)
.unwrap();
Ok(out)
}
pub fn print_summary(tests: &[TestInfo], root: &Path, label: &str) -> Result<(), String> {
let numbered: Vec<&TestInfo> = tests.iter().filter(|t| t.is_numbered()).collect();
let unnumbered: Vec<&TestInfo> = tests.iter().filter(|t| !t.is_numbered()).collect();
let missing_desc: Vec<&TestInfo> = numbered
.iter()
.filter(|t| t.description.trim().is_empty())
.copied()
.collect();
let mismatches = find_numbering_mismatches(tests);
let duplicates = find_duplicates(tests);
let suggestions = if duplicates.is_empty() {
HashMap::new()
} else {
suggest_free_numbers(&duplicates, tests)
};
println!("Project root : {}", root.display());
println!("Project type : {}", label);
println!("Scanning for tests...");
println!(" Found {} tests", tests.len());
println!(" Numbered : {}", numbered.len());
println!(" Unnumbered : {}", unnumbered.len());
println!(
" Missing descriptions on numbered tests: {}",
missing_desc.len()
);
println!(" Numbering mismatches : {}", mismatches.len());
let rel = |file_path: &Path| -> Result<String, String> {
file_path
.strip_prefix(root)
.map(|p| p.display().to_string())
.map_err(|_| {
format!(
"Path {} is outside the project root {}",
file_path.display(),
root.display()
)
})
};
if !duplicates.is_empty() {
println!("\nWARNING: {} duplicate test number(s) detected:", duplicates.len());
let mut sorted_keys: Vec<&String> = duplicates.keys().collect();
sorted_keys.sort_by_key(|k| k.parse::<u64>().unwrap_or(u64::MAX));
for number in sorted_keys {
let entries = &duplicates[number];
let free = &suggestions[number];
println!(" test{} used {} times:", number, entries.len());
for entry in entries {
println!(
" {} ({}:{})",
entry.function_name,
rel(&entry.file_path)?,
entry.line_number
);
}
let free_str: Vec<String> = free.iter().map(|n| format!("test{}", n)).collect();
println!(" → suggested free number(s): {}", free_str.join(", "));
}
} else {
println!("All numbered test numbers are unique.");
}
if !unnumbered.is_empty() {
println!("\nUnnumbered tests detected: {}", unnumbered.len());
let mut sorted = unnumbered.clone();
sorted.sort_by(|a, b| {
a.function_name
.cmp(&b.function_name)
.then(a.file_path.cmp(&b.file_path))
.then(a.line_number.cmp(&b.line_number))
});
for test in sorted {
println!(
" {} ({}:{})",
test.function_name,
rel(&test.file_path)?,
test.line_number
);
}
}
if !missing_desc.is_empty() {
println!(
"\nNumbered tests missing descriptions: {}",
missing_desc.len()
);
let mut sorted = missing_desc.clone();
sorted.sort_by(|a, b| {
let an = a.number.as_ref().unwrap().parse::<u64>().unwrap_or(u64::MAX);
let bn = b.number.as_ref().unwrap().parse::<u64>().unwrap_or(u64::MAX);
an.cmp(&bn).then(a.function_name.cmp(&b.function_name))
});
for test in sorted {
println!(
" test{} {} ({}:{})",
test.number.as_ref().unwrap(),
test.function_name,
rel(&test.file_path)?,
test.line_number
);
}
}
if !mismatches.is_empty() {
println!("\nNumbering mismatches: {}", mismatches.len());
for test in &mismatches {
let fn_num = test
.number
.as_ref()
.map(|n| format!("test{}", n))
.unwrap_or_else(|| "unnumbered".to_string());
let desc_num = format!("test{}", test.description_number().unwrap());
println!(
" {} vs {} {} ({}:{})",
fn_num,
desc_num,
test.function_name,
rel(&test.file_path)?,
test.line_number
);
}
}
let mut ranges: BTreeMap<u64, usize> = BTreeMap::new();
for test in &numbered {
if let Some(n) = test.number.as_ref().and_then(|n| n.parse::<u64>().ok()) {
let century = (n / 100) * 100;
*ranges.entry(century).or_insert(0) += 1;
}
}
if !ranges.is_empty() {
println!("\nNumbered test distribution by range:");
for (century, count) in &ranges {
println!(" {:03}-{:03}: {} tests", century, century + 99, count);
}
}
Ok(())
}
pub fn run(root: &Path, output: Option<&Path>) -> Result<(), String> {
let project_type = match ProjectType::detect(root) {
Ok(pt) => pt,
Err(_) => {
println!(" (no recognized project type at {})", root.display());
return Ok(());
}
};
let tests = scan_project(root, project_type)?;
print_summary(&tests, root, project_type.label())?;
let output_path = output
.map(|p| p.to_path_buf())
.unwrap_or_else(|| root.join("TEST_CATALOG.md"));
let markdown = generate_markdown(&tests, project_type, root)?;
fs::write(&output_path, &markdown)
.map_err(|e| format!("Failed to write {}: {}", output_path.display(), e))?;
println!("\n✓ Catalog generated successfully!");
println!(" File: {}", output_path.display());
Ok(())
}