From fc0d9cb1d3bdd0bccd23eaf795ac9881c86ef66e Mon Sep 17 00:00:00 2001 From: teernisse Date: Mon, 16 Feb 2026 09:43:22 -0500 Subject: [PATCH] feat(sync): colored stage output, functional sub-rows, and error visibility Overhaul the sync command's human output to use semantic colors and a cleaner rendering architecture. The changes fall into four areas: Stage lines: Replace direct finish_stage() calls with an emit_stage_line/emit_stage_block pattern that clears the spinner first, then prints static lines via MultiProgress::suspend. Stage icons are now color-coded green (success) or yellow (warning) via color_icon(). A separate "Status" stage line now appears after Issues, summarizing work-item status enrichment across all projects. Sub-rows: Replace the imperative print_issue_sub_rows/print_mr_sub_rows functions with functional issue_sub_rows(), mr_sub_rows(), and new status_sub_rows() that return Vec. Project paths use Theme::muted(), error/failure counts use Theme::warning(), and separators use the dim middle-dot style. Sub-rows are printed atomically with their parent stage line to avoid interleaving with spinners. Summary: In print_sync(), counts now use Theme::info().bold() for visual pop, detail-line separators are individually styled (dim middle-dot), and a new "Sync completed with issues" headline appears when any stage had failures. Document errors and embedding failures are surfaced in both the doc-parts line and the errors line. Tests: Full coverage for append_failures, summarize_status_enrichment, should_print_timings, issue_sub_rows, mr_sub_rows, and status_sub_rows. --- src/cli/commands/sync.rs | 601 +++++++++++++++++++++++++++++++++------ 1 file changed, 518 insertions(+), 83 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 23e58da..68de479 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -5,14 +5,17 @@ use tracing::Instrument; use tracing::{debug, warn}; use crate::Config; -use crate::cli::progress::{finish_stage, nested_progress, stage_spinner_v2}; +use crate::cli::progress::{format_stage_line, nested_progress, stage_spinner_v2}; use crate::core::error::Result; use crate::core::metrics::{MetricsLayer, StageTiming}; use crate::core::shutdown::ShutdownSignal; use super::embed::run_embed; use super::generate_docs::run_generate_docs; -use super::ingest::{DryRunPreview, IngestDisplay, ProjectSummary, run_ingest, run_ingest_dry_run}; +use super::ingest::{ + DryRunPreview, IngestDisplay, ProjectStatusEnrichment, ProjectSummary, run_ingest, + run_ingest_dry_run, +}; #[derive(Debug, Default)] pub struct SyncOptions { @@ -37,7 +40,9 @@ pub struct SyncResult { pub mr_diffs_fetched: usize, pub mr_diffs_failed: usize, pub documents_regenerated: usize, + pub documents_errored: usize, pub documents_embedded: usize, + pub embedding_failed: usize, pub status_enrichment_errors: usize, pub statuses_enriched: usize, #[serde(skip)] @@ -46,6 +51,15 @@ pub struct SyncResult { pub mr_projects: Vec, } +/// Apply semantic color to a stage-completion icon glyph. +fn color_icon(icon: &str, has_errors: bool) -> String { + if has_errors { + Theme::warning().render(icon) + } else { + Theme::success().render(icon) + } +} + pub async fn run_sync( config: &Config, options: SyncOptions, @@ -104,15 +118,61 @@ pub async fn run_sync( result.statuses_enriched += sep.enriched; } result.issue_projects = issues_result.project_summaries; - let issues_summary = format!( + let issues_elapsed = stage_start.elapsed(); + if !options.robot_mode { + let (status_summary, status_has_errors) = + summarize_status_enrichment(&issues_result.status_enrichment_projects); + let status_icon = color_icon( + if status_has_errors { + Icons::warning() + } else { + Icons::success() + }, + status_has_errors, + ); + let mut status_lines = vec![format_stage_line( + &status_icon, + "Status", + &status_summary, + issues_elapsed, + )]; + status_lines.extend(status_sub_rows(&issues_result.status_enrichment_projects)); + print_static_lines(&status_lines); + } + let mut issues_summary = format!( "{} issues from {} {}", format_number(result.issues_updated as i64), issues_result.projects_synced, if issues_result.projects_synced == 1 { "project" } else { "projects" } ); - finish_stage(&spinner, Icons::success(), "Issues", &issues_summary, stage_start.elapsed()); - if !options.robot_mode { - print_issue_sub_rows(&result.issue_projects); + append_failures( + &mut issues_summary, + &[ + ("event failures", issues_result.resource_events_failed), + ("status errors", issues_result.status_enrichment_errors), + ], + ); + let issues_icon = color_icon( + if issues_result.resource_events_failed > 0 || issues_result.status_enrichment_errors > 0 + { + Icons::warning() + } else { + Icons::success() + }, + issues_result.resource_events_failed > 0 || issues_result.status_enrichment_errors > 0, + ); + if options.robot_mode { + emit_stage_line(&spinner, &issues_icon, "Issues", &issues_summary, issues_elapsed); + } else { + let sub_rows = issue_sub_rows(&result.issue_projects); + emit_stage_block( + &spinner, + &issues_icon, + "Issues", + &issues_summary, + issues_elapsed, + &sub_rows, + ); } if signal.is_cancelled() { @@ -143,15 +203,33 @@ pub async fn run_sync( result.mr_diffs_fetched += mrs_result.mr_diffs_fetched; result.mr_diffs_failed += mrs_result.mr_diffs_failed; result.mr_projects = mrs_result.project_summaries; - let mrs_summary = format!( + let mrs_elapsed = stage_start.elapsed(); + let mut mrs_summary = format!( "{} merge requests from {} {}", format_number(result.mrs_updated as i64), mrs_result.projects_synced, if mrs_result.projects_synced == 1 { "project" } else { "projects" } ); - finish_stage(&spinner, Icons::success(), "MRs", &mrs_summary, stage_start.elapsed()); - if !options.robot_mode { - print_mr_sub_rows(&result.mr_projects); + append_failures( + &mut mrs_summary, + &[ + ("event failures", mrs_result.resource_events_failed), + ("diff failures", mrs_result.mr_diffs_failed), + ], + ); + let mrs_icon = color_icon( + if mrs_result.resource_events_failed > 0 || mrs_result.mr_diffs_failed > 0 { + Icons::warning() + } else { + Icons::success() + }, + mrs_result.resource_events_failed > 0 || mrs_result.mr_diffs_failed > 0, + ); + if options.robot_mode { + emit_stage_line(&spinner, &mrs_icon, "MRs", &mrs_summary, mrs_elapsed); + } else { + let sub_rows = mr_sub_rows(&result.mr_projects); + emit_stage_block(&spinner, &mrs_icon, "MRs", &mrs_summary, mrs_elapsed, &sub_rows); } if signal.is_cancelled() { @@ -175,12 +253,22 @@ pub async fn run_sync( }); let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?; result.documents_regenerated = docs_result.regenerated; + result.documents_errored = docs_result.errored; docs_bar.finish_and_clear(); - let docs_summary = format!( + let mut docs_summary = format!( "{} documents generated", format_number(result.documents_regenerated as i64), ); - finish_stage(&spinner, Icons::success(), "Docs", &docs_summary, stage_start.elapsed()); + append_failures(&mut docs_summary, &[("errors", docs_result.errored)]); + let docs_icon = color_icon( + if docs_result.errored > 0 { + Icons::warning() + } else { + Icons::success() + }, + docs_result.errored > 0, + ); + emit_stage_line(&spinner, &docs_icon, "Docs", &docs_summary, stage_start.elapsed()); } else { debug!("Sync: skipping document generation (--no-docs)"); } @@ -202,17 +290,49 @@ pub async fn run_sync( match run_embed(config, options.full, false, Some(embed_cb), signal).await { Ok(embed_result) => { result.documents_embedded = embed_result.docs_embedded; + result.embedding_failed = embed_result.failed; embed_bar.finish_and_clear(); - let embed_summary = format!( + let mut embed_summary = format!( "{} chunks embedded", format_number(embed_result.chunks_embedded as i64), ); - finish_stage(&spinner, Icons::success(), "Embed", &embed_summary, stage_start.elapsed()); + let mut tail_parts = Vec::new(); + if embed_result.failed > 0 { + tail_parts.push(format!("{} failed", embed_result.failed)); + } + if embed_result.skipped > 0 { + tail_parts.push(format!("{} skipped", embed_result.skipped)); + } + if !tail_parts.is_empty() { + embed_summary.push_str(&format!(" ({})", tail_parts.join(", "))); + } + let embed_icon = color_icon( + if embed_result.failed > 0 { + Icons::warning() + } else { + Icons::success() + }, + embed_result.failed > 0, + ); + emit_stage_line( + &spinner, + &embed_icon, + "Embed", + &embed_summary, + stage_start.elapsed(), + ); } Err(e) => { embed_bar.finish_and_clear(); let warn_summary = format!("skipped ({})", e); - finish_stage(&spinner, Icons::warning(), "Embed", &warn_summary, stage_start.elapsed()); + let warn_icon = color_icon(Icons::warning(), true); + emit_stage_line( + &spinner, + &warn_icon, + "Embed", + &warn_summary, + stage_start.elapsed(), + ); warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing"); } } @@ -243,6 +363,7 @@ pub fn print_sync( result: &SyncResult, elapsed: std::time::Duration, metrics: Option<&MetricsLayer>, + show_timings: bool, ) { let has_data = result.issues_updated > 0 || result.mrs_updated > 0 @@ -252,51 +373,92 @@ pub fn print_sync( || result.documents_regenerated > 0 || result.documents_embedded > 0 || result.statuses_enriched > 0; + let has_failures = result.resource_events_failed > 0 + || result.mr_diffs_failed > 0 + || result.status_enrichment_errors > 0 + || result.documents_errored > 0 + || result.embedding_failed > 0; - if !has_data { + if !has_data && !has_failures { println!( - "\n {} ({:.1}s)\n", + "\n {} ({})\n", Theme::dim().render("Already up to date"), - elapsed.as_secs_f64() + Theme::timing().render(&format!("{:.1}s", elapsed.as_secs_f64())) ); } else { - // Headline: what happened, how long + let headline = if has_failures { + Theme::warning().bold().render("Sync completed with issues") + } else { + Theme::success().bold().render("Synced") + }; println!( - "\n {} {} issues and {} MRs in {:.1}s", - Theme::success().bold().render("Synced"), - Theme::bold().render(&result.issues_updated.to_string()), - Theme::bold().render(&result.mrs_updated.to_string()), - elapsed.as_secs_f64() + "\n {} {} issues and {} MRs in {}", + headline, + Theme::info() + .bold() + .render(&result.issues_updated.to_string()), + Theme::info().bold().render(&result.mrs_updated.to_string()), + Theme::timing().render(&format!("{:.1}s", elapsed.as_secs_f64())) ); // Detail: supporting counts, compact middle-dot format, zero-suppressed let mut details: Vec = Vec::new(); if result.discussions_fetched > 0 { - details.push(format!("{} discussions", result.discussions_fetched)); + details.push(format!( + "{} {}", + Theme::info().render(&result.discussions_fetched.to_string()), + Theme::dim().render("discussions") + )); } if result.resource_events_fetched > 0 { - details.push(format!("{} events", result.resource_events_fetched)); + details.push(format!( + "{} {}", + Theme::info().render(&result.resource_events_fetched.to_string()), + Theme::dim().render("events") + )); } if result.mr_diffs_fetched > 0 { - details.push(format!("{} diffs", result.mr_diffs_fetched)); + details.push(format!( + "{} {}", + Theme::info().render(&result.mr_diffs_fetched.to_string()), + Theme::dim().render("diffs") + )); } if result.statuses_enriched > 0 { - details.push(format!("{} statuses updated", result.statuses_enriched)); + details.push(format!( + "{} {}", + Theme::info().render(&result.statuses_enriched.to_string()), + Theme::dim().render("statuses updated") + )); } if !details.is_empty() { - println!(" {}", Theme::dim().render(&details.join(" \u{b7} "))); + let sep = Theme::dim().render(" \u{b7} "); + println!(" {}", details.join(&sep)); } // Documents: regeneration + embedding as a second detail line let mut doc_parts: Vec = Vec::new(); if result.documents_regenerated > 0 { - doc_parts.push(format!("{} docs regenerated", result.documents_regenerated)); + doc_parts.push(format!( + "{} {}", + Theme::info().render(&result.documents_regenerated.to_string()), + Theme::dim().render("docs regenerated") + )); } if result.documents_embedded > 0 { - doc_parts.push(format!("{} embedded", result.documents_embedded)); + doc_parts.push(format!( + "{} {}", + Theme::info().render(&result.documents_embedded.to_string()), + Theme::dim().render("embedded") + )); + } + if result.documents_errored > 0 { + doc_parts + .push(Theme::error().render(&format!("{} doc errors", result.documents_errored))); } if !doc_parts.is_empty() { - println!(" {}", Theme::dim().render(&doc_parts.join(" \u{b7} "))); + let sep = Theme::dim().render(" \u{b7} "); + println!(" {}", doc_parts.join(&sep)); } // Errors: visually prominent, only if non-zero @@ -310,6 +472,9 @@ pub fn print_sync( if result.status_enrichment_errors > 0 { errors.push(format!("{} status errors", result.status_enrichment_errors)); } + if result.embedding_failed > 0 { + errors.push(format!("{} embedding failures", result.embedding_failed)); + } if !errors.is_empty() { println!(" {}", Theme::error().render(&errors.join(" \u{b7} "))); } @@ -319,70 +484,192 @@ pub fn print_sync( if let Some(metrics) = metrics { let stages = metrics.extract_timings(); - if !stages.is_empty() { + if should_print_timings(show_timings, &stages) { print_timing_summary(&stages); } } } -fn print_issue_sub_rows(projects: &[ProjectSummary]) { - if projects.len() <= 1 { - return; - } - for p in projects { - let mut parts: Vec = Vec::new(); - parts.push(format!( - "{} {}", - p.items_upserted, - if p.items_upserted == 1 { - "issue" - } else { - "issues" +fn issue_sub_rows(projects: &[ProjectSummary]) -> Vec { + projects + .iter() + .map(|p| { + let mut parts: Vec = Vec::new(); + parts.push(format!( + "{} {}", + p.items_upserted, + if p.items_upserted == 1 { + "issue" + } else { + "issues" + } + )); + if p.discussions_synced > 0 { + parts.push(format!("{} discussions", p.discussions_synced)); } - )); - if p.discussions_synced > 0 { - parts.push(format!("{} discussions", p.discussions_synced)); + if p.statuses_seen > 0 || p.statuses_enriched > 0 { + parts.push(format!("{} statuses updated", p.statuses_enriched)); + } + if p.events_fetched > 0 { + parts.push(format!("{} events", p.events_fetched)); + } + if p.status_errors > 0 { + parts.push(Theme::warning().render(&format!("{} status errors", p.status_errors))); + } + if p.events_failed > 0 { + parts.push(Theme::warning().render(&format!("{} event failures", p.events_failed))); + } + let sep = Theme::dim().render(" \u{b7} "); + let detail = parts.join(&sep); + let path = Theme::muted().render(&format!("{:<30}", p.path)); + format!(" {path} {detail}") + }) + .collect() +} + +fn status_sub_rows(projects: &[ProjectStatusEnrichment]) -> Vec { + projects + .iter() + .map(|p| { + let total_errors = p.partial_errors + usize::from(p.error.is_some()); + let mut parts: Vec = vec![format!("{} statuses updated", p.enriched)]; + if p.cleared > 0 { + parts.push(format!("{} cleared", p.cleared)); + } + if p.seen > 0 { + parts.push(format!("{} seen", p.seen)); + } + if total_errors > 0 { + parts.push(Theme::warning().render(&format!("{} errors", total_errors))); + } else if p.mode == "skipped" { + if let Some(reason) = &p.reason { + parts.push(Theme::dim().render(&format!("skipped ({reason})"))); + } else { + parts.push(Theme::dim().render("skipped")); + } + } + let sep = Theme::dim().render(" \u{b7} "); + let detail = parts.join(&sep); + let path = Theme::muted().render(&format!("{:<30}", p.path)); + format!(" {path} {detail}") + }) + .collect() +} + +fn mr_sub_rows(projects: &[ProjectSummary]) -> Vec { + projects + .iter() + .map(|p| { + let mut parts: Vec = Vec::new(); + parts.push(format!( + "{} {}", + p.items_upserted, + if p.items_upserted == 1 { "MR" } else { "MRs" } + )); + if p.discussions_synced > 0 { + parts.push(format!("{} discussions", p.discussions_synced)); + } + if p.mr_diffs_fetched > 0 { + parts.push(format!("{} diffs", p.mr_diffs_fetched)); + } + if p.events_fetched > 0 { + parts.push(format!("{} events", p.events_fetched)); + } + if p.mr_diffs_failed > 0 { + parts + .push(Theme::warning().render(&format!("{} diff failures", p.mr_diffs_failed))); + } + if p.events_failed > 0 { + parts.push(Theme::warning().render(&format!("{} event failures", p.events_failed))); + } + let sep = Theme::dim().render(" \u{b7} "); + let detail = parts.join(&sep); + let path = Theme::muted().render(&format!("{:<30}", p.path)); + format!(" {path} {detail}") + }) + .collect() +} + +fn emit_stage_line( + pb: &indicatif::ProgressBar, + icon: &str, + label: &str, + summary: &str, + elapsed: std::time::Duration, +) { + pb.finish_and_clear(); + print_static_lines(&[format_stage_line(icon, label, summary, elapsed)]); +} + +fn emit_stage_block( + pb: &indicatif::ProgressBar, + icon: &str, + label: &str, + summary: &str, + elapsed: std::time::Duration, + sub_rows: &[String], +) { + pb.finish_and_clear(); + let mut lines = Vec::with_capacity(1 + sub_rows.len()); + lines.push(format_stage_line(icon, label, summary, elapsed)); + lines.extend(sub_rows.iter().cloned()); + print_static_lines(&lines); +} + +fn print_static_lines(lines: &[String]) { + crate::cli::progress::multi().suspend(|| { + for line in lines { + println!("{line}"); } - if p.statuses_enriched > 0 { - parts.push(format!("{} statuses updated", p.statuses_enriched)); - } - if p.events_fetched > 0 { - parts.push(format!("{} events", p.events_fetched)); - } - let detail = parts.join(" \u{b7} "); - let _ = crate::cli::progress::multi().println(format!( - " {}", - Theme::dim().render(&format!("{:<30} {}", p.path, detail)) - )); + }); +} + +fn should_print_timings(show_timings: bool, stages: &[StageTiming]) -> bool { + show_timings && !stages.is_empty() +} + +fn append_failures(summary: &mut String, failures: &[(&str, usize)]) { + let rendered: Vec = failures + .iter() + .filter_map(|(label, count)| { + (*count > 0).then_some(Theme::warning().render(&format!("{count} {label}"))) + }) + .collect(); + if !rendered.is_empty() { + summary.push_str(&format!(" ({})", rendered.join(", "))); } } -fn print_mr_sub_rows(projects: &[ProjectSummary]) { - if projects.len() <= 1 { - return; - } - for p in projects { - let mut parts: Vec = Vec::new(); +fn summarize_status_enrichment(projects: &[ProjectStatusEnrichment]) -> (String, bool) { + let statuses_enriched: usize = projects.iter().map(|p| p.enriched).sum(); + let statuses_seen: usize = projects.iter().map(|p| p.seen).sum(); + let statuses_cleared: usize = projects.iter().map(|p| p.cleared).sum(); + let status_errors: usize = projects + .iter() + .map(|p| p.partial_errors + usize::from(p.error.is_some())) + .sum(); + let skipped = projects.iter().filter(|p| p.mode == "skipped").count(); + + let mut parts = vec![format!( + "{} statuses updated", + format_number(statuses_enriched as i64) + )]; + if statuses_cleared > 0 { parts.push(format!( - "{} {}", - p.items_upserted, - if p.items_upserted == 1 { "MR" } else { "MRs" } - )); - if p.discussions_synced > 0 { - parts.push(format!("{} discussions", p.discussions_synced)); - } - if p.mr_diffs_fetched > 0 { - parts.push(format!("{} diffs", p.mr_diffs_fetched)); - } - if p.events_fetched > 0 { - parts.push(format!("{} events", p.events_fetched)); - } - let detail = parts.join(" \u{b7} "); - let _ = crate::cli::progress::multi().println(format!( - " {}", - Theme::dim().render(&format!("{:<30} {}", p.path, detail)) + "{} cleared", + format_number(statuses_cleared as i64) )); } + if statuses_seen > 0 { + parts.push(format!("{} seen", format_number(statuses_seen as i64))); + } + if status_errors > 0 { + parts.push(format!("{} errors", format_number(status_errors as i64))); + } else if projects.is_empty() || skipped == projects.len() { + parts.push("skipped".to_string()); + } + + (parts.join(" \u{b7} "), status_errors > 0) } fn section(title: &str) { @@ -595,3 +882,151 @@ pub fn print_sync_dry_run_json(result: &SyncDryRunResult) { println!("{}", serde_json::to_string(&output).unwrap()); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn append_failures_skips_zeroes() { + let mut summary = "base".to_string(); + append_failures(&mut summary, &[("errors", 0), ("failures", 0)]); + assert_eq!(summary, "base"); + } + + #[test] + fn append_failures_renders_non_zero_counts() { + let mut summary = "base".to_string(); + append_failures(&mut summary, &[("errors", 2), ("failures", 1)]); + assert!(summary.contains("base")); + assert!(summary.contains("2 errors")); + assert!(summary.contains("1 failures")); + } + + #[test] + fn summarize_status_enrichment_reports_skipped_when_all_skipped() { + let projects = vec![ProjectStatusEnrichment { + path: "vs/typescript-code".to_string(), + mode: "skipped".to_string(), + reason: None, + seen: 0, + enriched: 0, + cleared: 0, + without_widget: 0, + partial_errors: 0, + first_partial_error: None, + error: None, + }]; + + let (summary, has_errors) = summarize_status_enrichment(&projects); + assert!(summary.contains("0 statuses updated")); + assert!(summary.contains("skipped")); + assert!(!has_errors); + } + + #[test] + fn summarize_status_enrichment_reports_errors() { + let projects = vec![ProjectStatusEnrichment { + path: "vs/typescript-code".to_string(), + mode: "fetched".to_string(), + reason: None, + seen: 3, + enriched: 1, + cleared: 1, + without_widget: 0, + partial_errors: 2, + first_partial_error: None, + error: Some("boom".to_string()), + }]; + + let (summary, has_errors) = summarize_status_enrichment(&projects); + assert!(summary.contains("1 statuses updated")); + assert!(summary.contains("1 cleared")); + assert!(summary.contains("3 seen")); + assert!(summary.contains("3 errors")); + assert!(has_errors); + } + + #[test] + fn should_print_timings_only_when_enabled_and_non_empty() { + let stages = vec![StageTiming { + name: "x".to_string(), + elapsed_ms: 10, + items_processed: 0, + items_skipped: 0, + errors: 0, + rate_limit_hits: 0, + retries: 0, + project: None, + sub_stages: vec![], + }]; + + assert!(should_print_timings(true, &stages)); + assert!(!should_print_timings(false, &stages)); + assert!(!should_print_timings(true, &[])); + } + + #[test] + fn issue_sub_rows_include_project_and_statuses() { + let rows = issue_sub_rows(&[ProjectSummary { + path: "vs/typescript-code".to_string(), + items_upserted: 2, + discussions_synced: 0, + events_fetched: 0, + events_failed: 0, + statuses_enriched: 1, + statuses_seen: 5, + status_errors: 0, + mr_diffs_fetched: 0, + mr_diffs_failed: 0, + }]); + + assert_eq!(rows.len(), 1); + assert!(rows[0].contains("vs/typescript-code")); + assert!(rows[0].contains("2 issues")); + assert!(rows[0].contains("1 statuses updated")); + } + + #[test] + fn mr_sub_rows_include_project_and_diff_failures() { + let rows = mr_sub_rows(&[ProjectSummary { + path: "vs/python-code".to_string(), + items_upserted: 3, + discussions_synced: 0, + events_fetched: 0, + events_failed: 0, + statuses_enriched: 0, + statuses_seen: 0, + status_errors: 0, + mr_diffs_fetched: 4, + mr_diffs_failed: 1, + }]); + + assert_eq!(rows.len(), 1); + assert!(rows[0].contains("vs/python-code")); + assert!(rows[0].contains("3 MRs")); + assert!(rows[0].contains("4 diffs")); + assert!(rows[0].contains("1 diff failures")); + } + + #[test] + fn status_sub_rows_include_project_and_skip_reason() { + let rows = status_sub_rows(&[ProjectStatusEnrichment { + path: "vs/python-code".to_string(), + mode: "skipped".to_string(), + reason: Some("disabled".to_string()), + seen: 0, + enriched: 0, + cleared: 0, + without_widget: 0, + partial_errors: 0, + first_partial_error: None, + error: None, + }]); + + assert_eq!(rows.len(), 1); + assert!(rows[0].contains("vs/python-code")); + assert!(rows[0].contains("0 statuses updated")); + assert!(rows[0].contains("skipped (disabled)")); + } +}