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<String>. 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.
This commit is contained in:
teernisse
2026-02-16 09:43:22 -05:00
parent c8b47bf8f8
commit fc0d9cb1d3

View File

@@ -5,14 +5,17 @@ use tracing::Instrument;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::Config; 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::error::Result;
use crate::core::metrics::{MetricsLayer, StageTiming}; use crate::core::metrics::{MetricsLayer, StageTiming};
use crate::core::shutdown::ShutdownSignal; use crate::core::shutdown::ShutdownSignal;
use super::embed::run_embed; use super::embed::run_embed;
use super::generate_docs::run_generate_docs; 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)] #[derive(Debug, Default)]
pub struct SyncOptions { pub struct SyncOptions {
@@ -37,7 +40,9 @@ pub struct SyncResult {
pub mr_diffs_fetched: usize, pub mr_diffs_fetched: usize,
pub mr_diffs_failed: usize, pub mr_diffs_failed: usize,
pub documents_regenerated: usize, pub documents_regenerated: usize,
pub documents_errored: usize,
pub documents_embedded: usize, pub documents_embedded: usize,
pub embedding_failed: usize,
pub status_enrichment_errors: usize, pub status_enrichment_errors: usize,
pub statuses_enriched: usize, pub statuses_enriched: usize,
#[serde(skip)] #[serde(skip)]
@@ -46,6 +51,15 @@ pub struct SyncResult {
pub mr_projects: Vec<ProjectSummary>, pub mr_projects: Vec<ProjectSummary>,
} }
/// 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( pub async fn run_sync(
config: &Config, config: &Config,
options: SyncOptions, options: SyncOptions,
@@ -104,15 +118,61 @@ pub async fn run_sync(
result.statuses_enriched += sep.enriched; result.statuses_enriched += sep.enriched;
} }
result.issue_projects = issues_result.project_summaries; 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 {} {}", "{} issues from {} {}",
format_number(result.issues_updated as i64), format_number(result.issues_updated as i64),
issues_result.projects_synced, issues_result.projects_synced,
if issues_result.projects_synced == 1 { "project" } else { "projects" } if issues_result.projects_synced == 1 { "project" } else { "projects" }
); );
finish_stage(&spinner, Icons::success(), "Issues", &issues_summary, stage_start.elapsed()); append_failures(
if !options.robot_mode { &mut issues_summary,
print_issue_sub_rows(&result.issue_projects); &[
("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() { 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_fetched += mrs_result.mr_diffs_fetched;
result.mr_diffs_failed += mrs_result.mr_diffs_failed; result.mr_diffs_failed += mrs_result.mr_diffs_failed;
result.mr_projects = mrs_result.project_summaries; 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 {} {}", "{} merge requests from {} {}",
format_number(result.mrs_updated as i64), format_number(result.mrs_updated as i64),
mrs_result.projects_synced, mrs_result.projects_synced,
if mrs_result.projects_synced == 1 { "project" } else { "projects" } if mrs_result.projects_synced == 1 { "project" } else { "projects" }
); );
finish_stage(&spinner, Icons::success(), "MRs", &mrs_summary, stage_start.elapsed()); append_failures(
if !options.robot_mode { &mut mrs_summary,
print_mr_sub_rows(&result.mr_projects); &[
("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() { 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))?; let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?;
result.documents_regenerated = docs_result.regenerated; result.documents_regenerated = docs_result.regenerated;
result.documents_errored = docs_result.errored;
docs_bar.finish_and_clear(); docs_bar.finish_and_clear();
let docs_summary = format!( let mut docs_summary = format!(
"{} documents generated", "{} documents generated",
format_number(result.documents_regenerated as i64), 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 { } else {
debug!("Sync: skipping document generation (--no-docs)"); 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 { match run_embed(config, options.full, false, Some(embed_cb), signal).await {
Ok(embed_result) => { Ok(embed_result) => {
result.documents_embedded = embed_result.docs_embedded; result.documents_embedded = embed_result.docs_embedded;
result.embedding_failed = embed_result.failed;
embed_bar.finish_and_clear(); embed_bar.finish_and_clear();
let embed_summary = format!( let mut embed_summary = format!(
"{} chunks embedded", "{} chunks embedded",
format_number(embed_result.chunks_embedded as i64), 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) => { Err(e) => {
embed_bar.finish_and_clear(); embed_bar.finish_and_clear();
let warn_summary = format!("skipped ({})", e); 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"); warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
} }
} }
@@ -243,6 +363,7 @@ pub fn print_sync(
result: &SyncResult, result: &SyncResult,
elapsed: std::time::Duration, elapsed: std::time::Duration,
metrics: Option<&MetricsLayer>, metrics: Option<&MetricsLayer>,
show_timings: bool,
) { ) {
let has_data = result.issues_updated > 0 let has_data = result.issues_updated > 0
|| result.mrs_updated > 0 || result.mrs_updated > 0
@@ -252,51 +373,92 @@ pub fn print_sync(
|| result.documents_regenerated > 0 || result.documents_regenerated > 0
|| result.documents_embedded > 0 || result.documents_embedded > 0
|| result.statuses_enriched > 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!( println!(
"\n {} ({:.1}s)\n", "\n {} ({})\n",
Theme::dim().render("Already up to date"), Theme::dim().render("Already up to date"),
elapsed.as_secs_f64() Theme::timing().render(&format!("{:.1}s", elapsed.as_secs_f64()))
); );
} else { } 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!( println!(
"\n {} {} issues and {} MRs in {:.1}s", "\n {} {} issues and {} MRs in {}",
Theme::success().bold().render("Synced"), headline,
Theme::bold().render(&result.issues_updated.to_string()), Theme::info()
Theme::bold().render(&result.mrs_updated.to_string()), .bold()
elapsed.as_secs_f64() .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 // Detail: supporting counts, compact middle-dot format, zero-suppressed
let mut details: Vec<String> = Vec::new(); let mut details: Vec<String> = Vec::new();
if result.discussions_fetched > 0 { 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 { 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 { 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 { 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() { 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 // Documents: regeneration + embedding as a second detail line
let mut doc_parts: Vec<String> = Vec::new(); let mut doc_parts: Vec<String> = Vec::new();
if result.documents_regenerated > 0 { 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 { 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() { 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 // Errors: visually prominent, only if non-zero
@@ -310,6 +472,9 @@ pub fn print_sync(
if result.status_enrichment_errors > 0 { if result.status_enrichment_errors > 0 {
errors.push(format!("{} status errors", result.status_enrichment_errors)); 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() { if !errors.is_empty() {
println!(" {}", Theme::error().render(&errors.join(" \u{b7} "))); println!(" {}", Theme::error().render(&errors.join(" \u{b7} ")));
} }
@@ -319,17 +484,16 @@ pub fn print_sync(
if let Some(metrics) = metrics { if let Some(metrics) = metrics {
let stages = metrics.extract_timings(); let stages = metrics.extract_timings();
if !stages.is_empty() { if should_print_timings(show_timings, &stages) {
print_timing_summary(&stages); print_timing_summary(&stages);
} }
} }
} }
fn print_issue_sub_rows(projects: &[ProjectSummary]) { fn issue_sub_rows(projects: &[ProjectSummary]) -> Vec<String> {
if projects.len() <= 1 { projects
return; .iter()
} .map(|p| {
for p in projects {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
parts.push(format!( parts.push(format!(
"{} {}", "{} {}",
@@ -343,25 +507,59 @@ fn print_issue_sub_rows(projects: &[ProjectSummary]) {
if p.discussions_synced > 0 { if p.discussions_synced > 0 {
parts.push(format!("{} discussions", p.discussions_synced)); parts.push(format!("{} discussions", p.discussions_synced));
} }
if p.statuses_enriched > 0 { if p.statuses_seen > 0 || p.statuses_enriched > 0 {
parts.push(format!("{} statuses updated", p.statuses_enriched)); parts.push(format!("{} statuses updated", p.statuses_enriched));
} }
if p.events_fetched > 0 { if p.events_fetched > 0 {
parts.push(format!("{} events", p.events_fetched)); parts.push(format!("{} events", p.events_fetched));
} }
let detail = parts.join(" \u{b7} "); if p.status_errors > 0 {
let _ = crate::cli::progress::multi().println(format!( parts.push(Theme::warning().render(&format!("{} status errors", p.status_errors)));
" {}",
Theme::dim().render(&format!("{:<30} {}", p.path, detail))
));
} }
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 print_mr_sub_rows(projects: &[ProjectSummary]) { fn status_sub_rows(projects: &[ProjectStatusEnrichment]) -> Vec<String> {
if projects.len() <= 1 { projects
return; .iter()
.map(|p| {
let total_errors = p.partial_errors + usize::from(p.error.is_some());
let mut parts: Vec<String> = vec![format!("{} statuses updated", p.enriched)];
if p.cleared > 0 {
parts.push(format!("{} cleared", p.cleared));
} }
for p in projects { 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<String> {
projects
.iter()
.map(|p| {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
parts.push(format!( parts.push(format!(
"{} {}", "{} {}",
@@ -377,12 +575,101 @@ fn print_mr_sub_rows(projects: &[ProjectSummary]) {
if p.events_fetched > 0 { if p.events_fetched > 0 {
parts.push(format!("{} events", p.events_fetched)); parts.push(format!("{} events", p.events_fetched));
} }
let detail = parts.join(" \u{b7} "); if p.mr_diffs_failed > 0 {
let _ = crate::cli::progress::multi().println(format!( parts
" {}", .push(Theme::warning().render(&format!("{} diff failures", p.mr_diffs_failed)));
Theme::dim().render(&format!("{:<30} {}", p.path, detail)) }
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}");
}
});
}
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<String> = 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 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!(
"{} 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) { fn section(title: &str) {
@@ -595,3 +882,151 @@ pub fn print_sync_dry_run_json(result: &SyncDryRunResult) {
println!("{}", serde_json::to_string(&output).unwrap()); 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)"));
}
}