Files
gitlore/src/cli/commands/count.rs
Taylor Eernisse dd00a2b840 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>
2026-02-13 22:32:35 -05:00

385 lines
12 KiB
Rust

use crate::cli::render::{self, Theme};
use rusqlite::Connection;
use serde::Serialize;
use crate::Config;
use crate::cli::robot::RobotMeta;
use crate::core::db::create_connection;
use crate::core::error::Result;
use crate::core::events_db::{self, EventCounts};
use crate::core::paths::get_db_path;
pub struct CountResult {
pub entity: String,
pub count: i64,
pub system_count: Option<i64>,
pub state_breakdown: Option<StateBreakdown>,
}
pub struct StateBreakdown {
pub opened: i64,
pub closed: i64,
pub merged: Option<i64>,
pub locked: Option<i64>,
}
pub fn run_count(config: &Config, entity: &str, type_filter: Option<&str>) -> Result<CountResult> {
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
match entity {
"issues" => count_issues(&conn),
"discussions" => count_discussions(&conn, type_filter),
"notes" => count_notes(&conn, type_filter),
"mrs" => count_mrs(&conn),
_ => Ok(CountResult {
entity: entity.to_string(),
count: 0,
system_count: None,
state_breakdown: None,
}),
}
}
fn count_issues(conn: &Connection) -> Result<CountResult> {
// Single query with conditional aggregation instead of 3 separate queries
let (count, opened, closed): (i64, i64, i64) = conn.query_row(
"SELECT
COUNT(*),
COALESCE(SUM(CASE WHEN state = 'opened' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN state = 'closed' THEN 1 ELSE 0 END), 0)
FROM issues",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
Ok(CountResult {
entity: "Issues".to_string(),
count,
system_count: None,
state_breakdown: Some(StateBreakdown {
opened,
closed,
merged: None,
locked: None,
}),
})
}
fn count_mrs(conn: &Connection) -> Result<CountResult> {
// Single query with conditional aggregation instead of 5 separate queries
let (count, opened, merged, closed, locked): (i64, i64, i64, i64, i64) = conn.query_row(
"SELECT
COUNT(*),
COALESCE(SUM(CASE WHEN state = 'opened' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN state = 'merged' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN state = 'closed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN state = 'locked' THEN 1 ELSE 0 END), 0)
FROM merge_requests",
[],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
)?;
Ok(CountResult {
entity: "Merge Requests".to_string(),
count,
system_count: None,
state_breakdown: Some(StateBreakdown {
opened,
closed,
merged: Some(merged),
locked: Some(locked),
}),
})
}
fn count_discussions(conn: &Connection, type_filter: Option<&str>) -> Result<CountResult> {
let (count, entity_name) = match type_filter {
Some("issue") => {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM discussions WHERE noteable_type = 'Issue'",
[],
|row| row.get(0),
)?;
(count, "Issue Discussions")
}
Some("mr") => {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM discussions WHERE noteable_type = 'MergeRequest'",
[],
|row| row.get(0),
)?;
(count, "MR Discussions")
}
_ => {
let count: i64 =
conn.query_row("SELECT COUNT(*) FROM discussions", [], |row| row.get(0))?;
(count, "Discussions")
}
};
Ok(CountResult {
entity: entity_name.to_string(),
count,
system_count: None,
state_breakdown: None,
})
}
fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResult> {
let (total, system_count, entity_name) = match type_filter {
Some("issue") => {
let (total, system): (i64, i64) = conn.query_row(
"SELECT COUNT(*), COALESCE(SUM(n.is_system), 0)
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = 'Issue'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
(total, system, "Issue Notes")
}
Some("mr") => {
let (total, system): (i64, i64) = conn.query_row(
"SELECT COUNT(*), COALESCE(SUM(n.is_system), 0)
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = 'MergeRequest'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
(total, system, "MR Notes")
}
_ => {
let (total, system): (i64, i64) = conn.query_row(
"SELECT COUNT(*), COALESCE(SUM(is_system), 0) FROM notes",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
(total, system, "Notes")
}
};
let non_system = total - system_count;
Ok(CountResult {
entity: entity_name.to_string(),
count: non_system,
system_count: Some(system_count),
state_breakdown: None,
})
}
#[derive(Serialize)]
struct CountJsonOutput {
ok: bool,
data: CountJsonData,
meta: RobotMeta,
}
#[derive(Serialize)]
struct CountJsonData {
entity: String,
count: i64,
#[serde(skip_serializing_if = "Option::is_none")]
system_excluded: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
breakdown: Option<CountJsonBreakdown>,
}
#[derive(Serialize)]
struct CountJsonBreakdown {
opened: i64,
closed: i64,
#[serde(skip_serializing_if = "Option::is_none")]
merged: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
locked: Option<i64>,
}
pub fn run_count_events(config: &Config) -> Result<EventCounts> {
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
events_db::count_events(&conn)
}
#[derive(Serialize)]
struct EventCountJsonOutput {
ok: bool,
data: EventCountJsonData,
meta: RobotMeta,
}
#[derive(Serialize)]
struct EventCountJsonData {
state_events: EventTypeCounts,
label_events: EventTypeCounts,
milestone_events: EventTypeCounts,
total: usize,
}
#[derive(Serialize)]
struct EventTypeCounts {
issue: usize,
merge_request: usize,
total: usize,
}
pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) {
let output = EventCountJsonOutput {
ok: true,
data: EventCountJsonData {
state_events: EventTypeCounts {
issue: counts.state_issue,
merge_request: counts.state_mr,
total: counts.state_issue + counts.state_mr,
},
label_events: EventTypeCounts {
issue: counts.label_issue,
merge_request: counts.label_mr,
total: counts.label_issue + counts.label_mr,
},
milestone_events: EventTypeCounts {
issue: counts.milestone_issue,
merge_request: counts.milestone_mr,
total: counts.milestone_issue + counts.milestone_mr,
},
total: counts.total(),
},
meta: RobotMeta { elapsed_ms },
};
println!("{}", serde_json::to_string(&output).unwrap());
}
pub fn print_event_count(counts: &EventCounts) {
println!(
"{:<20} {:>8} {:>8} {:>8}",
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;
let label_total = counts.label_issue + counts.label_mr;
let milestone_total = counts.milestone_issue + counts.milestone_mr;
println!(
"{:<20} {:>8} {:>8} {:>8}",
"State events",
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",
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",
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}",
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))
);
}
pub fn print_count_json(result: &CountResult, elapsed_ms: u64) {
let breakdown = result.state_breakdown.as_ref().map(|b| CountJsonBreakdown {
opened: b.opened,
closed: b.closed,
merged: b.merged,
locked: b.locked.filter(|&l| l > 0),
});
let output = CountJsonOutput {
ok: true,
data: CountJsonData {
entity: result.entity.to_lowercase().replace(' ', "_"),
count: result.count,
system_excluded: result.system_count,
breakdown,
},
meta: RobotMeta { elapsed_ms },
};
println!("{}", serde_json::to_string(&output).unwrap());
}
pub fn print_count(result: &CountResult) {
let count_str = render::format_number(result.count);
if let Some(system_count) = result.system_count {
println!(
"{}: {} {}",
Theme::info().render(&result.entity),
Theme::bold().render(&count_str),
Theme::dim().render(&format!(
"(excluding {} system)",
render::format_number(system_count)
))
);
} else {
println!(
"{}: {}",
Theme::info().render(&result.entity),
Theme::bold().render(&count_str)
);
}
if let Some(breakdown) = &result.state_breakdown {
println!(" opened: {}", render::format_number(breakdown.opened));
if let Some(merged) = breakdown.merged {
println!(" merged: {}", render::format_number(merged));
}
println!(" closed: {}", render::format_number(breakdown.closed));
if let Some(locked) = breakdown.locked
&& locked > 0
{
println!(" locked: {}", render::format_number(locked));
}
}
}
#[cfg(test)]
mod tests {
use crate::cli::render;
#[test]
fn format_number_handles_small_numbers() {
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!(render::format_number(1000), "1,000");
assert_eq!(render::format_number(12345), "12,345");
assert_eq!(render::format_number(1234567), "1,234,567");
}
}