use crate::console_format::{self, ComparisonStats};
use crate::types::{CommandType, OfferedRow, TestResult, VersionSource};
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use term::color::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusIcon {
Passed, Failed, Skipped, }
impl StatusIcon {
pub fn as_str(&self) -> &'static str {
match self {
StatusIcon::Passed => "✓",
StatusIcon::Failed => "✗",
StatusIcon::Skipped => "⊘",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolution {
Exact, Upgraded, Mismatch, }
impl Resolution {
pub fn as_str(&self) -> &'static str {
match self {
Resolution::Exact => "=",
Resolution::Upgraded => "↑",
Resolution::Mismatch => "≠",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum OfferedCell {
Baseline,
Tested {
icon: StatusIcon,
resolution: Resolution,
version: String,
forced: bool, patch_depth: crate::compile::PatchDepth, },
}
impl OfferedCell {
pub fn from_offered_row(row: &OfferedRow) -> Self {
if row.offered.is_none() {
return OfferedCell::Baseline;
}
let offered = row.offered.as_ref().unwrap();
let overall_passed = row.test.commands.iter().all(|cmd| cmd.result.passed);
let not_used = !offered.forced && !row.primary.used_offered_version;
let icon = if not_used {
StatusIcon::Skipped } else {
match (row.baseline_passed, overall_passed) {
(Some(true), true) => StatusIcon::Passed, (Some(true), false) => StatusIcon::Failed, (Some(false), _) => StatusIcon::Failed, (None, true) => StatusIcon::Passed, (None, false) => StatusIcon::Failed, }
};
let resolution = if offered.forced {
Resolution::Mismatch } else if row.primary.used_offered_version {
Resolution::Exact } else {
Resolution::Upgraded };
OfferedCell::Tested {
icon,
resolution,
version: offered.version.clone(),
forced: offered.forced,
patch_depth: offered.patch_depth,
}
}
pub fn format(&self) -> String {
match self {
OfferedCell::Baseline => "- baseline".to_string(),
OfferedCell::Tested { icon, resolution, version, patch_depth, .. } => {
let marker = patch_depth.marker();
let mut result = format!("{} {}{}", icon.as_str(), resolution.as_str(), version);
if !marker.is_empty() {
result.push('→');
result.push_str(marker);
}
result
}
}
}
}
pub fn init_table_widths(versions: &[String], display_version: &str, force_versions: bool) {
console_format::init_table_widths(versions, display_version, force_versions);
}
pub fn print_table_header(
crate_name: &str,
display_version: &str,
total_deps: usize,
test_plan: Option<&str>,
this_path: Option<&str>,
) {
console_format::print_table_header(crate_name, display_version, total_deps, test_plan, this_path);
}
pub fn format_table_header(
crate_name: &str,
display_version: &str,
total_deps: usize,
test_plan: Option<&str>,
this_path: Option<&str>,
) -> String {
console_format::format_table_header(crate_name, display_version, total_deps, test_plan, this_path)
}
pub fn print_separator_line() {
console_format::print_separator_line();
}
pub fn format_table_footer() -> String {
console_format::format_table_footer()
}
pub fn print_table_footer() {
console_format::print_table_footer();
}
fn normalize_path_hex_codes(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut i = 0;
let chars: Vec<char> = text.chars().collect();
while i < chars.len() {
result.push(chars[i]);
if chars[i] == '/' || chars[i] == '\\' {
let mut j = i + 1;
let mut component = String::new();
while j < chars.len() && chars[j] != '/' && chars[j] != '\\' && !chars[j].is_whitespace() {
component.push(chars[j]);
j += 1;
}
if let Some(dash_pos) = component.rfind('-') {
let potential_hex = &component[dash_pos + 1..];
if potential_hex.len() >= 8 && potential_hex.chars().all(|c| c.is_ascii_hexdigit()) {
result.push_str(&component[..dash_pos]);
i = j;
continue;
}
}
result.push_str(&component);
i = j;
continue;
}
i += 1;
}
result
}
pub fn error_signature(text: &str) -> String {
use std::collections::BTreeSet;
let mut errors = BTreeSet::new();
for line in text.lines() {
if let Some(start) = line.find("error[")
&& let Some(end) = line[start..].find("]:")
{
let code = &line[start..start + end + 2];
let message = line[start + end + 2..].trim();
let normalized = message.split("-->").next().unwrap_or(message).trim();
errors.insert(format!("{} {}", code, normalized));
}
}
errors.into_iter().collect::<Vec<_>>().join("\n")
}
pub fn extract_error_text(row: &OfferedRow) -> Option<String> {
let formatted = format_offered_row(row, 0);
if formatted.error_details.is_empty() {
None
} else {
let error_text = formatted.error_details.join("\n");
Some(error_signature(&error_text))
}
}
pub fn print_offered_row(row: &OfferedRow, is_last_in_group: bool, prev_error: Option<&str>, max_error_lines: usize) {
let mut formatted = format_offered_row(row, max_error_lines);
let is_baseline = row.offered.is_none();
if !is_baseline
&& let Some(prev) = prev_error
&& !formatted.error_details.is_empty()
{
let full_formatted = format_offered_row(row, 0);
let current_error = full_formatted.error_details.join("\n");
let current_signature = error_signature(¤t_error);
if current_signature == prev {
formatted.error_details.clear();
formatted.result = formatted
.result
.replace("test failed", "same failure")
.replace("build failed", "same failure")
.replace("fetch failed", "same failure")
.replace("test broken", "same failure")
.replace("build broken", "same failure");
}
}
let result_display = if formatted.time.is_empty() {
format!("{:>18}", formatted.result)
} else {
format!("{:>12} {:>5}", formatted.result, formatted.time)
};
console_format::print_main_row(
[&formatted.offered, &formatted.spec, &formatted.resolved, &formatted.dependent, &result_display],
formatted.color,
);
if !formatted.error_details.is_empty() {
console_format::print_error_box_top();
for error_line in &formatted.error_details {
console_format::print_error_box_line(error_line);
}
if !is_last_in_group {
console_format::print_error_box_bottom();
}
}
console_format::print_multi_version_rows(&formatted.multi_version_rows);
}
pub struct FormattedRow {
pub offered: String,
pub spec: String,
pub resolved: String,
pub dependent: String,
pub result: String,
pub time: String,
pub color: Color,
pub error_details: Vec<String>,
pub multi_version_rows: Vec<(String, String, String)>,
}
fn format_offered_row(row: &OfferedRow, max_error_lines: usize) -> FormattedRow {
let offered_cell = OfferedCell::from_offered_row(row);
let offered_str = offered_cell.format();
let spec_str = if let Some(ref offered) = row.offered {
if offered.forced { format!("→ ={}", offered.version) } else { row.primary.spec.clone() }
} else {
row.primary.spec.clone()
};
let source_icon = match row.primary.resolved_source {
VersionSource::CratesIo => "📦",
VersionSource::Local => "📁",
VersionSource::Git => "🔀",
};
let resolved_str = format!("{} {}", row.primary.resolved_version, source_icon);
let dependent_str = format!("{} {}", row.primary.dependent_name, row.primary.dependent_version);
let overall_passed = row.test.commands.iter().all(|cmd| cmd.result.passed);
let failed_step = row.test.commands.iter().find(|cmd| !cmd.result.passed).map(|cmd| match cmd.command {
CommandType::Fetch => "fetch failed",
CommandType::Check => "build failed",
CommandType::Test => "test failed",
});
let not_used =
if let Some(ref offered) = row.offered { !offered.forced && !row.primary.used_offered_version } else { false };
let is_baseline = row.offered.is_none();
let result_status = if not_used {
"not used".to_string()
} else if is_baseline {
if overall_passed {
"passed".to_string()
} else if let Some(step) = failed_step {
step.replace("failed", "broken")
} else {
"broken".to_string()
}
} else {
match (row.baseline_passed, overall_passed, failed_step) {
(Some(true), true, _) => "passed".to_string(),
(Some(true), false, Some(step)) => step.to_string(),
(Some(true), false, None) => "regressed".to_string(),
(Some(false), _, Some(step)) => step.replace("failed", "broken"),
(Some(false), _, None) => "broken".to_string(),
(None, true, _) => "passed".to_string(),
(None, false, Some(step)) => step.to_string(),
(None, false, None) => "failed".to_string(),
}
};
let mut ict_marks = String::new();
for cmd in &row.test.commands {
match cmd.command {
CommandType::Fetch => ict_marks.push(if cmd.result.passed { '✓' } else { '✗' }),
CommandType::Check => ict_marks.push(if cmd.result.passed { '✓' } else { '✗' }),
CommandType::Test => ict_marks.push(if cmd.result.passed { '✓' } else { '✗' }),
}
}
while ict_marks.len() < 3 {
ict_marks.push('-');
}
let result_str = format!("{} {}", result_status, ict_marks);
let total_time: f64 = row.test.commands.iter().map(|cmd| cmd.result.duration).sum();
let time_str = format!("{:.1}s", total_time);
let color = if not_used {
term::color::YELLOW } else if is_baseline && !overall_passed {
term::color::BRIGHT_YELLOW } else {
match (row.baseline_passed, overall_passed) {
(Some(true), true) => term::color::BRIGHT_GREEN,
(Some(true), false) => term::color::BRIGHT_RED,
(Some(false), _) => term::color::BRIGHT_YELLOW, (None, true) => term::color::BRIGHT_GREEN,
(None, false) => term::color::BRIGHT_RED,
}
};
let mut error_details = Vec::new();
for cmd in &row.test.commands {
if !cmd.result.passed {
let cmd_name = match cmd.command {
CommandType::Fetch => "fetch",
CommandType::Check => "check",
CommandType::Test => "test",
};
for failure in &cmd.result.failures {
error_details.push(format!("cargo {} failed on {}", cmd_name, failure.crate_name));
if !failure.error_message.is_empty() {
let lines: Vec<&str> = failure.error_message.lines().collect();
let display_lines = if max_error_lines == 0 {
&lines[..] } else {
&lines[..lines.len().min(max_error_lines)]
};
for line in display_lines {
if !line.trim().is_empty() {
error_details.push(format!(" {}", line));
}
}
if max_error_lines > 0 && lines.len() > max_error_lines {
error_details.push(format!(" ... ({} more lines)", lines.len() - max_error_lines));
}
}
}
}
}
let mut multi_version_rows = Vec::new();
for transitive in &row.transitive {
let source_icon = match transitive.dependency.resolved_source {
VersionSource::CratesIo => "📦",
VersionSource::Local => "📁",
VersionSource::Git => "🔀",
};
multi_version_rows.push((
transitive.dependency.spec.clone(),
format!("{} {}", transitive.dependency.resolved_version, source_icon),
format!("{} {}", transitive.dependency.dependent_name, transitive.dependency.dependent_version),
));
}
FormattedRow {
offered: offered_str,
spec: spec_str,
resolved: resolved_str,
dependent: dependent_str,
result: result_str,
time: time_str,
color,
error_details,
multi_version_rows,
}
}
pub struct TestSummary {
pub passed: usize,
pub regressed: usize,
pub broken: usize,
pub total: usize,
}
pub fn summarize_offered_rows(rows: &[OfferedRow]) -> TestSummary {
let mut passed = 0;
let mut regressed = 0;
let mut broken = 0;
for row in rows {
if row.offered.is_some() {
let overall_passed = row.test.commands.iter().all(|cmd| cmd.result.passed);
let baseline_compiles = row.baseline_check_passed.unwrap_or_else(|| row.baseline_passed.unwrap_or(false));
match (baseline_compiles, row.baseline_passed, overall_passed) {
(false, _, _) => broken += 1,
(true, Some(true), true) => passed += 1,
(true, Some(true), false) => regressed += 1,
(true, Some(false), true) => passed += 1,
(true, Some(false), false) => passed += 1,
(true, None, true) => passed += 1,
(true, None, false) => broken += 1,
}
}
}
TestSummary { passed, regressed, broken, total: passed + regressed + broken }
}
pub struct CompatibilityReport {
pub total_dependents: usize,
pub baseline_version: String,
pub target_version: Option<String>,
pub regressions: Vec<RegressionInfo>,
pub fixed: Vec<String>,
pub baseline_failures: crate::categorize::FailureSummary,
pub version_conflict_count: usize,
pub baseline_passing: usize,
pub baseline_broken_total: usize,
pub is_baseline_only: bool,
}
pub struct RegressionInfo {
pub dependent_name: String,
pub error_snippet: Option<String>,
}
pub fn build_compatibility_report(rows: &[OfferedRow], base_crate: &str) -> CompatibilityReport {
use std::collections::HashSet;
let mut unique_dependents = HashSet::new();
let mut regressions = Vec::new();
let mut fixed = Vec::new();
let mut baseline_failures = Vec::new();
let mut baseline_passing = 0;
let mut version_conflict_count = 0;
let mut baseline_version = String::from("unknown");
let mut has_offered = false;
let mut target_version = None;
for row in rows {
unique_dependents.insert(row.primary.dependent_name.clone());
if row.offered.is_none() {
baseline_version = row.primary.resolved_version.clone();
let check_passed = row
.test
.commands
.iter()
.filter(|cmd| matches!(cmd.command, CommandType::Check | CommandType::Fetch))
.all(|cmd| cmd.result.passed);
if row.test_passed() {
baseline_passing += 1;
} else if !check_passed {
let failure = crate::categorize::categorize_failure(row, base_crate);
if failure.category == crate::categorize::FailureCategory::VersionConflict {
version_conflict_count += 1;
}
baseline_failures.push(failure);
}
} else {
has_offered = true;
if target_version.is_none() {
target_version = row.offered.as_ref().map(|o| o.version.clone());
}
let overall_passed = row.test_passed();
let baseline_compiles = row.baseline_check_passed.unwrap_or_else(|| row.baseline_passed.unwrap_or(false));
if !baseline_compiles {
} else if row.baseline_passed == Some(true) && !overall_passed {
let snippet = crate::categorize::categorize_failure(row, base_crate).error_snippet;
regressions.push(RegressionInfo {
dependent_name: row.primary.dependent_name.clone(),
error_snippet: snippet,
});
} else if row.baseline_passed == Some(true) && overall_passed {
} else if row.baseline_passed == Some(false) && overall_passed {
fixed.push(row.primary.dependent_name.clone());
}
}
}
let failure_summary = crate::categorize::FailureSummary::from_failures(baseline_failures);
CompatibilityReport {
total_dependents: unique_dependents.len(),
baseline_version,
target_version,
regressions,
fixed,
baseline_failures: failure_summary,
version_conflict_count,
baseline_passing,
baseline_broken_total: rows
.iter()
.filter(|r| {
r.offered.is_none()
&& !r
.test
.commands
.iter()
.filter(|cmd| matches!(cmd.command, CommandType::Check | CommandType::Fetch))
.all(|cmd| cmd.result.passed)
})
.count(),
is_baseline_only: !has_offered,
}
}
pub fn print_compatibility_report(report: &CompatibilityReport, report_dir: &Path) {
let bar = "=".repeat(65);
let thin_bar = "-".repeat(65);
if report.is_baseline_only {
println!();
println!("{}", bar);
println!("{:^65}", "BASELINE REPORT");
println!("{}", bar);
println!("Tested: {} dependents Version: {} (published)", report.total_dependents, report.baseline_version);
println!("{}", bar);
println!();
println!(" Passing: {:>4}", report.baseline_passing);
println!(" Broken: {:>4}", report.baseline_broken_total);
if report.baseline_broken_total > 0 {
println!();
println!("BROKEN BY CATEGORY:");
for (cat, failures) in &report.baseline_failures.categories {
let names: Vec<&str> = failures.iter().map(|f| f.dependent_name.as_str()).collect();
let display =
if names.len() > 5 { format!("{} ...", names[..5].join(" ")) } else { names.join(" ") };
println!(" {} ({}): {}", cat.label(), failures.len(), display);
}
}
} else {
let target = report.target_version.as_deref().unwrap_or("offered");
println!();
println!("{}", bar);
println!("{:^65}", "COMPATIBILITY REPORT");
println!("{}", bar);
println!(
"Tested: {} dependents Baseline: {} Target: {}",
report.total_dependents, report.baseline_version, target
);
println!("{}", bar);
println!();
println!("YOUR CHANGES");
println!("{}", thin_bar);
let reg_count = report.regressions.len();
let fix_count = report.fixed.len();
let net: i64 = fix_count as i64 - reg_count as i64;
if reg_count > 0 {
println!(" Regressions: {:>4} <-- crates broken by your version", reg_count);
} else {
println!(" Regressions: {:>4}", reg_count);
}
if fix_count > 0 {
println!(" Fixed: {:>4} <-- crates you fixed vs baseline", fix_count);
} else {
println!(" Fixed: {:>4}", fix_count);
}
println!(
" Net: {:>+4} {}",
net,
if net < 0 {
"(improvement)"
} else if net > 0 {
"(worse)"
} else {
"(no change)"
}
);
if !report.regressions.is_empty() {
println!();
println!("REGRESSIONS (investigate these):");
for reg in &report.regressions {
if let Some(ref snippet) = reg.error_snippet {
println!(" {:<20} {}", reg.dependent_name, snippet);
} else {
println!(" {}", reg.dependent_name);
}
}
}
if !report.fixed.is_empty() {
println!();
println!("FIXED BY YOUR CHANGES:");
for name in &report.fixed {
println!(" {} (passed)", name);
}
}
if report.baseline_broken_total > 0 || report.version_conflict_count > 0 {
println!();
println!("NOT YOUR PROBLEM");
println!("{}", thin_bar);
if report.baseline_broken_total > 0 {
println!(" Baseline broken: {:>4} (fail with published deps too)", report.baseline_broken_total);
}
if report.version_conflict_count > 0 {
println!(" Version conflicts: {:>2}", report.version_conflict_count);
println!(" Re-run with --force-versions to check if these would pass");
}
if report.baseline_failures.total() > 0 {
println!();
println!(" By category:");
for (cat, failures) in &report.baseline_failures.categories {
let names: Vec<&str> = failures.iter().map(|f| f.dependent_name.as_str()).collect();
let display =
if names.len() > 4 { format!("{} ...", names[..4].join(" ")) } else { names.join(" ") };
println!(" {} ({}): {}", cat.label(), failures.len(), display);
}
}
}
}
println!();
println!("{}", bar);
println!();
println!("Reports:");
println!(" Markdown: {}/report.md", report_dir.display());
println!(" JSON: {}/report.json", report_dir.display());
}
pub fn generate_comparison_table(rows: &[OfferedRow]) -> Vec<ComparisonStats> {
use std::collections::{HashMap, HashSet};
let baseline_rows: Vec<&OfferedRow> = rows.iter().filter(|r| r.offered.is_none()).collect();
let mut baseline_stats = ComparisonStats {
version_label: "Default".to_string(),
total_tested: 0,
already_broken: Some(0),
passed_fetch: 0,
passed_check: 0,
passed_test: 0,
fully_passing: 0,
regressions: vec![],
};
let mut seen_baseline: HashSet<String> = HashSet::new();
for row in &baseline_rows {
let dep_name = &row.primary.dependent_name;
if !seen_baseline.insert(dep_name.clone()) {
continue;
}
baseline_stats.total_tested += 1;
let passed_fetch =
row.test.commands.iter().filter(|cmd| cmd.command == CommandType::Fetch).all(|cmd| cmd.result.passed);
let passed_check = row
.test
.commands
.iter()
.filter(|cmd| cmd.command == CommandType::Check || cmd.command == CommandType::Fetch)
.all(|cmd| cmd.result.passed);
let passed_test = row.test.commands.iter().all(|cmd| cmd.result.passed);
if passed_check && !passed_test {
baseline_stats.already_broken = Some(baseline_stats.already_broken.unwrap() + 1);
}
if passed_fetch {
baseline_stats.passed_fetch += 1;
}
if passed_check {
baseline_stats.passed_check += 1;
}
if passed_test {
baseline_stats.passed_test += 1;
baseline_stats.fully_passing += 1;
}
}
let mut all_stats = vec![baseline_stats];
let mut by_version: HashMap<String, Vec<&OfferedRow>> = HashMap::new();
for row in rows {
if let Some(ref offered) = row.offered {
by_version.entry(offered.version.clone()).or_default().push(row);
}
}
let mut versions: Vec<String> = by_version.keys().cloned().collect();
versions.sort();
for version in versions {
let version_rows = &by_version[&version];
let mut stats = ComparisonStats {
version_label: version.clone(),
total_tested: 0,
already_broken: None, passed_fetch: 0,
passed_check: 0,
passed_test: 0,
fully_passing: 0,
regressions: vec![],
};
let mut seen: HashSet<String> = HashSet::new();
for row in version_rows {
let dep_name = &row.primary.dependent_name;
if !seen.insert(dep_name.clone()) {
continue;
}
stats.total_tested += 1;
let passed_fetch =
row.test.commands.iter().filter(|cmd| cmd.command == CommandType::Fetch).all(|cmd| cmd.result.passed);
let passed_check = row
.test
.commands
.iter()
.filter(|cmd| cmd.command == CommandType::Check || cmd.command == CommandType::Fetch)
.all(|cmd| cmd.result.passed);
let passed_test = row.test.commands.iter().all(|cmd| cmd.result.passed);
let baseline_row = baseline_rows.iter().find(|br| br.primary.dependent_name == *dep_name);
let baseline_passed_check = baseline_row
.map(|br| {
br.test
.commands
.iter()
.filter(|cmd| cmd.command == CommandType::Check || cmd.command == CommandType::Fetch)
.all(|cmd| cmd.result.passed)
})
.unwrap_or(false);
let baseline_passed_test =
baseline_row.map(|br| br.test.commands.iter().all(|cmd| cmd.result.passed)).unwrap_or(false);
if baseline_passed_check {
if passed_fetch {
stats.passed_fetch += 1;
}
if passed_check {
stats.passed_check += 1;
}
if passed_test {
stats.passed_test += 1;
stats.fully_passing += 1;
}
if baseline_passed_test && !passed_test {
let baseline_version = baseline_row.map(|br| br.primary.resolved_version.as_str()).unwrap_or("?");
stats.regressions.push(format!("{} ({})", dep_name, baseline_version));
}
}
}
all_stats.push(stats);
}
all_stats
}
pub fn print_comparison_table(stats_list: &[ComparisonStats]) {
console_format::print_comparison_table(stats_list);
}
pub fn export_json_report(
rows: &[OfferedRow],
output_path: &PathBuf,
crate_name: &str,
display_version: &str,
total_deps: usize,
) -> std::io::Result<()> {
use serde_json::json;
let summary = summarize_offered_rows(rows);
let comparison_stats = generate_comparison_table(rows);
let report = json!({
"crate_name": crate_name,
"crate_version": display_version,
"total_dependents": total_deps,
"summary": {
"passed": summary.passed,
"regressed": summary.regressed,
"broken": summary.broken,
"total": summary.total,
},
"comparison_stats": comparison_stats,
"test_results": rows,
});
let file = File::create(output_path)?;
serde_json::to_writer_pretty(file, &report)?;
Ok(())
}
pub fn export_markdown_table_report(
rows: &[OfferedRow],
output_path: &PathBuf,
crate_name: &str,
display_version: &str,
total_deps: usize,
test_plan: Option<&str>,
this_path: Option<&str>,
) -> std::io::Result<()> {
let mut file = File::create(output_path)?;
let summary = summarize_offered_rows(rows);
writeln!(file, "# Cargo Copter Test Report\n")?;
writeln!(file, "**Crate**: {} ({})", crate_name, display_version)?;
writeln!(file, "**Dependents Tested**: {}\n", total_deps)?;
writeln!(file, "## Summary\n")?;
writeln!(file, "- ✓ Passed: {}", summary.passed)?;
writeln!(file, "- ✗ Regressed: {}", summary.regressed)?;
writeln!(file, "- ⚠ Broken: {}", summary.broken)?;
writeln!(file, "- **Total**: {}\n", summary.total)?;
writeln!(file, "## Test Results\n")?;
writeln!(file, "```")?;
write!(file, "{}", format_table_header(crate_name, display_version, total_deps, test_plan, this_path))?;
for row in rows.iter() {
let is_last_in_group = true;
write!(file, "{}", format_offered_row_string(row, is_last_in_group))?;
}
write!(file, "{}", format_table_footer())?;
let comparison_stats = generate_comparison_table(rows);
let mut table_writer = console_format::TableWriter::new(&mut file, false); table_writer.write_comparison_table(&comparison_stats)?;
writeln!(file, "```\n")?;
Ok(())
}
fn format_offered_row_string(row: &OfferedRow, is_last_in_group: bool) -> String {
let formatted = format_offered_row(row, 0);
let w = console_format::get_widths();
let mut output = String::new();
let offered_display = console_format::truncate_with_padding(&formatted.offered, w.offered - 2);
let spec_display = console_format::truncate_from_start_with_padding(&formatted.spec, w.spec - 2);
let resolved_display = console_format::truncate_from_start_with_padding(&formatted.resolved, w.resolved - 2);
let dependent_display = console_format::truncate_from_start_with_padding(&formatted.dependent, w.dependent - 2);
let result_display = format!("{:>12} {:>5}", formatted.result, formatted.time);
let result_display = console_format::truncate_with_padding(&result_display, w.result - 2);
output.push_str(&format!(
"│ {} │ {} │ {} │ {} │ {} │\n",
offered_display, spec_display, resolved_display, dependent_display, result_display
));
if !formatted.error_details.is_empty() {
let error_text_width = w.total - 1 - w.offered - 1 - 1 - 1 - 1;
let corner1_width = w.spec;
let corner2_width = w.dependent;
let padding_width = w.spec + w.resolved + w.dependent - corner1_width - corner2_width;
output.push_str(&format!(
"│{:w_offered$}├{:─<corner1$}┘{:padding$}└{:─<corner2$}┘{:w_result$}│\n",
"",
"",
"",
"",
"",
w_offered = w.offered,
corner1 = corner1_width,
padding = padding_width,
corner2 = corner2_width,
w_result = w.result
));
for error_line in &formatted.error_details {
let truncated = console_format::truncate_with_padding(error_line, error_text_width);
output.push_str(&format!("│{:w_offered$}│ {} │\n", "", truncated, w_offered = w.offered));
}
if !is_last_in_group {
output.push_str(&format!(
"│{:w_offered$}├{:─<w_spec$}┬{:─<w_resolved$}┬{:─<w_dependent$}┬{:─<w_result$}┤\n",
"",
"",
"",
"",
"",
w_offered = w.offered,
w_spec = w.spec,
w_resolved = w.resolved,
w_dependent = w.dependent,
w_result = w.result
));
}
}
if !formatted.multi_version_rows.is_empty() {
let last_idx = formatted.multi_version_rows.len() - 1;
for (i, (spec, resolved, dependent)) in formatted.multi_version_rows.iter().enumerate() {
let prefix = if i == last_idx { "└─" } else { "├─" };
let spec_display = format!("{} {}", prefix, spec);
let spec_display = console_format::truncate_from_start_with_padding(&spec_display, w.spec - 2);
let resolved_display = format!("{} {}", prefix, resolved);
let resolved_display = console_format::truncate_from_start_with_padding(&resolved_display, w.resolved - 2);
let dependent_display = format!("{} {}", prefix, dependent);
let dependent_display =
console_format::truncate_from_start_with_padding(&dependent_display, w.dependent - 2);
output.push_str(&format!(
"│{:width$}│ {} │ {} │ {} │{:w_result$}│\n",
"",
spec_display,
resolved_display,
dependent_display,
"",
width = w.offered,
w_result = w.result
));
}
}
output
}
pub fn write_failure_log(report_dir: &Path, staging_dir: &Path, result: &TestResult) {
let dependent_name = &result.dependent.name;
let dependent_version = result.dependent.version.display();
let base_version = result.base_version.version.display();
let filename = format!("{}-{}_{}.txt", dependent_name, dependent_version, base_version);
let log_path = report_dir.join(&filename);
let dependent_staging_path = staging_dir.join(format!("{}-{}", dependent_name, dependent_version));
let mut content = String::new();
content.push_str(&format!(
"# Failure Log: {} {} with base crate version {}\n",
dependent_name, dependent_version, base_version
));
content.push_str(&format!("# Generated: {}\n", chrono::Local::now().format("%Y-%m-%d %H:%M:%S")));
content.push_str(&format!(
"# Source: {}\n\n",
dependent_staging_path.canonicalize().unwrap_or(dependent_staging_path).display()
));
fn write_step_output(content: &mut String, result: &crate::compile::CompileResult, step_name: &str) {
content.push_str(&format!("=== {} ===\n", step_name));
content.push_str(&format!("Status: FAILED ({:.1}s)\n\n", result.duration.as_secs_f64()));
if !result.diagnostics.is_empty() {
for diag in &result.diagnostics {
content.push_str(&diag.rendered);
if !diag.rendered.ends_with('\n') {
content.push('\n');
}
}
} else if !result.stderr.is_empty() {
content.push_str(&result.stderr);
if !result.stderr.ends_with('\n') {
content.push('\n');
}
}
content.push('\n');
}
if !result.execution.fetch.success {
write_step_output(&mut content, &result.execution.fetch, "FETCH (cargo fetch)");
}
if let Some(ref check) = result.execution.check
&& !check.success
{
write_step_output(&mut content, check, "CHECK (cargo check)");
}
if let Some(ref test) = result.execution.test
&& !test.success
{
write_step_output(&mut content, test, "TEST (cargo test)");
}
match File::create(&log_path) {
Ok(mut file) => {
if let Err(e) = file.write_all(content.as_bytes()) {
eprintln!("Warning: Failed to write failure log {}: {}", filename, e);
}
}
Err(e) => {
eprintln!("Warning: Failed to create failure log {}: {}", filename, e);
}
}
}
#[derive(Default)]
pub struct DependentResults {
pub dependent_name: String,
pub dependent_version: String,
pub baseline: Option<OfferedRow>,
pub offered_versions: Vec<OfferedRow>,
}
pub fn print_simple_header(base_crate: &str, display_version: &str, dependents: &[String], base_versions: &[String]) {
println!("Testing {}:{} against {} dependents", base_crate, display_version, dependents.len());
println!();
println!("Dependents: {}", dependents.join(", "));
println!("Versions to test: {}", base_versions.join(", "));
println!();
println!("Markers: [!] forced [!!] auto-patched [!!!] deep conflict (see blocking deps)");
println!();
}
pub fn print_simple_dependent_result(results: &DependentResults, base_crate: &str, report_dir: &Path) {
let dep = format!("{}:{}", results.dependent_name, results.dependent_version);
let baseline_row = results.baseline.as_ref();
let baseline_passed = baseline_row.map(|r| r.test_passed()).unwrap_or(false);
let baseline_check_passed = baseline_row
.map(|r| {
r.test
.commands
.iter()
.filter(|c| c.command == CommandType::Check || c.command == CommandType::Fetch)
.all(|c| c.result.passed)
})
.unwrap_or(false);
let baseline_test_passed = baseline_row.map(|r| r.test.commands.iter().all(|c| c.result.passed)).unwrap_or(false);
let baseline_version = baseline_row.map(|r| r.primary.resolved_version.as_str()).unwrap_or("?");
let baseline_spec = baseline_row.map(|r| r.primary.spec.as_str()).unwrap_or("?");
let mut build_regressions: Vec<(&OfferedRow, &'static str)> = Vec::new();
let mut test_regressions: Vec<&OfferedRow> = Vec::new();
let mut passed_versions: Vec<String> = Vec::new();
let mut still_broken: Vec<String> = Vec::new();
let mut patch_explanations: Vec<(String, crate::compile::PatchDepth)> = Vec::new();
for row in &results.offered_versions {
let version = row.offered.as_ref().map(|o| o.version.as_str()).unwrap_or("?");
let patch_depth = row.offered.as_ref().map(|o| o.patch_depth).unwrap_or_default();
let marker = patch_depth.marker();
let version_display = if !marker.is_empty() {
format!("{}:{} [{}]", base_crate, version, marker)
} else {
format!("{}:{}", base_crate, version)
};
let this_passed = row.test_passed();
if this_passed {
passed_versions.push(version_display.clone());
if patch_depth == crate::compile::PatchDepth::Patch || patch_depth == crate::compile::PatchDepth::DeepPatch
{
patch_explanations.push((version.to_string(), patch_depth));
}
} else {
let failed_step = failed_step_name(row);
if failed_step == "build" || failed_step == "fetch" {
if baseline_check_passed {
build_regressions.push((row, failed_step));
} else {
still_broken.push(version_display);
}
} else {
if baseline_test_passed {
test_regressions.push(row);
}
}
}
}
if !build_regressions.is_empty() {
for (row, step) in &build_regressions {
let version = row.offered.as_ref().map(|o| o.version.as_str()).unwrap_or("?");
let patch_depth = row.offered.as_ref().map(|o| o.patch_depth).unwrap_or_default();
let marker = patch_depth.marker();
let depth_marker = if !marker.is_empty() { format!(" [{}]", marker) } else { String::new() };
let baseline_info = format!("{}:{} ({})", base_crate, baseline_version, baseline_spec);
let baseline_note = if baseline_test_passed {
format!("baseline {} passed", baseline_info)
} else {
format!("baseline {} built, tests failed", baseline_info)
};
println!(
"REGRESSION: {} with {}:{}{} - {} failed ({})",
dep, base_crate, version, depth_marker, step, baseline_note
);
if let Some(error) = first_error_line(row) {
println!(" {}", error);
}
if patch_depth == crate::compile::PatchDepth::Patch {
println!(
" [!!] Tried [patch.crates-io] to unify transitive {} versions, but build still failed",
base_crate
);
}
print_blocking_crates_advice(row, base_crate, version);
}
}
if !test_regressions.is_empty() {
for row in &test_regressions {
let version = row.offered.as_ref().map(|o| o.version.as_str()).unwrap_or("?");
let patch_depth = row.offered.as_ref().map(|o| o.patch_depth).unwrap_or_default();
let marker = patch_depth.marker();
let depth_marker = if !marker.is_empty() { format!(" [{}]", marker) } else { String::new() };
let baseline_info = format!("{}:{} ({})", base_crate, baseline_version, baseline_spec);
println!(
"REGRESSION: {} with {}:{}{} - tests failed (baseline {} passed)",
dep, base_crate, version, depth_marker, baseline_info
);
if let Some(error) = first_error_line(row) {
println!(" {}", error);
}
if patch_depth == crate::compile::PatchDepth::Patch {
println!(
" [!!] Used [patch.crates-io] to unify transitive {} versions, but tests still failed",
base_crate
);
}
print_blocking_crates_advice(row, base_crate, version);
}
}
if !passed_versions.is_empty() {
println!("OK: {} - passed with {}", dep, passed_versions.join(", "));
for (version, depth) in &patch_explanations {
match depth {
crate::compile::PatchDepth::Patch => {
println!(
" [!!] {}:{} needed [patch.crates-io] to unify transitive {} versions",
base_crate, version, base_crate
);
}
crate::compile::PatchDepth::DeepPatch => {
println!(
" [!!!] {}:{} has deep transitive conflicts even with [patch.crates-io]",
base_crate, version
);
}
_ => {}
}
}
}
if !still_broken.is_empty() && build_regressions.is_empty() && test_regressions.is_empty() {
println!("BROKEN: {} with {} (baseline check also failed)", dep, still_broken.join(", "));
}
if results.offered_versions.is_empty() && !baseline_passed {
let step = baseline_row.map(failed_step_name).unwrap_or("unknown");
println!("BASELINE FAILED: {} ({} failed)", dep, step);
}
}
fn failed_step_name(row: &OfferedRow) -> &'static str {
for cmd in &row.test.commands {
if !cmd.result.passed {
return match cmd.command {
CommandType::Fetch => "fetch",
CommandType::Check => "build",
CommandType::Test => "test suite",
};
}
}
"unknown"
}
fn first_error_line(row: &OfferedRow) -> Option<String> {
for cmd in &row.test.commands {
if !cmd.result.passed {
for failure in &cmd.result.failures {
if !failure.error_message.is_empty() {
for line in failure.error_message.lines() {
let trimmed = line.trim();
if trimmed.starts_with("error") {
let display = if trimmed.len() > 100 {
format!("{}...", &trimmed[..100])
} else {
trimmed.to_string()
};
return Some(display);
}
}
if let Some(first) = failure.error_message.lines().find(|l| !l.trim().is_empty()) {
let trimmed = first.trim();
let display =
if trimmed.len() > 100 { format!("{}...", &trimmed[..100]) } else { trimmed.to_string() };
return Some(display);
}
}
}
}
}
None
}
fn print_blocking_crates_advice(row: &OfferedRow, base_crate: &str, base_version: &str) {
let patch_depth = row.offered.as_ref().map(|o| o.patch_depth).unwrap_or_default();
if patch_depth != crate::compile::PatchDepth::DeepPatch {
return;
}
if row.transitive.is_empty() {
for cmd in &row.test.commands {
if !cmd.result.passed {
for failure in &cmd.result.failures {
if failure.error_message.contains("two different versions of crate")
|| failure.error_message.contains("multiple different versions of crate")
{
let parts: Vec<&str> = base_version.split('.').collect();
let major_minor = if parts.len() >= 2 {
format!("{}.{}", parts[0], parts[1])
} else {
base_version.to_string()
};
println!(" BLOCKING TRANSITIVE DEPS (need semver-compatible {} specs):", base_crate);
println!(" Recommend: Change restrictive specs (like =X.Y.Z) to ^{}", major_minor);
println!(" For forward compat: Use >={} instead of exact version pins", major_minor);
return;
}
}
}
}
return;
}
println!(" BLOCKING TRANSITIVE DEPS:");
for transitive in &row.transitive {
let spec = &transitive.dependency.spec;
let resolved = &transitive.dependency.resolved_version;
let dep_name = &transitive.dependency.dependent_name;
let is_restrictive = spec.starts_with('=') || spec.starts_with('<');
if is_restrictive || transitive.dependency.resolved_version != base_version {
let parts: Vec<&str> = base_version.split('.').collect();
let major_minor =
if parts.len() >= 2 { format!("{}.{}", parts[0], parts[1]) } else { base_version.to_string() };
let recommendation = if spec.starts_with('=') {
format!("^{} (for backward compat) or >={} (for forward compat)", major_minor, major_minor)
} else if spec.starts_with('~') {
format!("^{} (allows more flexibility)", major_minor)
} else if spec.starts_with('<') {
format!(">={} with adjusted upper bound", major_minor)
} else {
format!("^{}", major_minor)
};
println!(
" {} requires {} {} → resolved {} (recommend: {})",
dep_name, base_crate, spec, resolved, recommendation
);
}
}
}
pub fn write_combined_log(report_dir: &Path, rows: &[OfferedRow], base_crate: &str) -> PathBuf {
let log_path = report_dir.join("failures.log");
let mut content = String::new();
content.push_str("# Cargo Copter - Combined Failure Log\n");
content.push_str(&format!("# Generated: {}\n", chrono::Local::now().format("%Y-%m-%d %H:%M:%S")));
content.push_str(&format!("# Base crate: {}\n\n", base_crate));
let mut failure_count = 0;
for row in rows {
if row.test_passed() {
continue;
}
failure_count += 1;
let dep = format!("{}:{}", row.primary.dependent_name, row.primary.dependent_version);
let version = row.offered.as_ref().map(|o| o.version.as_str()).unwrap_or("baseline");
let version_display = format!("{}:{}", base_crate, version);
content.push_str("========================================\n");
content.push_str(&format!("FAILURE #{}: {} with {}\n", failure_count, dep, version_display));
content.push_str("========================================\n\n");
for cmd in &row.test.commands {
if !cmd.result.passed {
content.push_str(&format!("Failed at: {}\n\n", cmd.command.as_str()));
for failure in &cmd.result.failures {
content.push_str(&failure.error_message);
if !failure.error_message.ends_with('\n') {
content.push('\n');
}
}
break;
}
}
content.push_str("\n\n");
}
if failure_count == 0 {
content.push_str("No failures recorded.\n");
}
if let Err(e) = std::fs::write(&log_path, &content) {
eprintln!("Warning: Failed to write combined log: {}", e);
}
log_path
}
pub fn print_simple_summary(rows: &[OfferedRow], report_dir: &Path, base_crate: &str, combined_log_path: &Path) {
use std::collections::{HashMap, HashSet};
let mut by_version: HashMap<(String, bool), (Vec<String>, Vec<String>)> = HashMap::new();
let mut broken_already: Vec<String> = Vec::new();
let mut baseline_failed_deps: HashSet<String> = HashSet::new();
let mut baseline_check_passed_deps: HashSet<String> = HashSet::new();
for row in rows {
if row.offered.is_none() {
let dep = format!("{}:{}", row.primary.dependent_name, row.primary.dependent_version);
if !row.test_passed() {
baseline_failed_deps.insert(row.primary.dependent_name.clone());
let check_passed = row
.test
.commands
.iter()
.filter(|c| c.command == CommandType::Check || c.command == CommandType::Fetch)
.all(|c| c.result.passed);
if check_passed {
baseline_check_passed_deps.insert(row.primary.dependent_name.clone());
} else {
broken_already.push(dep);
}
}
}
}
for row in rows {
if row.offered.is_none() {
continue; }
let offered = row.offered.as_ref().unwrap();
let version = offered.version.clone();
let forced = offered.forced;
let dep = format!("{}:{}", row.primary.dependent_name, row.primary.dependent_version);
let entry = by_version.entry((version, forced)).or_insert_with(|| (Vec::new(), Vec::new()));
if row.test_passed() {
entry.1.push(dep); } else {
let is_regression = if matches!(row.baseline_passed, Some(true)) {
true } else if baseline_check_passed_deps.contains(&row.primary.dependent_name) {
let failed_step = failed_step_name(row);
failed_step == "fetch" || failed_step == "check"
} else {
false };
if is_regression {
entry.0.push(dep); }
}
}
println!();
println!("========================================");
println!("SUMMARY");
println!("========================================");
for ((version, forced), (regressed, _)) in &by_version {
if !regressed.is_empty() {
let forced_marker = if *forced { "[forced]" } else { "" };
println!("REGRESSED with {}:{}{}: {}", base_crate, version, forced_marker, regressed.join(", "));
}
}
for ((version, forced), (_, worked)) in &by_version {
if !worked.is_empty() {
let forced_marker = if *forced { "[forced]" } else { "" };
println!("WORKED with {}:{}{}: {}", base_crate, version, forced_marker, worked.join(", "));
}
}
if !broken_already.is_empty() {
let baseline_failed_rows: Vec<&OfferedRow> = rows
.iter()
.filter(|r| {
r.offered.is_none()
&& !r.test_passed()
&& !baseline_check_passed_deps.contains(&r.primary.dependent_name)
})
.collect();
let categorized: Vec<crate::categorize::CategorizedFailure> =
baseline_failed_rows.iter().map(|r| crate::categorize::categorize_failure(r, base_crate)).collect();
let summary = crate::categorize::FailureSummary::from_failures(categorized);
if summary.categories.len() > 1
|| summary.categories.iter().any(|(cat, _)| *cat != crate::categorize::FailureCategory::Other)
{
println!("BROKEN ALREADY ({}):", broken_already.len());
for (cat, failures) in &summary.categories {
let names: Vec<&str> = failures.iter().map(|f| f.dependent_name.as_str()).collect();
let display =
if names.len() > 5 { format!("{} ...", names[..5].join(", ")) } else { names.join(", ") };
println!(" {} ({}): {}", cat.label(), failures.len(), display);
}
} else {
println!("BROKEN ALREADY: {}", broken_already.join(", "));
}
}
let total_regressed: usize = by_version.values().map(|(r, _)| r.len()).sum();
let total_worked: usize = by_version.values().map(|(_, w)| w.len()).sum();
println!();
println!("Regressed: {}", total_regressed);
println!("Worked: {}", total_worked);
println!("Broken: {}", broken_already.len());
println!();
println!("Reports:");
println!(" Combined log: {}", combined_log_path.display());
println!(" Markdown: {}/report.md", report_dir.display());
println!(" JSON: {}/report.json", report_dir.display());
}