micromux 0.0.7

Micromux is a local process supervisor with a terminal UI
Documentation
use color_eyre::eyre;
use std::path::{Path, PathBuf};
use yaml_spanned::Spanned;

use crate::{
    config::{self},
    env,
    scheduler::ServiceID,
};

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config;
    use indexmap::IndexMap;
    use std::fs;

    use std::time::{SystemTime, UNIX_EPOCH};
    use yaml_spanned::Spanned;

    fn spanned_string(value: &str) -> Spanned<String> {
        Spanned {
            span: yaml_spanned::spanned::Span::default(),
            inner: value.to_string(),
        }
    }

    fn unique_tmp_dir(prefix: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        std::env::temp_dir().join(format!("micromux-{prefix}-{nanos}"))
    }

    fn service_config(name: &str, command: (&str, &[&str])) -> config::Service {
        config::Service {
            name: spanned_string(name),
            command: (
                spanned_string(command.0),
                command
                    .1
                    .iter()
                    .map(|v| spanned_string(v))
                    .collect::<Vec<_>>(),
            ),
            working_dir: None,
            env_file: vec![],
            environment: IndexMap::new(),
            depends_on: vec![],
            healthcheck: None,
            ports: vec![],
            restart: None,
            color: None,
        }
    }

    #[test]
    fn env_file_missing_is_error() -> eyre::Result<()> {
        let dir = unique_tmp_dir("env-missing");
        std::fs::create_dir_all(&dir)?;
        let mut cfg = service_config("svc", ("sh", &["-c", "true"]));
        cfg.env_file = vec![config::EnvFile {
            path: spanned_string("./definitely-not-present.env"),
        }];

        match Service::new("svc", &dir, cfg) {
            Ok(_) => Err(eyre::eyre!("expected error for missing env file")),
            Err(err) => {
                let msg = err.to_string();
                assert!(msg.contains("failed to read env file"), "{msg}");
                Ok(())
            }
        }
    }

    #[test]
    fn env_file_parse_error_is_error() -> eyre::Result<()> {
        let dir = unique_tmp_dir("env-parse-error");
        fs::create_dir_all(&dir)?;
        let env_path = dir.join("bad.env");
        fs::write(&env_path, "NOT_A_KV_LINE\n")?;

        let mut cfg = service_config("svc", ("sh", &["-c", "true"]));
        cfg.env_file = vec![config::EnvFile {
            path: spanned_string(env_path.to_string_lossy().as_ref()),
        }];

        match Service::new("svc", &dir, cfg) {
            Ok(_) => Err(eyre::eyre!("expected error for invalid env file")),
            Err(err) => {
                let msg = err.to_string();
                assert!(msg.contains("failed to parse env file"), "{msg}");
                Ok(())
            }
        }
    }

    #[test]
    fn env_precedence_env_file_then_environment() -> eyre::Result<()> {
        let dir = unique_tmp_dir("env-precedence");
        fs::create_dir_all(&dir)?;
        let env_path = dir.join("svc.env");
        fs::write(&env_path, "FOO=from_file\n")?;

        let mut cfg = service_config("svc", ("sh", &["-c", "true"]));
        cfg.env_file = vec![config::EnvFile {
            path: spanned_string(env_path.to_string_lossy().as_ref()),
        }];
        cfg.environment
            .insert(spanned_string("FOO"), spanned_string("from_config"));

        let svc = Service::new("svc", &dir, cfg)?;
        assert_eq!(
            svc.environment.get("FOO").map(String::as_str),
            Some("from_config")
        );
        Ok(())
    }

    #[test]
    fn environment_and_ports_interpolate_using_merged_env() -> eyre::Result<()> {
        let dir = unique_tmp_dir("env-interpolate");
        fs::create_dir_all(&dir)?;
        let env_path = dir.join("svc.env");
        fs::write(&env_path, "BASE=10\n")?;

        let mut cfg = service_config("svc", ("sh", &["-c", "true"]));
        cfg.env_file = vec![config::EnvFile {
            path: spanned_string(env_path.to_string_lossy().as_ref()),
        }];
        cfg.environment
            .insert(spanned_string("PORT"), spanned_string("${BASE}23"));
        cfg.ports.push(spanned_string("${PORT}"));

        let svc = Service::new("svc", &dir, cfg)?;
        assert_eq!(
            svc.environment.get("PORT").map(String::as_str),
            Some("1023")
        );
        assert_eq!(svc.open_ports, vec![1023]);
        Ok(())
    }

    #[test]
    fn env_file_path_is_relative_to_config_dir_and_expands_in_order() -> eyre::Result<()> {
        let dir = unique_tmp_dir("env-relative");
        fs::create_dir_all(&dir)?;
        fs::write(
            dir.join(".env"),
            "SPICEDB_PORT=50051\nAIRTYPE_API_SPICEDB_ENDPOINT=\"http://0.0.0.0:${SPICEDB_PORT}\"\n",
        )?;

        let mut cfg = service_config("svc", ("sh", &["-c", "true"]));
        cfg.env_file = vec![config::EnvFile {
            path: spanned_string("./.env"),
        }];

        let svc = Service::new("svc", &dir, cfg)?;
        assert_eq!(
            svc.environment
                .get("AIRTYPE_API_SPICEDB_ENDPOINT")
                .map(String::as_str),
            Some("http://0.0.0.0:50051")
        );
        Ok(())
    }
}

#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum RestartPolicy {
    Always,
    UnlessStopped,
    #[default]
    Never,
    OnFailure {
        remaining_attempts: usize,
    },
}

impl std::fmt::Display for RestartPolicy {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Always => write!(f, "Always"),
            Self::UnlessStopped => write!(f, "UnlessStopped"),
            Self::Never => write!(f, "Never"),
            Self::OnFailure { remaining_attempts } => f
                .debug_struct("OnFailure")
                .field("remaining", remaining_attempts)
                .finish(),
        }
    }
}

#[derive(Debug)]
pub struct Service {
    pub id: ServiceID,
    pub name: Spanned<String>,
    pub command: (String, Vec<String>),
    pub working_dir: Option<PathBuf>,
    pub restart_policy: RestartPolicy,
    pub depends_on: Vec<config::Dependency>,
    pub environment: indexmap::IndexMap<String, String>,
    pub health_check: Option<config::HealthCheck>,
    pub open_ports: Vec<u16>,
    pub enable_color: bool,
}

impl Service {
    pub fn new(
        id: impl Into<ServiceID>,
        config_dir: &Path,
        config: config::Service,
    ) -> eyre::Result<Self> {
        let (prog, args) = config.command;
        let id: ServiceID = id.into();

        let working_dir = config
            .working_dir
            .as_ref()
            .map(|dir| env::resolve_path(config_dir, dir.as_ref()))
            .transpose()?;

        let env_files = config
            .env_file
            .iter()
            .map(|env_file| env::resolve_path(config_dir, env_file.path.as_ref()))
            .collect::<Result<Vec<_>, _>>()?;

        let base_env: std::collections::HashMap<String, String> = std::env::vars().collect();
        let env_file_env = env::load_env_files_sync(&env_files)?;
        let env_file_env = env::expand_env_values(&env_file_env, &base_env);

        let mut base_with_env_file = base_env.clone();
        for (k, v) in env_file_env.iter() {
            base_with_env_file.insert(k.clone(), v.clone());
        }

        let mut config_env_map = env::EnvMap::new();
        for (k, v) in &config.environment {
            config_env_map.insert(k.as_ref().clone(), v.as_ref().clone());
        }
        let config_env_map = env::expand_env_values(&config_env_map, &base_with_env_file);

        let mut full_env = base_with_env_file.clone();
        for (k, v) in config_env_map.iter() {
            full_env.insert(k.clone(), v.clone());
        }

        let open_ports = config
            .ports
            .iter()
            .map(|port| {
                let expanded = env::interpolate_str(port.as_ref(), &full_env);
                expanded
                    .parse::<u16>()
                    .map_err(|err| eyre::eyre!("invalid port `{}`: {err}", expanded))
            })
            .collect::<Result<Vec<_>, _>>()?;

        let mut environment = indexmap::IndexMap::new();
        for (k, v) in env_file_env.iter() {
            environment.insert(k.clone(), v.clone());
        }
        for (k, v) in config_env_map.iter() {
            environment.insert(k.clone(), v.clone());
        }

        Ok(Self {
            id,
            name: config.name,
            command: (
                prog.into_inner(),
                args.into_iter()
                    .map(|value| value.to_string())
                    .collect::<Vec<_>>(),
            ),
            working_dir,
            open_ports,
            restart_policy: config.restart.unwrap_or_default(),
            depends_on: config.depends_on,
            environment,
            health_check: config.healthcheck,
            enable_color: config.color.as_deref().copied().unwrap_or(true),
        })
    }
}