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;
@@ -178,27 +178,6 @@ fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResu
})
}
fn format_number(n: i64) -> String {
let (prefix, abs) = if n < 0 {
("-", n.unsigned_abs())
} else {
("", n.unsigned_abs())
};
let s = abs.to_string();
let chars: Vec<char> = 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");
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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!(
" Embedded: {} documents ({} chunks)",
result.docs_embedded, result.chunks_embedded
"\n {} nothing to embed",
Theme::success().bold().render("Embedding")
);
return;
}
println!(
"\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))
);
}
}

View File

@@ -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!(
"{} Document generation complete ({})",
style("done").green().bold(),
"\n {} no documents to update ({})",
Theme::success().bold().render("Docs"),
mode
);
return;
}
// Headline
println!(
"\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<String> = 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))
);
}
}

View File

@@ -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 {

View File

@@ -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<MrListResult>
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<String> = 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(&note.author_username, 12)),
Color::Magenta,
StyledCell::styled(note.gitlab_id.to_string(), Theme::info()),
StyledCell::styled(
format!("@{}", render::truncate(&note.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]>) {

View File

@@ -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]

View File

@@ -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<String> {
.collect()
}
/// Render FTS snippet with `<mark>` 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("<mark>") {
result.push_str(&remaining[..start]);
remaining = &remaining[start + 6..];
if let Some(end) = remaining.find("</mark>") {
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<String> = 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("<mark>", "").replace("</mark>", "");
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())

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]

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::{self, Theme};
use rusqlite::Connection;
use serde::Serialize;
@@ -322,77 +322,145 @@ 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!(
" {}",
Theme::dim().render(&format!("{} 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
// 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
|| result.queues.pending_dependent_fetches_stuck > 0
{
println!(
" Dependent fetch: {} pending, {} failed, {} stuck",
result.queues.pending_dependent_fetches,
result.queues.pending_dependent_fetches_failed,
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());
println!(
" {} FTS/document count mismatch",
Theme::error().render("\u{2717}")
);
}
if integrity.orphan_embeddings > 0 {
println!(
" {} {} orphan embeddings",
style("!").red(),
Theme::error().render("\u{2717}"),
integrity.orphan_embeddings
);
}
if integrity.stale_metadata > 0 {
println!(
" {} {} stale embedding metadata",
style("!").red(),
Theme::error().render("\u{2717}"),
integrity.stale_metadata
);
}
@@ -401,45 +469,36 @@ pub fn print_stats(result: &StatsResult) {
+ 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
" {} {} orphan resource events",
Theme::error().render("\u{2717}"),
orphan_events
);
}
if integrity.queue_stuck_locks > 0 {
println!(
" {} {} stuck queue locks",
style("!").yellow(),
Theme::warning().render("!"),
integrity.queue_stuck_locks
);
}
if integrity.queue_max_attempts > 3 {
println!(
" {} max queue retry attempts: {}",
style("!").yellow(),
integrity.queue_max_attempts
);
}
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)]

View File

@@ -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<String> = Vec::new();
if result.discussions_fetched > 0 {
details.push(format!("{} discussions", result.discussions_fetched));
}
if result.resource_events_fetched > 0 {
details.push(format!("{} events", result.resource_events_fetched));
}
if result.resource_events_fetched > 0 || result.resource_events_failed > 0 {
println!(
" Resource events fetched: {}",
result.resource_events_fetched
);
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<String> = 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<String> = Vec::new();
if result.resource_events_failed > 0 {
println!(
" Resource events failed: {}",
result.resource_events_failed
);
errors.push(format!("{} event failures", result.resource_events_failed));
}
if result.mr_diffs_failed > 0 {
errors.push(format!("{} diff failures", result.mr_diffs_failed));
}
println!(
" Documents regenerated: {}",
result.documents_regenerated
);
println!(" Documents embedded: {}", result.documents_embedded);
println!(" Elapsed: {:.1}s", elapsed.as_secs_f64());
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<String> = 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<Sync
pub fn print_sync_dry_run(result: &SyncDryRunResult) {
println!(
"{} {}",
style("Sync Dry Run Preview").cyan().bold(),
style("(no changes will be made)").yellow()
"\n {} {}",
Theme::info().bold().render("Dry run"),
Theme::dim().render("(no changes will be made)")
);
println!();
println!("{}", style("Stage 1: Issues Ingestion").white().bold());
println!(
" Sync mode: {}",
if result.issues_preview.sync_mode == "full" {
style("full").yellow()
} else {
style("incremental").green()
}
);
println!(" Projects: {}", result.issues_preview.projects.len());
for project in &result.issues_preview.projects {
let sync_status = if !project.has_cursor {
style("initial sync").yellow()
} else {
style("incremental").green()
};
println!(
" {} ({}) - {} existing",
&project.path, sync_status, project.existing_count
);
}
println!();
println!(
"{}",
style("Stage 2: Merge Requests Ingestion").white().bold()
);
println!(
" Sync mode: {}",
if result.mrs_preview.sync_mode == "full" {
style("full").yellow()
} else {
style("incremental").green()
}
);
println!(" Projects: {}", result.mrs_preview.projects.len());
for project in &result.mrs_preview.projects {
let sync_status = if !project.has_cursor {
style("initial sync").yellow()
} else {
style("incremental").green()
};
println!(
" {} ({}) - {} existing",
&project.path, sync_status, project.existing_count
);
}
println!();
print_dry_run_entity("Issues", &result.issues_preview);
print_dry_run_entity("Merge Requests", &result.mrs_preview);
// Pipeline stages
section("Pipeline");
let mut stages: Vec<String> = 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 {
stages.push("embed".to_string());
} else {
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!(
"{} {}",
style("Stage 4: Embedding").white().bold(),
style("(would run)").green()
" {} \u{b7} {} \u{b7} {} existing",
&project.path, sync_status, project.existing_count
);
} else {
println!(
"{} {}",
style("Stage 4: Embedding").white().bold(),
style("(skipped)").dim()
);
println!(" {} \u{b7} {}", &project.path, sync_status);
}
}
}

View File

@@ -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<char> = 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");
}
}

View File

@@ -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<String>,
pub since: Option<String>,
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<Ti
&conn,
&seed_result.seed_entities,
params.depth,
params.expand_mentions,
!params.no_mentions,
params.max_entities,
)?;
spinner.finish_and_clear();
@@ -171,19 +171,21 @@ pub fn print_timeline(result: &TimelineResult) {
println!();
println!(
"{}",
style(format!(
Theme::bold().render(&format!(
"Timeline: \"{}\" ({} events across {} entities)",
result.query,
result.events.len(),
entity_count,
))
.bold()
);
println!("{}", "".repeat(60));
println!("{}", "\u{2500}".repeat(60));
println!();
if result.events.is_empty() {
println!(" {}", style("No events found for this query.").dim());
println!(
" {}",
Theme::dim().render("No events found for this query.")
);
println!();
return;
}
@@ -193,12 +195,12 @@ pub fn print_timeline(result: &TimelineResult) {
}
println!();
println!("{}", "".repeat(60));
println!("{}", "\u{2500}".repeat(60));
print_timeline_footer(result);
}
fn print_timeline_event(event: &TimelineEvent) {
let date = format_date(event.timestamp);
let date = render::format_date(event.timestamp);
let tag = format_event_tag(&event.event_type);
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
let actor = event
@@ -208,18 +210,20 @@ fn print_timeline_event(event: &TimelineEvent) {
.unwrap_or_default();
let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
let summary = truncate_summary(&event.summary, 50);
let tag_padded = pad_str(&tag, 12, Alignment::Left, None);
let summary = render::truncate(&event.summary, 50);
let tag_padded = format!("{:<12}", tag);
println!("{date} {tag_padded} {entity_ref:7} {summary:50} {actor}{expanded_marker}");
// Show snippet for evidence notes
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type
&& !snippet.is_empty()
{
for line in wrap_snippet(snippet, 60) {
let mut lines = render::wrap_lines(snippet, 60);
lines.truncate(4);
for line in lines {
println!(
" \"{}\"",
style(line).dim()
Theme::dim().render(&line)
);
}
}
@@ -229,14 +233,14 @@ fn print_timeline_event(event: &TimelineEvent) {
let bar = "\u{2500}".repeat(44);
println!(" \u{2500}\u{2500} Discussion {bar}");
for note in notes {
let note_date = format_date(note.created_at);
let note_date = render::format_date(note.created_at);
let author = note
.author
.as_deref()
.map(|a| format!("@{a}"))
.unwrap_or_else(|| "unknown".to_owned());
println!(" {} ({note_date}):", style(author).bold());
for line in wrap_text(&note.body, 60) {
println!(" {} ({note_date}):", Theme::bold().render(&author));
for line in render::wrap_lines(&note.body, 60) {
println!(" {line}");
}
}
@@ -274,20 +278,20 @@ fn print_timeline_footer(result: &TimelineResult) {
fn format_event_tag(event_type: &TimelineEventType) -> 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<String> {
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<String> {
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,

View File

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