use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime};
use anyhow::Context;
use crate::client::{CookbookClient, CookbookTrait};
use crate::plugin::{PluginKind, get_plugins};
pub fn runtime_dir() -> anyhow::Result<PathBuf> {
let base = dirs::runtime_dir()
.or_else(|| dirs::cache_dir().map(|d| d.join("run")))
.context("Could not determine runtime or cache directory")?;
Ok(base.join("enwiro"))
}
pub fn write_cache_atomic(runtime_dir: &Path, content: &str) -> anyhow::Result<()> {
fs::create_dir_all(runtime_dir).context("Could not create runtime directory")?;
let cache_path = runtime_dir.join("recipes.cache");
let tmp_path = runtime_dir.join("recipes.cache.tmp");
fs::write(&tmp_path, content).context("Could not write temporary cache file")?;
fs::rename(&tmp_path, &cache_path).context("Could not rename cache file into place")?;
tracing::debug!(path = %cache_path.display(), "Cache file updated");
Ok(())
}
const CACHE_MAX_AGE: Duration = Duration::from_secs(330);
pub fn read_cached_recipes(runtime_dir: &Path) -> anyhow::Result<Option<String>> {
let cache_path = runtime_dir.join("recipes.cache");
let metadata = match fs::metadata(&cache_path) {
Ok(m) => m,
Err(_) => return Ok(None),
};
if let Ok(modified) = metadata.modified() {
let age = SystemTime::now()
.duration_since(modified)
.unwrap_or(Duration::ZERO);
if age > CACHE_MAX_AGE {
tracing::debug!(age_secs = age.as_secs(), "Cache is stale, ignoring");
return Ok(None);
}
}
let content = fs::read_to_string(&cache_path).context("Could not read cache file")?;
Ok(Some(content))
}
const IDLE_TIMEOUT: Duration = Duration::from_secs(3600);
pub fn touch_heartbeat(runtime_dir: &Path) -> anyhow::Result<()> {
fs::create_dir_all(runtime_dir).context("Could not create runtime directory")?;
let heartbeat_path = runtime_dir.join("heartbeat");
fs::write(&heartbeat_path, "").context("Could not touch heartbeat file")?;
Ok(())
}
fn check_idle_with_timeout(runtime_dir: &Path, timeout: Duration) -> bool {
let heartbeat_path = runtime_dir.join("heartbeat");
match fs::metadata(&heartbeat_path) {
Ok(metadata) => match metadata.modified() {
Ok(modified) => {
let elapsed = SystemTime::now()
.duration_since(modified)
.unwrap_or(Duration::ZERO);
elapsed > timeout
}
Err(_) => false,
},
Err(_) => false,
}
}
pub fn check_idle(runtime_dir: &Path) -> bool {
check_idle_with_timeout(runtime_dir, IDLE_TIMEOUT)
}
pub fn write_pid_file(runtime_dir: &Path) -> anyhow::Result<()> {
fs::create_dir_all(runtime_dir).context("Could not create runtime directory")?;
let pid_path = runtime_dir.join("daemon.pid");
fs::write(&pid_path, std::process::id().to_string()).context("Could not write PID file")?;
Ok(())
}
pub fn remove_pid_file(runtime_dir: &Path) {
let pid_path = runtime_dir.join("daemon.pid");
let _ = fs::remove_file(&pid_path);
}
pub fn is_daemon_running(runtime_dir: &Path) -> bool {
let pid_path = runtime_dir.join("daemon.pid");
let pid_str = match fs::read_to_string(&pid_path) {
Ok(s) => s,
Err(_) => return false,
};
let pid: i32 = match pid_str.trim().parse() {
Ok(p) => p,
Err(_) => return false,
};
unsafe { libc::kill(pid, 0) == 0 }
}
pub fn ensure_daemon_running(runtime_dir: &Path) -> anyhow::Result<bool> {
if is_daemon_running(runtime_dir) {
return Ok(false);
}
tracing::info!("Spawning background daemon");
std::process::Command::new(std::env::current_exe()?)
.arg("daemon")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("Could not spawn daemon process")?;
Ok(true)
}
pub fn collect_all_recipes(cookbooks: &[Box<dyn CookbookTrait>]) -> String {
let mut output = String::new();
for cookbook in cookbooks {
match cookbook.list_recipes() {
Ok(recipes) => {
for line in recipes {
output.push_str(&format!("{}: {}\n", cookbook.name(), line));
}
}
Err(e) => {
tracing::warn!(
cookbook = %cookbook.name(),
error = %e,
"Skipping cookbook due to error"
);
}
}
}
output
}
const REFRESH_INTERVAL: Duration = Duration::from_secs(300);
pub fn run_daemon() -> anyhow::Result<()> {
let setsid_result = unsafe { libc::setsid() };
if setsid_result == -1 {
tracing::warn!("setsid() failed, continuing anyway");
}
let dir = runtime_dir()?;
fs::create_dir_all(&dir)?;
write_pid_file(&dir)?;
let term = Arc::new(AtomicBool::new(false));
signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&term))?;
signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(&term))?;
signal_hook::flag::register(signal_hook::consts::SIGHUP, Arc::clone(&term))?;
touch_heartbeat(&dir)?;
tracing::info!(pid = std::process::id(), "Daemon started");
loop {
let plugins = get_plugins(PluginKind::Cookbook);
let cookbooks: Vec<Box<dyn CookbookTrait>> = plugins
.into_iter()
.map(|p| Box::new(CookbookClient::new(p)) as Box<dyn CookbookTrait>)
.collect();
let recipes = collect_all_recipes(&cookbooks);
if let Err(e) = write_cache_atomic(&dir, &recipes) {
tracing::error!(error = %e, "Failed to write cache");
}
let mut elapsed = Duration::ZERO;
while elapsed < REFRESH_INTERVAL {
if term.load(Ordering::Relaxed) {
tracing::info!("Received termination signal, exiting");
remove_pid_file(&dir);
return Ok(());
}
std::thread::sleep(Duration::from_secs(1));
elapsed += Duration::from_secs(1);
}
if check_idle(&dir) {
tracing::info!("Idle timeout reached, exiting");
remove_pid_file(&dir);
return Ok(());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::test_utilities::{FailingCookbook, FakeCookbook};
#[test]
fn test_collect_all_recipes_formats_output() {
let cookbooks: Vec<Box<dyn CookbookTrait>> = vec![Box::new(FakeCookbook::new(
"git",
vec!["repo-a", "repo-b"],
vec![],
))];
let output = collect_all_recipes(&cookbooks);
assert_eq!(output, "git: repo-a\ngit: repo-b\n");
}
#[test]
fn test_collect_all_recipes_multiple_cookbooks() {
let cookbooks: Vec<Box<dyn CookbookTrait>> = vec![
Box::new(FakeCookbook::new("git", vec!["repo-a"], vec![])),
Box::new(FakeCookbook::new("npm", vec!["pkg-x"], vec![])),
];
let output = collect_all_recipes(&cookbooks);
assert!(output.contains("git: repo-a\n"));
assert!(output.contains("npm: pkg-x\n"));
}
#[test]
fn test_collect_all_recipes_empty() {
let cookbooks: Vec<Box<dyn CookbookTrait>> = vec![];
let output = collect_all_recipes(&cookbooks);
assert_eq!(output, "");
}
#[test]
fn test_is_daemon_running_no_pid_file() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_daemon_running(dir.path()));
}
#[test]
fn test_is_daemon_running_with_own_pid() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("daemon.pid"),
std::process::id().to_string(),
)
.unwrap();
assert!(is_daemon_running(dir.path()));
}
#[test]
fn test_is_daemon_running_stale_pid() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("daemon.pid"), "999999999").unwrap();
assert!(!is_daemon_running(dir.path()));
}
#[test]
fn test_is_daemon_running_invalid_pid_content() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("daemon.pid"), "not-a-number").unwrap();
assert!(!is_daemon_running(dir.path()));
}
#[test]
fn test_write_and_remove_pid_file() {
let dir = tempfile::tempdir().unwrap();
write_pid_file(dir.path()).unwrap();
assert!(dir.path().join("daemon.pid").exists());
remove_pid_file(dir.path());
assert!(!dir.path().join("daemon.pid").exists());
}
#[test]
fn test_fresh_heartbeat_is_not_idle() {
let dir = tempfile::tempdir().unwrap();
touch_heartbeat(dir.path()).unwrap();
assert!(!check_idle(dir.path()));
}
#[test]
fn test_no_heartbeat_file_is_not_idle() {
let dir = tempfile::tempdir().unwrap();
assert!(!check_idle(dir.path()));
}
#[test]
fn test_old_heartbeat_is_idle() {
let dir = tempfile::tempdir().unwrap();
touch_heartbeat(dir.path()).unwrap();
let past = filetime::FileTime::from_system_time(
std::time::SystemTime::now() - std::time::Duration::from_secs(7200),
);
filetime::set_file_mtime(dir.path().join("heartbeat"), past).unwrap();
assert!(check_idle_with_timeout(
dir.path(),
std::time::Duration::from_secs(3600)
));
}
#[test]
fn test_write_and_read_cache() {
let dir = tempfile::tempdir().unwrap();
let content = "git: my-repo\nchezmoi: chezmoi\n";
write_cache_atomic(dir.path(), content).unwrap();
let read = read_cached_recipes(dir.path()).unwrap();
assert_eq!(read, Some(content.to_string()));
}
#[test]
fn test_read_cache_returns_none_when_missing() {
let dir = tempfile::tempdir().unwrap();
let read = read_cached_recipes(dir.path()).unwrap();
assert_eq!(read, None);
}
#[test]
fn test_write_cache_creates_directory() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("nested").join("enwiro");
write_cache_atomic(&nested, "test").unwrap();
let read = read_cached_recipes(&nested).unwrap();
assert_eq!(read, Some("test".to_string()));
}
#[test]
fn test_read_cache_returns_none_when_stale() {
let dir = tempfile::tempdir().unwrap();
write_cache_atomic(dir.path(), "git: old-repo\n").unwrap();
let past = filetime::FileTime::from_system_time(
std::time::SystemTime::now() - std::time::Duration::from_secs(600),
);
filetime::set_file_mtime(dir.path().join("recipes.cache"), past).unwrap();
let read = read_cached_recipes(dir.path()).unwrap();
assert_eq!(
read, None,
"Stale cache (older than refresh interval + 30s) should be treated as missing"
);
}
#[test]
fn test_read_cache_returns_content_when_fresh() {
let dir = tempfile::tempdir().unwrap();
write_cache_atomic(dir.path(), "git: fresh-repo\n").unwrap();
let read = read_cached_recipes(dir.path()).unwrap();
assert_eq!(read, Some("git: fresh-repo\n".to_string()));
}
#[test]
fn test_collect_all_recipes_skips_failing_cookbook() {
let cookbooks: Vec<Box<dyn CookbookTrait>> = vec![
Box::new(FailingCookbook {
cookbook_name: "broken".into(),
}),
Box::new(FakeCookbook::new("git", vec!["repo-a"], vec![])),
];
let output = collect_all_recipes(&cookbooks);
assert_eq!(output, "git: repo-a\n");
}
}