use anyhow::{anyhow, Context, Result};
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct ServiceConfig {
name: String,
description: String,
exec_args: String,
binaries: Vec<String>,
user: Option<String>,
user_home: Option<PathBuf>,
bin_dir: Option<PathBuf>,
workspace: Option<PathBuf>,
restart_sec: u32,
watchdog_sec: Option<u32>,
}
#[derive(Debug, Clone)]
struct Resolved {
user: String,
home: PathBuf,
bin_dir: PathBuf,
workspace: PathBuf,
}
impl ServiceConfig {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
Self {
description: format!("{name} service"),
exec_args: String::new(),
binaries: vec![name.clone()],
user: None,
user_home: None,
bin_dir: None,
workspace: None,
restart_sec: 5,
watchdog_sec: None,
name,
}
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = desc.into();
self
}
pub fn exec_args(mut self, args: impl Into<String>) -> Self {
self.exec_args = args.into();
self
}
pub fn binaries<I, S>(mut self, bins: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.binaries = bins.into_iter().map(Into::into).collect();
self
}
pub fn user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
pub fn user_home(mut self, home: impl Into<PathBuf>) -> Self {
self.user_home = Some(home.into());
self
}
pub fn bin_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.bin_dir = Some(dir.into());
self
}
pub fn workspace(mut self, path: impl Into<PathBuf>) -> Self {
self.workspace = Some(path.into());
self
}
pub fn restart_sec(mut self, secs: u32) -> Self {
self.restart_sec = secs;
self
}
pub fn watchdog_sec(mut self, secs: u32) -> Self {
self.watchdog_sec = Some(secs);
self
}
fn resolve(&self) -> Result<Resolved> {
let user = match &self.user {
Some(u) => u.clone(),
None => current_user()?,
};
let home = match &self.user_home {
Some(h) => h.clone(),
None => home::home_dir().unwrap_or_else(|| PathBuf::from(format!("/home/{user}"))),
};
let bin_dir = self.bin_dir.clone().unwrap_or_else(|| home.join(".local").join("bin"));
let workspace = self
.workspace
.clone()
.unwrap_or_else(|| home.join(".config").join(&self.name));
Ok(Resolved {
user,
home,
bin_dir,
workspace,
})
}
pub fn bin_dir_path(&self) -> Result<PathBuf> {
Ok(self.resolve()?.bin_dir)
}
pub fn workspace_path(&self) -> Result<PathBuf> {
Ok(self.resolve()?.workspace)
}
pub fn generate_unit(&self) -> Result<String> {
let r = self.resolve()?;
Ok(self.render_unit(&r))
}
fn render_unit(&self, r: &Resolved) -> String {
let workspace = posix(&r.workspace);
let bin_dir = posix(&r.bin_dir);
let bin = posix(&r.bin_dir.join(&self.name));
let exec_args = self.exec_args.replace("{workspace}", &workspace);
let exec_start = if exec_args.is_empty() {
bin
} else {
format!("{bin} {exec_args}")
};
let svc_type = "notify";
let watchdog_line = match self.watchdog_sec {
Some(secs) => format!("WatchdogSec={secs}\n"),
None => String::new(),
};
let path_line =
format!("Environment=PATH={bin_dir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n");
format!(
"[Unit]\n\
Description={description}\n\
After=network.target\n\
\n\
[Service]\n\
Type={svc_type}\n\
{path_line}\
ExecStart={exec_start}\n\
Restart=on-failure\n\
RestartSec={restart_sec}\n\
{watchdog_line}\
WorkingDirectory={workspace}\n\
\n\
[Install]\n\
WantedBy=default.target\n",
description = self.description,
svc_type = svc_type,
path_line = path_line,
exec_start = exec_start,
restart_sec = self.restart_sec,
watchdog_line = watchdog_line,
workspace = workspace,
)
}
#[cfg(all(target_os = "linux", feature = "updater"))]
pub fn install(&self) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let r = self.resolve()?;
self.warn_if_installed_elsewhere(&r);
let src_dir = std::env::current_exe()
.context("Failed to resolve current executable")?
.parent()
.ok_or_else(|| anyhow!("Current executable has no parent directory"))?
.to_path_buf();
std::fs::create_dir_all(&r.bin_dir)
.with_context(|| format!("Failed to create binary directory {}", r.bin_dir.display()))?;
for bin in &self.binaries {
let src = src_dir.join(bin);
let dest = r.bin_dir.join(bin);
if src != dest {
std::fs::copy(&src, &dest)
.with_context(|| format!("Failed to copy {} -> {}", src.display(), dest.display()))?;
}
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))
.with_context(|| format!("Failed to chmod {}", dest.display()))?;
}
std::fs::create_dir_all(&r.workspace)
.with_context(|| format!("Failed to create workspace {}", r.workspace.display()))?;
let unit_dir = r.home.join(".config").join("systemd").join("user");
std::fs::create_dir_all(&unit_dir).with_context(|| format!("Failed to create {}", unit_dir.display()))?;
let unit_path = unit_dir.join(format!("{}.service", self.name));
std::fs::write(&unit_path, self.render_unit(&r))
.with_context(|| format!("Failed to write {}", unit_path.display()))?;
run(Command::new("systemctl").args(["--user", "daemon-reload"]))
.context("systemctl --user daemon-reload failed")?;
run(Command::new("systemctl").args(["--user", "enable", &self.name]))
.context("systemctl --user enable failed")?;
if let Err(e) = run(Command::new("loginctl").args(["enable-linger", &r.user])) {
log::warn!(
"Could not enable linger for '{}' ({e}); run `loginctl enable-linger {}` \
as an admin so the service starts at boot",
r.user,
r.user
);
}
log::info!("Installed user systemd service '{}'", self.name);
return Ok(());
fn run(cmd: &mut Command) -> Result<()> {
let status = cmd.status().with_context(|| format!("Failed to spawn {cmd:?}"))?;
if !status.success() {
anyhow::bail!("command {cmd:?} exited with {status}");
}
Ok(())
}
}
#[cfg(not(all(target_os = "linux", feature = "updater")))]
pub fn install(&self) -> Result<()> {
anyhow::bail!(
"systemd install requires Linux and the `updater` feature; \
use generate_unit() for a dry-run preview"
)
}
#[cfg(all(target_os = "linux", feature = "updater"))]
fn warn_if_installed_elsewhere(&self, r: &Resolved) {
use std::path::Path;
if let Some(other) = std::env::var_os("PATH").and_then(|path| {
std::env::split_paths(&path)
.filter(|dir| *dir != r.bin_dir)
.map(|dir| dir.join(&self.name))
.find(|cand| cand.is_file())
}) {
log::warn!(
"'{}' is already installed at {} — after this install \
~/.local/bin must precede it on PATH or the old copy will shadow the new one",
self.name,
other.display()
);
}
let sys_unit = format!("/etc/systemd/system/{}.service", self.name);
if Path::new(&sys_unit).exists() {
log::warn!(
"a system-level unit {sys_unit} already exists; this user-level service may \
conflict — consider `sudo systemctl disable --now {}` and removing it",
self.name
);
}
}
}
fn posix(p: &std::path::Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
fn current_user() -> Result<String> {
std::env::var("USER")
.ok()
.or_else(|| std::env::var("USERNAME").ok())
.filter(|u| !u.is_empty())
.ok_or_else(|| anyhow!("Cannot determine the current user; set it explicitly via .user(..)"))
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(name: &str) -> ServiceConfig {
ServiceConfig::new(name).user("alarm").user_home("/home/alarm")
}
#[test]
fn unit_substitutes_workspace_placeholder() {
let unit = cfg("alarm-server")
.description("Alarm Server")
.exec_args("-w {workspace}")
.restart_sec(10)
.generate_unit()
.unwrap();
assert!(unit.contains("Description=Alarm Server"));
assert!(unit.contains("ExecStart=/home/alarm/.local/bin/alarm-server -w /home/alarm/.config/alarm-server"));
assert!(unit.contains("WorkingDirectory=/home/alarm/.config/alarm-server"));
assert!(unit.contains("RestartSec=10"));
assert!(unit.contains("WantedBy=default.target"));
}
#[test]
fn user_unit_has_no_user_or_group() {
let unit = cfg("svc").generate_unit().unwrap();
assert!(!unit.contains("User="));
assert!(!unit.contains("Group="));
assert!(!unit.contains("multi-user.target"));
}
#[test]
fn unit_without_exec_args_omits_trailing_space() {
let unit = cfg("svc").generate_unit().unwrap();
assert!(unit.contains("ExecStart=/home/alarm/.local/bin/svc\n"));
}
#[test]
fn explicit_overrides_win() {
let unit = cfg("svc")
.bin_dir("/opt/svc/bin")
.workspace("/var/lib/svc")
.exec_args("-w {workspace}")
.generate_unit()
.unwrap();
assert!(unit.contains("ExecStart=/opt/svc/bin/svc -w /var/lib/svc"));
assert!(unit.contains("WorkingDirectory=/var/lib/svc"));
assert!(unit
.contains("Environment=PATH=/opt/svc/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n"));
}
#[test]
fn unit_environment_path_includes_bin_dir() {
let unit = cfg("alarm-server").generate_unit().unwrap();
assert!(unit.contains(
"Environment=PATH=/home/alarm/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n"
));
let env_idx = unit.find("Environment=PATH=").expect("PATH line present");
let exec_idx = unit.find("ExecStart=").expect("ExecStart present");
assert!(env_idx < exec_idx);
}
#[test]
fn default_is_type_notify_without_watchdogsec() {
let unit = cfg("svc").generate_unit().unwrap();
assert!(unit.contains("Type=notify"));
assert!(!unit.contains("WatchdogSec="));
}
#[test]
fn watchdog_sec_adds_watchdogsec_keeping_type_notify() {
let unit = cfg("svc").watchdog_sec(30).generate_unit().unwrap();
assert!(unit.contains("Type=notify"));
assert!(unit.contains("WatchdogSec=30\n"));
}
#[test]
fn defaults_derive_from_name_and_home() {
let r = cfg("foo").resolve().unwrap();
assert_eq!(r.user, "alarm");
assert_eq!(r.home, PathBuf::from("/home/alarm"));
assert_eq!(r.bin_dir, PathBuf::from("/home/alarm/.local/bin"));
assert_eq!(r.workspace, PathBuf::from("/home/alarm/.config/foo"));
assert_eq!(ServiceConfig::new("foo").binaries, vec!["foo".to_string()]);
}
}