use anyhow::{Result, bail};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
pub enum SystemdScope {
User,
System,
}
impl SystemdScope {
pub fn as_cli_flag(&self) -> &'static str {
match self {
Self::User => "--user",
Self::System => "--system",
}
}
pub fn drop_in_dir(&self, unit: &str) -> Result<PathBuf> {
if !unit.ends_with(".service") {
bail!("unit must end with .service: got {:?}", unit);
}
match self {
Self::User => {
let home = std::env::var("HOME")
.map_err(|_| anyhow::anyhow!("HOME env not set"))?;
Ok(PathBuf::from(home)
.join(".config/systemd/user")
.join(format!("{}.d", unit)))
}
Self::System => Ok(PathBuf::from("/etc/systemd/system").join(format!("{}.d", unit))),
}
}
}
pub fn build_drop_in(
app: &str,
exec_cmd: &str,
keep_env_files: &[PathBuf],
pre_execs: &[String],
ssmm_bin: &Path,
prefix_root: &str,
) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"# Generated by `ssmm migrate-to-exec` (ssmm {}).",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(
out,
"# Revert: rm this file, then `systemctl [--user|--system] daemon-reload`."
);
let _ = writeln!(out);
let _ = writeln!(out, "[Service]");
let _ = writeln!(out, "EnvironmentFile=");
for p in keep_env_files {
let _ = writeln!(out, "EnvironmentFile=-{}", p.display());
}
let _ = writeln!(out, "ExecStartPre=");
for cmd in pre_execs {
let _ = writeln!(out, "ExecStartPre={}", cmd);
}
let _ = writeln!(out, "ExecStart=");
let _ = writeln!(
out,
"ExecStart={} exec --app {} -- {}",
ssmm_bin.display(),
app,
exec_cmd
);
let _ = writeln!(out, "Environment=SSMM_PREFIX_ROOT={}", prefix_root);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_drop_in_minimal_no_keeps_no_pres() {
let out = build_drop_in(
"myapp",
"/usr/bin/echo hi",
&[],
&[],
Path::new("/home/me/.cargo/bin/ssmm"),
"/myteam",
);
assert!(
out.contains("\nEnvironmentFile=\nExecStartPre="),
"EnvironmentFile= reset without re-add should be immediately followed by \
ExecStartPre= reset, got:\n{out}"
);
assert!(
out.contains("\nExecStartPre=\nExecStart="),
"ExecStartPre= reset should be immediately followed by ExecStart= reset"
);
assert!(out.contains(
"\nExecStart=/home/me/.cargo/bin/ssmm exec --app myapp -- /usr/bin/echo hi\n"
));
assert!(out.contains("Environment=SSMM_PREFIX_ROOT=/myteam"));
}
#[test]
fn build_drop_in_with_keeps_and_pres() {
let out = build_drop_in(
"billing-api",
"/usr/bin/uv run python app.py --mode prod",
&[
PathBuf::from("/home/you/.config/sdtab/env"),
PathBuf::from("/etc/defaults/billing"),
],
&[
"/usr/bin/playwright install chromium".to_string(),
"/bin/mkdir -p /var/run/billing".to_string(),
],
Path::new("/usr/local/bin/ssmm"),
"/myteam",
);
assert!(out.contains("EnvironmentFile=-/home/you/.config/sdtab/env\n"));
assert!(out.contains("EnvironmentFile=-/etc/defaults/billing\n"));
assert!(out.contains("ExecStartPre=/usr/bin/playwright install chromium\n"));
assert!(out.contains("ExecStartPre=/bin/mkdir -p /var/run/billing\n"));
assert!(out.contains(
"ExecStart=/usr/local/bin/ssmm exec --app billing-api -- \
/usr/bin/uv run python app.py --mode prod\n"
));
}
#[test]
fn build_drop_in_preserves_reset_order() {
let out = build_drop_in(
"app",
"/bin/true",
&[PathBuf::from("/tmp/keep")],
&["/bin/pre".to_string()],
Path::new("/ssmm"),
"/root",
);
let reset_env = out.find("\nEnvironmentFile=\n").expect("has env reset");
let keep_env = out
.find("EnvironmentFile=-/tmp/keep\n")
.expect("has env keep");
assert!(
reset_env < keep_env,
"EnvironmentFile= reset must precede keep line"
);
let reset_pre = out.find("\nExecStartPre=\n").expect("has pre reset");
let add_pre = out.find("ExecStartPre=/bin/pre\n").expect("has pre add");
assert!(
reset_pre < add_pre,
"ExecStartPre= reset must precede add line"
);
}
}