diff --git a/src/cli/commands/count.rs b/src/cli/commands/count.rs index 1ad4fdd..083597a 100644 --- a/src/cli/commands/count.rs +++ b/src/cli/commands/count.rs @@ -1,4 +1,4 @@ -use console::style; +use crate::cli::render::{self, Theme}; use rusqlite::Connection; use serde::Serialize; @@ -178,27 +178,6 @@ fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result String { - let (prefix, abs) = if n < 0 { - ("-", n.unsigned_abs()) - } else { - ("", n.unsigned_abs()) - }; - - let s = abs.to_string(); - let chars: Vec = s.chars().collect(); - let mut result = String::from(prefix); - - for (i, c) in chars.iter().enumerate() { - if i > 0 && (chars.len() - i).is_multiple_of(3) { - result.push(','); - } - result.push(*c); - } - - result -} - #[derive(Serialize)] struct CountJsonOutput { ok: bool, @@ -284,10 +263,10 @@ pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) { pub fn print_event_count(counts: &EventCounts) { println!( "{:<20} {:>8} {:>8} {:>8}", - style("Event Type").cyan().bold(), - style("Issues").bold(), - style("MRs").bold(), - style("Total").bold() + Theme::info().bold().render("Event Type"), + Theme::bold().render("Issues"), + Theme::bold().render("MRs"), + Theme::bold().render("Total") ); let state_total = counts.state_issue + counts.state_mr; @@ -297,33 +276,33 @@ pub fn print_event_count(counts: &EventCounts) { println!( "{:<20} {:>8} {:>8} {:>8}", "State events", - format_number(counts.state_issue as i64), - format_number(counts.state_mr as i64), - format_number(state_total as i64) + render::format_number(counts.state_issue as i64), + render::format_number(counts.state_mr as i64), + render::format_number(state_total as i64) ); println!( "{:<20} {:>8} {:>8} {:>8}", "Label events", - format_number(counts.label_issue as i64), - format_number(counts.label_mr as i64), - format_number(label_total as i64) + render::format_number(counts.label_issue as i64), + render::format_number(counts.label_mr as i64), + render::format_number(label_total as i64) ); println!( "{:<20} {:>8} {:>8} {:>8}", "Milestone events", - format_number(counts.milestone_issue as i64), - format_number(counts.milestone_mr as i64), - format_number(milestone_total as i64) + render::format_number(counts.milestone_issue as i64), + render::format_number(counts.milestone_mr as i64), + render::format_number(milestone_total as i64) ); let total_issues = counts.state_issue + counts.label_issue + counts.milestone_issue; let total_mrs = counts.state_mr + counts.label_mr + counts.milestone_mr; println!( "{:<20} {:>8} {:>8} {:>8}", - style("Total").bold(), - format_number(total_issues as i64), - format_number(total_mrs as i64), - style(format_number(counts.total() as i64)).bold() + Theme::bold().render("Total"), + render::format_number(total_issues as i64), + render::format_number(total_mrs as i64), + Theme::bold().render(&render::format_number(counts.total() as i64)) ); } @@ -350,57 +329,56 @@ pub fn print_count_json(result: &CountResult, elapsed_ms: u64) { } pub fn print_count(result: &CountResult) { - let count_str = format_number(result.count); + let count_str = render::format_number(result.count); if let Some(system_count) = result.system_count { println!( "{}: {} {}", - style(&result.entity).cyan(), - style(&count_str).bold(), - style(format!( + Theme::info().render(&result.entity), + Theme::bold().render(&count_str), + Theme::dim().render(&format!( "(excluding {} system)", - format_number(system_count) + render::format_number(system_count) )) - .dim() ); } else { println!( "{}: {}", - style(&result.entity).cyan(), - style(&count_str).bold() + Theme::info().render(&result.entity), + Theme::bold().render(&count_str) ); } if let Some(breakdown) = &result.state_breakdown { - println!(" opened: {}", format_number(breakdown.opened)); + println!(" opened: {}", render::format_number(breakdown.opened)); if let Some(merged) = breakdown.merged { - println!(" merged: {}", format_number(merged)); + println!(" merged: {}", render::format_number(merged)); } - println!(" closed: {}", format_number(breakdown.closed)); + println!(" closed: {}", render::format_number(breakdown.closed)); if let Some(locked) = breakdown.locked && locked > 0 { - println!(" locked: {}", format_number(locked)); + println!(" locked: {}", render::format_number(locked)); } } } #[cfg(test)] mod tests { - use super::*; + use crate::cli::render; #[test] fn format_number_handles_small_numbers() { - assert_eq!(format_number(0), "0"); - assert_eq!(format_number(1), "1"); - assert_eq!(format_number(100), "100"); - assert_eq!(format_number(999), "999"); + assert_eq!(render::format_number(0), "0"); + assert_eq!(render::format_number(1), "1"); + assert_eq!(render::format_number(100), "100"); + assert_eq!(render::format_number(999), "999"); } #[test] fn format_number_adds_thousands_separators() { - assert_eq!(format_number(1000), "1,000"); - assert_eq!(format_number(12345), "12,345"); - assert_eq!(format_number(1234567), "1,234,567"); + assert_eq!(render::format_number(1000), "1,000"); + assert_eq!(render::format_number(12345), "12,345"); + assert_eq!(render::format_number(1234567), "1,234,567"); } } diff --git a/src/cli/commands/doctor.rs b/src/cli/commands/doctor.rs index 3b8741e..a9d9957 100644 --- a/src/cli/commands/doctor.rs +++ b/src/cli/commands/doctor.rs @@ -1,4 +1,4 @@ -use console::style; +use crate::cli::render::Theme; use serde::Serialize; use crate::core::config::Config; @@ -544,32 +544,33 @@ pub fn print_doctor_results(result: &DoctorResult) { if result.success { let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok; if ollama_ok { - println!("{}", style("Status: Ready").green()); + println!("{}", Theme::success().render("Status: Ready")); } else { println!( "{} {}", - style("Status: Ready").green(), - style("(lexical search available, semantic search requires Ollama)").yellow() + Theme::success().render("Status: Ready"), + Theme::warning() + .render("(lexical search available, semantic search requires Ollama)") ); } } else { - println!("{}", style("Status: Not ready").red()); + println!("{}", Theme::error().render("Status: Not ready")); } println!(); } fn print_check(name: &str, result: &CheckResult) { let symbol = match result.status { - CheckStatus::Ok => style("✓").green(), - CheckStatus::Warning => style("⚠").yellow(), - CheckStatus::Error => style("✗").red(), + CheckStatus::Ok => Theme::success().render("\u{2713}"), + CheckStatus::Warning => Theme::warning().render("\u{26a0}"), + CheckStatus::Error => Theme::error().render("\u{2717}"), }; let message = result.message.as_deref().unwrap_or(""); let message_styled = match result.status { CheckStatus::Ok => message.to_string(), - CheckStatus::Warning => style(message).yellow().to_string(), - CheckStatus::Error => style(message).red().to_string(), + CheckStatus::Warning => Theme::warning().render(message), + CheckStatus::Error => Theme::error().render(message), }; println!(" {symbol} {:<10} {message_styled}", name); diff --git a/src/cli/commands/drift.rs b/src/cli/commands/drift.rs index 999f8e9..a9096ad 100644 --- a/src/cli/commands/drift.rs +++ b/src/cli/commands/drift.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; use std::sync::LazyLock; -use console::style; use regex::Regex; use serde::Serialize; +use crate::cli::render::Theme; use crate::cli::robot::RobotMeta; use crate::core::config::Config; use crate::core::db::create_connection; @@ -420,7 +420,7 @@ pub fn print_drift_human(response: &DriftResponse) { "Drift Analysis: {} #{}", response.entity.entity_type, response.entity.iid ); - println!("{}", style(&header).bold()); + println!("{}", Theme::bold().render(&header)); println!("{}", "-".repeat(header.len().min(60))); println!("Title: {}", response.entity.title); println!("Threshold: {:.2}", response.threshold); @@ -428,7 +428,7 @@ pub fn print_drift_human(response: &DriftResponse) { println!(); if response.drift_detected { - println!("{}", style("DRIFT DETECTED").red().bold()); + println!("{}", Theme::error().bold().render("DRIFT DETECTED")); if let Some(dp) = &response.drift_point { println!( " At note #{} by @{} ({}) - similarity {:.2}", @@ -439,7 +439,7 @@ pub fn print_drift_human(response: &DriftResponse) { println!(" Topics: {}", response.drift_topics.join(", ")); } } else { - println!("{}", style("No drift detected").green()); + println!("{}", Theme::success().render("No drift detected")); } println!(); @@ -447,7 +447,7 @@ pub fn print_drift_human(response: &DriftResponse) { if !response.similarity_curve.is_empty() { println!(); - println!("{}", style("Similarity Curve:").bold()); + println!("{}", Theme::bold().render("Similarity Curve:")); for pt in &response.similarity_curve { let bar_len = ((pt.similarity.max(0.0)) * 30.0) as usize; let bar: String = "#".repeat(bar_len); diff --git a/src/cli/commands/embed.rs b/src/cli/commands/embed.rs index f9ed993..de1d13c 100644 --- a/src/cli/commands/embed.rs +++ b/src/cli/commands/embed.rs @@ -1,4 +1,4 @@ -use console::style; +use crate::cli::render::Theme; use serde::Serialize; use crate::Config; @@ -96,16 +96,31 @@ pub async fn run_embed( } pub fn print_embed(result: &EmbedCommandResult) { - println!("{} Embedding complete", style("done").green().bold(),); + if result.docs_embedded == 0 && result.failed == 0 && result.skipped == 0 { + println!( + "\n {} nothing to embed", + Theme::success().bold().render("Embedding") + ); + return; + } + println!( - " Embedded: {} documents ({} chunks)", - result.docs_embedded, result.chunks_embedded + "\n {} {} documents ({} chunks)", + Theme::success().bold().render("Embedded"), + Theme::bold().render(&result.docs_embedded.to_string()), + result.chunks_embedded ); if result.failed > 0 { - println!(" Failed: {}", style(result.failed).red()); + println!( + " {}", + Theme::error().render(&format!("{} failed", result.failed)) + ); } if result.skipped > 0 { - println!(" Skipped: {}", result.skipped); + println!( + " {}", + Theme::dim().render(&format!("{} skipped", result.skipped)) + ); } } diff --git a/src/cli/commands/generate_docs.rs b/src/cli/commands/generate_docs.rs index a218450..a6e3267 100644 --- a/src/cli/commands/generate_docs.rs +++ b/src/cli/commands/generate_docs.rs @@ -1,4 +1,4 @@ -use console::style; +use crate::cli::render::Theme; use rusqlite::Connection; use serde::Serialize; use tracing::info; @@ -185,19 +185,40 @@ pub fn print_generate_docs(result: &GenerateDocsResult) { } else { "incremental" }; + + if result.regenerated == 0 && result.errored == 0 { + println!( + "\n {} no documents to update ({})", + Theme::success().bold().render("Docs"), + mode + ); + return; + } + + // Headline println!( - "{} Document generation complete ({})", - style("done").green().bold(), + "\n {} {} documents ({})", + Theme::success().bold().render("Generated"), + Theme::bold().render(&result.regenerated.to_string()), mode ); - if result.full_mode { - println!(" Seeded: {}", result.seeded); + // Detail line: compact middle-dot format, zero-suppressed + let mut details: Vec = Vec::new(); + if result.full_mode && result.seeded > 0 { + details.push(format!("{} seeded", result.seeded)); + } + if result.unchanged > 0 { + details.push(format!("{} unchanged", result.unchanged)); + } + if !details.is_empty() { + println!(" {}", Theme::dim().render(&details.join(" \u{b7} "))); } - println!(" Regenerated: {}", result.regenerated); - println!(" Unchanged: {}", result.unchanged); if result.errored > 0 { - println!(" Errored: {}", style(result.errored).red()); + println!( + " {}", + Theme::error().render(&format!("{} errored", result.errored)) + ); } } diff --git a/src/cli/commands/ingest.rs b/src/cli/commands/ingest.rs index 519bd12..24468ed 100644 --- a/src/cli/commands/ingest.rs +++ b/src/cli/commands/ingest.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use console::style; +use crate::cli::render::Theme; use indicatif::{ProgressBar, ProgressStyle}; use rusqlite::Connection; use serde::Serialize; @@ -293,7 +293,7 @@ async fn run_ingest_inner( if display.show_text { println!( "{}", - style("Full sync: resetting cursors to fetch all data...").yellow() + Theme::warning().render("Full sync: resetting cursors to fetch all data...") ); } for (local_project_id, _, path) in &projects { @@ -341,7 +341,10 @@ async fn run_ingest_inner( "merge requests" }; if display.show_text { - println!("{}", style(format!("Ingesting {type_label}...")).blue()); + println!( + "{}", + Theme::info().render(&format!("Ingesting {type_label}...")) + ); println!(); } @@ -746,7 +749,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) { println!( " {}: {} issues fetched{}", - style(path).cyan(), + Theme::info().render(path), result.issues_upserted, labels_str ); @@ -761,7 +764,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) { if result.issues_skipped_discussion_sync > 0 { println!( " {} unchanged issues (discussion sync skipped)", - style(result.issues_skipped_discussion_sync).dim() + Theme::dim().render(&result.issues_skipped_discussion_sync.to_string()) ); } } @@ -784,7 +787,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) { println!( " {}: {} MRs fetched{}{}", - style(path).cyan(), + Theme::info().render(path), result.mrs_upserted, labels_str, assignees_str @@ -808,7 +811,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) { if result.mrs_skipped_discussion_sync > 0 { println!( " {} unchanged MRs (discussion sync skipped)", - style(result.mrs_skipped_discussion_sync).dim() + Theme::dim().render(&result.mrs_skipped_discussion_sync.to_string()) ); } } @@ -942,21 +945,19 @@ pub fn print_ingest_summary(result: &IngestResult) { if result.resource_type == "issues" { println!( "{}", - style(format!( + Theme::success().render(&format!( "Total: {} issues, {} discussions, {} notes", result.issues_upserted, result.discussions_fetched, result.notes_upserted )) - .green() ); if result.issues_skipped_discussion_sync > 0 { println!( "{}", - style(format!( + Theme::dim().render(&format!( "Skipped discussion sync for {} unchanged issues.", result.issues_skipped_discussion_sync )) - .dim() ); } } else { @@ -968,24 +969,22 @@ pub fn print_ingest_summary(result: &IngestResult) { println!( "{}", - style(format!( + Theme::success().render(&format!( "Total: {} MRs, {} discussions, {} notes{}", result.mrs_upserted, result.discussions_fetched, result.notes_upserted, diffnotes_str )) - .green() ); if result.mrs_skipped_discussion_sync > 0 { println!( "{}", - style(format!( + Theme::dim().render(&format!( "Skipped discussion sync for {} unchanged MRs.", result.mrs_skipped_discussion_sync )) - .dim() ); } } @@ -1006,8 +1005,8 @@ pub fn print_ingest_summary(result: &IngestResult) { pub fn print_dry_run_preview(preview: &DryRunPreview) { println!( "{} {}", - style("Dry Run Preview").cyan().bold(), - style("(no changes will be made)").yellow() + Theme::info().bold().render("Dry Run Preview"), + Theme::warning().render("(no changes will be made)") ); println!(); @@ -1017,27 +1016,31 @@ pub fn print_dry_run_preview(preview: &DryRunPreview) { "merge requests" }; - println!(" Resource type: {}", style(type_label).white().bold()); + println!(" Resource type: {}", Theme::bold().render(type_label)); println!( " Sync mode: {}", if preview.sync_mode == "full" { - style("full (all data will be re-fetched)").yellow() + Theme::warning().render("full (all data will be re-fetched)") } else { - style("incremental (only changes since last sync)").green() + Theme::success().render("incremental (only changes since last sync)") } ); println!(" Projects: {}", preview.projects.len()); println!(); - println!("{}", style("Projects to sync:").cyan().bold()); + println!("{}", Theme::info().bold().render("Projects to sync:")); for project in &preview.projects { let sync_status = if !project.has_cursor { - style("initial sync").yellow() + Theme::warning().render("initial sync") } else { - style("incremental").green() + Theme::success().render("incremental") }; - println!(" {} ({})", style(&project.path).white(), sync_status); + println!( + " {} ({})", + Theme::bold().render(&project.path), + sync_status + ); println!(" Existing {}: {}", type_label, project.existing_count); if let Some(ref last_synced) = project.last_synced { diff --git a/src/cli/commands/list.rs b/src/cli/commands/list.rs index 50e6dfa..874ecbf 100644 --- a/src/cli/commands/list.rs +++ b/src/cli/commands/list.rs @@ -1,4 +1,4 @@ -use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table}; +use crate::cli::render::{self, Align, StyledCell, Table as LoreTable, Theme}; use rusqlite::Connection; use serde::Serialize; @@ -9,39 +9,7 @@ use crate::core::error::{LoreError, Result}; use crate::core::path_resolver::escape_like as note_escape_like; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; -use crate::core::time::{ms_to_iso, now_ms, parse_since}; - -fn colored_cell(content: impl std::fmt::Display, color: Color) -> Cell { - let cell = Cell::new(content); - if console::colors_enabled() { - cell.fg(color) - } else { - cell - } -} - -fn colored_cell_hex(content: &str, hex: Option<&str>) -> Cell { - if !console::colors_enabled() { - return Cell::new(content); - } - let Some(hex) = hex else { - return Cell::new(content); - }; - let hex = hex.trim_start_matches('#'); - if hex.len() != 6 { - return Cell::new(content); - } - let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else { - return Cell::new(content); - }; - let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else { - return Cell::new(content); - }; - let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else { - return Cell::new(content); - }; - Cell::new(content).fg(Color::Rgb { r, g, b }) -} +use crate::core::time::{ms_to_iso, parse_since}; #[derive(Debug, Serialize)] pub struct IssueListRow { @@ -669,60 +637,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result Ok(MrListResult { mrs, total_count }) } -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_with_ellipsis(s: &str, max_width: usize) -> String { - if s.chars().count() <= max_width { - s.to_string() - } else { - let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect(); - format!("{truncated}...") - } -} - -fn format_labels(labels: &[String], max_shown: usize) -> String { - if labels.is_empty() { - return String::new(); - } - - let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect(); - let overflow = labels.len().saturating_sub(max_shown); - - if overflow > 0 { - format!("[{} +{}]", shown.join(", "), overflow) - } else { - format!("[{}]", shown.join(", ")) - } -} - fn format_assignees(assignees: &[String]) -> String { if assignees.is_empty() { return "-".to_string(); @@ -732,7 +646,7 @@ fn format_assignees(assignees: &[String]) -> String { let shown: Vec = assignees .iter() .take(max_shown) - .map(|s| format!("@{}", truncate_with_ellipsis(s, 10))) + .map(|s| format!("@{}", render::truncate(s, 10))) .collect(); let overflow = assignees.len().saturating_sub(max_shown); @@ -757,7 +671,7 @@ fn format_discussions(total: i64, unresolved: i64) -> String { fn format_branches(target: &str, source: &str, max_width: usize) -> String { let full = format!("{} <- {}", target, source); - truncate_with_ellipsis(&full, max_width) + render::truncate(&full, max_width) } pub fn print_list_issues(result: &ListResult) { @@ -774,64 +688,55 @@ pub fn print_list_issues(result: &ListResult) { let has_any_status = result.issues.iter().any(|i| i.status_name.is_some()); - let mut header = vec![ - Cell::new("IID").add_attribute(Attribute::Bold), - Cell::new("Title").add_attribute(Attribute::Bold), - Cell::new("State").add_attribute(Attribute::Bold), - ]; + let mut headers = vec!["IID", "Title", "State"]; if has_any_status { - header.push(Cell::new("Status").add_attribute(Attribute::Bold)); + headers.push("Status"); } - header.extend([ - Cell::new("Assignee").add_attribute(Attribute::Bold), - Cell::new("Labels").add_attribute(Attribute::Bold), - Cell::new("Disc").add_attribute(Attribute::Bold), - Cell::new("Updated").add_attribute(Attribute::Bold), - ]); + headers.extend(["Assignee", "Labels", "Disc", "Updated"]); - let mut table = Table::new(); - table - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(header); + let mut table = LoreTable::new().headers(&headers).align(0, Align::Right); for issue in &result.issues { - let title = truncate_with_ellipsis(&issue.title, 45); - let relative_time = format_relative_time(issue.updated_at); - let labels = format_labels(&issue.labels, 2); + let title = render::truncate(&issue.title, 45); + let relative_time = render::format_relative_time(issue.updated_at); + let labels = render::format_labels(&issue.labels, 2); let assignee = format_assignees(&issue.assignees); let discussions = format_discussions(issue.discussion_count, issue.unresolved_count); let state_cell = if issue.state == "opened" { - colored_cell(&issue.state, Color::Green) + StyledCell::styled(&issue.state, Theme::success()) } else { - colored_cell(&issue.state, Color::DarkGrey) + StyledCell::styled(&issue.state, Theme::dim()) }; let mut row = vec![ - colored_cell(format!("#{}", issue.iid), Color::Cyan), - Cell::new(title), + StyledCell::styled(format!("#{}", issue.iid), Theme::info()), + StyledCell::plain(title), state_cell, ]; if has_any_status { match &issue.status_name { Some(status) => { - row.push(colored_cell_hex(status, issue.status_color.as_deref())); + row.push(StyledCell::plain(render::style_with_hex( + status, + issue.status_color.as_deref(), + ))); } None => { - row.push(Cell::new("")); + row.push(StyledCell::plain("")); } } } row.extend([ - colored_cell(assignee, Color::Magenta), - colored_cell(labels, Color::Yellow), - Cell::new(discussions), - colored_cell(relative_time, Color::DarkGrey), + StyledCell::styled(assignee, Theme::accent()), + StyledCell::styled(labels, Theme::warning()), + StyledCell::plain(discussions), + StyledCell::styled(relative_time, Theme::dim()), ]); table.add_row(row); } - println!("{table}"); + println!("{}", table.render()); } pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) { @@ -883,53 +788,46 @@ pub fn print_list_mrs(result: &MrListResult) { result.total_count ); - let mut table = Table::new(); - table - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new("IID").add_attribute(Attribute::Bold), - Cell::new("Title").add_attribute(Attribute::Bold), - Cell::new("State").add_attribute(Attribute::Bold), - Cell::new("Author").add_attribute(Attribute::Bold), - Cell::new("Branches").add_attribute(Attribute::Bold), - Cell::new("Disc").add_attribute(Attribute::Bold), - Cell::new("Updated").add_attribute(Attribute::Bold), - ]); + let mut table = LoreTable::new() + .headers(&[ + "IID", "Title", "State", "Author", "Branches", "Disc", "Updated", + ]) + .align(0, Align::Right); for mr in &result.mrs { let title = if mr.draft { - format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38)) + format!("[DRAFT] {}", render::truncate(&mr.title, 38)) } else { - truncate_with_ellipsis(&mr.title, 45) + render::truncate(&mr.title, 45) }; - let relative_time = format_relative_time(mr.updated_at); + let relative_time = render::format_relative_time(mr.updated_at); let branches = format_branches(&mr.target_branch, &mr.source_branch, 25); let discussions = format_discussions(mr.discussion_count, mr.unresolved_count); let state_cell = match mr.state.as_str() { - "opened" => colored_cell(&mr.state, Color::Green), - "merged" => colored_cell(&mr.state, Color::Magenta), - "closed" => colored_cell(&mr.state, Color::Red), - "locked" => colored_cell(&mr.state, Color::Yellow), - _ => colored_cell(&mr.state, Color::DarkGrey), + "opened" => StyledCell::styled(&mr.state, Theme::success()), + "merged" => StyledCell::styled(&mr.state, Theme::accent()), + "closed" => StyledCell::styled(&mr.state, Theme::error()), + "locked" => StyledCell::styled(&mr.state, Theme::warning()), + _ => StyledCell::styled(&mr.state, Theme::dim()), }; table.add_row(vec![ - colored_cell(format!("!{}", mr.iid), Color::Cyan), - Cell::new(title), + StyledCell::styled(format!("!{}", mr.iid), Theme::info()), + StyledCell::plain(title), state_cell, - colored_cell( - format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)), - Color::Magenta, + StyledCell::styled( + format!("@{}", render::truncate(&mr.author_username, 12)), + Theme::accent(), ), - colored_cell(branches, Color::Blue), - Cell::new(discussions), - colored_cell(relative_time, Color::DarkGrey), + StyledCell::styled(branches, Theme::info()), + StyledCell::plain(discussions), + StyledCell::styled(relative_time, Theme::dim()), ]); } - println!("{table}"); + println!("{}", table.render()); } pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) { @@ -1016,18 +914,17 @@ pub fn print_list_notes(result: &NoteListResult) { result.total_count ); - let mut table = Table::new(); - table - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new("ID").add_attribute(Attribute::Bold), - Cell::new("Author").add_attribute(Attribute::Bold), - Cell::new("Type").add_attribute(Attribute::Bold), - Cell::new("Body").add_attribute(Attribute::Bold), - Cell::new("Path:Line").add_attribute(Attribute::Bold), - Cell::new("Parent").add_attribute(Attribute::Bold), - Cell::new("Created").add_attribute(Attribute::Bold), - ]); + let mut table = LoreTable::new() + .headers(&[ + "ID", + "Author", + "Type", + "Body", + "Path:Line", + "Parent", + "Created", + ]) + .align(0, Align::Right); for note in &result.notes { let body = note @@ -1037,24 +934,24 @@ pub fn print_list_notes(result: &NoteListResult) { .unwrap_or_default(); let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line); let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid); - let relative_time = format_relative_time(note.created_at); + let relative_time = render::format_relative_time(note.created_at); let note_type = format_note_type(note.note_type.as_deref()); table.add_row(vec![ - colored_cell(note.gitlab_id, Color::Cyan), - colored_cell( - format!("@{}", truncate_with_ellipsis(¬e.author_username, 12)), - Color::Magenta, + StyledCell::styled(note.gitlab_id.to_string(), Theme::info()), + StyledCell::styled( + format!("@{}", render::truncate(¬e.author_username, 12)), + Theme::accent(), ), - Cell::new(note_type), - Cell::new(body), - Cell::new(path), - Cell::new(parent), - colored_cell(relative_time, Color::DarkGrey), + StyledCell::plain(note_type), + StyledCell::plain(body), + StyledCell::plain(path), + StyledCell::plain(parent), + StyledCell::styled(relative_time, Theme::dim()), ]); } - println!("{table}"); + println!("{}", table.render()); } pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) { diff --git a/src/cli/commands/list_tests.rs b/src/cli/commands/list_tests.rs index 9854133..4780c79 100644 --- a/src/cli/commands/list_tests.rs +++ b/src/cli/commands/list_tests.rs @@ -1,47 +1,52 @@ use super::*; +use crate::cli::render; +use crate::core::time::now_ms; #[test] fn truncate_leaves_short_strings_alone() { - assert_eq!(truncate_with_ellipsis("short", 10), "short"); + assert_eq!(render::truncate("short", 10), "short"); } #[test] fn truncate_adds_ellipsis_to_long_strings() { assert_eq!( - truncate_with_ellipsis("this is a very long title", 15), + render::truncate("this is a very long title", 15), "this is a ve..." ); } #[test] fn truncate_handles_exact_length() { - assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!"); + assert_eq!(render::truncate("exactly10!", 10), "exactly10!"); } #[test] fn relative_time_formats_correctly() { let now = now_ms(); - assert_eq!(format_relative_time(now - 30_000), "just now"); - assert_eq!(format_relative_time(now - 120_000), "2 min ago"); - assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago"); - assert_eq!(format_relative_time(now - 172_800_000), "2 days ago"); + assert_eq!(render::format_relative_time(now - 30_000), "just now"); + assert_eq!(render::format_relative_time(now - 120_000), "2 min ago"); + assert_eq!(render::format_relative_time(now - 7_200_000), "2 hours ago"); + assert_eq!( + render::format_relative_time(now - 172_800_000), + "2 days ago" + ); } #[test] fn format_labels_empty() { - assert_eq!(format_labels(&[], 2), ""); + assert_eq!(render::format_labels(&[], 2), ""); } #[test] fn format_labels_single() { - assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]"); + assert_eq!(render::format_labels(&["bug".to_string()], 2), "[bug]"); } #[test] fn format_labels_multiple() { let labels = vec!["bug".to_string(), "urgent".to_string()]; - assert_eq!(format_labels(&labels, 2), "[bug, urgent]"); + assert_eq!(render::format_labels(&labels, 2), "[bug, urgent]"); } #[test] @@ -52,7 +57,7 @@ fn format_labels_overflow() { "wip".to_string(), "blocked".to_string(), ]; - assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]"); + assert_eq!(render::format_labels(&labels, 2), "[bug, urgent +2]"); } #[test] diff --git a/src/cli/commands/search.rs b/src/cli/commands/search.rs index fcd86fb..e7dc8b5 100644 --- a/src/cli/commands/search.rs +++ b/src/cli/commands/search.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use console::style; +use crate::cli::render::Theme; use serde::Serialize; use crate::Config; @@ -309,68 +309,94 @@ fn parse_json_array(json: &str) -> Vec { .collect() } +/// Render FTS snippet with `` tags as terminal bold+underline. +fn render_snippet(snippet: &str) -> String { + let mut result = String::new(); + let mut remaining = snippet; + while let Some(start) = remaining.find("") { + result.push_str(&remaining[..start]); + remaining = &remaining[start + 6..]; + if let Some(end) = remaining.find("") { + let highlighted = &remaining[..end]; + result.push_str(&Theme::bold().underline().render(highlighted)); + remaining = &remaining[end + 7..]; + } + } + result.push_str(remaining); + result +} + pub fn print_search_results(response: &SearchResponse) { if !response.warnings.is_empty() { for w in &response.warnings { - eprintln!("{} {}", style("Warning:").yellow(), w); + eprintln!("{} {}", Theme::warning().render("Warning:"), w); } } if response.results.is_empty() { - println!("No results found for '{}'", style(&response.query).bold()); + println!( + "No results found for '{}'", + Theme::bold().render(&response.query) + ); return; } println!( - "{} results for '{}' ({})", - response.total_results, - style(&response.query).bold(), - response.mode + "\n {} results for '{}' {}", + Theme::bold().render(&response.total_results.to_string()), + Theme::bold().render(&response.query), + Theme::dim().render(&format!("({})", response.mode)) ); println!(); for (i, result) in response.results.iter().enumerate() { - let type_prefix = match result.source_type.as_str() { - "issue" => "Issue", - "merge_request" => "MR", - "discussion" => "Discussion", - "note" => "Note", - _ => &result.source_type, + let type_badge = match result.source_type.as_str() { + "issue" => Theme::info().render("issue"), + "merge_request" => Theme::accent().render("mr"), + "discussion" => Theme::info().render("disc"), + "note" => Theme::info().render("note"), + _ => Theme::dim().render(&result.source_type), }; + // Title line: rank, type badge, title println!( - "[{}] {} - {} (score: {:.2})", - i + 1, - style(type_prefix).cyan(), - result.title, - result.score + " {} {} {}", + Theme::dim().render(&format!("{:>2}.", i + 1)), + type_badge, + Theme::bold().render(&result.title) ); - if let Some(ref url) = result.url { - println!(" {}", style(url).dim()); + // Metadata: project, author, labels — compact middle-dot line + let mut meta_parts: Vec = Vec::new(); + meta_parts.push(result.project_path.clone()); + if let Some(ref author) = result.author { + meta_parts.push(format!("@{author}")); } - - println!( - " {} | {}", - style(&result.project_path).dim(), - result - .author - .as_deref() - .map(|a| format!("@{}", a)) - .unwrap_or_default() - ); - if !result.labels.is_empty() { - println!(" Labels: {}", result.labels.join(", ")); + let label_str = if result.labels.len() <= 3 { + result.labels.join(", ") + } else { + format!( + "{} +{}", + result.labels[..2].join(", "), + result.labels.len() - 2 + ) + }; + meta_parts.push(label_str); } + println!( + " {}", + Theme::dim().render(&meta_parts.join(" \u{b7} ")) + ); - let clean_snippet = result.snippet.replace("", "").replace("", ""); - println!(" {}", style(clean_snippet).dim()); + // Snippet with proper highlighting + let rendered = render_snippet(&result.snippet); + println!(" {}", Theme::dim().render(&rendered)); if let Some(ref explain) = result.explain { println!( - " {} vector_rank={} fts_rank={} rrf_score={:.6}", - style("[explain]").magenta(), + " {} vec={} fts={} rrf={:.4}", + Theme::accent().render("explain"), explain .vector_rank .map(|r| r.to_string()) diff --git a/src/cli/commands/show.rs b/src/cli/commands/show.rs index 887b755..3e7913f 100644 --- a/src/cli/commands/show.rs +++ b/src/cli/commands/show.rs @@ -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 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(¤t_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(¤t_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] diff --git a/src/cli/commands/stats.rs b/src/cli/commands/stats.rs index e0841ea..5906aad 100644 --- a/src/cli/commands/stats.rs +++ b/src/cli/commands/stats.rs @@ -1,4 +1,4 @@ -use console::style; +use crate::cli::render::{self, Theme}; use rusqlite::Connection; use serde::Serialize; @@ -322,124 +322,183 @@ fn table_exists(conn: &Connection, table: &str) -> bool { > 0 } +fn section(title: &str) { + println!("{}", render::section_divider(title)); +} + pub fn print_stats(result: &StatsResult) { - println!("{}", style("Documents").cyan().bold()); - println!(" Total: {}", result.documents.total); - println!(" Issues: {}", result.documents.issues); - println!(" Merge Requests: {}", result.documents.merge_requests); - println!(" Discussions: {}", result.documents.discussions); + section("Documents"); + let mut parts = vec![format!("{} total", result.documents.total)]; + if result.documents.issues > 0 { + parts.push(format!("{} issues", result.documents.issues)); + } + if result.documents.merge_requests > 0 { + parts.push(format!("{} MRs", result.documents.merge_requests)); + } + if result.documents.discussions > 0 { + parts.push(format!("{} discussions", result.documents.discussions)); + } + println!(" {}", parts.join(" \u{b7} ")); if result.documents.truncated > 0 { println!( - " Truncated: {}", - style(result.documents.truncated).yellow() + " {}", + Theme::warning().render(&format!("{} truncated", result.documents.truncated)) ); } - println!(); - println!("{}", style("Search Index").cyan().bold()); - println!(" FTS indexed: {}", result.fts.indexed); + section("Search Index"); + println!(" {} FTS indexed", result.fts.indexed); + let coverage_color = if result.embeddings.coverage_pct >= 95.0 { + Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct)) + } else if result.embeddings.coverage_pct >= 50.0 { + Theme::warning().render(&format!("{:.0}%", result.embeddings.coverage_pct)) + } else { + Theme::error().render(&format!("{:.0}%", result.embeddings.coverage_pct)) + }; println!( - " Embedding coverage: {:.1}% ({}/{})", - result.embeddings.coverage_pct, - result.embeddings.embedded_documents, - result.documents.total + " {} embedding coverage ({}/{})", + coverage_color, result.embeddings.embedded_documents, result.documents.total, ); if result.embeddings.total_chunks > 0 { - println!(" Total chunks: {}", result.embeddings.total_chunks); - } - println!(); - - println!("{}", style("Queues").cyan().bold()); - println!( - " Dirty sources: {} pending, {} failed", - result.queues.dirty_sources, result.queues.dirty_sources_failed - ); - println!( - " Discussion fetch: {} pending, {} failed", - result.queues.pending_discussion_fetches, result.queues.pending_discussion_fetches_failed - ); - if result.queues.pending_dependent_fetches > 0 - || result.queues.pending_dependent_fetches_failed > 0 - || result.queues.pending_dependent_fetches_stuck > 0 - { println!( - " Dependent fetch: {} pending, {} failed, {} stuck", - result.queues.pending_dependent_fetches, - result.queues.pending_dependent_fetches_failed, - result.queues.pending_dependent_fetches_stuck + " {}", + Theme::dim().render(&format!("{} chunks", result.embeddings.total_chunks)) ); } + // Queues: only show if there's anything to report + let has_queue_activity = result.queues.dirty_sources > 0 + || result.queues.dirty_sources_failed > 0 + || result.queues.pending_discussion_fetches > 0 + || result.queues.pending_discussion_fetches_failed > 0 + || result.queues.pending_dependent_fetches > 0 + || result.queues.pending_dependent_fetches_failed > 0; + + if has_queue_activity { + section("Queues"); + if result.queues.dirty_sources > 0 || result.queues.dirty_sources_failed > 0 { + let mut q = Vec::new(); + if result.queues.dirty_sources > 0 { + q.push(format!("{} pending", result.queues.dirty_sources)); + } + if result.queues.dirty_sources_failed > 0 { + q.push( + Theme::error() + .render(&format!("{} failed", result.queues.dirty_sources_failed)), + ); + } + println!(" dirty sources: {}", q.join(", ")); + } + if result.queues.pending_discussion_fetches > 0 + || result.queues.pending_discussion_fetches_failed > 0 + { + let mut q = Vec::new(); + if result.queues.pending_discussion_fetches > 0 { + q.push(format!( + "{} pending", + result.queues.pending_discussion_fetches + )); + } + if result.queues.pending_discussion_fetches_failed > 0 { + q.push(Theme::error().render(&format!( + "{} failed", + result.queues.pending_discussion_fetches_failed + ))); + } + println!(" discussion fetch: {}", q.join(", ")); + } + if result.queues.pending_dependent_fetches > 0 + || result.queues.pending_dependent_fetches_failed > 0 + { + let mut q = Vec::new(); + if result.queues.pending_dependent_fetches > 0 { + q.push(format!( + "{} pending", + result.queues.pending_dependent_fetches + )); + } + if result.queues.pending_dependent_fetches_failed > 0 { + q.push(Theme::error().render(&format!( + "{} failed", + result.queues.pending_dependent_fetches_failed + ))); + } + if result.queues.pending_dependent_fetches_stuck > 0 { + q.push(Theme::warning().render(&format!( + "{} stuck", + result.queues.pending_dependent_fetches_stuck + ))); + } + println!(" dependent fetch: {}", q.join(", ")); + } + } else { + section("Queues"); + println!(" {}", Theme::success().render("all clear")); + } + if let Some(ref integrity) = result.integrity { - println!(); - let status = if integrity.ok { - style("OK").green().bold() + section("Integrity"); + if integrity.ok { + println!( + " {} all checks passed", + Theme::success().render("\u{2713}") + ); } else { - style("ISSUES FOUND").red().bold() - }; - println!("{} Integrity: {}", style("Check").cyan().bold(), status); - - if integrity.fts_doc_mismatch { - println!(" {} FTS/document count mismatch", style("!").red()); - } - if integrity.orphan_embeddings > 0 { - println!( - " {} {} orphan embeddings", - style("!").red(), - integrity.orphan_embeddings - ); - } - if integrity.stale_metadata > 0 { - println!( - " {} {} stale embedding metadata", - style("!").red(), - integrity.stale_metadata - ); - } - let orphan_events = integrity.orphan_state_events - + integrity.orphan_label_events - + integrity.orphan_milestone_events; - if orphan_events > 0 { - println!( - " {} {} orphan resource events (state: {}, label: {}, milestone: {})", - style("!").red(), - orphan_events, - integrity.orphan_state_events, - integrity.orphan_label_events, - integrity.orphan_milestone_events - ); - } - if integrity.queue_stuck_locks > 0 { - println!( - " {} {} stuck queue locks", - style("!").yellow(), - integrity.queue_stuck_locks - ); - } - if integrity.queue_max_attempts > 3 { - println!( - " {} max queue retry attempts: {}", - style("!").yellow(), - integrity.queue_max_attempts - ); + if integrity.fts_doc_mismatch { + println!( + " {} FTS/document count mismatch", + Theme::error().render("\u{2717}") + ); + } + if integrity.orphan_embeddings > 0 { + println!( + " {} {} orphan embeddings", + Theme::error().render("\u{2717}"), + integrity.orphan_embeddings + ); + } + if integrity.stale_metadata > 0 { + println!( + " {} {} stale embedding metadata", + Theme::error().render("\u{2717}"), + integrity.stale_metadata + ); + } + let orphan_events = integrity.orphan_state_events + + integrity.orphan_label_events + + integrity.orphan_milestone_events; + if orphan_events > 0 { + println!( + " {} {} orphan resource events", + Theme::error().render("\u{2717}"), + orphan_events + ); + } + if integrity.queue_stuck_locks > 0 { + println!( + " {} {} stuck queue locks", + Theme::warning().render("!"), + integrity.queue_stuck_locks + ); + } } if let Some(ref repair) = integrity.repair { println!(); if repair.dry_run { println!( - "{} {}", - style("Repair").cyan().bold(), - style("(dry run - no changes made)").yellow() + " {} {}", + Theme::bold().render("Repair"), + Theme::warning().render("(dry run)") ); } else { - println!("{}", style("Repair").cyan().bold()); + println!(" {}", Theme::bold().render("Repair")); } let action = if repair.dry_run { - style("would fix").yellow() + Theme::warning().render("would fix") } else { - style("fixed").green() + Theme::success().render("fixed") }; if repair.fts_rebuilt { @@ -453,15 +512,17 @@ pub fn print_stats(result: &StatsResult) { } if repair.stale_cleared > 0 { println!( - " {} {} stale metadata entries cleared", + " {} {} stale metadata cleared", action, repair.stale_cleared ); } if !repair.fts_rebuilt && repair.orphans_deleted == 0 && repair.stale_cleared == 0 { - println!(" No issues to repair."); + println!(" {}", Theme::dim().render("nothing to repair")); } } } + + println!(); } #[derive(Serialize)] diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 2372944..2de1d22 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -1,4 +1,4 @@ -use console::style; +use crate::cli::render::{self, Theme}; use indicatif::{ProgressBar, ProgressStyle}; use serde::Serialize; use std::sync::Arc; @@ -240,7 +240,7 @@ pub async fn run_sync( embed_bar.finish_and_clear(); spinner.finish_and_clear(); if !options.robot_mode { - eprintln!(" {} Embedding skipped ({})", style("warn").yellow(), e); + eprintln!(" {} Embedding skipped ({})", Theme::warning().render("warn"), e); } warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing"); } @@ -273,37 +273,58 @@ pub fn print_sync( elapsed: std::time::Duration, metrics: Option<&MetricsLayer>, ) { - println!("{} Sync complete:", style("done").green().bold(),); - println!(" Issues updated: {}", result.issues_updated); - println!(" MRs updated: {}", result.mrs_updated); + // Headline: what happened, how long println!( - " Discussions fetched: {}", - result.discussions_fetched + "\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() ); - if result.mr_diffs_fetched > 0 || result.mr_diffs_failed > 0 { - println!(" MR diffs fetched: {}", result.mr_diffs_fetched); - if result.mr_diffs_failed > 0 { - println!(" MR diffs failed: {}", result.mr_diffs_failed); - } + + // 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)); } - if result.resource_events_fetched > 0 || result.resource_events_failed > 0 { - println!( - " Resource events fetched: {}", - result.resource_events_fetched - ); - if result.resource_events_failed > 0 { - println!( - " Resource events failed: {}", - result.resource_events_failed - ); - } + if result.resource_events_fetched > 0 { + details.push(format!("{} events", result.resource_events_fetched)); } - println!( - " Documents regenerated: {}", - result.documents_regenerated - ); - println!(" Documents embedded: {}", result.documents_embedded); - println!(" Elapsed: {:.1}s", elapsed.as_secs_f64()); + if result.mr_diffs_fetched > 0 { + details.push(format!("{} diffs", result.mr_diffs_fetched)); + } + if !details.is_empty() { + println!(" {}", Theme::dim().render(&details.join(" \u{b7} "))); + } + + // 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)); + } + if result.documents_embedded > 0 { + doc_parts.push(format!("{} embedded", result.documents_embedded)); + } + if !doc_parts.is_empty() { + println!(" {}", Theme::dim().render(&doc_parts.join(" \u{b7} "))); + } + + // Errors: visually prominent, only if non-zero + let mut errors: Vec = Vec::new(); + if result.resource_events_failed > 0 { + errors.push(format!("{} event failures", result.resource_events_failed)); + } + if result.mr_diffs_failed > 0 { + errors.push(format!("{} diff failures", result.mr_diffs_failed)); + } + if result.status_enrichment_errors > 0 { + errors.push(format!("{} status errors", result.status_enrichment_errors)); + } + if !errors.is_empty() { + println!(" {}", Theme::error().render(&errors.join(" \u{b7} "))); + } + + println!(); if let Some(metrics) = metrics { let stages = metrics.extract_timings(); @@ -313,9 +334,12 @@ pub fn print_sync( } } +fn section(title: &str) { + println!("{}", render::section_divider(title)); +} + fn print_timing_summary(stages: &[StageTiming]) { - println!(); - println!("{}", style("Stage timing:").dim()); + section("Timing"); for stage in stages { for sub in &stage.sub_stages { print_stage_line(sub, 1); @@ -331,29 +355,25 @@ fn print_stage_line(stage: &StageTiming, depth: usize) { stage.name.clone() }; let pad_width = 30_usize.saturating_sub(indent.len() + name.len()); - let dots = ".".repeat(pad_width.max(2)); + let dots = Theme::dim().render(&".".repeat(pad_width.max(2))); - let mut suffix = String::new(); + let time_str = Theme::bold().render(&format!("{:.1}s", stage.elapsed_ms as f64 / 1000.0)); + + let mut parts: Vec = Vec::new(); if stage.items_processed > 0 { - suffix.push_str(&format!("{} items", stage.items_processed)); + parts.push(format!("{} items", stage.items_processed)); } if stage.errors > 0 { - if !suffix.is_empty() { - suffix.push_str(", "); - } - suffix.push_str(&format!("{} errors", stage.errors)); + parts.push(Theme::error().render(&format!("{} errors", stage.errors))); } if stage.rate_limit_hits > 0 { - if !suffix.is_empty() { - suffix.push_str(", "); - } - suffix.push_str(&format!("{} rate limits", stage.rate_limit_hits)); + parts.push(Theme::warning().render(&format!("{} rate limits", stage.rate_limit_hits))); } - let time_str = format!("{:.1}s", stage.elapsed_ms as f64 / 1000.0); - if suffix.is_empty() { + if parts.is_empty() { println!("{indent}{name} {dots} {time_str}"); } else { + let suffix = parts.join(" \u{b7} "); println!("{indent}{name} {dots} {time_str} ({suffix})"); } @@ -423,87 +443,52 @@ async fn run_sync_dry_run(config: &Config, options: &SyncOptions) -> Result = Vec::new(); if result.would_generate_docs { - println!( - "{} {}", - style("Stage 3: Document Generation").white().bold(), - style("(would run)").green() - ); + stages.push("generate-docs".to_string()); } else { - println!( - "{} {}", - style("Stage 3: Document Generation").white().bold(), - style("(skipped)").dim() - ); + stages.push(Theme::dim().render("generate-docs (skip)")); } - if result.would_embed { - println!( - "{} {}", - style("Stage 4: Embedding").white().bold(), - style("(would run)").green() - ); + stages.push("embed".to_string()); } else { - println!( - "{} {}", - style("Stage 4: Embedding").white().bold(), - style("(skipped)").dim() - ); + stages.push(Theme::dim().render("embed (skip)")); + } + println!(" {}", stages.join(" \u{b7} ")); +} + +fn print_dry_run_entity(label: &str, preview: &DryRunPreview) { + section(label); + let mode = if preview.sync_mode == "full" { + Theme::warning().render("full") + } else { + Theme::success().render("incremental") + }; + println!(" {} \u{b7} {} projects", mode, preview.projects.len()); + for project in &preview.projects { + let sync_status = if !project.has_cursor { + Theme::warning().render("initial sync") + } else { + Theme::success().render("incremental") + }; + if project.existing_count > 0 { + println!( + " {} \u{b7} {} \u{b7} {} existing", + &project.path, sync_status, project.existing_count + ); + } else { + println!(" {} \u{b7} {}", &project.path, sync_status); + } } } diff --git a/src/cli/commands/sync_status.rs b/src/cli/commands/sync_status.rs index 03640d8..1018dc9 100644 --- a/src/cli/commands/sync_status.rs +++ b/src/cli/commands/sync_status.rs @@ -1,4 +1,4 @@ -use console::style; +use crate::cli::render::{self, Theme}; use rusqlite::Connection; use serde::Serialize; @@ -166,27 +166,6 @@ fn format_duration(ms: i64) -> String { } } -fn format_number(n: i64) -> String { - let is_negative = n < 0; - let abs_n = n.unsigned_abs(); - let s = abs_n.to_string(); - let chars: Vec = s.chars().collect(); - let mut result = String::new(); - - if is_negative { - result.push('-'); - } - - for (i, c) in chars.iter().enumerate() { - if i > 0 && (chars.len() - i).is_multiple_of(3) { - result.push(','); - } - result.push(*c); - } - - result -} - #[derive(Serialize)] struct SyncStatusJsonOutput { ok: bool, @@ -293,14 +272,14 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) { } pub fn print_sync_status(result: &SyncStatusResult) { - println!("{}", style("Recent Sync Runs").bold().underlined()); + println!("{}", Theme::bold().underline().render("Recent Sync Runs")); println!(); if result.runs.is_empty() { - println!(" {}", style("No sync runs recorded yet.").dim()); + println!(" {}", Theme::dim().render("No sync runs recorded yet.")); println!( " {}", - style("Run 'lore sync' or 'lore ingest' to start.").dim() + Theme::dim().render("Run 'lore sync' or 'lore ingest' to start.") ); } else { for run in &result.runs { @@ -310,16 +289,16 @@ pub fn print_sync_status(result: &SyncStatusResult) { println!(); - println!("{}", style("Cursor Positions").bold().underlined()); + println!("{}", Theme::bold().underline().render("Cursor Positions")); println!(); if result.cursors.is_empty() { - println!(" {}", style("No cursors recorded yet.").dim()); + println!(" {}", Theme::dim().render("No cursors recorded yet.")); } else { for cursor in &result.cursors { println!( " {} ({}):", - style(&cursor.project_path).cyan(), + Theme::info().render(&cursor.project_path), cursor.resource_type ); @@ -328,7 +307,10 @@ pub fn print_sync_status(result: &SyncStatusResult) { println!(" Last updated_at: {}", ms_to_iso(ts)); } _ => { - println!(" Last updated_at: {}", style("Not started").dim()); + println!( + " Last updated_at: {}", + Theme::dim().render("Not started") + ); } } @@ -340,40 +322,39 @@ pub fn print_sync_status(result: &SyncStatusResult) { println!(); - println!("{}", style("Data Summary").bold().underlined()); + println!("{}", Theme::bold().underline().render("Data Summary")); println!(); println!( " Issues: {}", - style(format_number(result.summary.issue_count)).bold() + Theme::bold().render(&render::format_number(result.summary.issue_count)) ); println!( " MRs: {}", - style(format_number(result.summary.mr_count)).bold() + Theme::bold().render(&render::format_number(result.summary.mr_count)) ); println!( " Discussions: {}", - style(format_number(result.summary.discussion_count)).bold() + Theme::bold().render(&render::format_number(result.summary.discussion_count)) ); let user_notes = result.summary.note_count - result.summary.system_note_count; println!( " Notes: {} {}", - style(format_number(user_notes)).bold(), - style(format!( + Theme::bold().render(&render::format_number(user_notes)), + Theme::dim().render(&format!( "(excluding {} system)", - format_number(result.summary.system_note_count) + render::format_number(result.summary.system_note_count) )) - .dim() ); } fn print_run_line(run: &SyncRunInfo) { let status_styled = match run.status.as_str() { - "succeeded" => style(&run.status).green(), - "failed" => style(&run.status).red(), - "running" => style(&run.status).yellow(), - _ => style(&run.status).dim(), + "succeeded" => Theme::success().render(&run.status), + "failed" => Theme::error().render(&run.status), + "running" => Theme::warning().render(&run.status), + _ => Theme::dim().render(&run.status), }; let run_label = run @@ -386,9 +367,9 @@ fn print_run_line(run: &SyncRunInfo) { let time = format_full_datetime(run.started_at); let mut parts = vec![ - format!("{}", style(run_label).bold()), - format!("{status_styled}"), - format!("{}", style(&run.command).dim()), + Theme::bold().render(&run_label), + status_styled, + Theme::dim().render(&run.command), time, ]; @@ -403,16 +384,13 @@ fn print_run_line(run: &SyncRunInfo) { } if run.total_errors > 0 { - parts.push(format!( - "{}", - style(format!("{} errors", run.total_errors)).red() - )); + parts.push(Theme::error().render(&format!("{} errors", run.total_errors))); } println!(" {}", parts.join(" | ")); if let Some(error) = &run.error { - println!(" {}", style(error).red()); + println!(" {}", Theme::error().render(error)); } } @@ -448,7 +426,7 @@ mod tests { #[test] fn format_number_adds_thousands_separators() { - assert_eq!(format_number(1000), "1,000"); - assert_eq!(format_number(1234567), "1,234,567"); + assert_eq!(render::format_number(1000), "1,000"); + assert_eq!(render::format_number(1234567), "1,234,567"); } } diff --git a/src/cli/commands/timeline.rs b/src/cli/commands/timeline.rs index 518e0ea..78284c0 100644 --- a/src/cli/commands/timeline.rs +++ b/src/cli/commands/timeline.rs @@ -1,4 +1,4 @@ -use console::{Alignment, pad_str, style}; +use crate::cli::render::{self, Theme}; use serde::Serialize; use crate::Config; @@ -22,7 +22,7 @@ pub struct TimelineParams { pub project: Option, pub since: Option, pub depth: u32, - pub expand_mentions: bool, + pub no_mentions: bool, pub limit: usize, pub max_seeds: usize, pub max_entities: usize, @@ -133,7 +133,7 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result String { match event_type { - TimelineEventType::Created => style("CREATED").green().to_string(), + TimelineEventType::Created => Theme::success().render("CREATED"), TimelineEventType::StateChanged { state } => match state.as_str() { - "closed" => style("CLOSED").red().to_string(), - "reopened" => style("REOPENED").yellow().to_string(), - _ => style(state.to_uppercase()).dim().to_string(), + "closed" => Theme::error().render("CLOSED"), + "reopened" => Theme::warning().render("REOPENED"), + _ => Theme::dim().render(&state.to_uppercase()), }, - TimelineEventType::LabelAdded { .. } => style("LABEL+").blue().to_string(), - TimelineEventType::LabelRemoved { .. } => style("LABEL-").blue().to_string(), - TimelineEventType::MilestoneSet { .. } => style("MILESTONE+").magenta().to_string(), - TimelineEventType::MilestoneRemoved { .. } => style("MILESTONE-").magenta().to_string(), - TimelineEventType::Merged => style("MERGED").cyan().to_string(), - TimelineEventType::NoteEvidence { .. } => style("NOTE").dim().to_string(), - TimelineEventType::DiscussionThread { .. } => style("THREAD").yellow().to_string(), - TimelineEventType::CrossReferenced { .. } => style("REF").dim().to_string(), + TimelineEventType::LabelAdded { .. } => Theme::info().render("LABEL+"), + TimelineEventType::LabelRemoved { .. } => Theme::info().render("LABEL-"), + TimelineEventType::MilestoneSet { .. } => Theme::accent().render("MILESTONE+"), + TimelineEventType::MilestoneRemoved { .. } => Theme::accent().render("MILESTONE-"), + TimelineEventType::Merged => Theme::info().render("MERGED"), + TimelineEventType::NoteEvidence { .. } => Theme::dim().render("NOTE"), + TimelineEventType::DiscussionThread { .. } => Theme::warning().render("THREAD"), + TimelineEventType::CrossReferenced { .. } => Theme::dim().render("REF"), } } @@ -299,48 +303,6 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String { } } -fn format_date(ms: i64) -> String { - let iso = ms_to_iso(ms); - iso.split('T').next().unwrap_or(&iso).to_string() -} - -fn truncate_summary(s: &str, max: usize) -> String { - if s.chars().count() <= max { - s.to_owned() - } else { - let truncated: String = s.chars().take(max - 3).collect(); - format!("{truncated}...") - } -} - -fn wrap_text(text: &str, width: usize) -> Vec { - let mut lines = Vec::new(); - let mut current = String::new(); - - for word in text.split_whitespace() { - if current.is_empty() { - current = word.to_string(); - } else if current.len() + 1 + word.len() <= width { - current.push(' '); - current.push_str(word); - } else { - lines.push(current); - current = word.to_string(); - } - } - if !current.is_empty() { - lines.push(current); - } - - lines -} - -fn wrap_snippet(text: &str, width: usize) -> Vec { - let mut lines = wrap_text(text, width); - lines.truncate(4); - lines -} - // ─── Robot JSON output ─────────────────────────────────────────────────────── /// Render timeline as robot-mode JSON in {ok, data, meta} envelope. @@ -348,7 +310,7 @@ pub fn print_timeline_json_with_meta( result: &TimelineResult, total_events_before_limit: usize, depth: u32, - expand_mentions: bool, + include_mentions: bool, fields: Option<&[String]>, ) { let output = TimelineJsonEnvelope { @@ -357,7 +319,7 @@ pub fn print_timeline_json_with_meta( meta: TimelineMetaJson { search_mode: result.search_mode.clone(), expansion_depth: depth, - expand_mentions, + include_mentions, total_entities: result.seed_entities.len() + result.expanded_entities.len(), total_events: total_events_before_limit, evidence_notes_included: count_evidence_notes(&result.events), @@ -586,7 +548,7 @@ fn event_type_to_json(event_type: &TimelineEventType) -> (String, serde_json::Va struct TimelineMetaJson { search_mode: String, expansion_depth: u32, - expand_mentions: bool, + include_mentions: bool, total_entities: usize, total_events: usize, evidence_notes_included: usize, diff --git a/src/cli/commands/who.rs b/src/cli/commands/who.rs index 331c5c0..ea526f3 100644 --- a/src/cli/commands/who.rs +++ b/src/cli/commands/who.rs @@ -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)]