cfgd-core 0.4.0

Core library for cfgd — shared types, providers, reconciler, state
Documentation
use super::super::*;
use crate::PathDisplayExt;

/// Generate launchd plist content for the daemon service.
#[cfg(unix)]
pub(crate) fn generate_launchd_plist(
    binary: &Path,
    config_path: &Path,
    profile: Option<&str>,
    home: &Path,
) -> String {
    let mut args = vec![
        format!("<string>{}</string>", binary.display()),
        "<string>--config</string>".to_string(),
        format!("<string>{}</string>", config_path.display()),
    ];
    if let Some(p) = profile {
        args.push("<string>--profile</string>".to_string());
        args.push(format!("<string>{}</string>", p));
    }
    args.push("<string>--quiet</string>".to_string());
    args.push("<string>daemon</string>".to_string());

    let args_xml = args.join("\n            ");
    let label = LAUNCHD_LABEL;
    let home_display = home.display();

    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>{label}</string>
    <key>ProgramArguments</key>
    <array>
            {args_xml}
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>{home_display}/Library/Logs/cfgd.log</string>
    <key>StandardErrorPath</key>
    <string>{home_display}/Library/Logs/cfgd.err</string>
</dict>
</plist>"#
    )
}

#[cfg(unix)]
pub(crate) fn install_launchd_service(
    binary: &Path,
    config_path: &Path,
    profile: Option<&str>,
) -> Result<()> {
    let home = crate::expand_tilde(Path::new("~"));
    let plist_dir = home.join(LAUNCHD_AGENTS_DIR);
    std::fs::create_dir_all(&plist_dir).map_err(|e| DaemonError::ServiceInstallFailed {
        message: format!("create LaunchAgents dir: {}", e),
    })?;

    let plist_path = plist_dir.join(format!("{}.plist", LAUNCHD_LABEL));
    let config_abs =
        std::fs::canonicalize(config_path).unwrap_or_else(|_| config_path.to_path_buf());

    let plist = generate_launchd_plist(binary, &config_abs, profile, &home);

    crate::atomic_write_str(&plist_path, &plist).map_err(|e| {
        DaemonError::ServiceInstallFailed {
            message: format!("write plist: {}", e),
        }
    })?;

    tracing::info!(path = %plist_path.posix(), "installed launchd service");
    Ok(())
}

#[cfg(unix)]
pub(crate) fn uninstall_launchd_service() -> Result<()> {
    let home = crate::expand_tilde(Path::new("~"));
    let plist_path = home
        .join(LAUNCHD_AGENTS_DIR)
        .join(format!("{}.plist", LAUNCHD_LABEL));

    if plist_path.exists() {
        std::fs::remove_file(&plist_path).map_err(|e| DaemonError::ServiceInstallFailed {
            message: format!("remove plist: {}", e),
        })?;
        tracing::info!(path = %plist_path.posix(), "removed launchd service");
    }

    Ok(())
}

#[cfg(test)]
#[cfg(unix)]
mod tests {
    use super::*;
    use crate::test_helpers::EnvVarGuard;
    use std::path::PathBuf;
    use tempfile::TempDir;

    #[test]
    #[serial_test::serial]
    fn install_then_uninstall_launchd_service_writes_and_removes_plist() {
        let home_dir = TempDir::new().expect("tempdir");
        let _home_g = EnvVarGuard::set("HOME", home_dir.path().to_str().expect("utf8"));

        let config = home_dir.path().join("cfgd.yaml");
        std::fs::write(&config, "apiVersion: cfgd.io/v1alpha1\n").expect("write config");

        install_launchd_service(&PathBuf::from("/usr/local/bin/cfgd"), &config, Some("ws"))
            .expect("install");

        let plist_path = home_dir
            .path()
            .join(LAUNCHD_AGENTS_DIR)
            .join(format!("{}.plist", LAUNCHD_LABEL));
        let plist = std::fs::read_to_string(&plist_path).expect("read plist");
        assert!(plist.contains("<string>/usr/local/bin/cfgd</string>"));
        assert!(plist.contains("<string>--profile</string>"));
        assert!(plist.contains("<string>ws</string>"));

        uninstall_launchd_service().expect("uninstall");
        assert!(!plist_path.exists());

        // Second uninstall is a no-op (file absent).
        uninstall_launchd_service().expect("idempotent uninstall");
    }

    #[test]
    fn generate_launchd_plist_includes_binary_and_config_paths() {
        let plist = generate_launchd_plist(
            &PathBuf::from("/usr/local/bin/cfgd"),
            &PathBuf::from("/etc/cfgd/config.yaml"),
            None,
            &PathBuf::from("/Users/tj"),
        );
        assert!(plist.contains("<string>/usr/local/bin/cfgd</string>"));
        assert!(plist.contains("<string>/etc/cfgd/config.yaml</string>"));
        assert!(plist.contains("<string>--quiet</string>"));
        assert!(plist.contains("<string>daemon</string>"));
        assert!(plist.contains("/Users/tj/Library/Logs/cfgd.log"));
        assert!(plist.contains("/Users/tj/Library/Logs/cfgd.err"));
        assert!(!plist.contains("--profile"));
    }

    #[test]
    fn generate_launchd_plist_emits_profile_args_when_set() {
        let plist = generate_launchd_plist(
            &PathBuf::from("/usr/local/bin/cfgd"),
            &PathBuf::from("/etc/cfgd/config.yaml"),
            Some("workstation"),
            &PathBuf::from("/Users/tj"),
        );
        assert!(plist.contains("<string>--profile</string>"));
        assert!(plist.contains("<string>workstation</string>"));
    }

    #[test]
    fn generate_launchd_plist_emits_required_launchd_keys() {
        let plist = generate_launchd_plist(
            &PathBuf::from("/cfgd"),
            &PathBuf::from("/c.yaml"),
            None,
            &PathBuf::from("/h"),
        );
        assert!(plist.contains("<key>Label</key>"));
        assert!(plist.contains("<key>ProgramArguments</key>"));
        assert!(plist.contains("<key>RunAtLoad</key>"));
        assert!(plist.contains("<key>KeepAlive</key>"));
        assert!(plist.contains("<key>StandardOutPath</key>"));
        assert!(plist.contains("<key>StandardErrorPath</key>"));
    }
}