use super::{ServiceSpec, Status};
use anyhow::{bail, Context, Result};
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
const UNIT_PREFIX: &str = "reeve";
pub fn label(service: &str) -> String {
format!("{UNIT_PREFIX}-{service}")
}
fn unit_name(service: &str) -> String {
format!("{}.service", label(service))
}
fn unit_dir() -> Result<PathBuf> {
let base = dirs::config_dir().context("Could not determine config directory")?;
Ok(base.join("systemd/user"))
}
fn unit_path(service: &str) -> Result<PathBuf> {
Ok(unit_dir()?.join(unit_name(service)))
}
fn systemctl(args: &[&str]) -> Result<std::process::Output> {
Command::new("systemctl")
.arg("--user")
.args(args)
.stdin(Stdio::null())
.output()
.context("Failed to run systemctl --user")
}
fn daemon_reload() {
let _ = systemctl(&["daemon-reload"]);
}
fn systemd_quote(token: &str) -> String {
let escaped = token
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('%', "%%");
format!("\"{escaped}\"")
}
fn sanitize_path_value(p: &std::path::Path) -> String {
p.display().to_string().replace('\n', " ")
}
fn render_unit(spec: &ServiceSpec) -> String {
let mut exec = systemd_quote(&spec.program.display().to_string());
for arg in &spec.args {
exec.push(' ');
exec.push_str(&systemd_quote(arg));
}
let log = sanitize_path_value(&spec.log);
let restart = if spec.keep_alive {
"Restart=always\nRestartSec=1\n"
} else {
"Restart=no\n"
};
let install = if spec.run_at_load {
"\n[Install]\nWantedBy=default.target\n"
} else {
""
};
format!(
"# Generated by reeve — do not edit by hand.\n\
[Unit]\n\
Description=reeve managed service {service}\n\
\n\
[Service]\n\
Type=exec\n\
ExecStart={exec}\n\
{restart}\
StandardOutput=append:{log}\n\
StandardError=append:{log}\n\
Environment=HOME=%h\n\
{install}",
service = spec.service,
)
}
pub fn install(spec: &ServiceSpec) -> Result<()> {
let dir = unit_dir()?;
fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
if let Some(parent) = spec.log.parent() {
fs::create_dir_all(parent).ok();
}
let path = unit_path(&spec.service)?;
fs::write(&path, render_unit(spec))
.with_context(|| format!("Failed to write unit {}", path.display()))?;
daemon_reload();
if spec.run_at_load {
let _ = systemctl(&["enable", &unit_name(&spec.service)]);
}
Ok(())
}
#[allow(dead_code)]
pub fn load(service: &str) -> Result<()> {
let path = unit_path(service)?;
if !path.exists() {
bail!("No unit for service '{service}'. Install it first.");
}
let out = systemctl(&["start", &unit_name(service)])?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.trim().is_empty() {
bail!("systemctl --user start failed: {}", stderr.trim());
}
}
Ok(())
}
pub fn unload(service: &str) -> Result<()> {
let path = unit_path(service)?;
if !path.exists() {
return Ok(());
}
let out = systemctl(&["stop", &unit_name(service)])?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let benign = stderr.contains("not loaded")
|| stderr.contains("not active")
|| stderr.contains("not found");
if !benign && !stderr.trim().is_empty() {
bail!("systemctl --user stop failed: {}", stderr.trim());
}
}
Ok(())
}
pub fn restart(service: &str) -> Result<()> {
let path = unit_path(service)?;
if !path.exists() {
bail!("No unit for service '{service}'. Install it first.");
}
let out = systemctl(&["restart", &unit_name(service)])?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.trim().is_empty() {
bail!("systemctl --user restart failed: {}", stderr.trim());
}
}
Ok(())
}
pub fn uninstall(service: &str) -> Result<()> {
let path = unit_path(service)?;
if path.exists() {
let _ = systemctl(&["disable", "--now", &unit_name(service)]);
fs::remove_file(&path)
.with_context(|| format!("Failed to remove unit {}", path.display()))?;
daemon_reload();
}
Ok(())
}
pub fn status(service: &str) -> Status {
let out = systemctl(&["show", "-p", "ActiveState", "--value", &unit_name(service)]);
match out {
Ok(o) if o.status.success() => {
match String::from_utf8_lossy(&o.stdout).trim() {
"active" | "activating" | "reloading" => Status::Running,
"failed" => Status::Error,
_ => Status::Stopped,
}
}
_ => Status::Stopped,
}
}
pub fn pid(service: &str) -> Option<u32> {
let out = systemctl(&["show", "-p", "MainPID", "--value", &unit_name(service)]).ok()?;
if !out.status.success() {
return None;
}
match String::from_utf8_lossy(&out.stdout).trim().parse::<u32>() {
Ok(0) | Err(_) => None,
Ok(p) => Some(p),
}
}
fn current_user() -> String {
std::env::var("USER")
.ok()
.filter(|u| !u.is_empty())
.unwrap_or_else(|| {
Command::new("id")
.arg("-un")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default()
})
}
pub fn enable_linger() -> Result<()> {
let user = current_user();
let out = Command::new("loginctl")
.args(["enable-linger", &user])
.output()
.context("Failed to run loginctl enable-linger")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!(
"loginctl enable-linger failed: {}. Run `sudo loginctl enable-linger {user}` manually.",
stderr.trim()
);
}
Ok(())
}
pub fn linger_enabled() -> bool {
let user = current_user();
Command::new("loginctl")
.args(["show-user", &user, "-p", "Linger", "--value"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes")
.unwrap_or(false)
}
pub fn user_bus_ok() -> bool {
match systemctl(&["is-system-running"]) {
Ok(o) => {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
!combined.contains("Failed to connect")
}
Err(_) => false,
}
}