feat(cron): add lore cron command for automated sync scheduling

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 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-18 13:29:07 -05:00
parent 1808a4da8e
commit 53ce20595b
6 changed files with 844 additions and 43 deletions

369
src/core/cron.rs Normal file
View File

@@ -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<Option<SyncLockGuard>> {
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<Option<SyncLockGuard>> {
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::<libc::flock>() };
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<String> {
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 <user>" 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<CronInstallResult> {
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::<Vec<_>>()
.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<CronUninstallResult> {
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::<Vec<_>>()
.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<CronStatusResult> {
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<u32> {
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<String> {
// 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<u32>,
pub binary_path: Option<String>,
pub current_binary: Option<String>,
pub binary_mismatch: bool,
pub log_path: Option<PathBuf>,
pub cron_entry: Option<String>,
}
#[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"
);
}
}

View File

@@ -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;