refactor(cli): migrate all command modules from console::style to Theme

Replace all console::style() calls in command modules with the centralized
Theme API and render:: utility functions. This ensures consistent color
behavior across the entire CLI, proper NO_COLOR/--color never support via
the LoreRenderer singleton, and eliminates duplicated formatting code.

Changes per module:

- count.rs: Theme for table headers, render::format_number replacing local
  duplicate. Removed local format_number implementation.
- doctor.rs: Theme::success/warning/error for check status symbols and
  messages. Unicode escapes for check/warning/cross symbols.
- drift.rs: Theme::bold/error/success for drift detection headers and
  status messages.
- embed.rs: Compact output format — headline with count, zero-suppressed
  detail lines, 'nothing to embed' short-circuit for no-op runs.
- generate_docs.rs: Same compact pattern — headline + detail + hint for
  next step. No-op short-circuit when regenerated==0.
- ingest.rs: Theme for project summaries, sync status, dry-run preview.
  All console::style -> Theme replacements.
- list.rs: Replace comfy-table with render::LoreTable for issue/MR listing.
  Remove local colored_cell, colored_cell_hex, format_relative_time,
  truncate_with_ellipsis, and format_labels (all moved to render.rs).
- list_tests.rs: Update test assertions to use render:: functions.
- search.rs: Add render_snippet() for FTS5 <mark> tag highlighting via
  Theme::bold().underline(). Compact result layout with type badges.
- show.rs: Theme for entity detail views, delegate format_date and
  wrap_text to render module.
- stats.rs: Section-based layout using render::section_divider. Compact
  middle-dot format for document counts. Color-coded embedding coverage
  percentage (green >=95%, yellow >=50%, red <50%).
- sync.rs: Compact sync summary — headline with counts and elapsed time,
  zero-suppressed detail lines, visually prominent error-only section.
- sync_status.rs: Theme for run history headers, removed local
  format_number duplicate.
- timeline.rs: Theme for headers/footers, render:: for date/truncate,
  standard format! padding replacing console::pad_str.
- who.rs: Theme for all expert/workload/active/overlap/review output
  modes, render:: for relative time and truncation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-13 22:32:35 -05:00
parent c6a5461d41
commit dd00a2b840
15 changed files with 727 additions and 883 deletions

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::{self, Theme};
use rusqlite::Connection;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
@@ -1874,18 +1874,21 @@ fn print_scope_hint(project_path: Option<&str>) {
if project_path.is_none() {
println!(
" {}",
style("(aggregated across all projects; use -p to scope)").dim()
Theme::dim().render("(aggregated across all projects; use -p to scope)")
);
}
}
fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
println!();
println!("{}", style(format!("Experts for {}", r.path_query)).bold());
println!(
"{}",
Theme::bold().render(&format!("Experts for {}", r.path_query))
);
println!("{}", "\u{2500}".repeat(60));
println!(
" {}",
style(format!(
Theme::dim().render(&format!(
"(matching {} {})",
r.path_match,
if r.path_match == "exact" {
@@ -1894,26 +1897,28 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
"directory prefix"
}
))
.dim()
);
print_scope_hint(project_path);
println!();
if r.experts.is_empty() {
println!(" {}", style("No experts found for this path.").dim());
println!(
" {}",
Theme::dim().render("No experts found for this path.")
);
println!();
return;
}
println!(
" {:<16} {:>6} {:>12} {:>6} {:>12} {} {}",
style("Username").bold(),
style("Score").bold(),
style("Reviewed(MRs)").bold(),
style("Notes").bold(),
style("Authored(MRs)").bold(),
style("Last Seen").bold(),
style("MR Refs").bold(),
Theme::bold().render("Username"),
Theme::bold().render("Score"),
Theme::bold().render("Reviewed(MRs)"),
Theme::bold().render("Notes"),
Theme::bold().render("Authored(MRs)"),
Theme::bold().render("Last Seen"),
Theme::bold().render("MR Refs"),
);
for expert in &r.experts {
@@ -1946,12 +1951,12 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
};
println!(
" {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}",
style(format!("@{}", expert.username)).cyan(),
Theme::info().render(&format!("@{}", expert.username)),
expert.score,
reviews,
notes,
authored,
format_relative_time(expert.last_seen_ms),
render::format_relative_time(expert.last_seen_ms),
if mr_str.is_empty() {
String::new()
} else {
@@ -1971,17 +1976,17 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
};
println!(
" {:<3} {:<30} {:>30} {:>10} {}",
style(&d.role).dim(),
Theme::dim().render(&d.role),
d.mr_ref,
truncate_str(&format!("\"{}\"", d.title), 30),
render::truncate(&format!("\"{}\"", d.title), 30),
notes_str,
style(format_relative_time(d.last_activity_ms)).dim(),
Theme::dim().render(&render::format_relative_time(d.last_activity_ms)),
);
}
if details.len() > MAX_DETAIL_DISPLAY {
println!(
" {}",
style(format!("+{} more", details.len() - MAX_DETAIL_DISPLAY)).dim()
Theme::dim().render(&format!("+{} more", details.len() - MAX_DETAIL_DISPLAY))
);
}
}
@@ -1989,7 +1994,7 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
if r.truncated {
println!(
" {}",
style("(showing first -n; rerun with a higher --limit)").dim()
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
);
}
println!();
@@ -1999,7 +2004,7 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
"{}",
style(format!("@{} -- Workload Summary", r.username)).bold()
Theme::bold().render(&format!("@{} -- Workload Summary", r.username))
);
println!("{}", "\u{2500}".repeat(60));
@@ -2007,21 +2012,21 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
" {} ({})",
style("Assigned Issues").bold(),
Theme::bold().render("Assigned Issues"),
r.assigned_issues.len()
);
for item in &r.assigned_issues {
println!(
" {} {} {}",
style(&item.ref_).cyan(),
truncate_str(&item.title, 40),
style(format_relative_time(item.updated_at)).dim(),
Theme::info().render(&item.ref_),
render::truncate(&item.title, 40),
Theme::dim().render(&render::format_relative_time(item.updated_at)),
);
}
if r.assigned_issues_truncated {
println!(
" {}",
style("(truncated; rerun with a higher --limit)").dim()
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
@@ -2030,23 +2035,23 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
" {} ({})",
style("Authored MRs").bold(),
Theme::bold().render("Authored MRs"),
r.authored_mrs.len()
);
for mr in &r.authored_mrs {
let draft = if mr.draft { " [draft]" } else { "" };
println!(
" {} {}{} {}",
style(&mr.ref_).cyan(),
truncate_str(&mr.title, 35),
style(draft).dim(),
style(format_relative_time(mr.updated_at)).dim(),
Theme::info().render(&mr.ref_),
render::truncate(&mr.title, 35),
Theme::dim().render(draft),
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
);
}
if r.authored_mrs_truncated {
println!(
" {}",
style("(truncated; rerun with a higher --limit)").dim()
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
@@ -2055,7 +2060,7 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
" {} ({})",
style("Reviewing MRs").bold(),
Theme::bold().render("Reviewing MRs"),
r.reviewing_mrs.len()
);
for mr in &r.reviewing_mrs {
@@ -2066,16 +2071,16 @@ fn print_workload_human(r: &WorkloadResult) {
.unwrap_or_default();
println!(
" {} {}{} {}",
style(&mr.ref_).cyan(),
truncate_str(&mr.title, 30),
style(author).dim(),
style(format_relative_time(mr.updated_at)).dim(),
Theme::info().render(&mr.ref_),
render::truncate(&mr.title, 30),
Theme::dim().render(&author),
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
);
}
if r.reviewing_mrs_truncated {
println!(
" {}",
style("(truncated; rerun with a higher --limit)").dim()
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
@@ -2084,22 +2089,22 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
" {} ({})",
style("Unresolved Discussions").bold(),
Theme::bold().render("Unresolved Discussions"),
r.unresolved_discussions.len()
);
for disc in &r.unresolved_discussions {
println!(
" {} {} {} {}",
style(&disc.entity_type).dim(),
style(&disc.ref_).cyan(),
truncate_str(&disc.entity_title, 35),
style(format_relative_time(disc.last_note_at)).dim(),
Theme::dim().render(&disc.entity_type),
Theme::info().render(&disc.ref_),
render::truncate(&disc.entity_title, 35),
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
);
}
if r.unresolved_discussions_truncated {
println!(
" {}",
style("(truncated; rerun with a higher --limit)").dim()
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
@@ -2112,7 +2117,7 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
" {}",
style("No open work items found for this user.").dim()
Theme::dim().render("No open work items found for this user.")
);
}
@@ -2123,7 +2128,7 @@ fn print_reviews_human(r: &ReviewsResult) {
println!();
println!(
"{}",
style(format!("@{} -- Review Patterns", r.username)).bold()
Theme::bold().render(&format!("@{} -- Review Patterns", r.username))
);
println!("{}", "\u{2500}".repeat(60));
println!();
@@ -2131,7 +2136,7 @@ fn print_reviews_human(r: &ReviewsResult) {
if r.total_diffnotes == 0 {
println!(
" {}",
style("No review comments found for this user.").dim()
Theme::dim().render("No review comments found for this user.")
);
println!();
return;
@@ -2139,24 +2144,24 @@ fn print_reviews_human(r: &ReviewsResult) {
println!(
" {} DiffNotes across {} MRs ({} categorized)",
style(r.total_diffnotes).bold(),
style(r.mrs_reviewed).bold(),
style(r.categorized_count).bold(),
Theme::bold().render(&r.total_diffnotes.to_string()),
Theme::bold().render(&r.mrs_reviewed.to_string()),
Theme::bold().render(&r.categorized_count.to_string()),
);
println!();
if !r.categories.is_empty() {
println!(
" {:<16} {:>6} {:>6}",
style("Category").bold(),
style("Count").bold(),
style("%").bold(),
Theme::bold().render("Category"),
Theme::bold().render("Count"),
Theme::bold().render("%"),
);
for cat in &r.categories {
println!(
" {:<16} {:>6} {:>5.1}%",
style(&cat.name).cyan(),
Theme::info().render(&cat.name),
cat.count,
cat.percentage,
);
@@ -2168,7 +2173,7 @@ fn print_reviews_human(r: &ReviewsResult) {
println!();
println!(
" {} {} uncategorized (no **prefix** convention)",
style("Note:").dim(),
Theme::dim().render("Note:"),
uncategorized,
);
}
@@ -2180,11 +2185,10 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
println!();
println!(
"{}",
style(format!(
Theme::bold().render(&format!(
"Active Discussions ({} unresolved in window)",
r.total_unresolved_in_window
))
.bold()
);
println!("{}", "\u{2500}".repeat(60));
print_scope_hint(project_path);
@@ -2193,7 +2197,7 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
if r.discussions.is_empty() {
println!(
" {}",
style("No active unresolved discussions in this time window.").dim()
Theme::dim().render("No active unresolved discussions in this time window.")
);
println!();
return;
@@ -2210,20 +2214,20 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
println!(
" {} {} {} {} notes {}",
style(format!("{prefix}{}", disc.entity_iid)).cyan(),
truncate_str(&disc.entity_title, 40),
style(format_relative_time(disc.last_note_at)).dim(),
Theme::info().render(&format!("{prefix}{}", disc.entity_iid)),
render::truncate(&disc.entity_title, 40),
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
disc.note_count,
style(&disc.project_path).dim(),
Theme::dim().render(&disc.project_path),
);
if !participants_str.is_empty() {
println!(" {}", style(participants_str).dim());
println!(" {}", Theme::dim().render(&participants_str));
}
}
if r.truncated {
println!(
" {}",
style("(showing first -n; rerun with a higher --limit)").dim()
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
);
}
println!();
@@ -2231,11 +2235,14 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!();
println!("{}", style(format!("Overlap for {}", r.path_query)).bold());
println!(
"{}",
Theme::bold().render(&format!("Overlap for {}", r.path_query))
);
println!("{}", "\u{2500}".repeat(60));
println!(
" {}",
style(format!(
Theme::dim().render(&format!(
"(matching {} {})",
r.path_match,
if r.path_match == "exact" {
@@ -2244,7 +2251,6 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
"directory prefix"
}
))
.dim()
);
print_scope_hint(project_path);
println!();
@@ -2252,7 +2258,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
if r.users.is_empty() {
println!(
" {}",
style("No overlapping users found for this path.").dim()
Theme::dim().render("No overlapping users found for this path.")
);
println!();
return;
@@ -2260,11 +2266,11 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!(
" {:<16} {:<6} {:>7} {:<12} {}",
style("Username").bold(),
style("Role").bold(),
style("MRs").bold(),
style("Last Seen").bold(),
style("MR Refs").bold(),
Theme::bold().render("Username"),
Theme::bold().render("Role"),
Theme::bold().render("MRs"),
Theme::bold().render("Last Seen"),
Theme::bold().render("MR Refs"),
);
for user in &r.users {
@@ -2283,10 +2289,10 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!(
" {:<16} {:<6} {:>7} {:<12} {}{}",
style(format!("@{}", user.username)).cyan(),
Theme::info().render(&format!("@{}", user.username)),
format_overlap_role(user),
user.touch_count,
format_relative_time(user.last_seen_at),
render::format_relative_time(user.last_seen_at),
mr_str,
overflow,
);
@@ -2294,7 +2300,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
if r.truncated {
println!(
" {}",
style("(showing first -n; rerun with a higher --limit)").dim()
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
);
}
println!();
@@ -2532,47 +2538,6 @@ fn overlap_to_json(r: &OverlapResult) -> serde_json::Value {
})
}
// ─── Helper Functions ────────────────────────────────────────────────────────
fn format_relative_time(ms_epoch: i64) -> String {
let now = now_ms();
let diff = now - ms_epoch;
if diff < 0 {
return "in the future".to_string();
}
match diff {
d if d < 60_000 => "just now".to_string(),
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
d if d < 86_400_000 => {
let n = d / 3_600_000;
format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
}
d if d < 604_800_000 => {
let n = d / 86_400_000;
format!("{n} {} ago", if n == 1 { "day" } else { "days" })
}
d if d < 2_592_000_000 => {
let n = d / 604_800_000;
format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
}
_ => {
let n = diff / 2_592_000_000;
format!("{n} {} ago", if n == 1 { "month" } else { "months" })
}
}
}
fn truncate_str(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_owned()
} else {
let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
format!("{truncated}...")
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]