refactor: Remove redundant doc comments throughout codebase

Removes module-level doc comments (//! lines) and excessive inline doc
comments that were duplicating information already evident from:
- Function/struct names (self-documenting code)
- Type signatures (the what is clear from types)
- Implementation context (the how is clear from code)

Affected modules:
- cli/* - Removed command descriptions duplicating clap help text
- core/* - Removed module headers and obvious function docs
- documents/* - Removed extractor/regenerator/truncation docs
- embedding/* - Removed pipeline and chunking docs
- gitlab/* - Removed client and transformer docs (kept type definitions)
- ingestion/* - Removed orchestrator and ingestion docs
- search/* - Removed FTS and vector search docs

Philosophy: Code should be self-documenting. Comments should explain
"why" (business decisions, non-obvious constraints) not "what" (which
the code itself shows). This change reduces noise and maintenance burden
while keeping the codebase just as understandable.

Retains comments for:
- Non-obvious business logic
- Important safety invariants
- Complex algorithm explanations
- Public API boundaries where generated docs matter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-05 00:04:32 -05:00
parent 976ad92ef0
commit 65583ed5d6
57 changed files with 143 additions and 1693 deletions

View File

@@ -1,22 +1,16 @@
//! Auth test command - verify GitLab authentication.
use crate::core::config::Config;
use crate::core::error::{LoreError, Result};
use crate::gitlab::GitLabClient;
/// Result of successful auth test.
pub struct AuthTestResult {
pub username: String,
pub name: String,
pub base_url: String,
}
/// Run the auth-test command.
pub async fn run_auth_test(config_path: Option<&str>) -> Result<AuthTestResult> {
// 1. Load config
let config = Config::load(config_path)?;
// 2. Get token from environment
let token = std::env::var(&config.gitlab.token_env_var)
.map(|t| t.trim().to_string())
.map_err(|_| LoreError::TokenNotSet {
@@ -29,10 +23,8 @@ pub async fn run_auth_test(config_path: Option<&str>) -> Result<AuthTestResult>
});
}
// 3. Create client and test auth
let client = GitLabClient::new(&config.gitlab.base_url, &token, None);
// 4. Get current user
let user = client.get_current_user().await?;
Ok(AuthTestResult {

View File

@@ -1,5 +1,3 @@
//! Count command - display entity counts from local database.
use console::style;
use rusqlite::Connection;
use serde::Serialize;
@@ -10,23 +8,20 @@ use crate::core::error::Result;
use crate::core::events_db::{self, EventCounts};
use crate::core::paths::get_db_path;
/// Result of count query.
pub struct CountResult {
pub entity: String,
pub count: i64,
pub system_count: Option<i64>, // For notes only
pub state_breakdown: Option<StateBreakdown>, // For issues/MRs
pub system_count: Option<i64>,
pub state_breakdown: Option<StateBreakdown>,
}
/// State breakdown for issues or MRs.
pub struct StateBreakdown {
pub opened: i64,
pub closed: i64,
pub merged: Option<i64>, // MRs only
pub locked: Option<i64>, // MRs only
pub merged: Option<i64>,
pub locked: Option<i64>,
}
/// Run the count command.
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)?;
@@ -45,7 +40,6 @@ pub fn run_count(config: &Config, entity: &str, type_filter: Option<&str>) -> Re
}
}
/// Count issues with state breakdown.
fn count_issues(conn: &Connection) -> Result<CountResult> {
let count: i64 = conn.query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))?;
@@ -74,7 +68,6 @@ fn count_issues(conn: &Connection) -> Result<CountResult> {
})
}
/// Count merge requests with state breakdown.
fn count_mrs(conn: &Connection) -> Result<CountResult> {
let count: i64 = conn.query_row("SELECT COUNT(*) FROM merge_requests", [], |row| row.get(0))?;
@@ -115,7 +108,6 @@ fn count_mrs(conn: &Connection) -> Result<CountResult> {
})
}
/// Count discussions with optional noteable type filter.
fn count_discussions(conn: &Connection, type_filter: Option<&str>) -> Result<CountResult> {
let (count, entity_name) = match type_filter {
Some("issue") => {
@@ -149,7 +141,6 @@ fn count_discussions(conn: &Connection, type_filter: Option<&str>) -> Result<Cou
})
}
/// Count notes with optional noteable type filter.
fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResult> {
let (total, system_count, entity_name) = match type_filter {
Some("issue") => {
@@ -184,7 +175,6 @@ fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResu
}
};
// Non-system notes count
let non_system = total - system_count;
Ok(CountResult {
@@ -195,7 +185,6 @@ fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResu
})
}
/// Format number with thousands separators.
fn format_number(n: i64) -> String {
let s = n.to_string();
let chars: Vec<char> = s.chars().collect();
@@ -211,7 +200,6 @@ fn format_number(n: i64) -> String {
result
}
/// JSON output structure for count command.
#[derive(Serialize)]
struct CountJsonOutput {
ok: bool,
@@ -238,14 +226,12 @@ struct CountJsonBreakdown {
locked: Option<i64>,
}
/// Run the event count query.
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)
}
/// JSON output structure for event counts.
#[derive(Serialize)]
struct EventCountJsonOutput {
ok: bool,
@@ -267,7 +253,6 @@ struct EventTypeCounts {
total: usize,
}
/// Print event counts as JSON (robot mode).
pub fn print_event_count_json(counts: &EventCounts) {
let output = EventCountJsonOutput {
ok: true,
@@ -294,7 +279,6 @@ pub fn print_event_count_json(counts: &EventCounts) {
println!("{}", serde_json::to_string(&output).unwrap());
}
/// Print event counts (human-readable).
pub fn print_event_count(counts: &EventCounts) {
println!(
"{:<20} {:>8} {:>8} {:>8}",
@@ -341,7 +325,6 @@ pub fn print_event_count(counts: &EventCounts) {
);
}
/// Print count result as JSON (robot mode).
pub fn print_count_json(result: &CountResult) {
let breakdown = result.state_breakdown.as_ref().map(|b| CountJsonBreakdown {
opened: b.opened,
@@ -363,7 +346,6 @@ pub fn print_count_json(result: &CountResult) {
println!("{}", serde_json::to_string(&output).unwrap());
}
/// Print count result.
pub fn print_count(result: &CountResult) {
let count_str = format_number(result.count);
@@ -386,7 +368,6 @@ pub fn print_count(result: &CountResult) {
);
}
// Print state breakdown if available
if let Some(breakdown) = &result.state_breakdown {
println!(" opened: {}", format_number(breakdown.opened));
if let Some(merged) = breakdown.merged {

View File

@@ -1,5 +1,3 @@
//! Doctor command - check environment health.
use console::style;
use serde::Serialize;
@@ -100,30 +98,22 @@ pub struct LoggingCheck {
pub total_bytes: Option<u64>,
}
/// Run the doctor command.
pub async fn run_doctor(config_path: Option<&str>) -> DoctorResult {
let config_path_buf = get_config_path(config_path);
let config_path_str = config_path_buf.display().to_string();
// Check config
let (config_check, config) = check_config(&config_path_str);
// Check database
let database_check = check_database(config.as_ref());
// Check GitLab
let gitlab_check = check_gitlab(config.as_ref()).await;
// Check projects
let projects_check = check_projects(config.as_ref());
// Check Ollama
let ollama_check = check_ollama(config.as_ref()).await;
// Check logging
let logging_check = check_logging(config.as_ref());
// Success if all required checks pass (ollama and logging are optional)
let success = config_check.result.status == CheckStatus::Ok
&& database_check.result.status == CheckStatus::Ok
&& gitlab_check.result.status == CheckStatus::Ok
@@ -393,7 +383,6 @@ async fn check_ollama(config: Option<&Config>) -> OllamaCheck {
let base_url = &config.embedding.base_url;
let model = &config.embedding.model;
// Short timeout for Ollama check
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
@@ -418,9 +407,6 @@ async fn check_ollama(config: Option<&Config>) -> OllamaCheck {
.map(|m| m.name.split(':').next().unwrap_or(&m.name))
.collect();
// Strip tag from configured model name too (e.g.
// "nomic-embed-text:v1.5" → "nomic-embed-text") so both
// sides are compared at the same granularity.
let model_base = model.split(':').next().unwrap_or(model);
if !model_names.contains(&model_base) {
return OllamaCheck {
@@ -531,7 +517,6 @@ fn check_logging(config: Option<&Config>) -> LoggingCheck {
}
}
/// Format and print doctor results to console.
pub fn print_doctor_results(result: &DoctorResult) {
println!("\nlore doctor\n");

View File

@@ -1,5 +1,3 @@
//! Embed command: generate vector embeddings for documents via Ollama.
use console::style;
use serde::Serialize;
@@ -10,7 +8,6 @@ use crate::core::paths::get_db_path;
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
use crate::embedding::pipeline::embed_documents;
/// Result of the embed command.
#[derive(Debug, Default, Serialize)]
pub struct EmbedCommandResult {
pub embedded: usize,
@@ -18,9 +15,6 @@ pub struct EmbedCommandResult {
pub skipped: usize,
}
/// Run the embed command.
///
/// `progress_callback` reports `(processed, total)` as documents are embedded.
pub async fn run_embed(
config: &Config,
full: bool,
@@ -30,7 +24,6 @@ pub async fn run_embed(
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
// Build Ollama config from user settings
let ollama_config = OllamaConfig {
base_url: config.embedding.base_url.clone(),
model: config.embedding.model.clone(),
@@ -38,13 +31,9 @@ pub async fn run_embed(
};
let client = OllamaClient::new(ollama_config);
// Health check — fail fast if Ollama is down or model missing
client.health_check().await?;
if full {
// Clear ALL embeddings and metadata atomically for a complete re-embed.
// Wrapped in a transaction so a crash between the two DELETEs can't
// leave orphaned data.
conn.execute_batch(
"BEGIN;
DELETE FROM embedding_metadata;
@@ -52,7 +41,6 @@ pub async fn run_embed(
COMMIT;",
)?;
} else if retry_failed {
// Clear errors so they become pending again
conn.execute(
"UPDATE embedding_metadata SET last_error = NULL, attempt_count = 0
WHERE last_error IS NOT NULL",
@@ -70,7 +58,6 @@ pub async fn run_embed(
})
}
/// Print human-readable output.
pub fn print_embed(result: &EmbedCommandResult) {
println!("{} Embedding complete", style("done").green().bold(),);
println!(" Embedded: {}", result.embedded);
@@ -82,14 +69,12 @@ pub fn print_embed(result: &EmbedCommandResult) {
}
}
/// JSON output.
#[derive(Serialize)]
struct EmbedJsonOutput<'a> {
ok: bool,
data: &'a EmbedCommandResult,
}
/// Print JSON robot-mode output.
pub fn print_embed_json(result: &EmbedCommandResult) {
let output = EmbedJsonOutput {
ok: true,

View File

@@ -1,5 +1,3 @@
//! Generate searchable documents from ingested GitLab data.
use console::style;
use rusqlite::Connection;
use serde::Serialize;
@@ -14,7 +12,6 @@ use crate::documents::{SourceType, regenerate_dirty_documents};
const FULL_MODE_CHUNK_SIZE: i64 = 2000;
/// Result of a generate-docs run.
#[derive(Debug, Default)]
pub struct GenerateDocsResult {
pub regenerated: usize,
@@ -24,12 +21,6 @@ pub struct GenerateDocsResult {
pub full_mode: bool,
}
/// Run the generate-docs pipeline.
///
/// Default mode: process only existing dirty_sources entries.
/// Full mode: seed dirty_sources with ALL entities, then drain.
///
/// `progress_callback` reports `(processed, estimated_total)` as documents are generated.
pub fn run_generate_docs(
config: &Config,
full: bool,
@@ -56,7 +47,6 @@ pub fn run_generate_docs(
result.errored = regen.errored;
if full {
// Optimize FTS index after bulk rebuild
let _ = conn.execute(
"INSERT INTO documents_fts(documents_fts) VALUES('optimize')",
[],
@@ -67,7 +57,6 @@ pub fn run_generate_docs(
Ok(result)
}
/// Seed dirty_sources with all entities of the given type using keyset pagination.
fn seed_dirty(
conn: &Connection,
source_type: SourceType,
@@ -113,7 +102,6 @@ fn seed_dirty(
break;
}
// Advance keyset cursor to the max id within the chunk window
let max_id: i64 = conn.query_row(
&format!(
"SELECT MAX(id) FROM (SELECT id FROM {table} WHERE id > ?1 ORDER BY id LIMIT ?2)",
@@ -136,7 +124,6 @@ fn seed_dirty(
Ok(total_seeded)
}
/// Print human-readable output.
pub fn print_generate_docs(result: &GenerateDocsResult) {
let mode = if result.full_mode {
"full"
@@ -159,7 +146,6 @@ pub fn print_generate_docs(result: &GenerateDocsResult) {
}
}
/// JSON output structures.
#[derive(Serialize)]
struct GenerateDocsJsonOutput {
ok: bool,
@@ -176,7 +162,6 @@ struct GenerateDocsJsonData {
errored: usize,
}
/// Print JSON robot-mode output.
pub fn print_generate_docs_json(result: &GenerateDocsResult) {
let output = GenerateDocsJsonOutput {
ok: true,

View File

@@ -1,5 +1,3 @@
//! Ingest command - fetch data from GitLab.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -22,17 +20,14 @@ use crate::ingestion::{
ingest_project_merge_requests_with_progress,
};
/// Result of ingest command for display.
#[derive(Default)]
pub struct IngestResult {
pub resource_type: String,
pub projects_synced: usize,
// Issue-specific fields
pub issues_fetched: usize,
pub issues_upserted: usize,
pub issues_synced_discussions: usize,
pub issues_skipped_discussion_sync: usize,
// MR-specific fields
pub mrs_fetched: usize,
pub mrs_upserted: usize,
pub mrs_synced_discussions: usize,
@@ -40,17 +35,13 @@ pub struct IngestResult {
pub assignees_linked: usize,
pub reviewers_linked: usize,
pub diffnotes_count: usize,
// Shared fields
pub labels_created: usize,
pub discussions_fetched: usize,
pub notes_upserted: usize,
// Resource events
pub resource_events_fetched: usize,
pub resource_events_failed: usize,
}
/// Outcome of ingesting a single project, used to aggregate results
/// from concurrent project processing.
enum ProjectIngestOutcome {
Issues {
path: String,
@@ -62,24 +53,14 @@ enum ProjectIngestOutcome {
},
}
/// Controls what interactive UI elements `run_ingest` displays.
///
/// Separates progress indicators (spinners, bars) from text output (headers,
/// per-project summaries) so callers like `sync` can show progress without
/// duplicating summary text.
#[derive(Debug, Clone, Copy)]
pub struct IngestDisplay {
/// Show animated spinners and progress bars.
pub show_progress: bool,
/// Show the per-project spinner. When called from `sync`, the stage
/// spinner already covers this, so a second spinner causes flashing.
pub show_spinner: bool,
/// Show text headers ("Ingesting...") and per-project summary lines.
pub show_text: bool,
}
impl IngestDisplay {
/// Interactive mode: everything visible.
pub fn interactive() -> Self {
Self {
show_progress: true,
@@ -88,7 +69,6 @@ impl IngestDisplay {
}
}
/// Robot/JSON mode: everything hidden.
pub fn silent() -> Self {
Self {
show_progress: false,
@@ -97,8 +77,6 @@ impl IngestDisplay {
}
}
/// Progress bars only, no spinner or text (used by sync which provides its
/// own stage spinner).
pub fn progress_only() -> Self {
Self {
show_progress: true,
@@ -108,10 +86,6 @@ impl IngestDisplay {
}
}
/// Run the ingest command.
///
/// `stage_bar` is an optional `ProgressBar` (typically from sync's stage spinner)
/// that will be updated with aggregate progress across all projects.
pub async fn run_ingest(
config: &Config,
resource_type: &str,
@@ -138,7 +112,6 @@ pub async fn run_ingest(
.await
}
/// Inner implementation of run_ingest, instrumented with a root span.
async fn run_ingest_inner(
config: &Config,
resource_type: &str,
@@ -148,7 +121,6 @@ async fn run_ingest_inner(
display: IngestDisplay,
stage_bar: Option<ProgressBar>,
) -> Result<IngestResult> {
// Validate resource type early
if resource_type != "issues" && resource_type != "mrs" {
return Err(LoreError::Other(format!(
"Invalid resource type '{}'. Valid types: issues, mrs",
@@ -156,11 +128,9 @@ async fn run_ingest_inner(
)));
}
// Get database path and create connection
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
// Acquire single-flight lock
let lock_conn = create_connection(&db_path)?;
let mut lock = AppLock::new(
lock_conn,
@@ -172,23 +142,19 @@ async fn run_ingest_inner(
);
lock.acquire(force)?;
// Get token from environment
let token =
std::env::var(&config.gitlab.token_env_var).map_err(|_| LoreError::TokenNotSet {
env_var: config.gitlab.token_env_var.clone(),
})?;
// Create GitLab client
let client = GitLabClient::new(
&config.gitlab.base_url,
&token,
Some(config.sync.requests_per_second),
);
// Get projects to sync
let projects = get_projects_to_sync(&conn, &config.projects, project_filter)?;
// If --full flag is set, reset sync cursors and discussion watermarks for a complete re-fetch
if full {
if display.show_text {
println!(
@@ -198,20 +164,17 @@ async fn run_ingest_inner(
}
for (local_project_id, _, path) in &projects {
if resource_type == "issues" {
// Reset issue discussion and resource event watermarks so everything gets re-synced
conn.execute(
"UPDATE issues SET discussions_synced_for_updated_at = NULL, resource_events_synced_for_updated_at = NULL WHERE project_id = ?",
[*local_project_id],
)?;
} else if resource_type == "mrs" {
// Reset MR discussion and resource event watermarks
conn.execute(
"UPDATE merge_requests SET discussions_synced_for_updated_at = NULL, resource_events_synced_for_updated_at = NULL WHERE project_id = ?",
[*local_project_id],
)?;
}
// Then reset sync cursor
conn.execute(
"DELETE FROM sync_cursors WHERE project_id = ? AND resource_type = ?",
(*local_project_id, resource_type),
@@ -248,12 +211,9 @@ async fn run_ingest_inner(
println!();
}
// Process projects concurrently. Each project gets its own DB connection
// while sharing the rate limiter through the cloned GitLabClient.
let concurrency = config.sync.primary_concurrency as usize;
let resource_type_owned = resource_type.to_string();
// Aggregate counters for stage_bar updates (shared across concurrent projects)
let agg_fetched = Arc::new(AtomicUsize::new(0));
let agg_discussions = Arc::new(AtomicUsize::new(0));
let agg_disc_total = Arc::new(AtomicUsize::new(0));
@@ -328,7 +288,6 @@ async fn run_ingest_inner(
} else {
Box::new(move |event: ProgressEvent| match event {
ProgressEvent::IssuesFetchStarted | ProgressEvent::MrsFetchStarted => {
// Spinner already showing fetch message
}
ProgressEvent::IssuesFetchComplete { total } | ProgressEvent::MrsFetchComplete { total } => {
let agg = agg_fetched_clone.fetch_add(total, Ordering::Relaxed) + total;
@@ -410,6 +369,20 @@ async fn run_ingest_inner(
ProgressEvent::ResourceEventsFetchComplete { .. } => {
disc_bar_clone.finish_and_clear();
}
ProgressEvent::ClosesIssuesFetchStarted { total } => {
disc_bar_clone.reset();
disc_bar_clone.set_length(total as u64);
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
stage_bar_clone.set_message(
"Fetching closes-issues references...".to_string()
);
}
ProgressEvent::ClosesIssueFetched { current, total: _ } => {
disc_bar_clone.set_position(current as u64);
}
ProgressEvent::ClosesIssuesFetchComplete { .. } => {
disc_bar_clone.finish_and_clear();
}
})
};
@@ -453,9 +426,6 @@ async fn run_ingest_inner(
.collect()
.await;
// Aggregate results and print per-project summaries.
// Process all successes first, then return the first error (if any)
// so that successful project summaries are always printed.
let mut first_error: Option<LoreError> = None;
for project_result in project_results {
match project_result {
@@ -510,21 +480,17 @@ async fn run_ingest_inner(
return Err(e);
}
// Lock is released on drop
Ok(total)
}
/// Get projects to sync from database, optionally filtered.
fn get_projects_to_sync(
conn: &Connection,
configured_projects: &[crate::core::config::ProjectConfig],
filter: Option<&str>,
) -> Result<Vec<(i64, i64, String)>> {
// If a filter is provided, resolve it to a specific project
if let Some(filter_str) = filter {
let project_id = resolve_project(conn, filter_str)?;
// Verify the resolved project is in our config
let row: Option<(i64, String)> = conn
.query_row(
"SELECT gitlab_project_id, path_with_namespace FROM projects WHERE id = ?1",
@@ -534,7 +500,6 @@ fn get_projects_to_sync(
.ok();
if let Some((gitlab_id, path)) = row {
// Confirm it's a configured project
if configured_projects.iter().any(|p| p.path == path) {
return Ok(vec![(project_id, gitlab_id, path)]);
}
@@ -550,7 +515,6 @@ fn get_projects_to_sync(
)));
}
// No filter: return all configured projects
let mut projects = Vec::new();
for project_config in configured_projects {
let result: Option<(i64, i64)> = conn
@@ -569,7 +533,6 @@ fn get_projects_to_sync(
Ok(projects)
}
/// Print summary for a single project (issues).
fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
let labels_str = if result.labels_created > 0 {
format!(", {} new labels", result.labels_created)
@@ -599,7 +562,6 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
}
}
/// Print summary for a single project (merge requests).
fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
let labels_str = if result.labels_created > 0 {
format!(", {} new labels", result.labels_created)
@@ -647,7 +609,6 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
}
}
/// JSON output structures for robot mode.
#[derive(Serialize)]
struct IngestJsonOutput {
ok: bool,
@@ -688,7 +649,6 @@ struct IngestMrStats {
diffnotes_count: usize,
}
/// Print final summary as JSON (robot mode).
pub fn print_ingest_summary_json(result: &IngestResult) {
let (issues, merge_requests) = if result.resource_type == "issues" {
(
@@ -733,7 +693,6 @@ pub fn print_ingest_summary_json(result: &IngestResult) {
println!("{}", serde_json::to_string(&output).unwrap());
}
/// Print final summary.
pub fn print_ingest_summary(result: &IngestResult) {
println!();

View File

@@ -1,5 +1,3 @@
//! Init command - initialize configuration and database.
use std::fs;
use crate::core::config::{MinimalConfig, MinimalGitLabConfig, ProjectConfig};
@@ -8,21 +6,18 @@ use crate::core::error::{LoreError, Result};
use crate::core::paths::{get_config_path, get_data_dir};
use crate::gitlab::{GitLabClient, GitLabProject};
/// Input data for init command.
pub struct InitInputs {
pub gitlab_url: String,
pub token_env_var: String,
pub project_paths: Vec<String>,
}
/// Options for init command.
pub struct InitOptions {
pub config_path: Option<String>,
pub force: bool,
pub non_interactive: bool,
}
/// Result of successful init.
pub struct InitResult {
pub config_path: String,
pub data_dir: String,
@@ -40,12 +35,10 @@ pub struct ProjectInfo {
pub name: String,
}
/// Run the init command programmatically.
pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitResult> {
let config_path = get_config_path(options.config_path.as_deref());
let data_dir = get_data_dir();
// 1. Check if config exists (force takes precedence over non_interactive)
if config_path.exists() && !options.force {
if options.non_interactive {
return Err(LoreError::Other(format!(
@@ -59,7 +52,6 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitRe
));
}
// 2. Validate GitLab URL format
if url::Url::parse(&inputs.gitlab_url).is_err() {
return Err(LoreError::Other(format!(
"Invalid GitLab URL: {}",
@@ -67,12 +59,10 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitRe
)));
}
// 3. Check token is set in environment
let token = std::env::var(&inputs.token_env_var).map_err(|_| LoreError::TokenNotSet {
env_var: inputs.token_env_var.clone(),
})?;
// 4. Create GitLab client and test authentication
let client = GitLabClient::new(&inputs.gitlab_url, &token, None);
let gitlab_user = client.get_current_user().await.map_err(|e| {
@@ -88,7 +78,6 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitRe
name: gitlab_user.name,
};
// 5. Validate each project path
let mut validated_projects: Vec<(ProjectInfo, GitLabProject)> = Vec::new();
for project_path in &inputs.project_paths {
@@ -115,14 +104,10 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitRe
));
}
// 6. All validations passed - now write config and setup DB
// Create config directory if needed
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
// Write minimal config (rely on serde defaults)
let config = MinimalConfig {
gitlab: MinimalGitLabConfig {
base_url: inputs.gitlab_url,
@@ -138,16 +123,13 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitRe
let config_json = serde_json::to_string_pretty(&config)?;
fs::write(&config_path, format!("{config_json}\n"))?;
// 7. Create data directory and initialize database
fs::create_dir_all(&data_dir)?;
let db_path = data_dir.join("lore.db");
let conn = create_connection(&db_path)?;
// Run embedded migrations
run_migrations(&conn)?;
// 8. Insert validated projects
for (_, gitlab_project) in &validated_projects {
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace, default_branch, web_url)

View File

@@ -1,5 +1,3 @@
//! List command - display issues/MRs from local database.
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
use rusqlite::Connection;
use serde::Serialize;
@@ -11,7 +9,6 @@ use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::{ms_to_iso, now_ms, parse_since};
/// Apply foreground color to a Cell only if colors are enabled.
fn colored_cell(content: impl std::fmt::Display, color: Color) -> Cell {
let cell = Cell::new(content);
if console::colors_enabled() {
@@ -21,7 +18,6 @@ fn colored_cell(content: impl std::fmt::Display, color: Color) -> Cell {
}
}
/// Issue row for display.
#[derive(Debug, Serialize)]
pub struct IssueListRow {
pub iid: i64,
@@ -39,7 +35,6 @@ pub struct IssueListRow {
pub unresolved_count: i64,
}
/// Serializable version for JSON output.
#[derive(Serialize)]
pub struct IssueListRowJson {
pub iid: i64,
@@ -76,14 +71,12 @@ impl From<&IssueListRow> for IssueListRowJson {
}
}
/// Result of list query.
#[derive(Serialize)]
pub struct ListResult {
pub issues: Vec<IssueListRow>,
pub total_count: usize,
}
/// JSON output structure.
#[derive(Serialize)]
pub struct ListResultJson {
pub issues: Vec<IssueListRowJson>,
@@ -101,7 +94,6 @@ impl From<&ListResult> for ListResultJson {
}
}
/// MR row for display.
#[derive(Debug, Serialize)]
pub struct MrListRow {
pub iid: i64,
@@ -123,7 +115,6 @@ pub struct MrListRow {
pub unresolved_count: i64,
}
/// Serializable version for JSON output.
#[derive(Serialize)]
pub struct MrListRowJson {
pub iid: i64,
@@ -168,14 +159,12 @@ impl From<&MrListRow> for MrListRowJson {
}
}
/// Result of MR list query.
#[derive(Serialize)]
pub struct MrListResult {
pub mrs: Vec<MrListRow>,
pub total_count: usize,
}
/// JSON output structure for MRs.
#[derive(Serialize)]
pub struct MrListResultJson {
pub mrs: Vec<MrListRowJson>,
@@ -193,7 +182,6 @@ impl From<&MrListResult> for MrListResultJson {
}
}
/// Filter options for issue list query.
pub struct ListFilters<'a> {
pub limit: usize,
pub project: Option<&'a str>,
@@ -209,7 +197,6 @@ pub struct ListFilters<'a> {
pub order: &'a str,
}
/// Filter options for MR list query.
pub struct MrListFilters<'a> {
pub limit: usize,
pub project: Option<&'a str>,
@@ -227,7 +214,6 @@ pub struct MrListFilters<'a> {
pub order: &'a str,
}
/// Run the list issues command.
pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResult> {
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
@@ -236,9 +222,7 @@ pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResu
Ok(result)
}
/// Query issues from database with enriched data.
fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult> {
// Build WHERE clause
let mut where_clauses = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
@@ -255,14 +239,12 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
params.push(Box::new(state.to_string()));
}
// Handle author filter (strip leading @ if present)
if let Some(author) = filters.author {
let username = author.strip_prefix('@').unwrap_or(author);
where_clauses.push("i.author_username = ?");
params.push(Box::new(username.to_string()));
}
// Handle assignee filter (strip leading @ if present)
if let Some(assignee) = filters.assignee {
let username = assignee.strip_prefix('@').unwrap_or(assignee);
where_clauses.push(
@@ -272,7 +254,6 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
params.push(Box::new(username.to_string()));
}
// Handle since filter
if let Some(since_str) = filters.since {
let cutoff_ms = parse_since(since_str).ok_or_else(|| {
LoreError::Other(format!(
@@ -284,7 +265,6 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
params.push(Box::new(cutoff_ms));
}
// Handle label filters (AND logic - all labels must be present)
if let Some(labels) = filters.labels {
for label in labels {
where_clauses.push(
@@ -296,19 +276,16 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
}
}
// Handle milestone filter
if let Some(milestone) = filters.milestone {
where_clauses.push("i.milestone_title = ?");
params.push(Box::new(milestone.to_string()));
}
// Handle due_before filter
if let Some(due_before) = filters.due_before {
where_clauses.push("i.due_date IS NOT NULL AND i.due_date <= ?");
params.push(Box::new(due_before.to_string()));
}
// Handle has_due_date filter
if filters.has_due_date {
where_clauses.push("i.due_date IS NOT NULL");
}
@@ -319,7 +296,6 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
format!("WHERE {}", where_clauses.join(" AND "))
};
// Get total count
let count_sql = format!(
"SELECT COUNT(*) FROM issues i
JOIN projects p ON i.project_id = p.id
@@ -330,11 +306,10 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
let total_count = total_count as usize;
// Build ORDER BY
let sort_column = match filters.sort {
"created" => "i.created_at",
"iid" => "i.iid",
_ => "i.updated_at", // default
_ => "i.updated_at",
};
let order = if filters.order == "asc" {
"ASC"
@@ -342,7 +317,6 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
"DESC"
};
// Get issues with enriched data
let query_sql = format!(
"SELECT
i.iid,
@@ -416,7 +390,6 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
})
}
/// Run the list MRs command.
pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result<MrListResult> {
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
@@ -425,9 +398,7 @@ pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result<MrListRes
Ok(result)
}
/// Query MRs from database with enriched data.
fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult> {
// Build WHERE clause
let mut where_clauses = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
@@ -444,14 +415,12 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
params.push(Box::new(state.to_string()));
}
// Handle author filter (strip leading @ if present)
if let Some(author) = filters.author {
let username = author.strip_prefix('@').unwrap_or(author);
where_clauses.push("m.author_username = ?");
params.push(Box::new(username.to_string()));
}
// Handle assignee filter (strip leading @ if present)
if let Some(assignee) = filters.assignee {
let username = assignee.strip_prefix('@').unwrap_or(assignee);
where_clauses.push(
@@ -461,7 +430,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
params.push(Box::new(username.to_string()));
}
// Handle reviewer filter (strip leading @ if present)
if let Some(reviewer) = filters.reviewer {
let username = reviewer.strip_prefix('@').unwrap_or(reviewer);
where_clauses.push(
@@ -471,7 +439,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
params.push(Box::new(username.to_string()));
}
// Handle since filter
if let Some(since_str) = filters.since {
let cutoff_ms = parse_since(since_str).ok_or_else(|| {
LoreError::Other(format!(
@@ -483,7 +450,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
params.push(Box::new(cutoff_ms));
}
// Handle label filters (AND logic - all labels must be present)
if let Some(labels) = filters.labels {
for label in labels {
where_clauses.push(
@@ -495,20 +461,17 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
}
}
// Handle draft filter
if filters.draft {
where_clauses.push("m.draft = 1");
} else if filters.no_draft {
where_clauses.push("m.draft = 0");
}
// Handle target branch filter
if let Some(target_branch) = filters.target_branch {
where_clauses.push("m.target_branch = ?");
params.push(Box::new(target_branch.to_string()));
}
// Handle source branch filter
if let Some(source_branch) = filters.source_branch {
where_clauses.push("m.source_branch = ?");
params.push(Box::new(source_branch.to_string()));
@@ -520,7 +483,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
format!("WHERE {}", where_clauses.join(" AND "))
};
// Get total count
let count_sql = format!(
"SELECT COUNT(*) FROM merge_requests m
JOIN projects p ON m.project_id = p.id
@@ -531,11 +493,10 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
let total_count = total_count as usize;
// Build ORDER BY
let sort_column = match filters.sort {
"created" => "m.created_at",
"iid" => "m.iid",
_ => "m.updated_at", // default
_ => "m.updated_at",
};
let order = if filters.order == "asc" {
"ASC"
@@ -543,7 +504,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
"DESC"
};
// Get MRs with enriched data
let query_sql = format!(
"SELECT
m.iid,
@@ -631,7 +591,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
Ok(MrListResult { mrs, total_count })
}
/// Format relative time from ms epoch.
fn format_relative_time(ms_epoch: i64) -> String {
let now = now_ms();
let diff = now - ms_epoch;
@@ -662,7 +621,6 @@ fn format_relative_time(ms_epoch: i64) -> String {
}
}
/// Truncate string to max width with ellipsis.
fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
if s.chars().count() <= max_width {
s.to_string()
@@ -672,7 +630,6 @@ fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
}
}
/// Format labels for display: [bug, urgent +2]
fn format_labels(labels: &[String], max_shown: usize) -> String {
if labels.is_empty() {
return String::new();
@@ -688,7 +645,6 @@ fn format_labels(labels: &[String], max_shown: usize) -> String {
}
}
/// Format assignees for display: @user1, @user2 +1
fn format_assignees(assignees: &[String]) -> String {
if assignees.is_empty() {
return "-".to_string();
@@ -709,7 +665,6 @@ fn format_assignees(assignees: &[String]) -> String {
}
}
/// Format discussion count: "3/1!" (3 total, 1 unresolved)
fn format_discussions(total: i64, unresolved: i64) -> String {
if total == 0 {
return String::new();
@@ -722,13 +677,11 @@ fn format_discussions(total: i64, unresolved: i64) -> String {
}
}
/// Format branch info: target <- source
fn format_branches(target: &str, source: &str, max_width: usize) -> String {
let full = format!("{} <- {}", target, source);
truncate_with_ellipsis(&full, max_width)
}
/// Print issues list as a formatted table.
pub fn print_list_issues(result: &ListResult) {
if result.issues.is_empty() {
println!("No issues found.");
@@ -781,7 +734,6 @@ pub fn print_list_issues(result: &ListResult) {
println!("{table}");
}
/// Print issues list as JSON.
pub fn print_list_issues_json(result: &ListResult) {
let json_result = ListResultJson::from(result);
match serde_json::to_string_pretty(&json_result) {
@@ -790,7 +742,6 @@ pub fn print_list_issues_json(result: &ListResult) {
}
}
/// Open issue in browser. Returns the URL that was opened.
pub fn open_issue_in_browser(result: &ListResult) -> Option<String> {
let first_issue = result.issues.first()?;
let url = first_issue.web_url.as_ref()?;
@@ -807,7 +758,6 @@ pub fn open_issue_in_browser(result: &ListResult) -> Option<String> {
}
}
/// Print MRs list as a formatted table.
pub fn print_list_mrs(result: &MrListResult) {
if result.mrs.is_empty() {
println!("No merge requests found.");
@@ -869,7 +819,6 @@ pub fn print_list_mrs(result: &MrListResult) {
println!("{table}");
}
/// Print MRs list as JSON.
pub fn print_list_mrs_json(result: &MrListResult) {
let json_result = MrListResultJson::from(result);
match serde_json::to_string_pretty(&json_result) {
@@ -878,7 +827,6 @@ pub fn print_list_mrs_json(result: &MrListResult) {
}
}
/// Open MR in browser. Returns the URL that was opened.
pub fn open_mr_in_browser(result: &MrListResult) -> Option<String> {
let first_mr = result.mrs.first()?;
let url = first_mr.web_url.as_ref()?;
@@ -921,10 +869,10 @@ mod tests {
fn relative_time_formats_correctly() {
let now = now_ms();
assert_eq!(format_relative_time(now - 30_000), "just now"); // 30s ago
assert_eq!(format_relative_time(now - 120_000), "2 min ago"); // 2 min ago
assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago"); // 2 hours ago
assert_eq!(format_relative_time(now - 172_800_000), "2 days ago"); // 2 days ago
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");
}
#[test]

View File

@@ -1,5 +1,3 @@
//! CLI command implementations.
pub mod auth_test;
pub mod count;
pub mod doctor;

View File

@@ -1,5 +1,3 @@
//! Search command: lexical (FTS5) search with filter support and single-query hydration.
use console::style;
use serde::Serialize;
@@ -15,7 +13,6 @@ use crate::search::{
search_fts,
};
/// Display-ready search result with all fields hydrated.
#[derive(Debug, Serialize)]
pub struct SearchResultDisplay {
pub document_id: i64,
@@ -34,7 +31,6 @@ pub struct SearchResultDisplay {
pub explain: Option<ExplainData>,
}
/// Ranking explanation for --explain output.
#[derive(Debug, Serialize)]
pub struct ExplainData {
pub vector_rank: Option<usize>,
@@ -42,7 +38,6 @@ pub struct ExplainData {
pub rrf_score: f64,
}
/// Search response wrapper.
#[derive(Debug, Serialize)]
pub struct SearchResponse {
pub query: String,
@@ -52,7 +47,6 @@ pub struct SearchResponse {
pub warnings: Vec<String>,
}
/// Build SearchFilters from CLI args.
pub struct SearchCliFilters {
pub source_type: Option<String>,
pub author: Option<String>,
@@ -64,7 +58,6 @@ pub struct SearchCliFilters {
pub limit: usize,
}
/// Run a lexical search query.
pub fn run_search(
config: &Config,
query: &str,
@@ -75,7 +68,6 @@ pub fn run_search(
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
// Check if any documents exist
let doc_count: i64 = conn
.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))
.unwrap_or(0);
@@ -90,7 +82,6 @@ pub fn run_search(
});
}
// Build filters
let source_type = cli_filters
.source_type
.as_deref()
@@ -146,7 +137,6 @@ pub fn run_search(
limit: cli_filters.limit,
};
// Adaptive recall: wider initial fetch when filters applied
let requested = filters.clamp_limit();
let top_k = if filters.has_any_filter() {
(requested * 50).clamp(200, 1500)
@@ -154,24 +144,20 @@ pub fn run_search(
(requested * 10).clamp(50, 1500)
};
// FTS search
let fts_results = search_fts(&conn, query, top_k, fts_mode)?;
let fts_tuples: Vec<(i64, f64)> = fts_results
.iter()
.map(|r| (r.document_id, r.bm25_score))
.collect();
// Build snippet map before ranking
let snippet_map: std::collections::HashMap<i64, String> = fts_results
.iter()
.map(|r| (r.document_id, r.snippet.clone()))
.collect();
// RRF ranking (single-list for lexical mode)
let ranked = rank_rrf(&[], &fts_tuples);
let ranked_ids: Vec<i64> = ranked.iter().map(|r| r.document_id).collect();
// Apply post-retrieval filters
let filtered_ids = apply_filters(&conn, &ranked_ids, &filters)?;
if filtered_ids.is_empty() {
@@ -184,10 +170,8 @@ pub fn run_search(
});
}
// Hydrate results in single round-trip
let hydrated = hydrate_results(&conn, &filtered_ids)?;
// Build display results preserving filter order
let rrf_map: std::collections::HashMap<i64, &crate::search::RrfResult> =
ranked.iter().map(|r| (r.document_id, r)).collect();
@@ -233,7 +217,6 @@ pub fn run_search(
})
}
/// Raw row from hydration query.
struct HydratedRow {
document_id: i64,
source_type: String,
@@ -248,10 +231,6 @@ struct HydratedRow {
paths: Vec<String>,
}
/// Hydrate document IDs into full display rows in a single query.
///
/// Uses json_each() to pass ranked IDs and preserve ordering via ORDER BY j.key.
/// Labels and paths fetched via correlated json_group_array subqueries.
fn hydrate_results(conn: &rusqlite::Connection, document_ids: &[i64]) -> Result<Vec<HydratedRow>> {
if document_ids.is_empty() {
return Ok(Vec::new());
@@ -299,7 +278,6 @@ fn hydrate_results(conn: &rusqlite::Connection, document_ids: &[i64]) -> Result<
Ok(rows)
}
/// Parse a JSON array string into a Vec<String>, filtering out null/empty.
fn parse_json_array(json: &str) -> Vec<String> {
serde_json::from_str::<Vec<serde_json::Value>>(json)
.unwrap_or_default()
@@ -309,7 +287,6 @@ fn parse_json_array(json: &str) -> Vec<String> {
.collect()
}
/// Print human-readable search results.
pub fn print_search_results(response: &SearchResponse) {
if !response.warnings.is_empty() {
for w in &response.warnings {
@@ -364,7 +341,6 @@ pub fn print_search_results(response: &SearchResponse) {
println!(" Labels: {}", result.labels.join(", "));
}
// Strip HTML tags from snippet for terminal display
let clean_snippet = result.snippet.replace("<mark>", "").replace("</mark>", "");
println!(" {}", style(clean_snippet).dim());
@@ -384,7 +360,6 @@ pub fn print_search_results(response: &SearchResponse) {
}
}
/// JSON output structures.
#[derive(Serialize)]
struct SearchJsonOutput<'a> {
ok: bool,
@@ -397,7 +372,6 @@ struct SearchMeta {
elapsed_ms: u64,
}
/// Print JSON robot-mode output.
pub fn print_search_results_json(response: &SearchResponse, elapsed_ms: u64) {
let output = SearchJsonOutput {
ok: true,

View File

@@ -1,5 +1,3 @@
//! Show command - display detailed entity information from local database.
use console::style;
use rusqlite::Connection;
use serde::Serialize;
@@ -11,7 +9,6 @@ use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::ms_to_iso;
/// Merge request metadata for display.
#[derive(Debug, Serialize)]
pub struct MrDetail {
pub id: i64,
@@ -35,14 +32,12 @@ pub struct MrDetail {
pub discussions: Vec<MrDiscussionDetail>,
}
/// MR discussion detail for display.
#[derive(Debug, Serialize)]
pub struct MrDiscussionDetail {
pub notes: Vec<MrNoteDetail>,
pub individual_note: bool,
}
/// MR note detail for display (includes DiffNote position).
#[derive(Debug, Serialize)]
pub struct MrNoteDetail {
pub author_username: String,
@@ -52,7 +47,6 @@ pub struct MrNoteDetail {
pub position: Option<DiffNotePosition>,
}
/// DiffNote position context for display.
#[derive(Debug, Clone, Serialize)]
pub struct DiffNotePosition {
pub old_path: Option<String>,
@@ -62,7 +56,6 @@ pub struct DiffNotePosition {
pub position_type: Option<String>,
}
/// Issue metadata for display.
#[derive(Debug, Serialize)]
pub struct IssueDetail {
pub id: i64,
@@ -79,14 +72,12 @@ pub struct IssueDetail {
pub discussions: Vec<DiscussionDetail>,
}
/// Discussion detail for display.
#[derive(Debug, Serialize)]
pub struct DiscussionDetail {
pub notes: Vec<NoteDetail>,
pub individual_note: bool,
}
/// Note detail for display.
#[derive(Debug, Serialize)]
pub struct NoteDetail {
pub author_username: String,
@@ -95,7 +86,6 @@ pub struct NoteDetail {
pub is_system: bool,
}
/// Run the show issue command.
pub fn run_show_issue(
config: &Config,
iid: i64,
@@ -104,13 +94,10 @@ pub fn run_show_issue(
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
// Find the issue
let issue = find_issue(&conn, iid, project_filter)?;
// Load labels
let labels = get_issue_labels(&conn, issue.id)?;
// Load discussions with notes
let discussions = get_issue_discussions(&conn, issue.id)?;
Ok(IssueDetail {
@@ -129,7 +116,6 @@ pub fn run_show_issue(
})
}
/// Internal issue row from query.
struct IssueRow {
id: i64,
iid: i64,
@@ -143,7 +129,6 @@ struct IssueRow {
project_path: String,
}
/// Find issue by iid, optionally filtered by project.
fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow> {
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
Some(project) => {
@@ -201,7 +186,6 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
}
}
/// Get labels for an issue.
fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT l.name FROM labels l
@@ -217,9 +201,7 @@ fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
Ok(labels)
}
/// Get discussions with notes for an issue.
fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<DiscussionDetail>> {
// First get all discussions
let mut disc_stmt = conn.prepare(
"SELECT id, individual_note FROM discussions
WHERE issue_id = ?
@@ -233,7 +215,6 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
// Then get notes for each discussion
let mut note_stmt = conn.prepare(
"SELECT author_username, body, created_at, is_system
FROM notes
@@ -255,7 +236,6 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
// Filter out discussions with only system notes
let has_user_notes = notes.iter().any(|n| !n.is_system);
if has_user_notes || notes.is_empty() {
discussions.push(DiscussionDetail {
@@ -268,24 +248,18 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
Ok(discussions)
}
/// Run the show MR command.
pub fn run_show_mr(config: &Config, iid: i64, project_filter: Option<&str>) -> Result<MrDetail> {
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
// Find the MR
let mr = find_mr(&conn, iid, project_filter)?;
// Load labels
let labels = get_mr_labels(&conn, mr.id)?;
// Load assignees
let assignees = get_mr_assignees(&conn, mr.id)?;
// Load reviewers
let reviewers = get_mr_reviewers(&conn, mr.id)?;
// Load discussions with notes
let discussions = get_mr_discussions(&conn, mr.id)?;
Ok(MrDetail {
@@ -311,7 +285,6 @@ pub fn run_show_mr(config: &Config, iid: i64, project_filter: Option<&str>) -> R
})
}
/// Internal MR row from query.
struct MrRow {
id: i64,
iid: i64,
@@ -330,7 +303,6 @@ struct MrRow {
project_path: String,
}
/// Find MR by iid, optionally filtered by project.
fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<MrRow> {
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
Some(project) => {
@@ -398,7 +370,6 @@ fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<
}
}
/// Get labels for an MR.
fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT l.name FROM labels l
@@ -414,7 +385,6 @@ fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
Ok(labels)
}
/// Get assignees for an MR.
fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT username FROM mr_assignees
@@ -429,7 +399,6 @@ fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
Ok(assignees)
}
/// Get reviewers for an MR.
fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT username FROM mr_reviewers
@@ -444,9 +413,7 @@ fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
Ok(reviewers)
}
/// Get discussions with notes for an MR.
fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionDetail>> {
// First get all discussions
let mut disc_stmt = conn.prepare(
"SELECT id, individual_note FROM discussions
WHERE merge_request_id = ?
@@ -460,7 +427,6 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
// Then get notes for each discussion (with DiffNote position fields)
let mut note_stmt = conn.prepare(
"SELECT author_username, body, created_at, is_system,
position_old_path, position_new_path, position_old_line,
@@ -507,7 +473,6 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
// Filter out discussions with only system notes
let has_user_notes = notes.iter().any(|n| !n.is_system);
if has_user_notes || notes.is_empty() {
discussions.push(MrDiscussionDetail {
@@ -520,14 +485,11 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
Ok(discussions)
}
/// Format date from ms epoch.
fn format_date(ms: i64) -> String {
let iso = ms_to_iso(ms);
// Extract just the date part (YYYY-MM-DD)
iso.split('T').next().unwrap_or(&iso).to_string()
}
/// Truncate text with ellipsis (character-safe for UTF-8).
fn truncate(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
@@ -537,7 +499,6 @@ fn truncate(s: &str, max_len: usize) -> String {
}
}
/// Wrap text to width, with indent prefix on continuation lines.
fn wrap_text(text: &str, width: usize, indent: &str) -> String {
let mut result = String::new();
let mut current_line = String::new();
@@ -569,15 +530,12 @@ fn wrap_text(text: &str, width: usize, indent: &str) -> String {
result
}
/// Print issue detail.
pub fn print_show_issue(issue: &IssueDetail) {
// Header
let header = format!("Issue #{}: {}", issue.iid, issue.title);
println!("{}", style(&header).bold());
println!("{}", "".repeat(header.len().min(80)));
println!();
// Metadata
println!("Project: {}", style(&issue.project_path).cyan());
let state_styled = if issue.state == "opened" {
@@ -603,7 +561,6 @@ pub fn print_show_issue(issue: &IssueDetail) {
println!();
// Description
println!("{}", style("Description:").bold());
if let Some(desc) = &issue.description {
let truncated = truncate(desc, 500);
@@ -615,7 +572,6 @@ pub fn print_show_issue(issue: &IssueDetail) {
println!();
// Discussions
let user_discussions: Vec<&DiscussionDetail> = issue
.discussions
.iter()
@@ -636,7 +592,6 @@ pub fn print_show_issue(issue: &IssueDetail) {
discussion.notes.iter().filter(|n| !n.is_system).collect();
if let Some(first_note) = user_notes.first() {
// First note of discussion (not indented)
println!(
" {} ({}):",
style(format!("@{}", first_note.author_username)).cyan(),
@@ -646,7 +601,6 @@ pub fn print_show_issue(issue: &IssueDetail) {
println!(" {}", wrapped);
println!();
// Replies (indented)
for reply in user_notes.iter().skip(1) {
println!(
" {} ({}):",
@@ -662,16 +616,13 @@ pub fn print_show_issue(issue: &IssueDetail) {
}
}
/// Print MR detail.
pub fn print_show_mr(mr: &MrDetail) {
// Header with draft indicator
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!();
// Metadata
println!("Project: {}", style(&mr.project_path).cyan());
let state_styled = match mr.state.as_str() {
@@ -735,7 +686,6 @@ pub fn print_show_mr(mr: &MrDetail) {
println!();
// Description
println!("{}", style("Description:").bold());
if let Some(desc) = &mr.description {
let truncated = truncate(desc, 500);
@@ -747,7 +697,6 @@ pub fn print_show_mr(mr: &MrDetail) {
println!();
// Discussions
let user_discussions: Vec<&MrDiscussionDetail> = mr
.discussions
.iter()
@@ -768,12 +717,10 @@ pub fn print_show_mr(mr: &MrDetail) {
discussion.notes.iter().filter(|n| !n.is_system).collect();
if let Some(first_note) = user_notes.first() {
// Print DiffNote position context if present
if let Some(pos) = &first_note.position {
print_diff_position(pos);
}
// First note of discussion (not indented)
println!(
" {} ({}):",
style(format!("@{}", first_note.author_username)).cyan(),
@@ -783,7 +730,6 @@ pub fn print_show_mr(mr: &MrDetail) {
println!(" {}", wrapped);
println!();
// Replies (indented)
for reply in user_notes.iter().skip(1) {
println!(
" {} ({}):",
@@ -799,7 +745,6 @@ pub fn print_show_mr(mr: &MrDetail) {
}
}
/// Print DiffNote position context.
fn print_diff_position(pos: &DiffNotePosition) {
let file = pos.new_path.as_ref().or(pos.old_path.as_ref());
@@ -821,11 +766,6 @@ fn print_diff_position(pos: &DiffNotePosition) {
}
}
// ============================================================================
// JSON Output Structs (with ISO timestamps for machine consumption)
// ============================================================================
/// JSON output for issue detail.
#[derive(Serialize)]
pub struct IssueDetailJson {
pub id: i64,
@@ -842,14 +782,12 @@ pub struct IssueDetailJson {
pub discussions: Vec<DiscussionDetailJson>,
}
/// JSON output for discussion detail.
#[derive(Serialize)]
pub struct DiscussionDetailJson {
pub notes: Vec<NoteDetailJson>,
pub individual_note: bool,
}
/// JSON output for note detail.
#[derive(Serialize)]
pub struct NoteDetailJson {
pub author_username: String,
@@ -897,7 +835,6 @@ impl From<&NoteDetail> for NoteDetailJson {
}
}
/// JSON output for MR detail.
#[derive(Serialize)]
pub struct MrDetailJson {
pub id: i64,
@@ -921,14 +858,12 @@ pub struct MrDetailJson {
pub discussions: Vec<MrDiscussionDetailJson>,
}
/// JSON output for MR discussion detail.
#[derive(Serialize)]
pub struct MrDiscussionDetailJson {
pub notes: Vec<MrNoteDetailJson>,
pub individual_note: bool,
}
/// JSON output for MR note detail.
#[derive(Serialize)]
pub struct MrNoteDetailJson {
pub author_username: String,
@@ -985,7 +920,6 @@ impl From<&MrNoteDetail> for MrNoteDetailJson {
}
}
/// Print issue detail as JSON.
pub fn print_show_issue_json(issue: &IssueDetail) {
let json_result = IssueDetailJson::from(issue);
match serde_json::to_string_pretty(&json_result) {
@@ -994,7 +928,6 @@ pub fn print_show_issue_json(issue: &IssueDetail) {
}
}
/// Print MR detail as JSON.
pub fn print_show_mr_json(mr: &MrDetail) {
let json_result = MrDetailJson::from(mr);
match serde_json::to_string_pretty(&json_result) {
@@ -1030,7 +963,6 @@ mod tests {
#[test]
fn format_date_extracts_date_part() {
// 2024-01-15T00:00:00Z in milliseconds
let ms = 1705276800000;
let date = format_date(ms);
assert!(date.starts_with("2024-01-15"));

View File

@@ -1,5 +1,3 @@
//! Stats command: document counts, embedding coverage, queue status, integrity checks.
use console::style;
use rusqlite::Connection;
use serde::Serialize;
@@ -9,7 +7,6 @@ use crate::core::db::create_connection;
use crate::core::error::Result;
use crate::core::paths::get_db_path;
/// Result of the stats command.
#[derive(Debug, Default, Serialize)]
pub struct StatsResult {
pub documents: DocumentStats,
@@ -74,14 +71,12 @@ pub struct RepairResult {
pub stale_cleared: i64,
}
/// Run the stats command.
pub fn run_stats(config: &Config, check: bool, repair: bool) -> Result<StatsResult> {
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
let mut result = StatsResult::default();
// Document counts
result.documents.total = count_query(&conn, "SELECT COUNT(*) FROM documents")?;
result.documents.issues = count_query(
&conn,
@@ -100,7 +95,6 @@ pub fn run_stats(config: &Config, check: bool, repair: bool) -> Result<StatsResu
"SELECT COUNT(*) FROM documents WHERE is_truncated = 1",
)?;
// Embedding stats — skip gracefully if table doesn't exist (Gate A only)
if table_exists(&conn, "embedding_metadata") {
let embedded = count_query(
&conn,
@@ -119,10 +113,8 @@ pub fn run_stats(config: &Config, check: bool, repair: bool) -> Result<StatsResu
};
}
// FTS stats
result.fts.indexed = count_query(&conn, "SELECT COUNT(*) FROM documents_fts")?;
// Queue stats
result.queues.dirty_sources = count_query(
&conn,
"SELECT COUNT(*) FROM dirty_sources WHERE last_error IS NULL",
@@ -158,15 +150,12 @@ pub fn run_stats(config: &Config, check: bool, repair: bool) -> Result<StatsResu
)?;
}
// Integrity check
#[allow(clippy::field_reassign_with_default)]
if check {
let mut integrity = IntegrityResult::default();
// FTS/doc count mismatch
integrity.fts_doc_mismatch = result.fts.indexed != result.documents.total;
// Orphan embeddings (rowid/1000 should match a document ID)
if table_exists(&conn, "embeddings") {
integrity.orphan_embeddings = count_query(
&conn,
@@ -175,7 +164,6 @@ pub fn run_stats(config: &Config, check: bool, repair: bool) -> Result<StatsResu
)?;
}
// Stale metadata (document_hash != current content_hash)
if table_exists(&conn, "embedding_metadata") {
integrity.stale_metadata = count_query(
&conn,
@@ -185,7 +173,6 @@ pub fn run_stats(config: &Config, check: bool, repair: bool) -> Result<StatsResu
)?;
}
// Orphaned resource events (FK targets missing)
if table_exists(&conn, "resource_state_events") {
integrity.orphan_state_events = count_query(
&conn,
@@ -211,7 +198,6 @@ pub fn run_stats(config: &Config, check: bool, repair: bool) -> Result<StatsResu
)?;
}
// Queue health: stuck locks and max retry attempts
if table_exists(&conn, "pending_dependent_fetches") {
integrity.queue_stuck_locks = count_query(
&conn,
@@ -232,7 +218,6 @@ pub fn run_stats(config: &Config, check: bool, repair: bool) -> Result<StatsResu
&& integrity.stale_metadata == 0
&& orphan_events == 0;
// Repair
if repair {
let mut repair_result = RepairResult::default();
@@ -252,7 +237,6 @@ pub fn run_stats(config: &Config, check: bool, repair: bool) -> Result<StatsResu
)?;
repair_result.orphans_deleted = deleted as i64;
// Also clean orphaned vectors if vec0 table exists
if table_exists(&conn, "embeddings") {
let _ = conn.execute(
"DELETE FROM embeddings
@@ -299,7 +283,6 @@ fn table_exists(conn: &Connection, table: &str) -> bool {
> 0
}
/// Print human-readable stats.
pub fn print_stats(result: &StatsResult) {
println!("{}", style("Documents").cyan().bold());
println!(" Total: {}", result.documents.total);
@@ -429,14 +412,12 @@ pub fn print_stats(result: &StatsResult) {
}
}
/// JSON output structures.
#[derive(Serialize)]
struct StatsJsonOutput {
ok: bool,
data: StatsResult,
}
/// Print JSON robot-mode output.
pub fn print_stats_json(result: &StatsResult) {
let output = StatsJsonOutput {
ok: true,

View File

@@ -1,10 +1,8 @@
//! Sync command: unified orchestrator for ingest -> generate-docs -> embed.
use console::style;
use indicatif::{ProgressBar, ProgressStyle};
use serde::Serialize;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tracing::Instrument;
use tracing::{info, warn};
@@ -16,7 +14,6 @@ use super::embed::run_embed;
use super::generate_docs::run_generate_docs;
use super::ingest::{IngestDisplay, run_ingest};
/// Options for the sync command.
#[derive(Debug, Default)]
pub struct SyncOptions {
pub full: bool,
@@ -27,7 +24,6 @@ pub struct SyncOptions {
pub robot_mode: bool,
}
/// Result of the sync command.
#[derive(Debug, Default, Serialize)]
pub struct SyncResult {
#[serde(skip)]
@@ -41,10 +37,6 @@ pub struct SyncResult {
pub documents_embedded: usize,
}
/// Create a styled spinner for a sync stage.
///
/// Uses `{prefix}` for the `[N/M]` stage label so callers can update `{msg}`
/// independently without losing the stage context.
fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> ProgressBar {
if robot_mode {
return ProgressBar::hidden();
@@ -61,11 +53,6 @@ fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> ProgressB
pb
}
/// Run the full sync pipeline: ingest -> generate-docs -> embed.
///
/// `run_id` is an optional correlation ID for log/metrics tracing.
/// When called from `handle_sync_cmd`, this should be the same ID
/// stored in the `sync_runs` table so logs and DB records correlate.
pub async fn run_sync(
config: &Config,
options: SyncOptions,
@@ -102,7 +89,6 @@ pub async fn run_sync(
};
let mut current_stage: u8 = 0;
// Stage 1: Ingest issues
current_stage += 1;
let spinner = stage_spinner(
current_stage,
@@ -127,7 +113,6 @@ pub async fn run_sync(
result.resource_events_failed += issues_result.resource_events_failed;
spinner.finish_and_clear();
// Stage 2: Ingest MRs
current_stage += 1;
let spinner = stage_spinner(
current_stage,
@@ -152,7 +137,6 @@ pub async fn run_sync(
result.resource_events_failed += mrs_result.resource_events_failed;
spinner.finish_and_clear();
// Stage 3: Generate documents (unless --no-docs)
if !options.no_docs {
current_stage += 1;
let spinner = stage_spinner(
@@ -163,7 +147,6 @@ pub async fn run_sync(
);
info!("Sync stage {current_stage}/{total_stages}: generating documents");
// Create a dedicated progress bar matching the ingest stage style
let docs_bar = if options.robot_mode {
ProgressBar::hidden()
} else {
@@ -186,8 +169,6 @@ pub async fn run_sync(
if !tick_started_clone.swap(true, Ordering::Relaxed) {
docs_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
}
// Update length every callback — the regenerator's estimated_total
// can grow if new dirty items are queued during processing.
docs_bar_clone.set_length(total as u64);
docs_bar_clone.set_position(processed as u64);
}
@@ -200,7 +181,6 @@ pub async fn run_sync(
info!("Sync: skipping document generation (--no-docs)");
}
// Stage 4: Embed documents (unless --no-embed)
if !options.no_embed {
current_stage += 1;
let spinner = stage_spinner(
@@ -211,7 +191,6 @@ pub async fn run_sync(
);
info!("Sync stage {current_stage}/{total_stages}: embedding documents");
// Create a dedicated progress bar matching the ingest stage style
let embed_bar = if options.robot_mode {
ProgressBar::hidden()
} else {
@@ -245,7 +224,6 @@ pub async fn run_sync(
spinner.finish_and_clear();
}
Err(e) => {
// Graceful degradation: Ollama down is a warning, not an error
embed_bar.finish_and_clear();
spinner.finish_and_clear();
if !options.robot_mode {
@@ -275,7 +253,6 @@ pub async fn run_sync(
.await
}
/// Print human-readable sync summary.
pub fn print_sync(
result: &SyncResult,
elapsed: std::time::Duration,
@@ -307,7 +284,6 @@ pub fn print_sync(
println!(" Documents embedded: {}", result.documents_embedded);
println!(" Elapsed: {:.1}s", elapsed.as_secs_f64());
// Print per-stage timing breakdown if metrics are available
if let Some(metrics) = metrics {
let stages = metrics.extract_timings();
if !stages.is_empty() {
@@ -316,7 +292,6 @@ pub fn print_sync(
}
}
/// Print per-stage timing breakdown for interactive users.
fn print_timing_summary(stages: &[StageTiming]) {
println!();
println!("{}", style("Stage timing:").dim());
@@ -327,7 +302,6 @@ fn print_timing_summary(stages: &[StageTiming]) {
}
}
/// Print a single stage timing line with indentation.
fn print_stage_line(stage: &StageTiming, depth: usize) {
let indent = " ".repeat(depth);
let name = if let Some(ref project) = stage.project {
@@ -367,7 +341,6 @@ fn print_stage_line(stage: &StageTiming, depth: usize) {
}
}
/// JSON output for sync.
#[derive(Serialize)]
struct SyncJsonOutput<'a> {
ok: bool,
@@ -383,7 +356,6 @@ struct SyncMeta {
stages: Vec<StageTiming>,
}
/// Print JSON robot-mode sync output with optional metrics.
pub fn print_sync_json(result: &SyncResult, elapsed_ms: u64, metrics: Option<&MetricsLayer>) {
let stages = metrics.map_or_else(Vec::new, MetricsLayer::extract_timings);
let output = SyncJsonOutput {

View File

@@ -1,5 +1,3 @@
//! Sync status command - display synchronization state from local database.
use console::style;
use rusqlite::Connection;
use serde::Serialize;
@@ -13,7 +11,6 @@ use crate::core::time::{format_full_datetime, ms_to_iso};
const RECENT_RUNS_LIMIT: usize = 10;
/// Sync run information.
#[derive(Debug)]
pub struct SyncRunInfo {
pub id: i64,
@@ -28,7 +25,6 @@ pub struct SyncRunInfo {
pub stages: Option<Vec<StageTiming>>,
}
/// Cursor position information.
#[derive(Debug)]
pub struct CursorInfo {
pub project_path: String,
@@ -37,7 +33,6 @@ pub struct CursorInfo {
pub tie_breaker_id: Option<i64>,
}
/// Data summary counts.
#[derive(Debug)]
pub struct DataSummary {
pub issue_count: i64,
@@ -47,7 +42,6 @@ pub struct DataSummary {
pub system_note_count: i64,
}
/// Complete sync status result.
#[derive(Debug)]
pub struct SyncStatusResult {
pub runs: Vec<SyncRunInfo>,
@@ -55,7 +49,6 @@ pub struct SyncStatusResult {
pub summary: DataSummary,
}
/// Run the sync-status command.
pub fn run_sync_status(config: &Config) -> Result<SyncStatusResult> {
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
@@ -71,7 +64,6 @@ pub fn run_sync_status(config: &Config) -> Result<SyncStatusResult> {
})
}
/// Get the most recent sync runs.
fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunInfo>> {
let mut stmt = conn.prepare(
"SELECT id, started_at, finished_at, status, command, error,
@@ -105,7 +97,6 @@ fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunIn
Ok(runs?)
}
/// Get cursor positions for all projects/resource types.
fn get_cursor_positions(conn: &Connection) -> Result<Vec<CursorInfo>> {
let mut stmt = conn.prepare(
"SELECT p.path_with_namespace, sc.resource_type, sc.updated_at_cursor, sc.tie_breaker_id
@@ -128,7 +119,6 @@ fn get_cursor_positions(conn: &Connection) -> Result<Vec<CursorInfo>> {
Ok(cursors?)
}
/// Get data summary counts.
fn get_data_summary(conn: &Connection) -> Result<DataSummary> {
let issue_count: i64 = conn
.query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))
@@ -159,7 +149,6 @@ fn get_data_summary(conn: &Connection) -> Result<DataSummary> {
})
}
/// Format duration in milliseconds to human-readable string.
fn format_duration(ms: i64) -> String {
let seconds = ms / 1000;
let minutes = seconds / 60;
@@ -176,7 +165,6 @@ fn format_duration(ms: i64) -> String {
}
}
/// Format number with thousands separators.
fn format_number(n: i64) -> String {
let is_negative = n < 0;
let abs_n = n.unsigned_abs();
@@ -198,10 +186,6 @@ fn format_number(n: i64) -> String {
result
}
// ============================================================================
// JSON output structures for robot mode
// ============================================================================
#[derive(Serialize)]
struct SyncStatusJsonOutput {
ok: bool,
@@ -254,7 +238,6 @@ struct SummaryJsonInfo {
system_notes: i64,
}
/// Print sync status as JSON (robot mode).
pub fn print_sync_status_json(result: &SyncStatusResult) {
let runs = result
.runs
@@ -306,13 +289,7 @@ pub fn print_sync_status_json(result: &SyncStatusResult) {
println!("{}", serde_json::to_string(&output).unwrap());
}
// ============================================================================
// Human-readable output
// ============================================================================
/// Print sync status result.
pub fn print_sync_status(result: &SyncStatusResult) {
// Recent Runs section
println!("{}", style("Recent Sync Runs").bold().underlined());
println!();
@@ -330,7 +307,6 @@ pub fn print_sync_status(result: &SyncStatusResult) {
println!();
// Cursor Positions section
println!("{}", style("Cursor Positions").bold().underlined());
println!();
@@ -361,7 +337,6 @@ pub fn print_sync_status(result: &SyncStatusResult) {
println!();
// Data Summary section
println!("{}", style("Data Summary").bold().underlined());
println!();
@@ -390,7 +365,6 @@ pub fn print_sync_status(result: &SyncStatusResult) {
);
}
/// Print a single run as a compact one-liner.
fn print_run_line(run: &SyncRunInfo) {
let status_styled = match run.status.as_str() {
"succeeded" => style(&run.status).green(),