diff --git a/src/cli/commands/auth_test.rs b/src/cli/commands/auth_test.rs index 5562875..751e357 100644 --- a/src/cli/commands/auth_test.rs +++ b/src/cli/commands/auth_test.rs @@ -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 { - // 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 }); } - // 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 { diff --git a/src/cli/commands/count.rs b/src/cli/commands/count.rs index 751d91e..668afcb 100644 --- a/src/cli/commands/count.rs +++ b/src/cli/commands/count.rs @@ -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, // For notes only - pub state_breakdown: Option, // For issues/MRs + pub system_count: Option, + pub state_breakdown: Option, } -/// State breakdown for issues or MRs. pub struct StateBreakdown { pub opened: i64, pub closed: i64, - pub merged: Option, // MRs only - pub locked: Option, // MRs only + pub merged: Option, + pub locked: Option, } -/// Run the count command. pub fn run_count(config: &Config, entity: &str, type_filter: Option<&str>) -> Result { 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 { let count: i64 = conn.query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))?; @@ -74,7 +68,6 @@ fn count_issues(conn: &Connection) -> Result { }) } -/// Count merge requests with state breakdown. fn count_mrs(conn: &Connection) -> Result { 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 { }) } -/// Count discussions with optional noteable type filter. fn count_discussions(conn: &Connection, type_filter: Option<&str>) -> Result { let (count, entity_name) = match type_filter { Some("issue") => { @@ -149,7 +141,6 @@ fn count_discussions(conn: &Connection, type_filter: Option<&str>) -> Result) -> Result { 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) -> Result String { let s = n.to_string(); let chars: Vec = 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, } -/// Run the event count query. pub fn run_count_events(config: &Config) -> Result { 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 { diff --git a/src/cli/commands/doctor.rs b/src/cli/commands/doctor.rs index 6b7e96c..a356a67 100644 --- a/src/cli/commands/doctor.rs +++ b/src/cli/commands/doctor.rs @@ -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, } -/// 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"); diff --git a/src/cli/commands/embed.rs b/src/cli/commands/embed.rs index e46f88f..9f043e4 100644 --- a/src/cli/commands/embed.rs +++ b/src/cli/commands/embed.rs @@ -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, diff --git a/src/cli/commands/generate_docs.rs b/src/cli/commands/generate_docs.rs index 64476f6..0132b5f 100644 --- a/src/cli/commands/generate_docs.rs +++ b/src/cli/commands/generate_docs.rs @@ -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, diff --git a/src/cli/commands/ingest.rs b/src/cli/commands/ingest.rs index 4108e33..1fcd11b 100644 --- a/src/cli/commands/ingest.rs +++ b/src/cli/commands/ingest.rs @@ -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, ) -> Result { - // 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 = 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> { - // 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!(); diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs index 3dfaaba..5c5a2d0 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -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, } -/// Options for init command. pub struct InitOptions { pub config_path: Option, 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 { 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 Result Result = Vec::new(); for project_path in &inputs.project_paths { @@ -115,14 +104,10 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result Result 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, pub total_count: usize, } -/// JSON output structure. #[derive(Serialize)] pub struct ListResultJson { pub issues: Vec, @@ -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, pub total_count: usize, } -/// JSON output structure for MRs. #[derive(Serialize)] pub struct MrListResultJson { pub mrs: Vec, @@ -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 { 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 Result { - // Build WHERE clause let mut where_clauses = Vec::new(); let mut params: Vec> = Vec::new(); @@ -255,14 +239,12 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result 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 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 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 } } - // 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 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 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 "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 }) } -/// Run the list MRs command. pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result { 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 Result { - // Build WHERE clause let mut where_clauses = Vec::new(); let mut params: Vec> = Vec::new(); @@ -444,14 +415,12 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result 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 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 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 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 } } - // 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 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 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 "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 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 { 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 { } } -/// 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 { 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] diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 8bb3735..bb0eb41 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -1,5 +1,3 @@ -//! CLI command implementations. - pub mod auth_test; pub mod count; pub mod doctor; diff --git a/src/cli/commands/search.rs b/src/cli/commands/search.rs index 63004d5..3f62701 100644 --- a/src/cli/commands/search.rs +++ b/src/cli/commands/search.rs @@ -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, } -/// Ranking explanation for --explain output. #[derive(Debug, Serialize)] pub struct ExplainData { pub vector_rank: Option, @@ -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, } -/// Build SearchFilters from CLI args. pub struct SearchCliFilters { pub source_type: Option, pub author: Option, @@ -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 = 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 = 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 = 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, } -/// 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> { 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, filtering out null/empty. fn parse_json_array(json: &str) -> Vec { serde_json::from_str::>(json) .unwrap_or_default() @@ -309,7 +287,6 @@ fn parse_json_array(json: &str) -> Vec { .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("", "").replace("", ""); 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, diff --git a/src/cli/commands/show.rs b/src/cli/commands/show.rs index 8855b95..28e23a4 100644 --- a/src/cli/commands/show.rs +++ b/src/cli/commands/show.rs @@ -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, } -/// MR discussion detail for display. #[derive(Debug, Serialize)] pub struct MrDiscussionDetail { pub notes: Vec, 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, } -/// DiffNote position context for display. #[derive(Debug, Clone, Serialize)] pub struct DiffNotePosition { pub old_path: Option, @@ -62,7 +56,6 @@ pub struct DiffNotePosition { pub position_type: Option, } -/// Issue metadata for display. #[derive(Debug, Serialize)] pub struct IssueDetail { pub id: i64, @@ -79,14 +72,12 @@ pub struct IssueDetail { pub discussions: Vec, } -/// Discussion detail for display. #[derive(Debug, Serialize)] pub struct DiscussionDetail { pub notes: Vec, 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 { let (sql, params): (&str, Vec>) = 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> { 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> { Ok(labels) } -/// Get discussions with notes for an issue. fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result> { - // 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, _>>()?; - // 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, _>>()?; - // 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) -> Result { 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 { let (sql, params): (&str, Vec>) = 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> { 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> { Ok(labels) } -/// Get assignees for an MR. fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT username FROM mr_assignees @@ -429,7 +399,6 @@ fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result> { Ok(assignees) } -/// Get reviewers for an MR. fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT username FROM mr_reviewers @@ -444,9 +413,7 @@ fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result> { Ok(reviewers) } -/// Get discussions with notes for an MR. fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result> { - // 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, _>>()?; - // 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, _>>()?; - // 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 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, } -/// JSON output for discussion detail. #[derive(Serialize)] pub struct DiscussionDetailJson { pub notes: Vec, 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, } -/// JSON output for MR discussion detail. #[derive(Serialize)] pub struct MrDiscussionDetailJson { pub notes: Vec, 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")); diff --git a/src/cli/commands/stats.rs b/src/cli/commands/stats.rs index 2303ad4..38ec6c1 100644 --- a/src/cli/commands/stats.rs +++ b/src/cli/commands/stats.rs @@ -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 { 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 Result Result Result Result Result Result Result 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, diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 969e531..4c40380 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -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, } -/// 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 { diff --git a/src/cli/commands/sync_status.rs b/src/cli/commands/sync_status.rs index a68ab92..69f4708 100644 --- a/src/cli/commands/sync_status.rs +++ b/src/cli/commands/sync_status.rs @@ -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>, } -/// Cursor position information. #[derive(Debug)] pub struct CursorInfo { pub project_path: String, @@ -37,7 +33,6 @@ pub struct CursorInfo { pub tie_breaker_id: Option, } -/// 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, @@ -55,7 +49,6 @@ pub struct SyncStatusResult { pub summary: DataSummary, } -/// Run the sync-status command. pub fn run_sync_status(config: &Config) -> Result { 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 { }) } -/// Get the most recent sync runs. fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result> { 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 Result> { 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> { Ok(cursors?) } -/// Get data summary counts. fn get_data_summary(conn: &Connection) -> Result { 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 { }) } -/// 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(), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8692577..d6a1897 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,41 +1,31 @@ -//! CLI module with clap command definitions. - pub mod commands; pub mod progress; use clap::{Parser, Subcommand}; use std::io::IsTerminal; -/// Gitlore - Local GitLab data management with semantic search #[derive(Parser)] #[command(name = "lore")] #[command(version, about, long_about = None)] pub struct Cli { - /// Path to config file #[arg(short = 'c', long, global = true)] pub config: Option, - /// Machine-readable JSON output (auto-enabled when piped) #[arg(long, global = true, env = "LORE_ROBOT")] pub robot: bool, - /// JSON output (global shorthand) #[arg(short = 'J', long = "json", global = true)] pub json: bool, - /// Color output: auto (default), always, or never #[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto")] pub color: String, - /// Suppress non-essential output #[arg(short = 'q', long, global = true)] pub quiet: bool, - /// Increase log verbosity (-v, -vv, -vvv) #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)] pub verbose: u8, - /// Log format for stderr output: text (default) or json #[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text")] pub log_format: String, @@ -44,7 +34,6 @@ pub struct Cli { } impl Cli { - /// Check if robot mode is active (explicit flag, env var, or non-TTY stdout) pub fn is_robot_mode(&self) -> bool { self.robot || self.json || !std::io::stdout().is_terminal() } @@ -53,104 +42,74 @@ impl Cli { #[derive(Subcommand)] #[allow(clippy::large_enum_variant)] pub enum Commands { - /// List or show issues Issues(IssuesArgs), - /// List or show merge requests Mrs(MrsArgs), - /// Ingest data from GitLab Ingest(IngestArgs), - /// Count entities in local database Count(CountArgs), - /// Show sync state Status, - /// Verify GitLab authentication Auth, - /// Check environment health Doctor, - /// Show version information Version, - /// Initialize configuration and database Init { - /// Skip overwrite confirmation #[arg(short = 'f', long)] force: bool, - /// Fail if prompts would be shown #[arg(long)] non_interactive: bool, - /// GitLab base URL (required in robot mode) #[arg(long)] gitlab_url: Option, - /// Environment variable name holding GitLab token (required in robot mode) #[arg(long)] token_env_var: Option, - /// Comma-separated project paths (required in robot mode) #[arg(long)] projects: Option, }, - /// Create timestamped database backup #[command(hide = true)] Backup, - /// Delete database and reset all state #[command(hide = true)] Reset { - /// Skip confirmation prompt #[arg(short = 'y', long)] yes: bool, }, - /// Search indexed documents Search(SearchArgs), - /// Show document and index statistics Stats(StatsArgs), - /// Generate searchable documents from ingested data #[command(name = "generate-docs")] GenerateDocs(GenerateDocsArgs), - /// Generate vector embeddings for documents via Ollama Embed(EmbedArgs), - /// Run full sync pipeline: ingest -> generate-docs -> embed Sync(SyncArgs), - /// Run pending database migrations Migrate, - /// Quick health check: config, database, schema version Health, - /// Machine-readable command manifest for agent self-discovery #[command(name = "robot-docs")] RobotDocs, - /// Generate shell completions #[command(hide = true)] Completions { - /// Shell to generate completions for #[arg(value_parser = ["bash", "zsh", "fish", "powershell"])] shell: String, }, - // --- Hidden backward-compat aliases --- - /// List issues or MRs (deprecated: use 'lore issues' or 'lore mrs') #[command(hide = true)] List { - /// Entity type to list #[arg(value_parser = ["issues", "mrs"])] entity: String, @@ -192,36 +151,28 @@ pub enum Commands { source_branch: Option, }, - /// Show detailed entity information (deprecated: use 'lore issues ' or 'lore mrs ') #[command(hide = true)] Show { - /// Entity type to show #[arg(value_parser = ["issue", "mr"])] entity: String, - /// Entity IID iid: i64, #[arg(long)] project: Option, }, - /// Verify GitLab authentication (deprecated: use 'lore auth') #[command(hide = true, name = "auth-test")] AuthTest, - /// Show sync state (deprecated: use 'lore status') #[command(hide = true, name = "sync-status")] SyncStatus, } -/// Arguments for `lore issues [IID]` #[derive(Parser)] pub struct IssuesArgs { - /// Issue IID (omit to list, provide to show details) pub iid: Option, - /// Maximum results #[arg( short = 'n', long = "limit", @@ -230,39 +181,30 @@ pub struct IssuesArgs { )] pub limit: usize, - /// Filter by state (opened, closed, all) #[arg(short = 's', long, help_heading = "Filters")] pub state: Option, - /// Filter by project path #[arg(short = 'p', long, help_heading = "Filters")] pub project: Option, - /// Filter by author username #[arg(short = 'a', long, help_heading = "Filters")] pub author: Option, - /// Filter by assignee username #[arg(short = 'A', long, help_heading = "Filters")] pub assignee: Option, - /// Filter by label (repeatable, AND logic) #[arg(short = 'l', long, help_heading = "Filters")] pub label: Option>, - /// Filter by milestone title #[arg(short = 'm', long, help_heading = "Filters")] pub milestone: Option, - /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) #[arg(long, help_heading = "Filters")] pub since: Option, - /// Filter by due date (before this date, YYYY-MM-DD) #[arg(long = "due-before", help_heading = "Filters")] pub due_before: Option, - /// Show only issues with a due date #[arg( long = "has-due", help_heading = "Filters", @@ -273,18 +215,15 @@ pub struct IssuesArgs { #[arg(long = "no-has-due", hide = true, overrides_with = "has_due")] pub no_has_due: bool, - /// Sort field (updated, created, iid) #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")] pub sort: String, - /// Sort ascending (default: descending) #[arg(long, help_heading = "Sorting", overrides_with = "no_asc")] pub asc: bool, #[arg(long = "no-asc", hide = true, overrides_with = "asc")] pub no_asc: bool, - /// Open first matching item in browser #[arg( short = 'o', long, @@ -297,13 +236,10 @@ pub struct IssuesArgs { pub no_open: bool, } -/// Arguments for `lore mrs [IID]` #[derive(Parser)] pub struct MrsArgs { - /// MR IID (omit to list, provide to show details) pub iid: Option, - /// Maximum results #[arg( short = 'n', long = "limit", @@ -312,35 +248,27 @@ pub struct MrsArgs { )] pub limit: usize, - /// Filter by state (opened, merged, closed, locked, all) #[arg(short = 's', long, help_heading = "Filters")] pub state: Option, - /// Filter by project path #[arg(short = 'p', long, help_heading = "Filters")] pub project: Option, - /// Filter by author username #[arg(short = 'a', long, help_heading = "Filters")] pub author: Option, - /// Filter by assignee username #[arg(short = 'A', long, help_heading = "Filters")] pub assignee: Option, - /// Filter by reviewer username #[arg(short = 'r', long, help_heading = "Filters")] pub reviewer: Option, - /// Filter by label (repeatable, AND logic) #[arg(short = 'l', long, help_heading = "Filters")] pub label: Option>, - /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) #[arg(long, help_heading = "Filters")] pub since: Option, - /// Show only draft MRs #[arg( short = 'd', long, @@ -349,7 +277,6 @@ pub struct MrsArgs { )] pub draft: bool, - /// Exclude draft MRs #[arg( short = 'D', long = "no-draft", @@ -358,26 +285,21 @@ pub struct MrsArgs { )] pub no_draft: bool, - /// Filter by target branch #[arg(long, help_heading = "Filters")] pub target: Option, - /// Filter by source branch #[arg(long, help_heading = "Filters")] pub source: Option, - /// Sort field (updated, created, iid) #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")] pub sort: String, - /// Sort ascending (default: descending) #[arg(long, help_heading = "Sorting", overrides_with = "no_asc")] pub asc: bool, #[arg(long = "no-asc", hide = true, overrides_with = "asc")] pub no_asc: bool, - /// Open first matching item in browser #[arg( short = 'o', long, @@ -390,25 +312,20 @@ pub struct MrsArgs { pub no_open: bool, } -/// Arguments for `lore ingest [ENTITY]` #[derive(Parser)] pub struct IngestArgs { - /// Entity to ingest (issues, mrs). Omit to ingest everything. #[arg(value_parser = ["issues", "mrs"])] pub entity: Option, - /// Filter to single project #[arg(short = 'p', long)] pub project: Option, - /// Override stale sync lock #[arg(short = 'f', long, overrides_with = "no_force")] pub force: bool, #[arg(long = "no-force", hide = true, overrides_with = "force")] pub no_force: bool, - /// Full re-sync: reset cursors and fetch all data from scratch #[arg(long, overrides_with = "no_full")] pub full: bool, @@ -416,60 +333,46 @@ pub struct IngestArgs { pub no_full: bool, } -/// Arguments for `lore stats` #[derive(Parser)] pub struct StatsArgs { - /// Run integrity checks #[arg(long, overrides_with = "no_check")] pub check: bool, #[arg(long = "no-check", hide = true, overrides_with = "check")] pub no_check: bool, - /// Repair integrity issues (auto-enables --check) #[arg(long)] pub repair: bool, } -/// Arguments for `lore search ` #[derive(Parser)] pub struct SearchArgs { - /// Search query string pub query: String, - /// Search mode (lexical, hybrid, semantic) #[arg(long, default_value = "hybrid", value_parser = ["lexical", "hybrid", "semantic"], help_heading = "Output")] pub mode: String, - /// Filter by source type (issue, mr, discussion) #[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion"], help_heading = "Filters")] pub source_type: Option, - /// Filter by author username #[arg(long, help_heading = "Filters")] pub author: Option, - /// Filter by project path #[arg(short = 'p', long, help_heading = "Filters")] pub project: Option, - /// Filter by label (repeatable, AND logic) #[arg(long, action = clap::ArgAction::Append, help_heading = "Filters")] pub label: Vec, - /// Filter by file path (trailing / for prefix match) #[arg(long, help_heading = "Filters")] pub path: Option, - /// Filter by created after (7d, 2w, or YYYY-MM-DD) #[arg(long, help_heading = "Filters")] pub after: Option, - /// Filter by updated after (7d, 2w, or YYYY-MM-DD) #[arg(long = "updated-after", help_heading = "Filters")] pub updated_after: Option, - /// Maximum results (default 20, max 100) #[arg( short = 'n', long = "limit", @@ -478,71 +381,57 @@ pub struct SearchArgs { )] pub limit: usize, - /// Show ranking explanation per result #[arg(long, help_heading = "Output", overrides_with = "no_explain")] pub explain: bool, #[arg(long = "no-explain", hide = true, overrides_with = "explain")] pub no_explain: bool, - /// FTS query mode: safe (default) or raw #[arg(long = "fts-mode", default_value = "safe", value_parser = ["safe", "raw"], help_heading = "Output")] pub fts_mode: String, } -/// Arguments for `lore generate-docs` #[derive(Parser)] pub struct GenerateDocsArgs { - /// Full rebuild: seed all entities into dirty queue, then drain #[arg(long)] pub full: bool, - /// Filter to single project #[arg(short = 'p', long)] pub project: Option, } -/// Arguments for `lore sync` #[derive(Parser)] pub struct SyncArgs { - /// Reset cursors, fetch everything #[arg(long, overrides_with = "no_full")] pub full: bool, #[arg(long = "no-full", hide = true, overrides_with = "full")] pub no_full: bool, - /// Override stale lock #[arg(long, overrides_with = "no_force")] pub force: bool, #[arg(long = "no-force", hide = true, overrides_with = "force")] pub no_force: bool, - /// Skip embedding step #[arg(long)] pub no_embed: bool, - /// Skip document regeneration #[arg(long)] pub no_docs: bool, - /// Skip resource event fetching (overrides config) #[arg(long = "no-events")] pub no_events: bool, } -/// Arguments for `lore embed` #[derive(Parser)] pub struct EmbedArgs { - /// Re-embed all documents (clears existing embeddings first) #[arg(long, overrides_with = "no_full")] pub full: bool, #[arg(long = "no-full", hide = true, overrides_with = "full")] pub no_full: bool, - /// Retry previously failed embeddings #[arg(long, overrides_with = "no_retry_failed")] pub retry_failed: bool, @@ -550,14 +439,11 @@ pub struct EmbedArgs { pub no_retry_failed: bool, } -/// Arguments for `lore count ` #[derive(Parser)] pub struct CountArgs { - /// Entity type to count (issues, mrs, discussions, notes, events) #[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])] pub entity: String, - /// Parent type filter: issue or mr (for discussions/notes) #[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])] pub for_entity: Option, } diff --git a/src/cli/progress.rs b/src/cli/progress.rs index fadea11..7249ac9 100644 --- a/src/cli/progress.rs +++ b/src/cli/progress.rs @@ -1,41 +1,17 @@ -//! Shared progress bar infrastructure. -//! -//! All progress bars must be created via [`multi()`] to ensure coordinated -//! rendering. The [`SuspendingWriter`] suspends the multi-progress before -//! writing tracing output, preventing log lines from interleaving with -//! progress bar animations. - use indicatif::MultiProgress; use std::io::Write; use std::sync::LazyLock; use tracing_subscriber::fmt::MakeWriter; -/// Global multi-progress that coordinates all progress bar rendering. -/// -/// Every `ProgressBar` displayed to the user **must** be registered via -/// `multi().add(bar)`. Standalone bars bypass the coordination and will -/// fight with other bars for the terminal line, causing rapid flashing. static MULTI: LazyLock = LazyLock::new(MultiProgress::new); -/// Returns the shared [`MultiProgress`] instance. pub fn multi() -> &'static MultiProgress { &MULTI } -/// A tracing `MakeWriter` that suspends the shared [`MultiProgress`] while -/// writing, so log output doesn't interleave with progress bar animations. -/// -/// # How it works -/// -/// `MultiProgress::suspend` temporarily clears all active progress bars from -/// the terminal, executes the closure (which writes the log line), then -/// redraws the bars. This ensures a clean, flicker-free display even when -/// logging happens concurrently with progress updates. #[derive(Clone)] pub struct SuspendingWriter; -/// Writer returned by [`SuspendingWriter`] that buffers a single log line -/// and flushes it inside a `MultiProgress::suspend` call. pub struct SuspendingWriterInner { buf: Vec, } @@ -47,7 +23,6 @@ impl Write for SuspendingWriterInner { } fn flush(&mut self) -> std::io::Result<()> { - // Nothing to do — actual flush happens on drop. Ok(()) } } @@ -102,10 +77,8 @@ mod tests { fn suspending_writer_buffers_and_flushes() { let writer = SuspendingWriter; let mut w = MakeWriter::make_writer(&writer); - // Write should succeed and buffer data let n = w.write(b"test log line\n").unwrap(); assert_eq!(n, 14); - // Drop flushes via suspend — no panic means it works drop(w); } @@ -113,7 +86,6 @@ mod tests { fn suspending_writer_empty_does_not_flush() { let writer = SuspendingWriter; let w = MakeWriter::make_writer(&writer); - // Drop with empty buffer — should be a no-op drop(w); } } diff --git a/src/core/backoff.rs b/src/core/backoff.rs index d1f0bd3..ffd3981 100644 --- a/src/core/backoff.rs +++ b/src/core/backoff.rs @@ -1,24 +1,10 @@ use rand::Rng; -/// Compute next_attempt_at with exponential backoff and jitter. -/// -/// Formula: now + min(3600000, 1000 * 2^attempt_count) * (0.9 to 1.1) -/// - Capped at 1 hour to prevent runaway delays -/// - ±10% jitter prevents synchronized retries after outages -/// -/// Used by: -/// - `dirty_sources` retry scheduling (document regeneration failures) -/// - `pending_discussion_fetches` retry scheduling (API fetch failures) -/// -/// Having one implementation prevents subtle divergence between queues -/// (e.g., different caps or jitter ranges). pub fn compute_next_attempt_at(now: i64, attempt_count: i64) -> i64 { - // Cap attempt_count to prevent overflow (2^30 > 1 hour anyway) let capped_attempts = attempt_count.min(30) as u32; let base_delay_ms = 1000_i64.saturating_mul(1 << capped_attempts); - let capped_delay_ms = base_delay_ms.min(3_600_000); // 1 hour cap + let capped_delay_ms = base_delay_ms.min(3_600_000); - // Add ±10% jitter let jitter_factor = rand::thread_rng().gen_range(0.9..=1.1); let delay_with_jitter = (capped_delay_ms as f64 * jitter_factor) as i64; @@ -34,7 +20,6 @@ mod tests { #[test] fn test_exponential_curve() { let now = 1_000_000_000_i64; - // Each attempt should roughly double the delay (within jitter) for attempt in 1..=10 { let result = compute_next_attempt_at(now, attempt); let delay = result - now; @@ -65,7 +50,7 @@ mod tests { #[test] fn test_jitter_range() { let now = 1_000_000_000_i64; - let attempt = 5; // base = 32000 + let attempt = 5; let base = 1000_i64 * (1 << attempt); let min_delay = (base as f64 * 0.89) as i64; let max_delay = (base as f64 * 1.11) as i64; @@ -85,7 +70,6 @@ mod tests { let now = 1_000_000_000_i64; let result = compute_next_attempt_at(now, 1); let delay = result - now; - // attempt 1: base = 2000ms, with jitter: 1800-2200ms assert!( (1800..=2200).contains(&delay), "first retry delay: {delay}ms" @@ -95,7 +79,6 @@ mod tests { #[test] fn test_overflow_safety() { let now = i64::MAX / 2; - // Should not panic even with very large attempt_count let result = compute_next_attempt_at(now, i64::MAX); assert!(result > now); } diff --git a/src/core/config.rs b/src/core/config.rs index 6cae328..547ed77 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1,7 +1,3 @@ -//! Configuration loading and validation. -//! -//! Config schema mirrors the TypeScript version with serde for deserialization. - use serde::Deserialize; use std::fs; use std::path::Path; @@ -9,7 +5,6 @@ use std::path::Path; use super::error::{LoreError, Result}; use super::paths::get_config_path; -/// GitLab connection settings. #[derive(Debug, Clone, Deserialize)] pub struct GitLabConfig { #[serde(rename = "baseUrl")] @@ -23,13 +18,11 @@ fn default_token_env_var() -> String { "GITLAB_TOKEN".to_string() } -/// Project to sync. #[derive(Debug, Clone, Deserialize)] pub struct ProjectConfig { pub path: String, } -/// Sync behavior settings. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct SyncConfig { @@ -77,7 +70,6 @@ impl Default for SyncConfig { } } -/// Storage settings. #[derive(Debug, Clone, Deserialize, Default)] #[serde(default)] pub struct StorageConfig { @@ -98,7 +90,6 @@ fn default_compress_raw_payloads() -> bool { true } -/// Embedding provider settings. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct EmbeddingConfig { @@ -120,19 +111,15 @@ impl Default for EmbeddingConfig { } } -/// Logging and observability settings. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct LoggingConfig { - /// Directory for log files. Default: ~/.local/share/lore/logs/ #[serde(rename = "logDir")] pub log_dir: Option, - /// Days to retain log files. Default: 30. Set to 0 to disable file logging. #[serde(rename = "retentionDays", default = "default_retention_days")] pub retention_days: u32, - /// Enable JSON log files. Default: true. #[serde(rename = "fileLogging", default = "default_file_logging")] pub file_logging: bool, } @@ -155,7 +142,6 @@ impl Default for LoggingConfig { } } -/// Main configuration structure. #[derive(Debug, Clone, Deserialize)] pub struct Config { pub gitlab: GitLabConfig, @@ -175,7 +161,6 @@ pub struct Config { } impl Config { - /// Load and validate configuration from file. pub fn load(cli_override: Option<&str>) -> Result { let config_path = get_config_path(cli_override); @@ -188,7 +173,6 @@ impl Config { Self::load_from_path(&config_path) } - /// Load configuration from a specific path. pub fn load_from_path(path: &Path) -> Result { let content = fs::read_to_string(path).map_err(|e| LoreError::ConfigInvalid { details: format!("Failed to read config file: {e}"), @@ -199,7 +183,6 @@ impl Config { details: format!("Invalid JSON: {e}"), })?; - // Validate required fields if config.projects.is_empty() { return Err(LoreError::ConfigInvalid { details: "At least one project is required".to_string(), @@ -214,7 +197,6 @@ impl Config { } } - // Validate URL format if url::Url::parse(&config.gitlab.base_url).is_err() { return Err(LoreError::ConfigInvalid { details: format!("Invalid GitLab URL: {}", config.gitlab.base_url), @@ -225,7 +207,6 @@ impl Config { } } -/// Minimal config for writing during init (relies on defaults when loaded). #[derive(Debug, serde::Serialize)] pub struct MinimalConfig { pub gitlab: MinimalGitLabConfig, diff --git a/src/core/db.rs b/src/core/db.rs index c2b7079..7ef61f8 100644 --- a/src/core/db.rs +++ b/src/core/db.rs @@ -1,7 +1,3 @@ -//! Database connection and migration management. -//! -//! Uses rusqlite with WAL mode for crash safety. - use rusqlite::Connection; use sqlite_vec::sqlite3_vec_init; use std::fs; @@ -10,11 +6,8 @@ use tracing::{debug, info}; use super::error::{LoreError, Result}; -/// Latest schema version, derived from the embedded migrations count. -/// Used by the health check to verify databases are up-to-date. pub const LATEST_SCHEMA_VERSION: i32 = MIGRATIONS.len() as i32; -/// Embedded migrations - compiled into the binary. const MIGRATIONS: &[(&str, &str)] = &[ ("001", include_str!("../../migrations/001_initial.sql")), ("002", include_str!("../../migrations/002_issues.sql")), @@ -53,9 +46,7 @@ const MIGRATIONS: &[(&str, &str)] = &[ ), ]; -/// Create a database connection with production-grade pragmas. pub fn create_connection(db_path: &Path) -> Result { - // Register sqlite-vec extension globally (safe to call multiple times) #[allow(clippy::missing_transmute_annotations)] unsafe { rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute( @@ -63,30 +54,26 @@ pub fn create_connection(db_path: &Path) -> Result { ))); } - // Ensure parent directory exists if let Some(parent) = db_path.parent() { fs::create_dir_all(parent)?; } let conn = Connection::open(db_path)?; - // Production-grade pragmas for single-user CLI conn.pragma_update(None, "journal_mode", "WAL")?; - conn.pragma_update(None, "synchronous", "NORMAL")?; // Safe for WAL on local disk + conn.pragma_update(None, "synchronous", "NORMAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; - conn.pragma_update(None, "busy_timeout", 5000)?; // 5s wait on lock contention - conn.pragma_update(None, "temp_store", "MEMORY")?; // Small speed win - conn.pragma_update(None, "cache_size", -64000)?; // 64MB cache (negative = KB) - conn.pragma_update(None, "mmap_size", 268_435_456)?; // 256MB memory-mapped I/O + conn.pragma_update(None, "busy_timeout", 5000)?; + conn.pragma_update(None, "temp_store", "MEMORY")?; + conn.pragma_update(None, "cache_size", -64000)?; + conn.pragma_update(None, "mmap_size", 268_435_456)?; debug!(db_path = %db_path.display(), "Database connection created"); Ok(conn) } -/// Run all pending migrations using embedded SQL. pub fn run_migrations(conn: &Connection) -> Result<()> { - // Get current schema version let has_version_table: bool = conn .query_row( "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='schema_version'", @@ -114,9 +101,6 @@ pub fn run_migrations(conn: &Connection) -> Result<()> { continue; } - // Wrap each migration in a transaction to prevent partial application. - // If the migration SQL already contains BEGIN/COMMIT, execute_batch handles - // it, but wrapping in a savepoint ensures atomicity for those that don't. let savepoint_name = format!("migration_{}", version); conn.execute_batch(&format!("SAVEPOINT {}", savepoint_name)) .map_err(|e| LoreError::MigrationFailed { @@ -150,7 +134,6 @@ pub fn run_migrations(conn: &Connection) -> Result<()> { Ok(()) } -/// Run migrations from filesystem (for testing or custom migrations). #[allow(dead_code)] pub fn run_migrations_from_dir(conn: &Connection, migrations_dir: &Path) -> Result<()> { let has_version_table: bool = conn @@ -194,8 +177,6 @@ pub fn run_migrations_from_dir(conn: &Connection, migrations_dir: &Path) -> Resu let sql = fs::read_to_string(entry.path())?; - // Wrap each migration in a savepoint to prevent partial application, - // matching the safety guarantees of run_migrations(). let savepoint_name = format!("migration_{}", version); conn.execute_batch(&format!("SAVEPOINT {}", savepoint_name)) .map_err(|e| LoreError::MigrationFailed { @@ -229,8 +210,6 @@ pub fn run_migrations_from_dir(conn: &Connection, migrations_dir: &Path) -> Resu Ok(()) } -/// Verify database pragmas are set correctly. -/// Used by lore doctor command. pub fn verify_pragmas(conn: &Connection) -> (bool, Vec) { let mut issues = Vec::new(); @@ -258,7 +237,6 @@ pub fn verify_pragmas(conn: &Connection) -> (bool, Vec) { let synchronous: i32 = conn .pragma_query_value(None, "synchronous", |row| row.get(0)) .unwrap_or(0); - // NORMAL = 1 if synchronous != 1 { issues.push(format!("synchronous is {synchronous}, expected 1 (NORMAL)")); } @@ -266,7 +244,6 @@ pub fn verify_pragmas(conn: &Connection) -> (bool, Vec) { (issues.is_empty(), issues) } -/// Get current schema version. pub fn get_schema_version(conn: &Connection) -> i32 { let has_version_table: bool = conn .query_row( diff --git a/src/core/dependent_queue.rs b/src/core/dependent_queue.rs index 7fc1263..0652473 100644 --- a/src/core/dependent_queue.rs +++ b/src/core/dependent_queue.rs @@ -1,8 +1,3 @@ -//! Generic dependent fetch queue for resource events, MR closes, and MR diffs. -//! -//! Provides enqueue, claim, complete, fail (with exponential backoff), and -//! stale lock reclamation operations against the `pending_dependent_fetches` table. - use std::collections::HashMap; use rusqlite::Connection; @@ -10,7 +5,6 @@ use rusqlite::Connection; use super::error::Result; use super::time::now_ms; -/// A pending job from the dependent fetch queue. #[derive(Debug)] pub struct PendingJob { pub id: i64, @@ -23,9 +17,6 @@ pub struct PendingJob { pub attempts: i32, } -/// Enqueue a dependent fetch job. Idempotent via UNIQUE constraint (INSERT OR IGNORE). -/// -/// Returns `true` if actually inserted (not deduped). pub fn enqueue_job( conn: &Connection, project_id: i64, @@ -54,10 +45,6 @@ pub fn enqueue_job( Ok(changes > 0) } -/// Claim a batch of jobs for processing, scoped to a specific project. -/// -/// Atomically selects and locks jobs within a transaction. Only claims jobs -/// where `locked_at IS NULL` and `(next_retry_at IS NULL OR next_retry_at <= now)`. pub fn claim_jobs( conn: &Connection, job_type: &str, @@ -70,8 +57,6 @@ pub fn claim_jobs( let now = now_ms(); - // Use UPDATE ... RETURNING to atomically select and lock in one statement. - // This eliminates the race between SELECT and UPDATE. let mut stmt = conn.prepare_cached( "UPDATE pending_dependent_fetches SET locked_at = ?1 @@ -109,7 +94,6 @@ pub fn claim_jobs( Ok(jobs) } -/// Mark a job as complete (DELETE the row). pub fn complete_job(conn: &Connection, job_id: i64) -> Result<()> { conn.execute( "DELETE FROM pending_dependent_fetches WHERE id = ?1", @@ -119,17 +103,9 @@ pub fn complete_job(conn: &Connection, job_id: i64) -> Result<()> { Ok(()) } -/// Mark a job as failed. Increments attempts, sets next_retry_at with exponential -/// backoff, clears locked_at, and records the error. -/// -/// Backoff: 30s * 2^(attempts), capped at 480s. Uses a single atomic UPDATE -/// to avoid a read-then-write race on the `attempts` counter. pub fn fail_job(conn: &Connection, job_id: i64, error: &str) -> Result<()> { let now = now_ms(); - // Atomic increment + backoff calculation in one UPDATE. - // MIN(attempts, 4) caps the shift to prevent overflow; the overall - // backoff is clamped to 480 000 ms via MIN(..., 480000). let changes = conn.execute( "UPDATE pending_dependent_fetches SET attempts = attempts + 1, @@ -149,9 +125,6 @@ pub fn fail_job(conn: &Connection, job_id: i64, error: &str) -> Result<()> { Ok(()) } -/// Reclaim stale locks (locked_at older than threshold). -/// -/// Returns count of reclaimed jobs. pub fn reclaim_stale_locks(conn: &Connection, stale_threshold_minutes: u32) -> Result { let threshold_ms = now_ms() - (i64::from(stale_threshold_minutes) * 60 * 1000); @@ -163,7 +136,6 @@ pub fn reclaim_stale_locks(conn: &Connection, stale_threshold_minutes: u32) -> R Ok(changes) } -/// Count pending jobs by job_type, optionally scoped to a project. pub fn count_pending_jobs( conn: &Connection, project_id: Option, @@ -205,11 +177,6 @@ pub fn count_pending_jobs( Ok(counts) } -/// Count jobs that are actually claimable right now, by job_type. -/// -/// Only counts jobs where `locked_at IS NULL` and `(next_retry_at IS NULL OR next_retry_at <= now)`, -/// matching the exact WHERE clause used by [`claim_jobs`]. This gives an accurate total -/// for progress bars — unlike [`count_pending_jobs`] which includes locked and backing-off jobs. pub fn count_claimable_jobs(conn: &Connection, project_id: i64) -> Result> { let now = now_ms(); let mut counts = HashMap::new(); diff --git a/src/core/error.rs b/src/core/error.rs index f80d580..7feb524 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -1,11 +1,6 @@ -//! Custom error types for gitlore. -//! -//! Uses thiserror for ergonomic error definitions with structured error codes. - use serde::Serialize; use thiserror::Error; -/// Error codes for programmatic error handling. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ErrorCode { ConfigNotFound, @@ -55,7 +50,6 @@ impl std::fmt::Display for ErrorCode { } impl ErrorCode { - /// Get the exit code for this error (for robot mode). pub fn exit_code(&self) -> i32 { match self { Self::InternalError => 1, @@ -80,7 +74,6 @@ impl ErrorCode { } } -/// Main error type for gitlore. #[derive(Error, Debug)] pub enum LoreError { #[error("Config file not found at {path}. Run \"lore init\" first.")] @@ -163,7 +156,6 @@ pub enum LoreError { } impl LoreError { - /// Get the error code for programmatic handling. pub fn code(&self) -> ErrorCode { match self { Self::ConfigNotFound { .. } => ErrorCode::ConfigNotFound, @@ -190,7 +182,6 @@ impl LoreError { } } - /// Get a suggestion for how to fix this error, including inline examples. pub fn suggestion(&self) -> Option<&'static str> { match self { Self::ConfigNotFound { .. } => Some( @@ -240,21 +231,14 @@ impl LoreError { } } - /// Whether this error represents a permanent API failure that should not be retried. - /// - /// Only 404 (not found) is truly permanent: the resource doesn't exist and never will. - /// 403 and auth errors are NOT permanent — they may be environmental (VPN down, - /// token rotation, temporary restrictions) and should be retried with backoff. pub fn is_permanent_api_error(&self) -> bool { matches!(self, Self::GitLabNotFound { .. }) } - /// Get the exit code for this error. pub fn exit_code(&self) -> i32 { self.code().exit_code() } - /// Convert to robot-mode JSON error output. pub fn to_robot_error(&self) -> RobotError { RobotError { code: self.code().to_string(), @@ -264,7 +248,6 @@ impl LoreError { } } -/// Structured error for robot mode JSON output. #[derive(Debug, Serialize)] pub struct RobotError { pub code: String, @@ -273,7 +256,6 @@ pub struct RobotError { pub suggestion: Option, } -/// Wrapper for robot mode error output. #[derive(Debug, Serialize)] pub struct RobotErrorOutput { pub error: RobotError, diff --git a/src/core/events_db.rs b/src/core/events_db.rs index 7bf6480..58c0900 100644 --- a/src/core/events_db.rs +++ b/src/core/events_db.rs @@ -1,15 +1,9 @@ -//! Database upsert functions for resource events (state, label, milestone). - use rusqlite::Connection; use super::error::{LoreError, Result}; use super::time::iso_to_ms_strict; use crate::gitlab::types::{GitLabLabelEvent, GitLabMilestoneEvent, GitLabStateEvent}; -/// Upsert state events for an entity. -/// -/// Uses INSERT OR REPLACE keyed on UNIQUE(gitlab_id, project_id). -/// Caller is responsible for wrapping in a transaction if atomicity is needed. pub fn upsert_state_events( conn: &Connection, project_id: i64, @@ -52,8 +46,6 @@ pub fn upsert_state_events( Ok(count) } -/// Upsert label events for an entity. -/// Caller is responsible for wrapping in a transaction if atomicity is needed. pub fn upsert_label_events( conn: &Connection, project_id: i64, @@ -93,8 +85,6 @@ pub fn upsert_label_events( Ok(count) } -/// Upsert milestone events for an entity. -/// Caller is responsible for wrapping in a transaction if atomicity is needed. pub fn upsert_milestone_events( conn: &Connection, project_id: i64, @@ -135,8 +125,6 @@ pub fn upsert_milestone_events( Ok(count) } -/// Resolve entity type string to (issue_id, merge_request_id) pair. -/// Exactly one is Some, the other is None. fn resolve_entity_ids( entity_type: &str, entity_local_id: i64, @@ -150,11 +138,9 @@ fn resolve_entity_ids( } } -/// Count resource events by type for the count command. pub fn count_events(conn: &Connection) -> Result { let mut counts = EventCounts::default(); - // State events let row: (i64, i64) = conn .query_row( "SELECT @@ -168,7 +154,6 @@ pub fn count_events(conn: &Connection) -> Result { counts.state_issue = row.0 as usize; counts.state_mr = row.1 as usize; - // Label events let row: (i64, i64) = conn .query_row( "SELECT @@ -182,7 +167,6 @@ pub fn count_events(conn: &Connection) -> Result { counts.label_issue = row.0 as usize; counts.label_mr = row.1 as usize; - // Milestone events let row: (i64, i64) = conn .query_row( "SELECT @@ -199,7 +183,6 @@ pub fn count_events(conn: &Connection) -> Result { Ok(counts) } -/// Event counts broken down by type and entity. #[derive(Debug, Default)] pub struct EventCounts { pub state_issue: usize, diff --git a/src/core/lock.rs b/src/core/lock.rs index edfc543..8abd886 100644 --- a/src/core/lock.rs +++ b/src/core/lock.rs @@ -1,7 +1,3 @@ -//! Crash-safe single-flight lock using heartbeat pattern. -//! -//! Prevents concurrent sync operations and allows recovery from crashed processes. - use rusqlite::{Connection, TransactionBehavior}; use std::path::PathBuf; use std::sync::Arc; @@ -15,17 +11,14 @@ use super::db::create_connection; use super::error::{LoreError, Result}; use super::time::{ms_to_iso, now_ms}; -/// Maximum consecutive heartbeat failures before signaling error. const MAX_HEARTBEAT_FAILURES: u32 = 3; -/// Lock configuration options. pub struct LockOptions { pub name: String, pub stale_lock_minutes: u32, pub heartbeat_interval_seconds: u32, } -/// App lock with heartbeat for crash recovery. pub struct AppLock { conn: Connection, db_path: PathBuf, @@ -40,7 +33,6 @@ pub struct AppLock { } impl AppLock { - /// Create a new app lock instance. pub fn new(conn: Connection, options: LockOptions) -> Self { let db_path = conn.path().map(PathBuf::from).unwrap_or_default(); @@ -58,23 +50,17 @@ impl AppLock { } } - /// Check if heartbeat has failed (indicates lock may be compromised). pub fn is_heartbeat_healthy(&self) -> bool { !self.heartbeat_failed.load(Ordering::SeqCst) } - /// Attempt to acquire the lock atomically. - /// - /// Returns Ok(true) if lock acquired, Err if lock is held by another active process. pub fn acquire(&mut self, force: bool) -> Result { let now = now_ms(); - // Use IMMEDIATE transaction to prevent race conditions let tx = self .conn .transaction_with_behavior(TransactionBehavior::Immediate)?; - // Check for existing lock within the transaction let existing: Option<(String, i64, i64)> = tx .query_row( "SELECT owner, acquired_at, heartbeat_at FROM app_locks WHERE name = ?", @@ -85,7 +71,6 @@ impl AppLock { match existing { None => { - // No lock exists, acquire it tx.execute( "INSERT INTO app_locks (name, owner, acquired_at, heartbeat_at) VALUES (?, ?, ?, ?)", (&self.name, &self.owner, now, now), @@ -96,7 +81,6 @@ impl AppLock { let is_stale = now - heartbeat_at > self.stale_lock_ms; if is_stale || force { - // Lock is stale or force override, take it tx.execute( "UPDATE app_locks SET owner = ?, acquired_at = ?, heartbeat_at = ? WHERE name = ?", (&self.owner, now, now, &self.name), @@ -108,13 +92,11 @@ impl AppLock { "Lock acquired (override)" ); } else if existing_owner == self.owner { - // Re-entrant, update heartbeat tx.execute( "UPDATE app_locks SET heartbeat_at = ? WHERE name = ?", (now, &self.name), )?; } else { - // Lock held by another active process - rollback and return error drop(tx); return Err(LoreError::DatabaseLocked { owner: existing_owner, @@ -124,20 +106,17 @@ impl AppLock { } } - // Commit the transaction atomically tx.commit()?; self.start_heartbeat(); Ok(true) } - /// Release the lock. pub fn release(&mut self) { if self.released.swap(true, Ordering::SeqCst) { - return; // Already released + return; } - // Stop heartbeat thread if let Some(handle) = self.heartbeat_handle.take() { let _ = handle.join(); } @@ -150,7 +129,6 @@ impl AppLock { info!(owner = %self.owner, "Lock released"); } - /// Start the heartbeat thread to keep the lock alive. fn start_heartbeat(&mut self) { let name = self.name.clone(); let owner = self.owner.clone(); @@ -161,11 +139,10 @@ impl AppLock { let db_path = self.db_path.clone(); if db_path.as_os_str().is_empty() { - return; // In-memory database, skip heartbeat + return; } self.heartbeat_handle = Some(thread::spawn(move || { - // Open a new connection with proper pragmas let conn = match create_connection(&db_path) { Ok(c) => c, Err(e) => { @@ -175,11 +152,9 @@ impl AppLock { } }; - // Poll frequently for early exit, but only update heartbeat at full interval const POLL_INTERVAL: Duration = Duration::from_millis(100); loop { - // Sleep in small increments, checking released flag frequently let mut elapsed = Duration::ZERO; while elapsed < interval { thread::sleep(POLL_INTERVAL); @@ -189,7 +164,6 @@ impl AppLock { } } - // Check once more after full interval elapsed if released.load(Ordering::SeqCst) { break; } @@ -203,12 +177,10 @@ impl AppLock { match result { Ok(rows_affected) => { if rows_affected == 0 { - // Lock was stolen or deleted warn!(owner = %owner, "Heartbeat failed: lock no longer held"); heartbeat_failed.store(true, Ordering::SeqCst); break; } - // Reset failure count on success failure_count.store(0, Ordering::SeqCst); debug!(owner = %owner, "Heartbeat updated"); } diff --git a/src/core/logging.rs b/src/core/logging.rs index a34d724..0759430 100644 --- a/src/core/logging.rs +++ b/src/core/logging.rs @@ -1,29 +1,13 @@ -//! Logging infrastructure: dual-layer subscriber setup and log file retention. -//! -//! Provides a layered tracing subscriber with: -//! - **stderr layer**: Human-readable or JSON format, controlled by `-v` flags -//! - **file layer**: Always-on JSON output to daily-rotated log files - use std::fs; use std::path::Path; use tracing_subscriber::EnvFilter; -/// Build an `EnvFilter` from the verbosity count. -/// -/// | Count | App Level | Dep Level | -/// |-------|-----------|-----------| -/// | 0 | INFO | WARN | -/// | 1 | DEBUG | WARN | -/// | 2 | DEBUG | INFO | -/// | 3+ | TRACE | DEBUG | pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter { - // RUST_LOG always wins if set if std::env::var("RUST_LOG").is_ok() { return EnvFilter::from_default_env(); } - // -q overrides -v for stderr if quiet { return EnvFilter::new("lore=warn,error"); } @@ -38,10 +22,6 @@ pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter { EnvFilter::new(directives) } -/// Build an `EnvFilter` for the file layer. -/// -/// Always captures DEBUG+ for `lore::*` and WARN+ for dependencies, -/// unless `RUST_LOG` is set (which overrides everything). pub fn build_file_filter() -> EnvFilter { if std::env::var("RUST_LOG").is_ok() { return EnvFilter::from_default_env(); @@ -50,10 +30,6 @@ pub fn build_file_filter() -> EnvFilter { EnvFilter::new("lore=debug,warn") } -/// Delete log files older than `retention_days` from the given directory. -/// -/// Only deletes files matching the `lore.YYYY-MM-DD.log` pattern. -/// Returns the number of files deleted. pub fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> usize { if retention_days == 0 || !log_dir.exists() { return 0; @@ -72,7 +48,6 @@ pub fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> usize { let file_name = entry.file_name(); let name = file_name.to_string_lossy(); - // Match pattern: lore.YYYY-MM-DD.log or lore.YYYY-MM-DD (tracing-appender format) if let Some(date_str) = extract_log_date(&name) && date_str < cutoff_date && fs::remove_file(entry.path()).is_ok() @@ -84,28 +59,20 @@ pub fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> usize { deleted } -/// Extract the date portion from a log filename. -/// -/// Matches: `lore.YYYY-MM-DD.log` or `lore.YYYY-MM-DD` fn extract_log_date(filename: &str) -> Option { let rest = filename.strip_prefix("lore.")?; - // Must have at least YYYY-MM-DD (10 ASCII chars). - // Use get() to avoid panicking on non-ASCII filenames. let date_part = rest.get(..10)?; - // Validate it looks like a date let parts: Vec<&str> = date_part.split('-').collect(); if parts.len() != 3 || parts[0].len() != 4 || parts[1].len() != 2 || parts[2].len() != 2 { return None; } - // Check all parts are numeric (also ensures ASCII) if !parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) { return None; } - // After the date, must be end-of-string or ".log" let suffix = rest.get(10..)?; if suffix.is_empty() || suffix == ".log" { Some(date_part.to_string()) @@ -153,16 +120,13 @@ mod tests { fn test_cleanup_old_logs_deletes_old_files() { let dir = TempDir::new().unwrap(); - // Create old log files (well before any reasonable retention) File::create(dir.path().join("lore.2020-01-01.log")).unwrap(); File::create(dir.path().join("lore.2020-01-15.log")).unwrap(); - // Create a recent log file (today) let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); let recent_name = format!("lore.{today}.log"); File::create(dir.path().join(&recent_name)).unwrap(); - // Create a non-log file that should NOT be deleted File::create(dir.path().join("other.txt")).unwrap(); let deleted = cleanup_old_logs(dir.path(), 7); @@ -192,7 +156,6 @@ mod tests { #[test] fn test_build_stderr_filter_default() { - // Can't easily assert filter contents, but verify it doesn't panic let _filter = build_stderr_filter(0, false); } @@ -206,7 +169,6 @@ mod tests { #[test] fn test_build_stderr_filter_quiet_overrides_verbose() { - // Quiet should win over verbose let _filter = build_stderr_filter(3, true); } diff --git a/src/core/metrics.rs b/src/core/metrics.rs index 3430596..b3eb2fb 100644 --- a/src/core/metrics.rs +++ b/src/core/metrics.rs @@ -1,9 +1,3 @@ -//! Performance metrics types and tracing layer for sync pipeline observability. -//! -//! Provides: -//! - [`StageTiming`]: Serializable timing/counter data for pipeline stages -//! - [`MetricsLayer`]: Custom tracing subscriber layer that captures span timing - use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -14,16 +8,10 @@ use tracing::span::{Attributes, Id, Record}; use tracing_subscriber::layer::{Context, Layer}; use tracing_subscriber::registry::LookupSpan; -/// Returns true when value is zero (for serde `skip_serializing_if`). fn is_zero(v: &usize) -> bool { *v == 0 } -/// Timing and counter data for a single pipeline stage. -/// -/// Supports nested sub-stages for hierarchical timing breakdowns. -/// Fields with zero/empty values are omitted from JSON output to -/// keep robot-mode payloads compact. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StageTiming { pub name: String, @@ -43,11 +31,6 @@ pub struct StageTiming { pub sub_stages: Vec, } -// ============================================================================ -// MetricsLayer: custom tracing subscriber layer -// ============================================================================ - -/// Internal data tracked per open span. struct SpanData { name: String, parent_id: Option, @@ -57,19 +40,12 @@ struct SpanData { retries: usize, } -/// Completed span data with its original ID and parent ID. struct CompletedSpan { id: u64, parent_id: Option, timing: StageTiming, } -/// Custom tracing layer that captures span timing and structured fields. -/// -/// Collects data from `#[instrument]` spans and materializes it into -/// a `Vec` tree via [`extract_timings`]. -/// -/// Thread-safe via `Arc>` — suitable for concurrent span operations. #[derive(Debug, Clone)] pub struct MetricsLayer { spans: Arc>>, @@ -90,45 +66,34 @@ impl MetricsLayer { } } - /// Extract timing tree for a completed run. - /// - /// Returns the top-level stages with sub-stages nested. - /// Call after the root span closes. pub fn extract_timings(&self) -> Vec { let completed = self.completed.lock().unwrap_or_else(|e| e.into_inner()); if completed.is_empty() { return Vec::new(); } - // Build children map: parent_id -> Vec let mut children_map: HashMap> = HashMap::new(); let mut roots = Vec::new(); let mut id_to_timing: HashMap = HashMap::new(); - // First pass: collect all timings by ID for entry in completed.iter() { id_to_timing.insert(entry.id, entry.timing.clone()); } - // Second pass: process in reverse order (children close before parents) - // to build the tree bottom-up for entry in completed.iter() { - // Attach any children that were collected for this span if let Some(timing) = id_to_timing.get_mut(&entry.id) && let Some(children) = children_map.remove(&entry.id) { timing.sub_stages = children; } - if let Some(parent_id) = entry.parent_id { - // This is a child span — attach to parent's children - if let Some(timing) = id_to_timing.remove(&entry.id) { - children_map.entry(parent_id).or_default().push(timing); - } + if let Some(parent_id) = entry.parent_id + && let Some(timing) = id_to_timing.remove(&entry.id) + { + children_map.entry(parent_id).or_default().push(timing); } } - // Remaining entries in id_to_timing are roots for entry in completed.iter() { if entry.parent_id.is_none() && let Some(mut timing) = id_to_timing.remove(&entry.id) @@ -144,7 +109,6 @@ impl MetricsLayer { } } -/// Visitor that extracts field values from span attributes. struct FieldVisitor<'a>(&'a mut HashMap); impl tracing::field::Visit for FieldVisitor<'_> { @@ -182,7 +146,6 @@ impl tracing::field::Visit for FieldVisitor<'_> { } } -/// Visitor that extracts event fields for rate-limit/retry detection. #[derive(Default)] struct EventVisitor { status_code: Option, @@ -248,7 +211,6 @@ where } fn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) { - // Count rate-limit and retry events on the current span if let Some(span_ref) = ctx.event_span(event) { let id = span_ref.id(); if let Some(data) = self @@ -317,7 +279,6 @@ where } } -// Manual Debug impl since SpanData and CompletedSpan don't derive Debug impl std::fmt::Debug for SpanData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SpanData") @@ -376,7 +337,6 @@ mod tests { assert_eq!(json["rate_limit_hits"], 2); assert_eq!(json["retries"], 5); - // Sub-stage present let sub = &json["sub_stages"][0]; assert_eq!(sub["name"], "ingest_issues"); assert_eq!(sub["project"], "group/repo"); @@ -400,7 +360,6 @@ mod tests { let json = serde_json::to_value(&timing).unwrap(); let obj = json.as_object().unwrap(); - // Zero fields must be absent assert!(!obj.contains_key("items_skipped")); assert!(!obj.contains_key("errors")); assert!(!obj.contains_key("rate_limit_hits")); @@ -408,7 +367,6 @@ mod tests { assert!(!obj.contains_key("sub_stages")); assert!(!obj.contains_key("project")); - // Required fields always present assert!(obj.contains_key("name")); assert!(obj.contains_key("elapsed_ms")); assert!(obj.contains_key("items_processed")); @@ -539,13 +497,12 @@ mod tests { tracing::subscriber::with_default(subscriber, || { let span = tracing::info_span!("test_stage"); let _guard = span.enter(); - // Simulate work }); let timings = metrics.extract_timings(); assert_eq!(timings.len(), 1); assert_eq!(timings[0].name, "test_stage"); - assert!(timings[0].elapsed_ms < 100); // Should be near-instant + assert!(timings[0].elapsed_ms < 100); } #[test] diff --git a/src/core/paths.rs b/src/core/paths.rs index a3a3a04..7c25591 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -1,50 +1,31 @@ -//! XDG-compliant path resolution for config and data directories. - use std::path::PathBuf; -/// Get the path to the config file. -/// -/// Resolution order: -/// 1. CLI flag override (if provided) -/// 2. LORE_CONFIG_PATH environment variable -/// 3. XDG default (~/.config/lore/config.json) -/// 4. Local fallback (./lore.config.json) if exists -/// 5. Returns XDG default even if not exists pub fn get_config_path(cli_override: Option<&str>) -> PathBuf { - // 1. CLI flag override if let Some(path) = cli_override { return PathBuf::from(path); } - // 2. Environment variable if let Ok(path) = std::env::var("LORE_CONFIG_PATH") { return PathBuf::from(path); } - // 3. XDG default let xdg_path = get_xdg_config_dir().join("lore").join("config.json"); if xdg_path.exists() { return xdg_path; } - // 4. Local fallback (for development) let local_path = PathBuf::from("lore.config.json"); if local_path.exists() { return local_path; } - // 5. Return XDG path (will trigger not-found error if missing) xdg_path } -/// Get the data directory path. -/// Uses XDG_DATA_HOME or defaults to ~/.local/share/lore pub fn get_data_dir() -> PathBuf { get_xdg_data_dir().join("lore") } -/// Get the database file path. -/// Uses config override if provided, otherwise uses default in data dir. pub fn get_db_path(config_override: Option<&str>) -> PathBuf { if let Some(path) = config_override { return PathBuf::from(path); @@ -52,8 +33,6 @@ pub fn get_db_path(config_override: Option<&str>) -> PathBuf { get_data_dir().join("lore.db") } -/// Get the log directory path. -/// Uses config override if provided, otherwise uses default in data dir. pub fn get_log_dir(config_override: Option<&str>) -> PathBuf { if let Some(path) = config_override { return PathBuf::from(path); @@ -61,8 +40,6 @@ pub fn get_log_dir(config_override: Option<&str>) -> PathBuf { get_data_dir().join("logs") } -/// Get the backup directory path. -/// Uses config override if provided, otherwise uses default in data dir. pub fn get_backup_dir(config_override: Option<&str>) -> PathBuf { if let Some(path) = config_override { return PathBuf::from(path); @@ -70,7 +47,6 @@ pub fn get_backup_dir(config_override: Option<&str>) -> PathBuf { get_data_dir().join("backups") } -/// Get XDG config directory, falling back to ~/.config fn get_xdg_config_dir() -> PathBuf { std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) @@ -81,7 +57,6 @@ fn get_xdg_config_dir() -> PathBuf { }) } -/// Get XDG data directory, falling back to ~/.local/share fn get_xdg_data_dir() -> PathBuf { std::env::var("XDG_DATA_HOME") .map(PathBuf::from) @@ -102,8 +77,4 @@ mod tests { let path = get_config_path(Some("/custom/path.json")); assert_eq!(path, PathBuf::from("/custom/path.json")); } - - // Note: env var tests removed - mutating process-global env vars - // in parallel tests is unsafe in Rust 2024. The env var code path - // is trivial (std::env::var) and doesn't warrant the complexity. } diff --git a/src/core/payloads.rs b/src/core/payloads.rs index bc0e1cd..5012360 100644 --- a/src/core/payloads.rs +++ b/src/core/payloads.rs @@ -1,5 +1,3 @@ -//! Raw payload storage with optional compression and deduplication. - use flate2::Compression; use flate2::read::GzDecoder; use flate2::write::GzEncoder; @@ -10,26 +8,21 @@ use std::io::{Read, Write}; use super::error::Result; use super::time::now_ms; -/// Options for storing a payload. pub struct StorePayloadOptions<'a> { pub project_id: Option, - pub resource_type: &'a str, // 'project' | 'issue' | 'mr' | 'note' | 'discussion' - pub gitlab_id: &'a str, // TEXT because discussion IDs are strings + pub resource_type: &'a str, + pub gitlab_id: &'a str, pub json_bytes: &'a [u8], pub compress: bool, } -/// Store a raw API payload with optional compression and deduplication. -/// Returns the row ID (either new or existing if duplicate). pub fn store_payload(conn: &Connection, options: StorePayloadOptions) -> Result { let json_bytes = options.json_bytes; - // 2. SHA-256 hash the JSON bytes (pre-compression) let mut hasher = Sha256::new(); hasher.update(json_bytes); let payload_hash = format!("{:x}", hasher.finalize()); - // 3. Check for duplicate by (project_id, resource_type, gitlab_id, payload_hash) let existing: Option = conn .query_row( "SELECT id FROM raw_payloads @@ -44,12 +37,10 @@ pub fn store_payload(conn: &Connection, options: StorePayloadOptions) -> Result< ) .ok(); - // 4. If duplicate, return existing ID if let Some(id) = existing { return Ok(id); } - // 5. Compress if requested let (encoding, payload_bytes): (&str, std::borrow::Cow<'_, [u8]>) = if options.compress { let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); encoder.write_all(json_bytes)?; @@ -58,7 +49,6 @@ pub fn store_payload(conn: &Connection, options: StorePayloadOptions) -> Result< ("identity", std::borrow::Cow::Borrowed(json_bytes)) }; - // 6. INSERT with content_encoding conn.execute( "INSERT INTO raw_payloads (source, project_id, resource_type, gitlab_id, fetched_at, content_encoding, payload_hash, payload) @@ -77,8 +67,6 @@ pub fn store_payload(conn: &Connection, options: StorePayloadOptions) -> Result< Ok(conn.last_insert_rowid()) } -/// Read a raw payload by ID, decompressing if necessary. -/// Returns None if not found. pub fn read_payload(conn: &Connection, id: i64) -> Result> { let row: Option<(String, Vec)> = conn .query_row( @@ -92,7 +80,6 @@ pub fn read_payload(conn: &Connection, id: i64) -> Result Result { - // Step 1: Exact match let exact = conn.query_row( "SELECT id FROM projects WHERE path_with_namespace = ?1", rusqlite::params![project_str], @@ -19,7 +12,6 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result { return Ok(id); } - // Step 2: Case-insensitive exact match let ci = conn.query_row( "SELECT id FROM projects WHERE LOWER(path_with_namespace) = LOWER(?1)", rusqlite::params![project_str], @@ -29,7 +21,6 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result { return Ok(id); } - // Step 3: Suffix match (unambiguous) let mut suffix_stmt = conn.prepare( "SELECT id, path_with_namespace FROM projects WHERE path_with_namespace LIKE '%/' || ?1 @@ -59,7 +50,6 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result { _ => {} } - // Step 4: Case-insensitive substring match (unambiguous) let mut substr_stmt = conn.prepare( "SELECT id, path_with_namespace FROM projects WHERE LOWER(path_with_namespace) LIKE '%' || LOWER(?1) || '%'", @@ -88,7 +78,6 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result { _ => {} } - // Step 5: No match — list available projects let mut all_stmt = conn.prepare("SELECT path_with_namespace FROM projects ORDER BY path_with_namespace")?; let all_projects: Vec = all_stmt @@ -211,7 +200,6 @@ mod tests { let conn = setup_db(); insert_project(&conn, 1, "vs/python-code"); insert_project(&conn, 2, "vs/typescript-code"); - // "code" matches both projects let err = resolve_project(&conn, "code").unwrap_err(); let msg = err.to_string(); assert!( @@ -225,11 +213,9 @@ mod tests { #[test] fn test_suffix_preferred_over_substring() { - // Suffix match (step 3) should resolve before substring (step 4) let conn = setup_db(); insert_project(&conn, 1, "backend/auth-service"); insert_project(&conn, 2, "backend/auth-service-v2"); - // "auth-service" is an exact suffix of project 1 let id = resolve_project(&conn, "auth-service").unwrap(); assert_eq!(id, 1); } diff --git a/src/core/sync_run.rs b/src/core/sync_run.rs index 53442dd..326fb4d 100644 --- a/src/core/sync_run.rs +++ b/src/core/sync_run.rs @@ -1,25 +1,14 @@ -//! Sync run lifecycle recorder. -//! -//! Encapsulates the INSERT-on-start, UPDATE-on-finish lifecycle for the -//! `sync_runs` table, enabling sync history tracking and observability. - use rusqlite::Connection; use super::error::Result; use super::metrics::StageTiming; use super::time::now_ms; -/// Records a single sync run's lifecycle in the `sync_runs` table. -/// -/// Created via [`start`](Self::start), then finalized with either -/// [`succeed`](Self::succeed) or [`fail`](Self::fail). Both finalizers -/// consume `self` to enforce single-use at compile time. pub struct SyncRunRecorder { row_id: i64, } impl SyncRunRecorder { - /// Insert a new `sync_runs` row with `status='running'`. pub fn start(conn: &Connection, command: &str, run_id: &str) -> Result { let now = now_ms(); conn.execute( @@ -31,7 +20,6 @@ impl SyncRunRecorder { Ok(Self { row_id }) } - /// Mark run as succeeded with full metrics. pub fn succeed( self, conn: &Connection, @@ -57,7 +45,6 @@ impl SyncRunRecorder { Ok(()) } - /// Mark run as failed with error message and optional partial metrics. pub fn fail( self, conn: &Connection, @@ -158,7 +145,6 @@ mod tests { assert_eq!(total_items, 50); assert_eq!(total_errors, 2); - // Verify metrics_json is parseable let parsed: Vec = serde_json::from_str(&metrics_json.unwrap()).unwrap(); assert_eq!(parsed.len(), 1); assert_eq!(parsed[0].name, "ingest"); diff --git a/src/core/time.rs b/src/core/time.rs index 9f59601..bb37d2f 100644 --- a/src/core/time.rs +++ b/src/core/time.rs @@ -1,39 +1,24 @@ -//! Time utilities for consistent timestamp handling. -//! -//! All database *_at columns use milliseconds since epoch for consistency. - use chrono::{DateTime, Utc}; -/// Convert GitLab API ISO 8601 timestamp to milliseconds since epoch. pub fn iso_to_ms(iso_string: &str) -> Option { DateTime::parse_from_rfc3339(iso_string) .ok() .map(|dt| dt.timestamp_millis()) } -/// Convert milliseconds since epoch to ISO 8601 string. pub fn ms_to_iso(ms: i64) -> String { DateTime::from_timestamp_millis(ms) .map(|dt| dt.to_rfc3339()) .unwrap_or_else(|| "Invalid timestamp".to_string()) } -/// Get current time in milliseconds since epoch. pub fn now_ms() -> i64 { Utc::now().timestamp_millis() } -/// Parse a relative time string (7d, 2w, 1m) or ISO date into ms epoch. -/// -/// Returns the timestamp as of which to filter (cutoff point). -/// - `7d` = 7 days ago -/// - `2w` = 2 weeks ago -/// - `1m` = 1 month ago (30 days) -/// - `2024-01-15` = midnight UTC on that date pub fn parse_since(input: &str) -> Option { let input = input.trim(); - // Try relative format: Nd, Nw, Nm if let Some(num_str) = input.strip_suffix('d') { let days: i64 = num_str.parse().ok()?; return Some(now_ms() - (days * 24 * 60 * 60 * 1000)); @@ -49,25 +34,20 @@ pub fn parse_since(input: &str) -> Option { return Some(now_ms() - (months * 30 * 24 * 60 * 60 * 1000)); } - // Try ISO date: YYYY-MM-DD if input.len() == 10 && input.chars().filter(|&c| c == '-').count() == 2 { let iso_full = format!("{input}T00:00:00Z"); return iso_to_ms(&iso_full); } - // Try full ISO 8601 iso_to_ms(input) } -/// Convert ISO 8601 timestamp to milliseconds with strict error handling. -/// Returns Err with a descriptive message if the timestamp is invalid. pub fn iso_to_ms_strict(iso_string: &str) -> Result { DateTime::parse_from_rfc3339(iso_string) .map(|dt| dt.timestamp_millis()) .map_err(|_| format!("Invalid timestamp: {}", iso_string)) } -/// Convert optional ISO 8601 timestamp to optional milliseconds (strict). pub fn iso_to_ms_opt_strict(iso_string: &Option) -> Result, String> { match iso_string { Some(s) => iso_to_ms_strict(s).map(Some), @@ -75,7 +55,6 @@ pub fn iso_to_ms_opt_strict(iso_string: &Option) -> Result, } } -/// Format milliseconds epoch to human-readable full datetime. pub fn format_full_datetime(ms: i64) -> String { DateTime::from_timestamp_millis(ms) .map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string()) @@ -101,7 +80,7 @@ mod tests { #[test] fn test_now_ms() { let now = now_ms(); - assert!(now > 1700000000000); // After 2023 + assert!(now > 1700000000000); } #[test] @@ -109,7 +88,7 @@ mod tests { let now = now_ms(); let seven_days = parse_since("7d").unwrap(); let expected = now - (7 * 24 * 60 * 60 * 1000); - assert!((seven_days - expected).abs() < 1000); // Within 1 second + assert!((seven_days - expected).abs() < 1000); } #[test] @@ -132,7 +111,6 @@ mod tests { fn test_parse_since_iso_date() { let ms = parse_since("2024-01-15").unwrap(); assert!(ms > 0); - // Should be midnight UTC on that date let expected = iso_to_ms("2024-01-15T00:00:00Z").unwrap(); assert_eq!(ms, expected); } diff --git a/src/documents/extractor.rs b/src/documents/extractor.rs index bbeb03c..552db58 100644 --- a/src/documents/extractor.rs +++ b/src/documents/extractor.rs @@ -9,7 +9,6 @@ use super::truncation::{ }; use crate::core::error::Result; -/// Source type for documents. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SourceType { @@ -27,10 +26,6 @@ impl SourceType { } } - /// Parse from CLI input, accepting common aliases. - /// - /// Accepts: "issue", "issues", "mr", "mrs", "merge_request", "merge_requests", - /// "discussion", "discussions" pub fn parse(s: &str) -> Option { match s.to_lowercase().as_str() { "issue" | "issues" => Some(Self::Issue), @@ -47,7 +42,6 @@ impl std::fmt::Display for SourceType { } } -/// Generated document ready for storage. #[derive(Debug, Clone)] pub struct DocumentData { pub source_type: SourceType, @@ -68,16 +62,12 @@ pub struct DocumentData { pub truncated_reason: Option, } -/// Compute SHA-256 hash of content. pub fn compute_content_hash(content: &str) -> String { let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); format!("{:x}", hasher.finalize()) } -/// Compute SHA-256 hash over a sorted list of strings. -/// Used for labels_hash and paths_hash to detect changes efficiently. -/// Sorts by index reference to avoid cloning, hashes incrementally to avoid join allocation. pub fn compute_list_hash(items: &[String]) -> String { let mut indices: Vec = (0..items.len()).collect(); indices.sort_by(|a, b| items[*a].cmp(&items[*b])); @@ -91,10 +81,7 @@ pub fn compute_list_hash(items: &[String]) -> String { format!("{:x}", hasher.finalize()) } -/// Extract a searchable document from an issue. -/// Returns None if the issue has been deleted from the DB. pub fn extract_issue_document(conn: &Connection, issue_id: i64) -> Result> { - // Query main issue entity with project info let row = conn.query_row( "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, i.created_at, i.updated_at, i.web_url, @@ -105,17 +92,17 @@ pub fn extract_issue_document(conn: &Connection, issue_id: i64) -> Result