use serde::Serialize; use crate::Config; use crate::cli::render::Theme; use crate::cli::robot::RobotMeta; use crate::core::cron::{ CronInstallResult, CronStatusResult, CronUninstallResult, cron_status, install_cron, uninstall_cron, }; 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; // ── install ── pub fn run_cron_install(interval_minutes: u32) -> Result { install_cron(interval_minutes) } pub fn print_cron_install(result: &CronInstallResult) { if result.replaced { println!( " {} cron entry updated (was already installed)", Theme::success().render("Updated") ); } else { println!( " {} cron entry installed", Theme::success().render("Installed") ); } println!(); println!(" {} {}", Theme::dim().render("entry:"), result.entry); println!( " {} every {} minutes", Theme::dim().render("interval:"), result.interval_minutes ); println!( " {} {}", Theme::dim().render("log:"), result.log_path.display() ); if cfg!(target_os = "macos") { println!(); println!( " {} On macOS, the terminal running cron may need", Theme::warning().render("Note:") ); println!(" Full Disk Access in System Settings > Privacy & Security."); } println!(); } #[derive(Serialize)] struct CronInstallJson { ok: bool, data: CronInstallData, meta: RobotMeta, } #[derive(Serialize)] struct CronInstallData { action: &'static str, entry: String, interval_minutes: u32, log_path: String, replaced: bool, } pub fn print_cron_install_json(result: &CronInstallResult, elapsed_ms: u64) { let output = CronInstallJson { ok: true, data: CronInstallData { action: "install", entry: result.entry.clone(), interval_minutes: result.interval_minutes, log_path: result.log_path.display().to_string(), replaced: result.replaced, }, meta: RobotMeta { elapsed_ms }, }; if let Ok(json) = serde_json::to_string(&output) { println!("{json}"); } } // ── uninstall ── pub fn run_cron_uninstall() -> Result { uninstall_cron() } pub fn print_cron_uninstall(result: &CronUninstallResult) { if result.was_installed { println!( " {} cron entry removed", Theme::success().render("Removed") ); } else { println!( " {} no lore-sync cron entry found", Theme::dim().render("Nothing to remove:") ); } println!(); } #[derive(Serialize)] struct CronUninstallJson { ok: bool, data: CronUninstallData, meta: RobotMeta, } #[derive(Serialize)] struct CronUninstallData { action: &'static str, was_installed: bool, } pub fn print_cron_uninstall_json(result: &CronUninstallResult, elapsed_ms: u64) { let output = CronUninstallJson { ok: true, data: CronUninstallData { action: "uninstall", was_installed: result.was_installed, }, meta: RobotMeta { elapsed_ms }, }; if let Ok(json) = serde_json::to_string(&output) { println!("{json}"); } } // ── status ── pub fn run_cron_status(config: &Config) -> Result { let status = cron_status()?; // Query last sync run from DB let last_sync = get_last_sync_time(config).unwrap_or_default(); Ok(CronStatusInfo { status, last_sync }) } pub struct CronStatusInfo { pub status: CronStatusResult, pub last_sync: Option, } pub struct LastSyncInfo { pub started_at_iso: String, pub status: String, } fn get_last_sync_time(config: &Config) -> Result> { let db_path = get_db_path(config.storage.db_path.as_deref()); if !db_path.exists() { return Ok(None); } let conn = create_connection(&db_path)?; let result = conn.query_row( "SELECT started_at, status FROM sync_runs ORDER BY started_at DESC LIMIT 1", [], |row| { let started_at: i64 = row.get(0)?; let status: String = row.get(1)?; Ok(LastSyncInfo { started_at_iso: ms_to_iso(started_at), status, }) }, ); match result { Ok(info) => Ok(Some(info)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), // Table may not exist if migrations haven't run yet Err(rusqlite::Error::SqliteFailure(_, Some(ref msg))) if msg.contains("no such table") => { Ok(None) } Err(e) => Err(e.into()), } } pub fn print_cron_status(info: &CronStatusInfo) { if info.status.installed { println!( " {} lore-sync is installed in crontab", Theme::success().render("Installed") ); if let Some(interval) = info.status.interval_minutes { println!( " {} every {} minutes", Theme::dim().render("interval:"), interval ); } if let Some(ref binary) = info.status.binary_path { let label = if info.status.binary_mismatch { Theme::warning().render("binary:") } else { Theme::dim().render("binary:") }; println!(" {label} {binary}"); if info.status.binary_mismatch && let Some(ref current) = info.status.current_binary { println!( " {}", Theme::warning().render(&format!(" current binary is {current} (mismatch!)")) ); } } if let Some(ref log) = info.status.log_path { println!(" {} {}", Theme::dim().render("log:"), log.display()); } } else { println!( " {} lore-sync is not installed in crontab", Theme::dim().render("Not installed:") ); println!( " {} lore cron install", Theme::dim().render("install with:") ); } if let Some(ref last) = info.last_sync { println!( " {} {} ({})", Theme::dim().render("last sync:"), last.started_at_iso, last.status ); } println!(); } #[derive(Serialize)] struct CronStatusJson { ok: bool, data: CronStatusData, meta: RobotMeta, } #[derive(Serialize)] struct CronStatusData { installed: bool, #[serde(skip_serializing_if = "Option::is_none")] interval_minutes: Option, #[serde(skip_serializing_if = "Option::is_none")] binary_path: Option, #[serde(skip_serializing_if = "Option::is_none")] current_binary: Option, binary_mismatch: bool, #[serde(skip_serializing_if = "Option::is_none")] log_path: Option, #[serde(skip_serializing_if = "Option::is_none")] cron_entry: Option, #[serde(skip_serializing_if = "Option::is_none")] last_sync_at: Option, #[serde(skip_serializing_if = "Option::is_none")] last_sync_status: Option, } pub fn print_cron_status_json(info: &CronStatusInfo, elapsed_ms: u64) { let output = CronStatusJson { ok: true, data: CronStatusData { installed: info.status.installed, interval_minutes: info.status.interval_minutes, binary_path: info.status.binary_path.clone(), current_binary: info.status.current_binary.clone(), binary_mismatch: info.status.binary_mismatch, log_path: info .status .log_path .as_ref() .map(|p| p.display().to_string()), cron_entry: info.status.cron_entry.clone(), last_sync_at: info.last_sync.as_ref().map(|s| s.started_at_iso.clone()), last_sync_status: info.last_sync.as_ref().map(|s| s.status.clone()), }, meta: RobotMeta { elapsed_ms }, }; if let Ok(json) = serde_json::to_string(&output) { println!("{json}"); } }