//! Sync status command - display synchronization state from local database. use console::style; use rusqlite::Connection; use serde::Serialize; use crate::Config; use crate::core::db::create_connection; use crate::core::error::Result; use crate::core::paths::get_db_path; use crate::core::time::ms_to_iso; /// Sync run information. #[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, } /// Cursor position information. #[derive(Debug)] pub struct CursorInfo { pub project_path: String, pub resource_type: String, pub updated_at_cursor: Option, pub tie_breaker_id: Option, } /// Data summary counts. #[derive(Debug)] pub struct DataSummary { pub issue_count: i64, pub discussion_count: i64, pub note_count: i64, pub system_note_count: i64, } /// Complete sync status result. #[derive(Debug)] pub struct SyncStatusResult { pub last_run: Option, pub cursors: Vec, 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)?; let last_run = get_last_sync_run(&conn)?; let cursors = get_cursor_positions(&conn)?; let summary = get_data_summary(&conn)?; Ok(SyncStatusResult { last_run, cursors, summary, }) } /// Get the most recent sync run. fn get_last_sync_run(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, started_at, finished_at, status, command, error FROM sync_runs ORDER BY started_at DESC LIMIT 1", )?; let result = stmt.query_row([], |row| { 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)?, }) }); match result { Ok(info) => Ok(Some(info)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.into()), } } /// Get cursor positions for all projects/resource types. 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?) } /// 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)) .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, discussion_count, note_count, system_note_count, }) } /// Format duration in milliseconds to human-readable string. 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 { format!("{}s", seconds) } } /// Format number with thousands separators. fn format_number(n: i64) -> String { let is_negative = n < 0; let abs_n = n.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 } /// JSON output structures for robot mode. #[derive(Serialize)] struct SyncStatusJsonOutput { ok: bool, data: SyncStatusJsonData, } #[derive(Serialize)] struct SyncStatusJsonData { last_sync: Option, 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")] error: 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, discussions: i64, notes: i64, system_notes: i64, } /// Print sync status as JSON (robot mode). pub fn print_sync_status_json(result: &SyncStatusResult) { let last_sync = result.last_run.as_ref().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, error: run.error.clone(), } }); 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 { last_sync, cursors, summary: SummaryJsonInfo { issues: result.summary.issue_count, discussions: result.summary.discussion_count, notes: result.summary.note_count - result.summary.system_note_count, system_notes: result.summary.system_note_count, }, }, }; println!("{}", serde_json::to_string(&output).unwrap()); } /// Print sync status result. pub fn print_sync_status(result: &SyncStatusResult) { // Last Sync section println!("{}", style("Last Sync").bold().underlined()); println!(); match &result.last_run { Some(run) => { 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(), }; println!(" Status: {}", status_styled); println!(" Command: {}", run.command); println!(" Started: {}", ms_to_iso(run.started_at)); if let Some(finished) = run.finished_at { println!(" Completed: {}", ms_to_iso(finished)); let duration = finished - run.started_at; println!(" Duration: {}", format_duration(duration)); } if let Some(error) = &run.error { println!(" Error: {}", style(error).red()); } } None => { println!(" {}", style("No sync runs recorded yet.").dim()); println!( " {}", style("Run 'lore ingest --type=issues' to start.").dim() ); } } println!(); // Cursor Positions section 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!(); // Data Summary section println!("{}", style("Data Summary").bold().underlined()); println!(); println!( " Issues: {}", style(format_number(result.summary.issue_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() ); } #[cfg(test)] mod tests { use super::*; #[test] fn format_duration_handles_seconds() { assert_eq!(format_duration(5_000), "5s"); assert_eq!(format_duration(59_000), "59s"); } #[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_number_adds_thousands_separators() { assert_eq!(format_number(1000), "1,000"); assert_eq!(format_number(1234567), "1,234,567"); } }