use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use std::{fs, thread};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DaemonState {
pub pid: u32,
pub log_file: PathBuf,
pub project_dir: PathBuf,
}
impl DaemonState {
pub fn pid_file(project_dir: &Path) -> PathBuf {
project_dir.join(".regista/daemon.pid")
}
pub fn load(project_dir: &Path) -> Option<Self> {
let path = Self::pid_file(project_dir);
let content = fs::read_to_string(&path).ok()?;
toml::from_str(&content).ok()
}
pub fn save(&self, project_dir: &Path) -> anyhow::Result<()> {
let path = Self::pid_file(project_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
fs::write(&path, content)?;
Ok(())
}
pub fn remove(project_dir: &Path) {
let _ = fs::remove_file(Self::pid_file(project_dir));
}
}
pub struct PidCleanup(pub PathBuf);
impl Drop for PidCleanup {
fn drop(&mut self) {
DaemonState::remove(&self.0);
}
}
pub fn detach(project_dir: &Path, log_file_override: Option<&Path>) -> anyhow::Result<u32> {
let exe = std::env::current_exe()?;
let canonical_project = project_dir
.canonicalize()
.unwrap_or_else(|_| project_dir.to_path_buf());
let log_file = match log_file_override {
Some(p) => p.to_path_buf(),
None => canonical_project.join(".regista/daemon.log"),
};
if let Some(parent) = log_file.parent() {
fs::create_dir_all(parent)?;
}
let raw_args: Vec<String> = std::env::args().skip(1).collect();
let mut child_args: Vec<String> = vec![];
let mut has_log_file = false;
let mut i = 0;
while i < raw_args.len() {
let arg = raw_args[i].as_str();
match arg {
"--detach" | "--follow" | "--status" | "--kill" => {
i += 1;
continue;
}
"--log-file" => {
has_log_file = true;
child_args.push(raw_args[i].clone());
i += 1;
if i < raw_args.len() {
child_args.push(raw_args[i].clone());
i += 1;
}
continue;
}
_ => {
child_args.push(raw_args[i].clone());
i += 1;
}
}
}
child_args.push("--daemon".to_string());
if !has_log_file {
child_args.push("--log-file".to_string());
child_args.push(log_file.to_string_lossy().to_string());
}
let log_handle = fs::File::create(&log_file)?;
let child = Command::new(&exe)
.args(&child_args)
.stdin(std::process::Stdio::null())
.stdout(log_handle)
.stderr(std::process::Stdio::null())
.spawn()?;
let pid = child.id();
let state = DaemonState {
pid,
log_file: log_file.clone(),
project_dir: canonical_project.clone(),
};
state.save(&canonical_project)?;
Ok(pid)
}
pub fn status(project_dir: &Path) -> anyhow::Result<String> {
let canonical = project_dir
.canonicalize()
.unwrap_or_else(|_| project_dir.to_path_buf());
match DaemonState::load(&canonical) {
None => Ok("β No se encontrΓ³ archivo PID. El daemon no estΓ‘ corriendo.".to_string()),
Some(state) => {
if is_process_alive(state.pid) {
Ok(format!(
"β
Daemon corriendo (PID: {}, log: {})",
state.pid,
state.log_file.display()
))
} else {
DaemonState::remove(&canonical);
Ok(format!(
"β PID {} ya no existe. Archivo PID huΓ©rfano limpiado.",
state.pid
))
}
}
}
}
pub fn kill(project_dir: &Path) -> anyhow::Result<String> {
let canonical = project_dir
.canonicalize()
.unwrap_or_else(|_| project_dir.to_path_buf());
let state = match DaemonState::load(&canonical) {
Some(s) => s,
None => {
return Ok("β No se encontrΓ³ archivo PID. El daemon no estΓ‘ corriendo.".to_string());
}
};
if !is_process_alive(state.pid) {
DaemonState::remove(&canonical);
return Ok(format!(
"β PID {} ya no existe. Archivo PID huΓ©rfano limpiado.",
state.pid
));
}
send_signal(state.pid, 15);
thread::sleep(Duration::from_secs(2));
if is_process_alive(state.pid) {
send_signal(state.pid, 9);
thread::sleep(Duration::from_millis(500));
}
DaemonState::remove(&canonical);
if !is_process_alive(state.pid) {
Ok(format!(
"β
Daemon (PID: {}) detenido correctamente.",
state.pid
))
} else {
Ok(format!(
"β οΈ No se pudo detener el proceso {}. Prueba: kill -9 {}",
state.pid, state.pid
))
}
}
pub fn follow(project_dir: &Path) -> anyhow::Result<()> {
let canonical = project_dir
.canonicalize()
.unwrap_or_else(|_| project_dir.to_path_buf());
let state = match DaemonState::load(&canonical) {
Some(s) => s,
None => {
anyhow::bail!("No se encontrΓ³ archivo PID. ΒΏEstΓ‘ el daemon corriendo?");
}
};
if !is_process_alive(state.pid) {
DaemonState::remove(&canonical);
anyhow::bail!("El daemon (PID: {}) ya no estΓ‘ corriendo.", state.pid);
}
eprintln!(
"Siguiendo log: {}\nCtrl+C para salir (el daemon sigue corriendo).\n",
state.log_file.display()
);
let mut file = fs::File::open(&state.log_file)?;
file.seek(std::io::SeekFrom::End(0))?;
loop {
if !is_process_alive(state.pid) {
drain_remaining(&mut file)?;
eprintln!("\nββ Daemon terminado (PID: {}) ββ", state.pid);
DaemonState::remove(&canonical);
break;
}
let mut buf = [0u8; 4096];
match file.read(&mut buf) {
Ok(0) => {
thread::sleep(Duration::from_millis(200));
if !state.log_file.exists() {
break;
}
}
Ok(n) => {
std::io::stdout().write_all(&buf[..n])?;
std::io::stdout().flush()?;
}
Err(_) => break,
}
}
Ok(())
}
fn is_process_alive(pid: u32) -> bool {
Path::new(&format!("/proc/{pid}")).exists()
}
fn send_signal(pid: u32, sig: i32) -> bool {
Command::new("kill")
.arg(format!("-{sig}"))
.arg(pid.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn drain_remaining(file: &mut fs::File) -> anyhow::Result<()> {
let mut buf = String::new();
file.read_to_string(&mut buf)?;
if !buf.is_empty() {
std::io::stdout().write_all(buf.as_bytes())?;
std::io::stdout().flush()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pid_file_path_ends_with_correct_name() {
let path = DaemonState::pid_file(Path::new("/tmp/myproject"));
assert_eq!(path.file_name().unwrap(), "daemon.pid");
}
#[test]
fn is_process_alive_init_is_pid1() {
assert!(is_process_alive(1));
}
#[test]
fn is_process_alive_returns_false_for_impossible_pid() {
assert!(!is_process_alive(0xFFFF_FFF0));
}
#[test]
fn state_save_and_load_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let state = DaemonState {
pid: 12345,
log_file: PathBuf::from("/tmp/foo.log"),
project_dir: PathBuf::from("/tmp/myproject"),
};
state.save(tmp.path()).unwrap();
let loaded = DaemonState::load(tmp.path()).unwrap();
assert_eq!(loaded.pid, 12345);
assert_eq!(loaded.log_file, PathBuf::from("/tmp/foo.log"));
assert_eq!(loaded.project_dir, PathBuf::from("/tmp/myproject"));
DaemonState::remove(tmp.path());
}
#[test]
fn state_load_returns_none_when_no_file() {
let tmp = tempfile::tempdir().unwrap();
assert!(DaemonState::load(tmp.path()).is_none());
}
#[test]
fn state_remove_cleans_up() {
let tmp = tempfile::tempdir().unwrap();
let state = DaemonState {
pid: 42,
log_file: PathBuf::from("/dev/null"),
project_dir: tmp.path().to_path_buf(),
};
state.save(tmp.path()).unwrap();
assert!(DaemonState::pid_file(tmp.path()).exists());
DaemonState::remove(tmp.path());
assert!(!DaemonState::pid_file(tmp.path()).exists());
}
}