use std::time::Duration;
use tokio::time::sleep;
use crate::config::{AppConfig, AppPaths};
use crate::db::Database;
use crate::error::Result;
use crate::removal::remove_approved;
use crate::scanner::{Scanner, refresh};
#[cfg(unix)]
use tokio::signal::unix::{SignalKind, signal};
#[cfg(not(unix))]
use tokio::signal;
pub struct DaemonOptions {
pub interval_hours: Option<u32>,
pub once: bool,
pub scan_only: bool,
pub dry_run: bool,
}
pub struct Daemon {
app_config: AppConfig,
paths: AppPaths,
opts: DaemonOptions,
}
impl Daemon {
pub fn new(app_config: AppConfig, opts: DaemonOptions) -> Self {
Self {
app_config,
paths: AppPaths::new(),
opts,
}
}
#[allow(clippy::too_many_lines)]
pub async fn run(&self) -> Result<()> {
let config = &self.app_config.global;
let db_path = self.paths.database_file(config)?;
let lock_path = db_path.with_extension("daemon.lock");
let _lock = acquire_pid_lock(&lock_path)?;
let db = Database::open(&db_path)?;
let scanner = Scanner::new();
let interval_hours = self
.opts
.interval_hours
.unwrap_or(config.scan_interval_hours);
self.print_startup_banner(&db_path, interval_hours);
if self.opts.dry_run {
tracing::info!("Dry-run mode: no files will be modified");
self.run_dry_run_cycle(&db, &scanner).await;
return Ok(());
}
if self.opts.once {
self.run_single_cycle(&db, &scanner).await;
return Ok(());
}
#[cfg(unix)]
let mut sigint = signal(SignalKind::interrupt())?;
#[cfg(unix)]
let mut sigterm = signal(SignalKind::terminate())?;
#[cfg(not(unix))]
let mut shutdown = Box::pin(signal::ctrl_c());
loop {
let db_roots: Vec<_> = db.list_roots()?.into_iter().map(|r| r.path).collect();
tracing::debug!(
root_count = db_roots.len(),
"Reloading per-root configuration"
);
let app_config = match AppConfig::load(&self.paths, &db_roots) {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "Failed to reload config, continuing with previous");
self.app_config.clone()
}
};
#[cfg(unix)]
tokio::select! {
_ = sigint.recv() => {
tracing::info!("Received SIGINT, exiting gracefully");
break;
}
_ = sigterm.recv() => {
tracing::info!("Received SIGTERM, exiting gracefully");
break;
}
() = Self::run_cycle_inner(&app_config, &db, &scanner, self.opts.scan_only) => {}
}
#[cfg(not(unix))]
tokio::select! {
_ = &mut shutdown => {
tracing::info!("Received shutdown signal, exiting gracefully");
break;
}
() = Self::run_cycle_inner(&app_config, &db, &scanner, self.opts.scan_only) => {}
}
let sleep_duration = Duration::from_secs(u64::from(interval_hours) * 3600);
tracing::info!(
?sleep_duration,
"Scan cycle complete, sleeping until next iteration"
);
#[cfg(unix)]
tokio::select! {
_ = sigint.recv() => {
tracing::info!("Received SIGINT during sleep, exiting gracefully");
break;
}
_ = sigterm.recv() => {
tracing::info!("Received SIGTERM during sleep, exiting gracefully");
break;
}
() = sleep(sleep_duration) => {}
}
#[cfg(not(unix))]
tokio::select! {
_ = &mut shutdown => {
tracing::info!("Received shutdown signal during sleep, exiting gracefully");
break;
}
() = sleep(sleep_duration) => {}
}
}
Ok(())
}
fn print_startup_banner(&self, db_path: &std::path::Path, interval_hours: u32) {
let config = &self.app_config.global;
let mode = if self.opts.dry_run {
"dry-run"
} else if self.opts.once {
"single cycle"
} else if self.opts.scan_only {
"scan-only (no removals)"
} else {
"continuous"
};
eprintln!("{}", crate::cli::INFO);
eprintln!();
eprintln!(" mode: {mode}");
eprintln!(" database: {}", db_path.display());
eprintln!(" scan interval: {interval_hours}h");
eprintln!(" expiration: {} days", config.expiration_days);
eprintln!(" warning window: {} days", config.warning_days);
eprintln!(" auto-remove: {}", config.auto_remove);
eprintln!(" tracked paths: {}", config.tracked_paths.len());
for path in &config.tracked_paths {
eprintln!(" - {}", path.display());
}
eprintln!();
tracing::info!(
mode,
scan_interval_hours = interval_hours,
expiration_days = config.expiration_days,
warning_days = config.warning_days,
auto_remove = config.auto_remove,
tracked_path_count = config.tracked_paths.len(),
db_path = %db_path.display(),
"Daemon started"
);
}
async fn run_single_cycle(&self, db: &Database, scanner: &Scanner) {
let db_roots: Vec<_> = db
.list_roots()
.unwrap_or_default()
.into_iter()
.map(|r| r.path)
.collect();
let app_config =
AppConfig::load(&self.paths, &db_roots).unwrap_or_else(|_| self.app_config.clone());
Self::run_cycle_inner(&app_config, db, scanner, self.opts.scan_only).await;
}
async fn run_dry_run_cycle(&self, db: &Database, scanner: &Scanner) {
let db_roots: Vec<_> = db
.list_roots()
.unwrap_or_default()
.into_iter()
.map(|r| r.path)
.collect();
let app_config =
AppConfig::load(&self.paths, &db_roots).unwrap_or_else(|_| self.app_config.clone());
tracing::info!("Starting dry-run scan");
match refresh(db, scanner, &app_config).await {
Ok(summary) => {
eprintln!("Scan complete:");
eprintln!(
" {} directories, {} files, {} bytes",
summary.scan.total_directories,
summary.scan.total_files,
summary.scan.total_size_bytes
);
if summary.transitions.expired_to_pending > 0
|| summary.transitions.expired_to_approved > 0
|| summary.transitions.deferred_reset > 0
{
eprintln!("Transitions:");
eprintln!(
" {} expired → pending",
summary.transitions.expired_to_pending
);
eprintln!(
" {} expired → approved",
summary.transitions.expired_to_approved
);
eprintln!(" {} deferred reset", summary.transitions.deferred_reset);
}
}
Err(e) => {
eprintln!("Scan failed: {e}");
}
}
let roots = db.list_roots().unwrap_or_default();
let mut total_removable = 0usize;
let mut total_blocked = 0usize;
for root in &roots {
match crate::removal::dry_run_approved(db, root.id) {
Ok(result) => {
total_removable += result.removable_count;
total_blocked += result.failures.len();
for failure in &result.failures {
eprintln!(
" would fail: {} ({})",
failure.path.display(),
failure.reason
);
}
}
Err(e) => {
eprintln!("Dry run failed for {}: {e}", root.path.display());
}
}
}
eprintln!();
eprintln!("Dry run summary: {total_removable} removable, {total_blocked} would fail");
}
async fn run_cycle_inner(
app_config: &AppConfig,
db: &Database,
scanner: &Scanner,
scan_only: bool,
) {
let cycle_start = std::time::Instant::now();
let mut scan_directories: u64 = 0;
let mut scan_files: u64 = 0;
let mut scan_bytes: u64 = 0;
let mut expired_to_pending: u64 = 0;
let mut expired_to_approved: u64 = 0;
let mut deferred_reset: u64 = 0;
let mut scan_outcome = "success";
let mut removed_count: usize = 0;
let mut blocked_count: usize = 0;
let mut bytes_freed: i64 = 0;
let mut removal_outcome = if scan_only { "skipped" } else { "success" };
match refresh(db, scanner, app_config).await {
Ok(summary) => {
scan_directories = summary.scan.total_directories;
scan_files = summary.scan.total_files;
scan_bytes = summary.scan.total_size_bytes;
expired_to_pending = summary.transitions.expired_to_pending;
expired_to_approved = summary.transitions.expired_to_approved;
deferred_reset = summary.transitions.deferred_reset;
}
Err(e) => {
scan_outcome = "failed";
tracing::warn!(error = ?e, "Refresh failed, continuing to removal step");
}
}
if !scan_only {
match remove_approved(db) {
Ok(summary) => {
removed_count = summary.removed_count();
blocked_count = summary.blocked_count();
bytes_freed = summary.total_bytes_freed();
}
Err(e) => {
removal_outcome = "failed";
tracing::warn!(error = ?e, "Removal failed");
}
}
}
let cycle_duration_ms = cycle_start.elapsed().as_millis();
tracing::info!(
target: "stagecrew::daemon",
cycle_duration_ms,
scan.outcome = scan_outcome,
scan.directories = scan_directories,
scan.files = scan_files,
scan.bytes = scan_bytes,
transitions.expired_to_pending = expired_to_pending,
transitions.expired_to_approved = expired_to_approved,
transitions.deferred_reset = deferred_reset,
removal.outcome = removal_outcome,
removal.removed = removed_count,
removal.blocked = blocked_count,
removal.bytes_freed = bytes_freed,
config.expiration_days = app_config.global.expiration_days,
config.warning_days = app_config.global.warning_days,
config.auto_remove = app_config.global.auto_remove,
config.tracked_paths = app_config.global.tracked_paths.len(),
"daemon_cycle"
);
}
}
struct PidLock {
path: std::path::PathBuf,
}
impl Drop for PidLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn acquire_pid_lock(path: &std::path::Path) -> Result<PidLock> {
use std::io::Read;
if path.exists() {
let mut contents = String::new();
if let Ok(mut file) = std::fs::File::open(path) {
let _ = file.read_to_string(&mut contents);
}
if let Ok(pid) = contents.trim().parse::<u32>() {
if is_process_running(pid) {
return Err(crate::error::Error::Config(format!(
"Another daemon instance is already running (PID {pid}). \
Lock file: {}",
path.display()
)));
}
tracing::info!(
stale_pid = pid,
"Removing stale lock file from dead process"
);
}
}
std::fs::write(path, std::process::id().to_string())?;
Ok(PidLock {
path: path.to_path_buf(),
})
}
fn is_process_running(pid: u32) -> bool {
std::path::Path::new(&format!("/proc/{pid}")).exists()
}