From 53ce20595b8da3368d8fa016126191a5c06bb18f Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 18 Feb 2026 13:29:07 -0500 Subject: [PATCH] feat(cron): add lore cron command for automated sync scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lore cron {install,uninstall,status} to manage a crontab entry that runs lore sync on a configurable interval. Supports both human and robot output modes. Core implementation (src/core/cron.rs): - install_cron: appends a tagged crontab entry, detects existing entries - uninstall_cron: removes the tagged entry - cron_status: reads crontab + checks last-sync time from the database - Unix-only (#[cfg(unix)]) — compiles out on Windows CLI wiring: - CronAction enum and CronArgs in cli/mod.rs with after_help examples - Robot JSON envelope with RobotMeta timing for all 3 sub-actions - Dispatch in main.rs Also in this commit: - Add after_help example blocks to Status, Auth, Doctor, Init, Migrate, Health commands for better discoverability - Add LORE_ICONS env var documentation to CLI help text - Simplify notes format dispatch in main.rs (removed csv/jsonl paths) - Update commands/mod.rs re-exports for cron + notes cleanup Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/cron.rs | 292 +++++++++++++++++++++++++++++++ src/cli/commands/mod.rs | 11 +- src/cli/mod.rs | 99 +++++++++-- src/core/cron.rs | 369 +++++++++++++++++++++++++++++++++++++++ src/core/mod.rs | 2 + src/main.rs | 114 +++++++++--- 6 files changed, 844 insertions(+), 43 deletions(-) create mode 100644 src/cli/commands/cron.rs create mode 100644 src/core/cron.rs diff --git a/src/cli/commands/cron.rs b/src/cli/commands/cron.rs new file mode 100644 index 0000000..68e13b5 --- /dev/null +++ b/src/cli/commands/cron.rs @@ -0,0 +1,292 @@ +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}"); + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index d7a22f6..40e683e 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -1,5 +1,7 @@ pub mod auth_test; pub mod count; +#[cfg(unix)] +pub mod cron; pub mod doctor; pub mod drift; pub mod embed; @@ -22,6 +24,12 @@ pub use count::{ print_count, print_count_json, print_event_count, print_event_count_json, run_count, run_count_events, }; +#[cfg(unix)] +pub use cron::{ + print_cron_install, print_cron_install_json, print_cron_status, print_cron_status_json, + print_cron_uninstall, print_cron_uninstall_json, run_cron_install, run_cron_status, + run_cron_uninstall, +}; pub use doctor::{DoctorChecks, print_doctor_results, run_doctor}; pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift}; pub use embed::{print_embed, print_embed_json, run_embed}; @@ -35,8 +43,7 @@ pub use init::{InitInputs, InitOptions, InitResult, run_init}; pub use list::{ ListFilters, MrListFilters, NoteListFilters, open_issue_in_browser, open_mr_in_browser, print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json, - print_list_notes, print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, - query_notes, run_list_issues, run_list_mrs, + print_list_notes, print_list_notes_json, query_notes, run_list_issues, run_list_mrs, }; pub use search::{ SearchCliFilters, SearchResponse, print_search_results, print_search_results_json, run_search, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 08d81a9..1ec545e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -16,7 +16,9 @@ use std::io::IsTerminal; GITLAB_TOKEN GitLab personal access token (or name set in config) LORE_ROBOT Enable robot/JSON mode (non-empty, non-zero value) LORE_CONFIG_PATH Override config file location - NO_COLOR Disable color output (any non-empty value)")] + NO_COLOR Disable color output (any non-empty value) + LORE_ICONS Override icon set: nerd, unicode, or ascii + NERD_FONTS Enable Nerd Font icons when set to a non-empty value")] pub struct Cli { /// Path to config file #[arg(short = 'c', long, global = true, help = "Path to config file")] @@ -135,19 +137,35 @@ pub enum Commands { Count(CountArgs), /// Show sync state - #[command(visible_alias = "st")] + #[command( + visible_alias = "st", + after_help = "\x1b[1mExamples:\x1b[0m + lore status # Show last sync times per project + lore --robot status # JSON output for automation" + )] Status, /// Verify GitLab authentication + #[command(after_help = "\x1b[1mExamples:\x1b[0m + lore auth # Verify token and show user info + lore --robot auth # JSON output for automation")] Auth, /// Check environment health + #[command(after_help = "\x1b[1mExamples:\x1b[0m + lore doctor # Check config, token, database, Ollama + lore --robot doctor # JSON output for automation")] Doctor, /// Show version information Version, /// Initialize configuration and database + #[command(after_help = "\x1b[1mExamples:\x1b[0m + lore init # Interactive setup + lore init --force # Overwrite existing config + lore --robot init --gitlab-url https://gitlab.com \\ + --token-env-var GITLAB_TOKEN --projects group/repo # Non-interactive setup")] Init { /// Skip overwrite confirmation #[arg(short = 'f', long)] @@ -174,11 +192,14 @@ pub enum Commands { default_project: Option, }, + /// Back up local database (not yet implemented) #[command(hide = true)] Backup, + /// Reset local database (not yet implemented) #[command(hide = true)] Reset { + /// Skip confirmation prompt #[arg(short = 'y', long)] yes: bool, }, @@ -202,9 +223,15 @@ pub enum Commands { Sync(SyncArgs), /// Run pending database migrations + #[command(after_help = "\x1b[1mExamples:\x1b[0m + lore migrate # Apply pending migrations + lore --robot migrate # JSON output for automation")] Migrate, /// Quick health check: config, database, schema version + #[command(after_help = "\x1b[1mExamples:\x1b[0m + lore health # Quick pre-flight check (exit 0 = healthy) + lore --robot health # JSON output for automation")] Health, /// Machine-readable command manifest for agent self-discovery @@ -242,6 +269,10 @@ pub enum Commands { Trace(TraceArgs), /// Detect discussion divergence from original intent + #[command(after_help = "\x1b[1mExamples:\x1b[0m + lore drift issues 42 # Check drift on issue #42 + lore drift issues 42 --threshold 0.3 # Custom similarity threshold + lore --robot drift issues 42 -p group/repo # JSON output, scoped to project")] Drift { /// Entity type (currently only "issues" supported) #[arg(value_parser = ["issues"])] @@ -259,6 +290,14 @@ pub enum Commands { project: Option, }, + /// Manage cron-based automatic syncing + #[command(after_help = "\x1b[1mExamples:\x1b[0m + lore cron install # Install cron job (every 8 minutes) + lore cron install --interval 15 # Custom interval + lore cron status # Check if cron is installed + lore cron uninstall # Remove cron job")] + Cron(CronArgs), + #[command(hide = true)] List { #[arg(value_parser = ["issues", "mrs"])] @@ -344,7 +383,7 @@ pub struct IssuesArgs { pub fields: Option>, /// Filter by state (opened, closed, all) - #[arg(short = 's', long, help_heading = "Filters")] + #[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "closed", "all"])] pub state: Option, /// Filter by project path @@ -438,7 +477,7 @@ pub struct MrsArgs { pub fields: Option>, /// Filter by state (opened, merged, closed, locked, all) - #[arg(short = 's', long, help_heading = "Filters")] + #[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "merged", "closed", "locked", "all"])] pub state: Option, /// Filter by project path @@ -535,15 +574,6 @@ pub struct NotesArgs { #[arg(long, help_heading = "Output", value_delimiter = ',')] pub fields: Option>, - /// Output format (table, json, jsonl, csv) - #[arg( - long, - default_value = "table", - value_parser = ["table", "json", "jsonl", "csv"], - help_heading = "Output" - )] - pub format: String, - /// Filter by author username #[arg(short = 'a', long, help_heading = "Filters")] pub author: Option, @@ -655,6 +685,11 @@ pub struct IngestArgs { } #[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore stats # Show document and index statistics + lore stats --check # Run integrity checks + lore stats --repair --dry-run # Preview what repair would fix + lore --robot stats # JSON output for automation")] pub struct StatsArgs { /// Run integrity checks #[arg(long, overrides_with = "no_check")] @@ -743,6 +778,10 @@ pub struct SearchArgs { } #[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore generate-docs # Generate docs for dirty entities + lore generate-docs --full # Full rebuild of all documents + lore generate-docs --full -p group/repo # Full rebuild for one project")] pub struct GenerateDocsArgs { /// Full rebuild: seed all entities into dirty queue, then drain #[arg(long)] @@ -805,9 +844,17 @@ pub struct SyncArgs { /// Show detailed timing breakdown for sync stages #[arg(short = 't', long = "timings")] pub timings: bool, + + /// Acquire file lock before syncing (skip if another sync is running) + #[arg(long)] + pub lock: bool, } #[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore embed # Embed new/changed documents + lore embed --full # Re-embed all documents from scratch + lore embed --retry-failed # Retry previously failed embeddings")] pub struct EmbedArgs { /// Re-embed all documents (clears existing embeddings first) #[arg(long, overrides_with = "no_full")] @@ -1046,6 +1093,10 @@ pub struct TraceArgs { } #[derive(Parser)] +#[command(after_help = "\x1b[1mExamples:\x1b[0m + lore count issues # Total issues in local database + lore count notes --for mr # Notes on merge requests only + lore count discussions --for issue # Discussions on issues only")] pub struct CountArgs { /// Entity type to count (issues, mrs, discussions, notes, events) #[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])] @@ -1055,3 +1106,25 @@ pub struct CountArgs { #[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])] pub for_entity: Option, } + +#[derive(Parser)] +pub struct CronArgs { + #[command(subcommand)] + pub action: CronAction, +} + +#[derive(Subcommand)] +pub enum CronAction { + /// Install cron job for automatic syncing + Install { + /// Sync interval in minutes (default: 8) + #[arg(long, default_value = "8")] + interval: u32, + }, + + /// Remove cron job + Uninstall, + + /// Show current cron configuration + Status, +} diff --git a/src/core/cron.rs b/src/core/cron.rs new file mode 100644 index 0000000..3220d4a --- /dev/null +++ b/src/core/cron.rs @@ -0,0 +1,369 @@ +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::Serialize; + +use super::error::{LoreError, Result}; +use super::paths::get_data_dir; + +const CRON_TAG: &str = "# lore-sync"; + +// ── File-based sync lock (fcntl F_SETLK) ── + +/// RAII guard that holds an `fcntl` write lock on a file. +/// The lock is released when the guard is dropped. +pub struct SyncLockGuard { + _file: File, +} + +/// Try to acquire an exclusive file lock (non-blocking). +/// +/// Returns `Ok(Some(guard))` if the lock was acquired, `Ok(None)` if another +/// process holds it, or `Err` on I/O failure. +#[cfg(unix)] +pub fn acquire_sync_lock() -> Result> { + acquire_sync_lock_at(&lock_path()) +} + +fn lock_path() -> PathBuf { + get_data_dir().join("sync.lock") +} + +#[cfg(unix)] +fn acquire_sync_lock_at(path: &Path) -> Result> { + use std::os::unix::io::AsRawFd; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let file = File::options() + .create(true) + .truncate(false) + .write(true) + .open(path)?; + + let fd = file.as_raw_fd(); + + // SAFETY: zeroed memory is valid for libc::flock (all-zero is a valid + // representation on every Unix platform). We then set only the fields we need. + let mut flock = unsafe { std::mem::zeroed::() }; + flock.l_type = libc::F_WRLCK as libc::c_short; + flock.l_whence = libc::SEEK_SET as libc::c_short; + + // SAFETY: fd is a valid open file descriptor; flock is stack-allocated. + let rc = unsafe { libc::fcntl(fd, libc::F_SETLK, &mut flock) }; + if rc == -1 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::WouldBlock + || err.raw_os_error() == Some(libc::EAGAIN) + || err.raw_os_error() == Some(libc::EACCES) + { + return Ok(None); + } + return Err(LoreError::Io(err)); + } + + Ok(Some(SyncLockGuard { _file: file })) +} + +// ── Crontab management ── + +/// The crontab entry that `lore cron install` writes. +/// +/// Paths are single-quoted so spaces in binary or log paths don't break +/// the cron expression. +pub fn build_cron_entry(interval_minutes: u32) -> String { + let binary = std::env::current_exe() + .unwrap_or_else(|_| PathBuf::from("lore")) + .display() + .to_string(); + let log_path = sync_log_path(); + format!( + "*/{interval_minutes} * * * * '{binary}' sync -q --lock >> '{log}' 2>&1 {CRON_TAG}", + log = log_path.display(), + ) +} + +/// Path where cron-triggered sync output is appended. +pub fn sync_log_path() -> PathBuf { + get_data_dir().join("sync.log") +} + +/// Read the current user crontab. Returns empty string when no crontab exists. +fn read_crontab() -> Result { + let output = Command::new("crontab").arg("-l").output()?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + // exit 1 with "no crontab for " is normal — treat as empty + Ok(String::new()) + } +} + +/// Write a full crontab string. Replaces the current crontab entirely. +fn write_crontab(content: &str) -> Result<()> { + let mut child = Command::new("crontab") + .arg("-") + .stdin(std::process::Stdio::piped()) + .spawn()?; + if let Some(ref mut stdin) = child.stdin { + stdin.write_all(content.as_bytes())?; + } + let status = child.wait()?; + if !status.success() { + return Err(LoreError::Other(format!( + "crontab exited with status {status}" + ))); + } + Ok(()) +} + +/// Install (or update) the lore-sync crontab entry. +pub fn install_cron(interval_minutes: u32) -> Result { + let entry = build_cron_entry(interval_minutes); + + let existing = read_crontab()?; + let replaced = existing.contains(CRON_TAG); + + // Strip ALL old lore-sync lines first, then append one new entry. + // This is idempotent even if the crontab somehow has duplicate tagged lines. + let mut filtered: String = existing + .lines() + .filter(|line| !line.contains(CRON_TAG)) + .collect::>() + .join("\n"); + if !filtered.is_empty() && !filtered.ends_with('\n') { + filtered.push('\n'); + } + filtered.push_str(&entry); + filtered.push('\n'); + + write_crontab(&filtered)?; + + Ok(CronInstallResult { + entry, + interval_minutes, + log_path: sync_log_path(), + replaced, + }) +} + +/// Remove the lore-sync crontab entry. +pub fn uninstall_cron() -> Result { + let existing = read_crontab()?; + if !existing.contains(CRON_TAG) { + return Ok(CronUninstallResult { + was_installed: false, + }); + } + + let new_crontab: String = existing + .lines() + .filter(|line| !line.contains(CRON_TAG)) + .collect::>() + .join("\n") + + "\n"; + + // If the crontab would be empty (only whitespace), remove it entirely + if new_crontab.trim().is_empty() { + let status = Command::new("crontab").arg("-r").status()?; + if !status.success() { + return Err(LoreError::Other("crontab -r failed".to_string())); + } + } else { + write_crontab(&new_crontab)?; + } + + Ok(CronUninstallResult { + was_installed: true, + }) +} + +/// Inspect the current crontab for a lore-sync entry. +pub fn cron_status() -> Result { + let existing = read_crontab()?; + let lore_line = existing.lines().find(|l| l.contains(CRON_TAG)); + + match lore_line { + Some(line) => { + let interval = parse_interval(line); + let binary_path = parse_binary_path(line); + + let current_exe = std::env::current_exe() + .ok() + .map(|p| p.display().to_string()); + let binary_mismatch = current_exe + .as_ref() + .zip(binary_path.as_ref()) + .is_some_and(|(current, cron)| current != cron); + + Ok(CronStatusResult { + installed: true, + interval_minutes: interval, + binary_path, + current_binary: current_exe, + binary_mismatch, + log_path: Some(sync_log_path()), + cron_entry: Some(line.to_string()), + }) + } + None => Ok(CronStatusResult { + installed: false, + interval_minutes: None, + binary_path: None, + current_binary: std::env::current_exe() + .ok() + .map(|p| p.display().to_string()), + binary_mismatch: false, + log_path: None, + cron_entry: None, + }), + } +} + +/// Parse the interval from a cron expression like `*/8 * * * * ...` +fn parse_interval(line: &str) -> Option { + let first_field = line.split_whitespace().next()?; + if let Some(n) = first_field.strip_prefix("*/") { + n.parse().ok() + } else { + None + } +} + +/// Parse the binary path from the cron entry after the 5 time fields. +/// +/// Handles both quoted (`'/path with spaces/lore'`) and unquoted paths. +/// We skip the time fields manually to avoid `split_whitespace` breaking +/// on spaces inside single-quoted paths. +fn parse_binary_path(line: &str) -> Option { + // Skip the 5 cron time fields (min hour dom month dow). + // These never contain spaces, so whitespace-splitting is safe here. + let mut rest = line; + for _ in 0..5 { + rest = rest.trim_start(); + let end = rest.find(char::is_whitespace)?; + rest = &rest[end..]; + } + rest = rest.trim_start(); + + // The command starts here — it may be single-quoted. + if let Some(after_quote) = rest.strip_prefix('\'') { + let end = after_quote.find('\'')?; + Some(after_quote[..end].to_string()) + } else { + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + Some(rest[..end].to_string()) + } +} + +// ── Result types ── + +#[derive(Serialize)] +pub struct CronInstallResult { + pub entry: String, + pub interval_minutes: u32, + pub log_path: PathBuf, + pub replaced: bool, +} + +#[derive(Serialize)] +pub struct CronUninstallResult { + pub was_installed: bool, +} + +#[derive(Serialize)] +pub struct CronStatusResult { + pub installed: bool, + pub interval_minutes: Option, + pub binary_path: Option, + pub current_binary: Option, + pub binary_mismatch: bool, + pub log_path: Option, + pub cron_entry: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_cron_entry_formats_correctly() { + let entry = build_cron_entry(8); + assert!(entry.starts_with("*/8 * * * * ")); + assert!(entry.contains("sync -q --lock")); + assert!(entry.ends_with(CRON_TAG)); + } + + #[test] + fn parse_interval_extracts_number() { + assert_eq!(parse_interval("*/8 * * * * /usr/bin/lore sync"), Some(8)); + assert_eq!(parse_interval("*/15 * * * * /usr/bin/lore sync"), Some(15)); + assert_eq!(parse_interval("0 * * * * /usr/bin/lore sync"), None); + } + + #[test] + fn parse_binary_path_extracts_sixth_field() { + // Unquoted path + assert_eq!( + parse_binary_path( + "*/8 * * * * /usr/local/bin/lore sync -q --lock >> /tmp/log 2>&1 # lore-sync" + ), + Some("/usr/local/bin/lore".to_string()) + ); + // Single-quoted path without spaces + assert_eq!( + parse_binary_path( + "*/8 * * * * '/usr/local/bin/lore' sync -q --lock >> '/tmp/log' 2>&1 # lore-sync" + ), + Some("/usr/local/bin/lore".to_string()) + ); + // Single-quoted path WITH spaces (common on macOS) + assert_eq!( + parse_binary_path( + "*/8 * * * * '/Users/Taylor Eernisse/.cargo/bin/lore' sync -q --lock >> '/tmp/log' 2>&1 # lore-sync" + ), + Some("/Users/Taylor Eernisse/.cargo/bin/lore".to_string()) + ); + } + + #[test] + fn sync_lock_at_nonexistent_dir_creates_parents() { + let dir = tempfile::tempdir().unwrap(); + let lock_file = dir.path().join("nested").join("deep").join("sync.lock"); + let guard = acquire_sync_lock_at(&lock_file).unwrap(); + assert!(guard.is_some()); + assert!(lock_file.exists()); + } + + #[test] + fn sync_lock_is_exclusive_across_processes() { + // POSIX fcntl locks are per-process, so same-process re-lock always + // succeeds. We verify cross-process exclusion using a Python child + // that attempts the same fcntl F_SETLK. + let dir = tempfile::tempdir().unwrap(); + let lock_file = dir.path().join("sync.lock"); + let _guard = acquire_sync_lock_at(&lock_file).unwrap().unwrap(); + + let script = r#" +import fcntl, struct, sys +fd = open(sys.argv[1], "w") +try: + fcntl.fcntl(fd, fcntl.F_SETLK, struct.pack("hhllhh", fcntl.F_WRLCK, 0, 0, 0, 0, 0)) + sys.exit(0) +except (IOError, OSError): + sys.exit(1) +"#; + let status = std::process::Command::new("python3") + .args(["-c", script, &lock_file.display().to_string()]) + .status() + .unwrap(); + assert!( + !status.success(), + "child process should fail to acquire fcntl lock held by parent" + ); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 9bef0f6..ab16989 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,5 +1,7 @@ pub mod backoff; pub mod config; +#[cfg(unix)] +pub mod cron; pub mod db; pub mod dependent_queue; pub mod error; diff --git a/src/main.rs b/src/main.rs index 3edf1e3..628b1ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,26 +11,29 @@ use lore::cli::autocorrect::{self, CorrectionResult}; use lore::cli::commands::{ IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters, NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser, - open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_doctor_results, - print_drift_human, print_drift_json, print_dry_run_preview, print_dry_run_preview_json, - print_embed, print_embed_json, print_event_count, print_event_count_json, print_file_history, - print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary, - print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs, - print_list_mrs_json, print_list_notes, print_list_notes_csv, print_list_notes_json, - print_list_notes_jsonl, print_search_results, print_search_results_json, print_show_issue, - print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json, - print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline, - print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json, - query_notes, run_auth_test, run_count, run_count_events, run_doctor, run_drift, run_embed, - run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, - run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, - run_timeline, run_who, + open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_cron_install, + print_cron_install_json, print_cron_status, print_cron_status_json, print_cron_uninstall, + print_cron_uninstall_json, print_doctor_results, print_drift_human, print_drift_json, + print_dry_run_preview, print_dry_run_preview_json, print_embed, print_embed_json, + print_event_count, print_event_count_json, print_file_history, print_file_history_json, + print_generate_docs, print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, + print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json, + print_list_notes, print_list_notes_json, print_search_results, print_search_results_json, + print_show_issue, print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, + print_stats_json, print_sync, print_sync_json, print_sync_status, print_sync_status_json, + print_timeline, print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, + print_who_json, query_notes, run_auth_test, run_count, run_count_events, run_cron_install, + run_cron_status, run_cron_uninstall, run_doctor, run_drift, run_embed, run_file_history, + run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs, + run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline, + run_who, }; use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme}; use lore::cli::robot::{RobotMeta, strip_schemas}; use lore::cli::{ - Cli, Commands, CountArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, - MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs, TraceArgs, WhoArgs, + Cli, Commands, CountArgs, CronAction, CronArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs, + IngestArgs, IssuesArgs, MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs, + TraceArgs, WhoArgs, }; use lore::core::db::{ LATEST_SCHEMA_VERSION, create_connection, get_schema_version, run_migrations, @@ -203,6 +206,7 @@ async fn main() { handle_file_history(cli.config.as_deref(), args, robot_mode) } Some(Commands::Trace(args)) => handle_trace(cli.config.as_deref(), args, robot_mode), + Some(Commands::Cron(args)) => handle_cron(cli.config.as_deref(), args, robot_mode), Some(Commands::Drift { entity_type, iid, @@ -922,21 +926,14 @@ fn handle_notes( let result = query_notes(&conn, &filters, &config)?; - let format = if robot_mode && args.format == "table" { - "json" - } else { - &args.format - }; - - match format { - "json" => print_list_notes_json( + if robot_mode { + print_list_notes_json( &result, start.elapsed().as_millis() as u64, args.fields.as_deref(), - ), - "jsonl" => print_list_notes_jsonl(&result), - "csv" => print_list_notes_csv(&result), - _ => print_list_notes(&result), + ); + } else { + print_list_notes(&result); } Ok(()) @@ -1642,6 +1639,7 @@ struct VersionOutput { #[derive(Serialize)] struct VersionData { + name: &'static str, version: String, #[serde(skip_serializing_if = "Option::is_none")] git_hash: Option, @@ -1655,6 +1653,7 @@ fn handle_version(robot_mode: bool) -> Result<(), Box> { let output = VersionOutput { ok: true, data: VersionData { + name: "lore", version, git_hash: if git_hash.is_empty() { None @@ -2182,6 +2181,24 @@ async fn handle_sync_cmd( return Ok(()); } + // Acquire file lock if --lock was passed (used by cron to skip overlapping runs) + let _sync_lock = if args.lock { + match lore::core::cron::acquire_sync_lock() { + Ok(Some(guard)) => Some(guard), + Ok(None) => { + // Another sync is running — silently exit (expected for cron) + tracing::debug!("--lock: another sync is running, skipping"); + return Ok(()); + } + Err(e) => { + tracing::warn!(error = %e, "--lock: failed to acquire file lock, skipping sync"); + return Ok(()); + } + } + } else { + None + }; + let db_path = get_db_path(config.storage.db_path.as_deref()); let recorder_conn = create_connection(&db_path)?; let run_id = uuid::Uuid::new_v4().simple().to_string(); @@ -2254,6 +2271,47 @@ async fn handle_sync_cmd( } } +fn handle_cron( + config_override: Option<&str>, + args: CronArgs, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + + match args.action { + CronAction::Install { interval } => { + let result = run_cron_install(interval)?; + let elapsed_ms = start.elapsed().as_millis() as u64; + if robot_mode { + print_cron_install_json(&result, elapsed_ms); + } else { + print_cron_install(&result); + } + } + CronAction::Uninstall => { + let result = run_cron_uninstall()?; + let elapsed_ms = start.elapsed().as_millis() as u64; + if robot_mode { + print_cron_uninstall_json(&result, elapsed_ms); + } else { + print_cron_uninstall(&result); + } + } + CronAction::Status => { + let config = Config::load(config_override)?; + let info = run_cron_status(&config)?; + let elapsed_ms = start.elapsed().as_millis() as u64; + if robot_mode { + print_cron_status_json(&info, elapsed_ms); + } else { + print_cron_status(&info); + } + } + } + + Ok(()) +} + #[derive(Serialize)] struct HealthOutput { ok: bool,