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;
@@ -606,65 +606,37 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
}
fn format_date(ms: i64) -> String {
let iso = ms_to_iso(ms);
iso.split('T').next().unwrap_or(&iso).to_string()
render::format_date(ms)
}
fn wrap_text(text: &str, width: usize, indent: &str) -> String {
let mut result = String::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
current_line = word.to_string();
} else if current_line.len() + 1 + word.len() <= width {
current_line.push(' ');
current_line.push_str(word);
} else {
if !result.is_empty() {
result.push('\n');
result.push_str(indent);
}
result.push_str(&current_line);
current_line = word.to_string();
}
}
if !current_line.is_empty() {
if !result.is_empty() {
result.push('\n');
result.push_str(indent);
}
result.push_str(&current_line);
}
result
render::wrap_indent(text, width, indent)
}
pub fn print_show_issue(issue: &IssueDetail) {
let header = format!("Issue #{}: {}", issue.iid, issue.title);
println!("{}", style(&header).bold());
println!("{}", "".repeat(header.len().min(80)));
println!("{}", Theme::bold().render(&header));
println!("{}", "\u{2501}".repeat(header.len().min(80)));
println!();
println!("Ref: {}", style(&issue.references_full).dim());
println!("Project: {}", style(&issue.project_path).cyan());
println!("Ref: {}", Theme::dim().render(&issue.references_full));
println!("Project: {}", Theme::info().render(&issue.project_path));
let state_styled = if issue.state == "opened" {
style(&issue.state).green()
Theme::success().render(&issue.state)
} else {
style(&issue.state).dim()
Theme::dim().render(&issue.state)
};
println!("State: {}", state_styled);
if issue.confidential {
println!(" {}", style("CONFIDENTIAL").red().bold());
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
}
if let Some(status) = &issue.status_name {
println!(
"Status: {}",
style_with_hex(status, issue.status_color.as_deref())
render::style_with_hex(status, issue.status_color.as_deref())
);
}
@@ -705,37 +677,37 @@ pub fn print_show_issue(issue: &IssueDetail) {
}
if issue.labels.is_empty() {
println!("Labels: {}", style("(none)").dim());
println!("Labels: {}", Theme::dim().render("(none)"));
} else {
println!("Labels: {}", issue.labels.join(", "));
}
if !issue.closing_merge_requests.is_empty() {
println!();
println!("{}", style("Development:").bold());
println!("{}", Theme::bold().render("Development:"));
for mr in &issue.closing_merge_requests {
let state_indicator = match mr.state.as_str() {
"merged" => style(&mr.state).green(),
"opened" => style(&mr.state).cyan(),
"closed" => style(&mr.state).red(),
_ => style(&mr.state).dim(),
"merged" => Theme::success().render(&mr.state),
"opened" => Theme::info().render(&mr.state),
"closed" => Theme::error().render(&mr.state),
_ => Theme::dim().render(&mr.state),
};
println!(" !{} {} ({})", mr.iid, mr.title, state_indicator);
}
}
if let Some(url) = &issue.web_url {
println!("URL: {}", style(url).dim());
println!("URL: {}", Theme::dim().render(url));
}
println!();
println!("{}", style("Description:").bold());
println!("{}", Theme::bold().render("Description:"));
if let Some(desc) = &issue.description {
let wrapped = wrap_text(desc, 76, " ");
println!(" {}", wrapped);
} else {
println!(" {}", style("(no description)").dim());
println!(" {}", Theme::dim().render("(no description)"));
}
println!();
@@ -747,11 +719,11 @@ pub fn print_show_issue(issue: &IssueDetail) {
.collect();
if user_discussions.is_empty() {
println!("{}", style("Discussions: (none)").dim());
println!("{}", Theme::dim().render("Discussions: (none)"));
} else {
println!(
"{}",
style(format!("Discussions ({}):", user_discussions.len())).bold()
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
);
println!();
@@ -762,7 +734,7 @@ pub fn print_show_issue(issue: &IssueDetail) {
if let Some(first_note) = user_notes.first() {
println!(
" {} ({}):",
style(format!("@{}", first_note.author_username)).cyan(),
Theme::info().render(&format!("@{}", first_note.author_username)),
format_date(first_note.created_at)
);
let wrapped = wrap_text(&first_note.body, 72, " ");
@@ -772,7 +744,7 @@ pub fn print_show_issue(issue: &IssueDetail) {
for reply in user_notes.iter().skip(1) {
println!(
" {} ({}):",
style(format!("@{}", reply.author_username)).cyan(),
Theme::info().render(&format!("@{}", reply.author_username)),
format_date(reply.created_at)
);
let wrapped = wrap_text(&reply.body, 68, " ");
@@ -787,24 +759,24 @@ pub fn print_show_issue(issue: &IssueDetail) {
pub fn print_show_mr(mr: &MrDetail) {
let draft_prefix = if mr.draft { "[Draft] " } else { "" };
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title);
println!("{}", style(&header).bold());
println!("{}", "".repeat(header.len().min(80)));
println!("{}", Theme::bold().render(&header));
println!("{}", "\u{2501}".repeat(header.len().min(80)));
println!();
println!("Project: {}", style(&mr.project_path).cyan());
println!("Project: {}", Theme::info().render(&mr.project_path));
let state_styled = match mr.state.as_str() {
"opened" => style(&mr.state).green(),
"merged" => style(&mr.state).magenta(),
"closed" => style(&mr.state).red(),
_ => style(&mr.state).dim(),
"opened" => Theme::success().render(&mr.state),
"merged" => Theme::accent().render(&mr.state),
"closed" => Theme::error().render(&mr.state),
_ => Theme::dim().render(&mr.state),
};
println!("State: {}", state_styled);
println!(
"Branches: {} -> {}",
style(&mr.source_branch).cyan(),
style(&mr.target_branch).yellow()
Theme::info().render(&mr.source_branch),
Theme::warning().render(&mr.target_branch)
);
println!("Author: @{}", mr.author_username);
@@ -843,23 +815,23 @@ pub fn print_show_mr(mr: &MrDetail) {
}
if mr.labels.is_empty() {
println!("Labels: {}", style("(none)").dim());
println!("Labels: {}", Theme::dim().render("(none)"));
} else {
println!("Labels: {}", mr.labels.join(", "));
}
if let Some(url) = &mr.web_url {
println!("URL: {}", style(url).dim());
println!("URL: {}", Theme::dim().render(url));
}
println!();
println!("{}", style("Description:").bold());
println!("{}", Theme::bold().render("Description:"));
if let Some(desc) = &mr.description {
let wrapped = wrap_text(desc, 76, " ");
println!(" {}", wrapped);
} else {
println!(" {}", style("(no description)").dim());
println!(" {}", Theme::dim().render("(no description)"));
}
println!();
@@ -871,11 +843,11 @@ pub fn print_show_mr(mr: &MrDetail) {
.collect();
if user_discussions.is_empty() {
println!("{}", style("Discussions: (none)").dim());
println!("{}", Theme::dim().render("Discussions: (none)"));
} else {
println!(
"{}",
style(format!("Discussions ({}):", user_discussions.len())).bold()
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
);
println!();
@@ -890,7 +862,7 @@ pub fn print_show_mr(mr: &MrDetail) {
println!(
" {} ({}):",
style(format!("@{}", first_note.author_username)).cyan(),
Theme::info().render(&format!("@{}", first_note.author_username)),
format_date(first_note.created_at)
);
let wrapped = wrap_text(&first_note.body, 72, " ");
@@ -900,7 +872,7 @@ pub fn print_show_mr(mr: &MrDetail) {
for reply in user_notes.iter().skip(1) {
println!(
" {} ({}):",
style(format!("@{}", reply.author_username)).cyan(),
Theme::info().render(&format!("@{}", reply.author_username)),
format_date(reply.created_at)
);
let wrapped = wrap_text(&reply.body, 68, " ");
@@ -926,39 +898,13 @@ fn print_diff_position(pos: &DiffNotePosition) {
println!(
" {} {}{}",
style("📍").dim(),
style(file_path).yellow(),
style(line_str).dim()
Theme::dim().render("\u{1f4cd}"),
Theme::warning().render(file_path),
Theme::dim().render(&line_str)
);
}
}
fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str> {
let styled = console::style(text);
let Some(hex) = hex else { return styled };
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return styled;
}
let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else {
return styled;
};
let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else {
return styled;
};
let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else {
return styled;
};
styled.color256(ansi256_from_rgb(r, g, b))
}
fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 {
let ri = (u16::from(r) * 5 + 127) / 255;
let gi = (u16::from(g) * 5 + 127) / 255;
let bi = (u16::from(b) * 5 + 127) / 255;
(16 + 36 * ri + 6 * gi + bi) as u8
}
#[derive(Serialize)]
pub struct IssueDetailJson {
pub id: i64,
@@ -1387,8 +1333,9 @@ mod tests {
#[test]
fn test_ansi256_from_rgb() {
assert_eq!(ansi256_from_rgb(0, 0, 0), 16);
assert_eq!(ansi256_from_rgb(255, 255, 255), 231);
// Moved to render.rs — keeping basic hex sanity check
let result = render::style_with_hex("test", Some("#ff0000"));
assert!(!result.is_empty());
}
#[test]