use console::style; use rusqlite::Connection; use serde::Serialize; use crate::Config; use crate::cli::robot::RobotMeta; use crate::core::db::create_connection; use crate::core::error::Result; use crate::core::metrics::StageTiming; use crate::core::paths::get_db_path; use crate::core::time::{format_full_datetime, ms_to_iso}; const RECENT_RUNS_LIMIT: usize = 10; #[derive(Debug)] pub struct SyncRunInfo { pub id: i64, pub started_at: i64, pub finished_at: Option, pub status: String, pub command: String, pub error: Option, pub run_id: Option, pub total_items_processed: i64, pub total_errors: i64, pub stages: Option>, } #[derive(Debug)] pub struct CursorInfo { pub project_path: String, pub resource_type: String, pub updated_at_cursor: Option, pub tie_breaker_id: Option, } #[derive(Debug)] pub struct DataSummary { pub issue_count: i64, pub mr_count: i64, pub discussion_count: i64, pub note_count: i64, pub system_note_count: i64, } #[derive(Debug)] pub struct SyncStatusResult { pub runs: Vec, pub cursors: Vec, pub summary: DataSummary, } 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)?; let runs = get_recent_sync_runs(&conn, RECENT_RUNS_LIMIT)?; let cursors = get_cursor_positions(&conn)?; let summary = get_data_summary(&conn)?; Ok(SyncStatusResult { runs, cursors, summary, }) } fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result> { let mut stmt = conn.prepare( "SELECT id, started_at, finished_at, status, command, error, run_id, total_items_processed, total_errors, metrics_json FROM sync_runs ORDER BY started_at DESC LIMIT ?1", )?; let runs: std::result::Result, _> = stmt .query_map([limit as i64], |row| { let metrics_json: Option = row.get(9)?; let stages: Option> = metrics_json.and_then(|json| serde_json::from_str(&json).ok()); Ok(SyncRunInfo { id: row.get(0)?, started_at: row.get(1)?, finished_at: row.get(2)?, status: row.get(3)?, command: row.get(4)?, error: row.get(5)?, run_id: row.get(6)?, total_items_processed: row.get::<_, Option>(7)?.unwrap_or(0), total_errors: row.get::<_, Option>(8)?.unwrap_or(0), stages, }) })? .collect(); Ok(runs?) } fn get_cursor_positions(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT p.path_with_namespace, sc.resource_type, sc.updated_at_cursor, sc.tie_breaker_id FROM sync_cursors sc JOIN projects p ON sc.project_id = p.id ORDER BY p.path_with_namespace, sc.resource_type", )?; let cursors: std::result::Result, _> = stmt .query_map([], |row| { Ok(CursorInfo { project_path: row.get(0)?, resource_type: row.get(1)?, updated_at_cursor: row.get(2)?, tie_breaker_id: row.get(3)?, }) })? .collect(); Ok(cursors?) } fn get_data_summary(conn: &Connection) -> Result { let issue_count: i64 = conn .query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0)) .unwrap_or(0); let mr_count: i64 = conn .query_row("SELECT COUNT(*) FROM merge_requests", [], |row| row.get(0)) .unwrap_or(0); let discussion_count: i64 = conn .query_row("SELECT COUNT(*) FROM discussions", [], |row| row.get(0)) .unwrap_or(0); let (note_count, system_note_count): (i64, i64) = conn .query_row( "SELECT COUNT(*), COALESCE(SUM(is_system), 0) FROM notes", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap_or((0, 0)); Ok(DataSummary { issue_count, mr_count, discussion_count, note_count, system_note_count, }) } fn format_duration(ms: i64) -> String { let seconds = ms / 1000; let minutes = seconds / 60; let hours = minutes / 60; if hours > 0 { format!("{}h {}m {}s", hours, minutes % 60, seconds % 60) } else if minutes > 0 { format!("{}m {}s", minutes, seconds % 60) } else if ms >= 1000 { format!("{:.1}s", ms as f64 / 1000.0) } else { format!("{}ms", ms) } } fn format_number(n: i64) -> String { let is_negative = n < 0; let abs_n = n.unsigned_abs(); let s = abs_n.to_string(); let chars: Vec = s.chars().collect(); let mut result = String::new(); if is_negative { result.push('-'); } for (i, c) in chars.iter().enumerate() { if i > 0 && (chars.len() - i).is_multiple_of(3) { result.push(','); } result.push(*c); } result } #[derive(Serialize)] struct SyncStatusJsonOutput { ok: bool, data: SyncStatusJsonData, meta: RobotMeta, } #[derive(Serialize)] struct SyncStatusJsonData { runs: Vec, cursors: Vec, summary: SummaryJsonInfo, } #[derive(Serialize)] struct SyncRunJsonInfo { id: i64, status: String, command: String, started_at: String, #[serde(skip_serializing_if = "Option::is_none")] completed_at: Option, #[serde(skip_serializing_if = "Option::is_none")] duration_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] run_id: Option, total_items_processed: i64, total_errors: i64, #[serde(skip_serializing_if = "Option::is_none")] error: Option, #[serde(skip_serializing_if = "Option::is_none")] stages: Option>, } #[derive(Serialize)] struct CursorJsonInfo { project: String, resource_type: String, #[serde(skip_serializing_if = "Option::is_none")] updated_at_cursor: Option, #[serde(skip_serializing_if = "Option::is_none")] tie_breaker_id: Option, } #[derive(Serialize)] struct SummaryJsonInfo { issues: i64, merge_requests: i64, discussions: i64, notes: i64, system_notes: i64, } pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) { let runs = result .runs .iter() .map(|run| { let duration_ms = run.finished_at.map(|f| f - run.started_at); SyncRunJsonInfo { id: run.id, status: run.status.clone(), command: run.command.clone(), started_at: ms_to_iso(run.started_at), completed_at: run.finished_at.map(ms_to_iso), duration_ms, run_id: run.run_id.clone(), total_items_processed: run.total_items_processed, total_errors: run.total_errors, error: run.error.clone(), stages: run.stages.clone(), } }) .collect(); let cursors = result .cursors .iter() .map(|c| CursorJsonInfo { project: c.project_path.clone(), resource_type: c.resource_type.clone(), updated_at_cursor: c.updated_at_cursor.filter(|&ts| ts > 0).map(ms_to_iso), tie_breaker_id: c.tie_breaker_id, }) .collect(); let output = SyncStatusJsonOutput { ok: true, data: SyncStatusJsonData { runs, cursors, summary: SummaryJsonInfo { issues: result.summary.issue_count, merge_requests: result.summary.mr_count, discussions: result.summary.discussion_count, notes: result.summary.note_count - result.summary.system_note_count, system_notes: result.summary.system_note_count, }, }, meta: RobotMeta { elapsed_ms }, }; println!("{}", serde_json::to_string(&output).unwrap()); } pub fn print_sync_status(result: &SyncStatusResult) { println!("{}", style("Recent Sync Runs").bold().underlined()); println!(); if result.runs.is_empty() { println!(" {}", style("No sync runs recorded yet.").dim()); println!( " {}", style("Run 'lore sync' or 'lore ingest' to start.").dim() ); } else { for run in &result.runs { print_run_line(run); } } println!(); println!("{}", style("Cursor Positions").bold().underlined()); println!(); if result.cursors.is_empty() { println!(" {}", style("No cursors recorded yet.").dim()); } else { for cursor in &result.cursors { println!( " {} ({}):", style(&cursor.project_path).cyan(), cursor.resource_type ); match cursor.updated_at_cursor { Some(ts) if ts > 0 => { println!(" Last updated_at: {}", ms_to_iso(ts)); } _ => { println!(" Last updated_at: {}", style("Not started").dim()); } } if let Some(id) = cursor.tie_breaker_id { println!(" Last GitLab ID: {}", id); } } } println!(); println!("{}", style("Data Summary").bold().underlined()); println!(); println!( " Issues: {}", style(format_number(result.summary.issue_count)).bold() ); println!( " MRs: {}", style(format_number(result.summary.mr_count)).bold() ); println!( " Discussions: {}", style(format_number(result.summary.discussion_count)).bold() ); let user_notes = result.summary.note_count - result.summary.system_note_count; println!( " Notes: {} {}", style(format_number(user_notes)).bold(), style(format!( "(excluding {} system)", format_number(result.summary.system_note_count) )) .dim() ); } fn print_run_line(run: &SyncRunInfo) { let status_styled = match run.status.as_str() { "succeeded" => style(&run.status).green(), "failed" => style(&run.status).red(), "running" => style(&run.status).yellow(), _ => style(&run.status).dim(), }; let run_label = run .run_id .as_deref() .map_or_else(|| format!("#{}", run.id), |id| format!("Run {id}")); let duration = run.finished_at.map(|f| format_duration(f - run.started_at)); let time = format_full_datetime(run.started_at); let mut parts = vec![ format!("{}", style(run_label).bold()), format!("{status_styled}"), format!("{}", style(&run.command).dim()), time, ]; if let Some(d) = duration { parts.push(d); } else { parts.push("in progress".to_string()); } if run.total_items_processed > 0 { parts.push(format!("{} items", run.total_items_processed)); } if run.total_errors > 0 { parts.push(format!( "{}", style(format!("{} errors", run.total_errors)).red() )); } println!(" {}", parts.join(" | ")); if let Some(error) = &run.error { println!(" {}", style(error).red()); } } #[cfg(test)] mod tests { use super::*; #[test] fn format_duration_handles_seconds() { assert_eq!(format_duration(5_000), "5.0s"); assert_eq!(format_duration(59_000), "59.0s"); } #[test] fn format_duration_handles_minutes() { assert_eq!(format_duration(60_000), "1m 0s"); assert_eq!(format_duration(90_000), "1m 30s"); assert_eq!(format_duration(300_000), "5m 0s"); } #[test] fn format_duration_handles_hours() { assert_eq!(format_duration(3_600_000), "1h 0m 0s"); assert_eq!(format_duration(5_400_000), "1h 30m 0s"); assert_eq!(format_duration(3_723_000), "1h 2m 3s"); } #[test] fn format_duration_handles_milliseconds() { assert_eq!(format_duration(500), "500ms"); assert_eq!(format_duration(0), "0ms"); } #[test] fn format_number_adds_thousands_separators() { assert_eq!(format_number(1000), "1,000"); assert_eq!(format_number(1234567), "1,234,567"); } }