use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::SystemTime;
use crate::config::VYCTOR_DIR;
const PID_FILE: &str = "watch.pid";
const LOG_FILE: &str = "watch.log";
#[derive(Debug)]
pub struct DaemonStatus {
pub running: bool,
pub pid: Option<u32>,
pub started_at: Option<SystemTime>,
}
pub fn pid_file_path(root: &Path) -> PathBuf {
root.join(VYCTOR_DIR).join(PID_FILE)
}
pub fn log_file_path(root: &Path) -> PathBuf {
root.join(VYCTOR_DIR).join(LOG_FILE)
}
#[cfg(unix)]
fn is_process_running(pid: u32) -> bool {
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(not(unix))]
fn is_process_running(_pid: u32) -> bool {
true
}
pub fn is_daemon_running(root: &Path) -> Option<u32> {
let pid_path = pid_file_path(root);
if !pid_path.exists() {
return None;
}
let pid_str = match fs::read_to_string(&pid_path) {
Ok(s) => s,
Err(_) => return None,
};
let pid: u32 = match pid_str.trim().parse() {
Ok(p) => p,
Err(_) => {
let _ = fs::remove_file(&pid_path);
return None;
}
};
if is_process_running(pid) {
Some(pid)
} else {
let _ = fs::remove_file(&pid_path);
None
}
}
pub fn get_daemon_status(root: &Path) -> DaemonStatus {
let pid_path = pid_file_path(root);
if let Some(pid) = is_daemon_running(root) {
let started_at = fs::metadata(&pid_path).ok().and_then(|m| m.modified().ok());
DaemonStatus {
running: true,
pid: Some(pid),
started_at,
}
} else {
DaemonStatus {
running: false,
pid: None,
started_at: None,
}
}
}
pub fn start_daemon(root: &Path, debounce_ms: u64) -> Result<u32> {
if let Some(pid) = is_daemon_running(root) {
anyhow::bail!("Daemon already running (PID {})", pid);
}
let vyctor_dir = root.join(VYCTOR_DIR);
let pid_path = pid_file_path(root);
let log_path = log_file_path(root);
fs::create_dir_all(&vyctor_dir).context("Failed to create .vyctor directory")?;
let log_file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.context("Failed to open log file")?;
let log_file_err = log_file
.try_clone()
.context("Failed to clone log file handle")?;
let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
let child = Command::new(&exe_path)
.arg("watch")
.arg("--debounce")
.arg(debounce_ms.to_string())
.arg("--daemon-child")
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::from(log_file))
.stderr(Stdio::from(log_file_err))
.spawn()
.context("Failed to spawn daemon process")?;
let pid = child.id();
fs::write(&pid_path, pid.to_string()).context("Failed to write PID file")?;
Ok(pid)
}
#[cfg(unix)]
pub fn stop_daemon(root: &Path) -> Result<bool> {
let pid_path = pid_file_path(root);
let pid = match is_daemon_running(root) {
Some(p) => p,
None => return Ok(false),
};
let result = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
if result != 0 {
let _ = fs::remove_file(&pid_path);
return Ok(true);
}
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(100));
if !is_process_running(pid) {
break;
}
}
if is_process_running(pid) {
unsafe { libc::kill(pid as i32, libc::SIGKILL) };
std::thread::sleep(std::time::Duration::from_millis(100));
}
let _ = fs::remove_file(&pid_path);
Ok(true)
}
#[cfg(not(unix))]
pub fn stop_daemon(root: &Path) -> Result<bool> {
let pid_path = pid_file_path(root);
if is_daemon_running(root).is_none() {
return Ok(false);
}
let _ = fs::remove_file(&pid_path);
anyhow::bail!("Cannot stop daemon on this platform. Please stop the process manually.");
}
pub fn read_log_tail(root: &Path, lines: usize) -> Result<String> {
let log_path = log_file_path(root);
if !log_path.exists() {
return Ok(String::new());
}
let content = fs::read_to_string(&log_path).context("Failed to read log file")?;
let all_lines: Vec<&str> = content.lines().collect();
let start = all_lines.len().saturating_sub(lines);
Ok(all_lines[start..].join("\n"))
}
pub fn format_uptime(started_at: SystemTime) -> String {
let duration = match SystemTime::now().duration_since(started_at) {
Ok(d) => d,
Err(_) => return "unknown".to_string(),
};
let secs = duration.as_secs();
let mins = secs / 60;
let hours = mins / 60;
let days = hours / 24;
if days > 0 {
format!("{}d {}h", days, hours % 24)
} else if hours > 0 {
format!("{}h {}m", hours, mins % 60)
} else if mins > 0 {
format!("{}m {}s", mins, secs % 60)
} else {
format!("{}s", secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_pid_file_path() {
let root = PathBuf::from("/test/project");
let path = pid_file_path(&root);
assert_eq!(path, PathBuf::from("/test/project/.vyctor/watch.pid"));
}
#[test]
fn test_log_file_path() {
let root = PathBuf::from("/test/project");
let path = log_file_path(&root);
assert_eq!(path, PathBuf::from("/test/project/.vyctor/watch.log"));
}
#[test]
fn test_is_daemon_running_no_pid_file() {
let dir = tempdir().unwrap();
assert!(is_daemon_running(dir.path()).is_none());
}
#[test]
fn test_is_daemon_running_stale_pid() {
let dir = tempdir().unwrap();
let vyctor_dir = dir.path().join(".vyctor");
fs::create_dir_all(&vyctor_dir).unwrap();
fs::write(vyctor_dir.join("watch.pid"), "999999999").unwrap();
assert!(is_daemon_running(dir.path()).is_none());
assert!(!vyctor_dir.join("watch.pid").exists());
}
#[test]
fn test_is_daemon_running_invalid_pid() {
let dir = tempdir().unwrap();
let vyctor_dir = dir.path().join(".vyctor");
fs::create_dir_all(&vyctor_dir).unwrap();
fs::write(vyctor_dir.join("watch.pid"), "not_a_number").unwrap();
assert!(is_daemon_running(dir.path()).is_none());
assert!(!vyctor_dir.join("watch.pid").exists());
}
#[test]
fn test_format_uptime() {
use std::time::Duration;
let now = SystemTime::now();
let started = now - Duration::from_secs(30);
assert_eq!(format_uptime(started), "30s");
let started = now - Duration::from_secs(90);
assert_eq!(format_uptime(started), "1m 30s");
let started = now - Duration::from_secs(3700);
assert_eq!(format_uptime(started), "1h 1m");
let started = now - Duration::from_secs(90000);
assert_eq!(format_uptime(started), "1d 1h");
}
#[test]
fn test_read_log_tail_empty() {
let dir = tempdir().unwrap();
let result = read_log_tail(dir.path(), 10).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_read_log_tail() {
let dir = tempdir().unwrap();
let vyctor_dir = dir.path().join(".vyctor");
fs::create_dir_all(&vyctor_dir).unwrap();
let log_content = "line1\nline2\nline3\nline4\nline5\n";
fs::write(vyctor_dir.join("watch.log"), log_content).unwrap();
let result = read_log_tail(dir.path(), 3).unwrap();
assert_eq!(result, "line3\nline4\nline5");
}
#[test]
fn test_get_daemon_status_not_running() {
let dir = tempdir().unwrap();
let status = get_daemon_status(dir.path());
assert!(!status.running);
assert!(status.pid.is_none());
assert!(status.started_at.is_none());
}
}