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>
370 lines
11 KiB
Rust
370 lines
11 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|