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::{CachedRecipe, 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(70);
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 USER_IDLE_THRESHOLD: Duration = Duration::from_secs(5400);
fn check_idle_with_timeout(get_idle: impl Fn() -> Option<Duration>, threshold: Duration) -> bool {
match get_idle() {
Some(idle) => idle > threshold,
None => false,
}
}
pub fn check_idle() -> bool {
check_idle_with_timeout(
|| system_idle_time::get_idle_time().ok(),
USER_IDLE_THRESHOLD,
)
}
fn read_pid_file(runtime_dir: &Path) -> Option<(i32, Option<SystemTime>)> {
let content = fs::read_to_string(runtime_dir.join("daemon.pid")).ok()?;
let mut lines = content.lines();
let pid: i32 = lines.next()?.trim().parse().ok()?;
let mtime = lines
.next()
.and_then(|s| s.trim().parse::<u64>().ok())
.map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs));
Some((pid, mtime))
}
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");
if let Some((stored_pid, _)) = read_pid_file(runtime_dir)
&& stored_pid == std::process::id() as i32
{
let _ = fs::remove_file(&pid_path);
}
}
#[allow(dead_code)]
pub fn is_daemon_running(runtime_dir: &Path) -> bool {
match read_pid_file(runtime_dir) {
Some((pid, _)) => unsafe { libc::kill(pid, 0) == 0 },
None => false,
}
}
struct SortableCachedRecipe {
cached: CachedRecipe,
priority: u32,
}
pub fn collect_all_recipes(cookbooks: &[Box<dyn CookbookTrait>]) -> String {
let mut all_recipes: Vec<SortableCachedRecipe> = Vec::new();
for cookbook in cookbooks {
match cookbook.list_recipes() {
Ok(recipes) => {
for recipe in recipes {
all_recipes.push(SortableCachedRecipe {
cached: CachedRecipe {
cookbook: cookbook.name().to_string(),
name: recipe.name,
description: recipe.description,
sort_order: recipe.sort_order,
scores: None,
},
priority: cookbook.priority(),
});
}
}
Err(e) => {
tracing::warn!(
cookbook = %cookbook.name(),
error = %e,
"Skipping cookbook due to error"
);
}
}
}
all_recipes.sort_by(|a, b| {
a.cached
.sort_order
.cmp(&b.cached.sort_order)
.then_with(|| a.priority.cmp(&b.priority))
.then_with(|| a.cached.name.cmp(&b.cached.name))
});
let mut output = String::new();
for item in all_recipes {
if let Ok(json) = serde_json::to_string(&item.cached) {
output.push_str(&json);
output.push('\n');
}
}
output
}
pub fn parse_switch_event(line: &str) -> Option<(String, i64)> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
if v.get("type")?.as_str()? != "workspace_switch" {
return None;
}
let env_name = v.get("env_name")?.as_str()?.to_string();
let timestamp = v.get("timestamp")?.as_i64()?;
Some((env_name, timestamp))
}
const REFRESH_INTERVAL: Duration = Duration::from_secs(40);
fn should_refresh_cache(is_idle: bool) -> bool {
!is_idle
}
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))?;
let config: crate::config::ConfigurationValues =
confy::load("enwiro", "enwiro").unwrap_or_default();
let workspaces_directory = config.workspaces_directory.clone();
let adapter_plugin = get_plugins(PluginKind::Adapter).into_iter().next();
let mut listen_child: Option<std::process::Child> = None;
if let Some(ref plugin) = adapter_plugin {
match std::process::Command::new(&plugin.executable)
.arg("listen")
.arg("--debounce-secs")
.arg("5")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(child) => {
listen_child = Some(child);
}
Err(e) => {
tracing::warn!(error = %e, "Could not spawn adapter listen subprocess");
}
}
}
let (line_tx, line_rx) = std::sync::mpsc::channel::<String>();
if let Some(ref mut child) = listen_child
&& let Some(stdout) = child.stdout.take()
{
let tx = line_tx.clone();
std::thread::spawn(move || {
use std::io::BufRead;
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(l) => {
if tx.send(l).is_err() {
break;
}
}
Err(_) => break,
}
}
});
}
tracing::info!(pid = std::process::id(), "Daemon started");
loop {
loop {
match line_rx.try_recv() {
Ok(line) => {
if let Some((env_name, timestamp)) = parse_switch_event(&line)
&& !env_name.is_empty()
{
let env_dir = std::path::Path::new(&workspaces_directory).join(&env_name);
crate::usage_stats::record_switch_per_env(&env_dir, timestamp);
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
}
}
if should_refresh_cache(check_idle()) {
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");
if let Some(mut child) = listen_child.take() {
let _ = child.kill();
}
remove_pid_file(&dir);
return Ok(());
}
std::thread::sleep(Duration::from_secs(1));
elapsed += Duration::from_secs(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::CachedRecipe;
use crate::test_utils::test_utilities::{FailingCookbook, FakeCookbook};
fn parse_cached_lines(output: &str) -> Vec<CachedRecipe> {
output
.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[test]
fn test_collect_all_recipes_includes_description() {
let cookbooks: Vec<Box<dyn CookbookTrait>> =
vec![Box::new(FakeCookbook::new_with_descriptions(
"github",
vec![("owner/repo#42", Some("Fix auth bug"))],
vec![],
))];
let output = collect_all_recipes(&cookbooks);
let entries = parse_cached_lines(&output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].cookbook, "github");
assert_eq!(entries[0].name, "owner/repo#42");
assert_eq!(entries[0].description.as_deref(), Some("Fix auth bug"));
}
#[test]
fn test_collect_all_recipes_omits_description_when_none() {
let cookbooks: Vec<Box<dyn CookbookTrait>> = vec![Box::new(
FakeCookbook::new_with_descriptions("git", vec![("repo-a", None)], vec![]),
)];
let output = collect_all_recipes(&cookbooks);
let entries = parse_cached_lines(&output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "repo-a");
assert!(entries[0].description.is_none());
}
#[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);
let entries = parse_cached_lines(&output);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name, "repo-a");
assert_eq!(entries[1].name, "repo-b");
}
#[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);
let entries = parse_cached_lines(&output);
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"repo-a"));
assert!(names.contains(&"pkg-x"));
}
#[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()));
}
fn write_test_pid_file(dir: &Path, pid: i32) {
std::fs::write(dir.join("daemon.pid"), pid.to_string()).unwrap();
}
#[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_remove_pid_file_does_not_remove_other_pid() {
let dir = tempfile::tempdir().unwrap();
write_test_pid_file(dir.path(), 999999);
remove_pid_file(dir.path());
assert!(
dir.path().join("daemon.pid").exists(),
"Should not remove another process's PID file"
);
}
#[test]
fn test_user_is_idle_when_idle_time_exceeds_threshold() {
let threshold = Duration::from_secs(5400);
let idle_time = Duration::from_secs(6000);
assert!(check_idle_with_timeout(|| Some(idle_time), threshold));
}
#[test]
fn test_user_is_not_idle_when_idle_time_below_threshold() {
let threshold = Duration::from_secs(5400);
let idle_time = Duration::from_secs(60);
assert!(!check_idle_with_timeout(|| Some(idle_time), threshold));
}
#[test]
fn test_user_is_not_idle_when_idle_time_equals_threshold() {
let threshold = Duration::from_secs(5400);
assert!(!check_idle_with_timeout(|| Some(threshold), threshold));
}
#[test]
fn test_user_is_not_idle_when_idle_time_unavailable() {
let threshold = Duration::from_secs(5400);
assert!(!check_idle_with_timeout(|| None, threshold));
}
#[test]
fn test_write_and_read_cache() {
let dir = tempfile::tempdir().unwrap();
let content = r#"{"cookbook":"git","name":"my-repo"}
{"cookbook":"chezmoi","name":"chezmoi"}
"#;
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(), r#"{"cookbook":"git","name":"old-repo"}"#).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();
let content = "{\"cookbook\":\"git\",\"name\":\"fresh-repo\"}\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_collect_all_recipes_sorts_by_priority() {
let cookbooks: Vec<Box<dyn CookbookTrait>> = vec![
Box::new(FakeCookbook::new("github", vec!["repo#42"], vec![]).with_priority(30)),
Box::new(FakeCookbook::new("git", vec!["my-repo"], vec![]).with_priority(10)),
];
let output = collect_all_recipes(&cookbooks);
let entries = parse_cached_lines(&output);
assert_eq!(
entries[0].cookbook, "git",
"Higher priority (lower number) should come first"
);
assert_eq!(entries[0].name, "my-repo");
assert_eq!(entries[1].cookbook, "github");
assert_eq!(entries[1].name, "repo#42");
}
#[test]
fn test_collect_all_recipes_sorts_by_name_on_priority_tie() {
let cookbooks: Vec<Box<dyn CookbookTrait>> = vec![
Box::new(FakeCookbook::new("npm", vec!["pkg-x"], vec![]).with_priority(20)),
Box::new(FakeCookbook::new("git", vec!["repo-a"], vec![]).with_priority(20)),
];
let output = collect_all_recipes(&cookbooks);
let entries = parse_cached_lines(&output);
assert_eq!(
entries[0].name, "pkg-x",
"Same sort_order and priority should tie-break by recipe name"
);
assert_eq!(entries[0].cookbook, "npm");
assert_eq!(entries[1].name, "repo-a");
assert_eq!(entries[1].cookbook, "git");
}
#[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);
let entries = parse_cached_lines(&output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].cookbook, "git");
assert_eq!(entries[0].name, "repo-a");
}
#[test]
fn test_collect_all_recipes_sorts_globally_by_sort_order() {
let cookbooks: Vec<Box<dyn CookbookTrait>> = vec![
Box::new(
FakeCookbook::new("git", vec!["git-repo-a", "git-repo-b"], vec![])
.with_priority(10)
.with_sort_orders(vec![0, 50]),
),
Box::new(
FakeCookbook::new("github", vec!["gh-issue-1", "gh-issue-2"], vec![])
.with_priority(30)
.with_sort_orders(vec![0, 50]),
),
];
let output = collect_all_recipes(&cookbooks);
let entries = parse_cached_lines(&output);
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].name, "git-repo-a");
assert_eq!(entries[0].sort_order, 0);
assert_eq!(entries[1].name, "gh-issue-1");
assert_eq!(entries[1].sort_order, 0);
assert_eq!(entries[2].name, "git-repo-b");
assert_eq!(entries[2].sort_order, 50);
assert_eq!(entries[3].name, "gh-issue-2");
assert_eq!(entries[3].sort_order, 50);
}
#[test]
fn test_should_refresh_cache_when_not_idle() {
assert!(
should_refresh_cache(false),
"should_refresh_cache(false) must return true: active user means refresh is needed"
);
}
#[test]
fn test_should_not_refresh_cache_when_idle() {
assert!(
!should_refresh_cache(true),
"should_refresh_cache(true) must return false: idle user means skip the refresh"
);
}
#[test]
fn test_parse_switch_event_valid_line() {
let line = r#"{"type":"workspace_switch","timestamp":1700000000,"env_name":"my-project"}"#;
let result = parse_switch_event(line);
assert_eq!(
result,
Some(("my-project".to_string(), 1700000000i64)),
"valid switch event line must parse to (env_name, timestamp)"
);
}
#[test]
fn test_parse_switch_event_wrong_type_returns_none() {
let line = r#"{"type":"other_event","timestamp":1700000000,"env_name":"my-project"}"#;
let result = parse_switch_event(line);
assert_eq!(
result, None,
"event with type != 'workspace_switch' must return None"
);
}
#[test]
fn test_parse_switch_event_missing_env_name_returns_none() {
let line = r#"{"type":"workspace_switch","timestamp":1700000000}"#;
let result = parse_switch_event(line);
assert_eq!(result, None, "line without env_name field must return None");
}
#[test]
fn test_parse_switch_event_missing_timestamp_returns_none() {
let line = r#"{"type":"workspace_switch","env_name":"my-project"}"#;
let result = parse_switch_event(line);
assert_eq!(
result, None,
"line without timestamp field must return None"
);
}
#[test]
fn test_parse_switch_event_invalid_json_returns_none() {
let line = "this is not json at all";
let result = parse_switch_event(line);
assert_eq!(
result, None,
"invalid JSON must return None rather than panic"
);
}
#[test]
fn test_parse_switch_event_empty_string_returns_none() {
let result = parse_switch_event("");
assert_eq!(result, None, "empty string must return None");
}
#[test]
fn test_parse_switch_event_empty_env_name_is_returned() {
let line = r#"{"type":"workspace_switch","timestamp":1700000000,"env_name":""}"#;
let result = parse_switch_event(line);
assert_eq!(
result,
Some(("".to_string(), 1700000000i64)),
"empty env_name must still be returned; filtering is the caller's job"
);
}
#[test]
fn test_parse_switch_event_extra_fields_are_ignored() {
let line = r#"{"type":"workspace_switch","timestamp":1700000000,"env_name":"proj","extra":"ignored","count":42}"#;
let result = parse_switch_event(line);
assert_eq!(
result,
Some(("proj".to_string(), 1700000000i64)),
"extra unknown fields must not prevent parsing"
);
}
#[test]
fn test_collect_all_recipes_interleaves_cookbooks_by_sort_order() {
let cookbooks: Vec<Box<dyn CookbookTrait>> = vec![
Box::new(
FakeCookbook::new("git", vec!["low-priority-branch"], vec![])
.with_priority(10)
.with_sort_orders(vec![80]),
),
Box::new(
FakeCookbook::new("github", vec!["hot-issue"], vec![])
.with_priority(30)
.with_sort_orders(vec![0]),
),
];
let output = collect_all_recipes(&cookbooks);
let entries = parse_cached_lines(&output);
assert_eq!(entries.len(), 2);
assert_eq!(
entries[0].name, "hot-issue",
"Lower sort_order should come first regardless of cookbook priority"
);
assert_eq!(entries[1].name, "low-priority-branch");
}
}