devforge 0.3.0

Dev environment orchestrator — docker, health checks, mprocs, custom commands via TOML config
Documentation
use serde::de;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub env_files: Vec<EnvFile>,

    #[serde(default)]
    pub required_tools: Vec<String>,

    #[serde(default)]
    pub docker: DockerConfig,

    #[serde(default)]
    pub dev: DevConfig,

    #[serde(default)]
    pub commands: Vec<CustomCommand>,
}

#[derive(Debug, Clone)]
pub struct EnvFile {
    pub path: String,
    pub template: Option<String>,
}

impl<'de> Deserialize<'de> for EnvFile {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(untagged)]
        enum Raw {
            Simple(String),
            Full { path: String, template: Option<String> },
        }

        match Raw::deserialize(deserializer)? {
            Raw::Simple(path) => Ok(EnvFile { path, template: None }),
            Raw::Full { path, template } => Ok(EnvFile { path, template }),
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct DockerConfig {
    #[serde(default = "default_compose_file")]
    pub compose_file: String,

    #[serde(default)]
    pub health_checks: Vec<HealthCheck>,
}

impl Default for DockerConfig {
    fn default() -> Self {
        Self {
            compose_file: default_compose_file(),
            health_checks: Vec::new(),
        }
    }
}

fn default_compose_file() -> String {
    "docker-compose.yml".to_string()
}

#[derive(Debug)]
pub struct HealthCheck {
    pub name: String,
    pub cmd: Option<Vec<String>>,
    pub url: Option<String>,
    pub tcp: Option<String>,
    pub timeout: u64,
}

impl<'de> Deserialize<'de> for HealthCheck {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct Raw {
            name: String,
            #[serde(default)]
            cmd: Option<Vec<String>>,
            #[serde(default)]
            url: Option<String>,
            #[serde(default)]
            tcp: Option<String>,
            #[serde(default = "default_timeout")]
            timeout: u64,
        }

        let raw = Raw::deserialize(deserializer)?;

        let count = raw.cmd.is_some() as u8 + raw.url.is_some() as u8 + raw.tcp.is_some() as u8;
        if count == 0 {
            return Err(de::Error::custom(format!(
                "health check '{}': must specify one of cmd, url, or tcp",
                raw.name
            )));
        }
        if count > 1 {
            return Err(de::Error::custom(format!(
                "health check '{}': only one of cmd, url, or tcp may be set",
                raw.name
            )));
        }

        if let Some(url) = &raw.url {
            let lower = url.to_lowercase();
            if !lower.starts_with("http://") && !lower.starts_with("https://") {
                return Err(de::Error::custom(format!(
                    "health check '{}': url must use http:// or https:// scheme, got: {}",
                    raw.name, url
                )));
            }
        }

        Ok(HealthCheck {
            name: raw.name,
            cmd: raw.cmd,
            url: raw.url,
            tcp: raw.tcp,
            timeout: raw.timeout,
        })
    }
}

fn default_timeout() -> u64 {
    30
}

#[derive(Debug, Deserialize)]
pub struct DevConfig {
    #[serde(default = "default_mprocs_config")]
    pub mprocs_config: String,

    #[serde(default)]
    pub hooks: Vec<Hook>,

    #[serde(default)]
    pub runner: Option<RunnerConfig>,
}

impl Default for DevConfig {
    fn default() -> Self {
        Self {
            mprocs_config: default_mprocs_config(),
            hooks: Vec::new(),
            runner: None,
        }
    }
}

fn default_mprocs_config() -> String {
    "mprocs.yaml".to_string()
}

#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum RunnerConfig {
    #[serde(rename = "mprocs")]
    Mprocs,
    #[serde(rename = "shell")]
    Shell { cmd: String },
    #[serde(rename = "none")]
    None,
}

#[derive(Debug, Deserialize)]
pub struct Hook {
    pub cmd: String,

    #[serde(default)]
    pub cwd: Option<String>,

    #[serde(default)]
    pub condition: Option<HookCondition>,
}

#[derive(Debug, Deserialize)]
pub struct HookCondition {
    #[serde(default)]
    pub missing: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct CustomCommand {
    pub name: String,
    pub cmd: Vec<String>,

    #[serde(default)]
    pub description: String,

    #[serde(default)]
    pub docker: bool,
}

impl Config {
    pub fn load(toml_str: &str) -> Result<Self, toml::de::Error> {
        toml::from_str(toml_str)
    }
}