ssmm 0.6.0

AWS SSM Parameter Store helper for team-scoped .env sync (systemd friendly)
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))),
        }
    }
}

/// systemd drop-in として投入可能な `[Service]` セクションを組み立てる。
/// - `EnvironmentFile=` と `ExecStartPre=` は空行で一度 reset してから再指定する
///   (drop-in の list-accumulate を避けるため)。
/// - `ExecStart=` の値は `<ssmm> exec --app <app> -- <exec_cmd>` の生文字列。
///   systemd は ExecStart= を空白区切りで引数にする。`exec_cmd` 側に引用符や
///   エスケープが必要なら呼び出し側の責任で渡す。
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]");
    // SSM 由来の .env を読ませないため EnvironmentFile= を reset。
    // keep 指定があれば再追加 (`-` prefix でファイル欠如を許容)。
    let _ = writeln!(out, "EnvironmentFile=");
    for p in keep_env_files {
        let _ = writeln!(out, "EnvironmentFile=-{}", p.display());
    }
    // ExecStartPre= を reset してから指定分を再追加。
    let _ = writeln!(out, "ExecStartPre=");
    for cmd in pre_execs {
        let _ = writeln!(out, "ExecStartPre={}", cmd);
    }
    // ExecStart= を ssmm exec 経由に差し替え。
    let _ = writeln!(out, "ExecStart=");
    let _ = writeln!(
        out,
        "ExecStart={} exec --app {} -- {}",
        ssmm_bin.display(),
        app,
        exec_cmd
    );
    // ssmm が prefix を解決できるように環境変数を注入。
    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",
        );
        // reset 行が単独で存在し、再指定なしで下に進む
        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"
        );
        // ExecStart の最終行
        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() {
        // drop-in の規則: list-reset (空の `KEY=`) は必ず追加指定の「前」に来る。
        // 順序が逆だと systemd は追加指定を破棄してしまう。
        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"
        );
    }
}