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" ); } }