use crate::daemon::{DaemonStatus, get_exe_path};
use anyhow::{Context, Result};
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
pub fn get_systemd_dir() -> Result<PathBuf> {
let base = match env::var_os("XDG_CONFIG_HOME") {
Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => {
let home = env::var("HOME").context("Could not find HOME directory")?;
PathBuf::from(home).join(".config")
}
};
Ok(base.join("systemd").join("user"))
}
pub fn generate_service_unit(exe_path: &str) -> String {
format!(
r#"[Unit]
Description=DevPrune Background Runner
[Service]
Type=oneshot
ExecStart="{}" run --yes --daemon
"#,
exe_path
)
}
pub fn generate_timer_unit(interval_days: u64) -> String {
let interval_days = interval_days.max(1);
format!(
r#"[Unit]
Description=Run DevPrune every {interval_days} day(s)
[Timer]
OnBootSec=15min
OnUnitActiveSec={interval_days}d
[Install]
WantedBy=timers.target
"#
)
}
fn systemctl_available() -> bool {
!matches!(
Command::new("systemctl").arg("--version").output(),
Err(ref e) if e.kind() == std::io::ErrorKind::NotFound
)
}
pub fn install(interval_days: u64) -> Result<()> {
if !systemctl_available() {
anyhow::bail!("{NO_SYSTEMD_HELP}");
}
let exe_path = get_exe_path();
let service_content = generate_service_unit(&exe_path.to_string_lossy());
let timer_content = generate_timer_unit(interval_days);
let systemd_dir = get_systemd_dir()?;
fs::create_dir_all(&systemd_dir)?;
let mut service_path = systemd_dir.clone();
service_path.push("dev-prune.service");
fs::write(&service_path, service_content)?;
let mut timer_path = systemd_dir.clone();
timer_path.push("dev-prune.timer");
fs::write(&timer_path, timer_content)?;
Command::new("systemctl")
.args(["--user", "daemon-reload"])
.output()?;
let output = Command::new("systemctl")
.args(["--user", "enable", "--now", "dev-prune.timer"])
.output()?;
if output.status.success() {
Ok(())
} else {
anyhow::bail!(
"systemctl enable failed: {}",
String::from_utf8_lossy(&output.stderr)
)
}
}
pub fn uninstall() -> Result<()> {
let systemd_dir = get_systemd_dir()?;
let mut service_path = systemd_dir.clone();
service_path.push("dev-prune.service");
let mut timer_path = systemd_dir.clone();
timer_path.push("dev-prune.timer");
let _ = Command::new("systemctl")
.args(["--user", "disable", "--now", "dev-prune.timer"])
.output();
if service_path.exists() {
fs::remove_file(service_path)?;
}
if timer_path.exists() {
fs::remove_file(timer_path)?;
}
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.output();
Ok(())
}
pub const NO_SYSTEMD_HELP: &str = "systemd user units are not available here. \
Schedule `dev-prune run --yes --daemon` with whatever this system does use \
(cron, OpenRC, runit), or run `devp run` by hand — everything else works without \
a scheduler.";
fn classify_is_active(stdout: &str, stderr: &str) -> DaemonStatus {
match stdout.trim() {
"active" => DaemonStatus::Installed,
"inactive" | "failed" | "unknown" => DaemonStatus::NotInstalled,
other => {
let reason = if other.is_empty() {
stderr.trim()
} else {
other
};
if reason.is_empty() {
DaemonStatus::Unknown(NO_SYSTEMD_HELP.to_string())
} else {
DaemonStatus::Unknown(reason.to_string())
}
}
}
}
pub fn status() -> Result<DaemonStatus> {
let output = match Command::new("systemctl")
.args(["--user", "is-active", "dev-prune.timer"])
.output()
{
Ok(output) => output,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(DaemonStatus::Unknown(NO_SYSTEMD_HELP.to_string()));
}
Err(e) => return Err(e.into()),
};
Ok(classify_is_active(
&String::from_utf8_lossy(&output.stdout),
&String::from_utf8_lossy(&output.stderr),
))
}
fn parse_exec_start(unit: &str) -> Option<PathBuf> {
let value = unit
.lines()
.find_map(|line| line.trim_start().strip_prefix("ExecStart="))?
.trim();
let program = match value.strip_prefix('"') {
Some(quoted) => quoted.split('"').next()?,
None => value.split_whitespace().next()?,
};
if program.is_empty() {
return None;
}
Some(PathBuf::from(program))
}
pub fn registered_exe_path() -> Option<PathBuf> {
let unit = get_systemd_dir().ok()?.join("dev-prune.service");
parse_exec_start(&fs::read_to_string(unit).ok()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_service_unit() {
let content = generate_service_unit("/usr/bin/dev-prune");
assert!(content.contains("ExecStart=\"/usr/bin/dev-prune\" run --yes --daemon"));
}
#[test]
fn test_generate_service_unit_quotes_spaced_path() {
let content = generate_service_unit("/home/a b/dev-prune");
assert!(content.contains("ExecStart=\"/home/a b/dev-prune\" run --yes --daemon"));
}
#[test]
fn test_generate_timer_unit() {
let content = generate_timer_unit(2);
assert!(content.contains("OnUnitActiveSec=2d"));
assert!(!content.contains("OnCalendar"));
}
#[test]
fn test_generate_timer_unit_honours_interval() {
assert!(generate_timer_unit(7).contains("OnUnitActiveSec=7d"));
assert!(generate_timer_unit(0).contains("OnUnitActiveSec=1d"));
}
#[test]
fn a_running_timer_is_installed() {
assert!(matches!(
classify_is_active("active\n", ""),
DaemonStatus::Installed
));
}
#[test]
fn a_stopped_or_dead_timer_reads_as_absent_so_setup_can_replace_it() {
for answer in ["inactive\n", "failed\n", "unknown\n"] {
assert!(
matches!(classify_is_active(answer, ""), DaemonStatus::NotInstalled),
"{answer:?} should be replaceable"
);
}
}
#[test]
fn a_session_without_a_user_bus_explains_itself() {
let status = classify_is_active("", "Failed to connect to bus: No such file or directory");
match status {
DaemonStatus::Unknown(why) => assert!(why.contains("Failed to connect to bus")),
other => panic!("expected Unknown, got {other}"),
}
}
#[test]
fn a_wordless_failure_falls_back_to_the_help_text() {
match classify_is_active("", "") {
DaemonStatus::Unknown(why) => assert_eq!(why, NO_SYSTEMD_HELP),
other => panic!("expected Unknown, got {other}"),
}
}
#[test]
fn the_registered_binary_is_read_back_out_of_what_we_wrote() {
let unit = generate_service_unit("/usr/bin/dev-prune");
assert_eq!(
parse_exec_start(&unit),
Some(PathBuf::from("/usr/bin/dev-prune"))
);
}
#[test]
fn a_quoted_path_with_spaces_is_not_truncated() {
let unit = generate_service_unit("/home/a b/dev-prune");
assert_eq!(
parse_exec_start(&unit),
Some(PathBuf::from("/home/a b/dev-prune"))
);
}
#[test]
fn an_unquoted_hand_edited_unit_is_still_readable() {
let unit = "[Service]\nExecStart=/usr/bin/dev-prune run --yes\n";
assert_eq!(
parse_exec_start(unit),
Some(PathBuf::from("/usr/bin/dev-prune"))
);
}
#[test]
fn a_unit_without_an_exec_start_answers_nothing() {
assert!(parse_exec_start("[Unit]\nDescription=x\n").is_none());
assert!(parse_exec_start("ExecStart=\n").is_none());
}
#[test]
fn the_help_text_names_an_alternative_the_user_can_act_on() {
assert!(NO_SYSTEMD_HELP.contains("cron"));
assert!(NO_SYSTEMD_HELP.contains("devp run"));
}
}