aynur 0.2.0

A minimal pm2-like process guardian for local Rust binaries
use crate::paths::AynurPaths;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
    pub name: String,
    pub binary_path: PathBuf,
    pub args: Vec<String>,
    pub working_directory: PathBuf,
    pub env: BTreeMap<String, String>,
    pub env_file_path: Option<PathBuf>,
    pub restart_policy: RestartPolicy,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RestartPolicy {
    pub max_restarts: u32,
    pub window_seconds: u64,
}

impl AppConfig {
    pub fn validate(&self) -> anyhow::Result<()> {
        if self.name.trim().is_empty() {
            anyhow::bail!("app name is empty");
        }
        if self.name.contains('/') {
            anyhow::bail!("app name '{}' must not contain '/'", self.name);
        }
        let metadata = std::fs::metadata(&self.binary_path).with_context(|| {
            format!(
                "binary for app '{}' does not exist at {}",
                self.name,
                self.binary_path.display()
            )
        })?;
        if !metadata.is_file() {
            anyhow::bail!(
                "binary for app '{}' is not a file: {}",
                self.name,
                self.binary_path.display()
            );
        }
        if metadata.permissions().mode() & 0o111 == 0 {
            anyhow::bail!(
                "binary for app '{}' is not executable: {}",
                self.name,
                self.binary_path.display()
            );
        }
        let cwd_metadata = std::fs::metadata(&self.working_directory).with_context(|| {
            format!(
                "working directory for app '{}' does not exist at {}",
                self.name,
                self.working_directory.display()
            )
        })?;
        if !cwd_metadata.is_dir() {
            anyhow::bail!(
                "working directory for app '{}' is not a directory: {}",
                self.name,
                self.working_directory.display()
            );
        }
        Ok(())
    }

    pub fn save(&self, paths: &AynurPaths) -> anyhow::Result<()> {
        let content = serde_json::to_string_pretty(self)
            .with_context(|| format!("failed to serialize config for app '{}'", self.name))?;
        let path = paths.app_config_path(&self.name);
        std::fs::write(&path, content)
            .with_context(|| format!("failed to write app config at {}", path.display()))
    }

    pub fn delete(paths: &AynurPaths, name: &str) -> anyhow::Result<()> {
        let path = paths.app_config_path(name);
        if path.exists() {
            std::fs::remove_file(&path)
                .with_context(|| format!("failed to delete app config at {}", path.display()))?;
        }
        Ok(())
    }
}