pachy-config 0.1.0

Configuration loader for Pachycephalosaurus apps
Documentation
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PachyConfig {
    /// Application / binary name. Defaults to the current directory name.
    #[serde(default)]
    pub name: Option<String>,

    #[serde(default = "default_host")]
    pub host: String,

    #[serde(default = "default_port")]
    pub port: u16,

    #[serde(default = "default_watch_dirs")]
    pub watch_dirs: Vec<String>,

    #[serde(default)]
    pub debug_mode: bool,

    /// Minimum log level: error | warn | info | debug | trace
    #[serde(default = "default_log_level")]
    pub log_level: String,

    /// Seconds to wait for in-flight requests before forceful shutdown.
    #[serde(default = "default_shutdown_timeout_secs")]
    pub shutdown_timeout_secs: u64,

    /// Deployment target for `pachy build --target`.
    #[serde(default = "default_deploy_target")]
    pub deploy_target: DeployTarget,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DeployTarget {
    /// Standard native binary (default).
    #[default]
    Native,
    /// AWS Lambda / Vercel serverless function.
    Serverless,
    /// OCI container image via Docker.
    Docker,
}

impl std::fmt::Display for DeployTarget {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DeployTarget::Native => write!(f, "native"),
            DeployTarget::Serverless => write!(f, "serverless"),
            DeployTarget::Docker => write!(f, "docker"),
        }
    }
}

fn default_host() -> String {
    "127.0.0.1".to_string()
}

fn default_port() -> u16 {
    3000
}

fn default_watch_dirs() -> Vec<String> {
    vec!["src".to_string(), "Cargo.toml".to_string()]
}

fn default_log_level() -> String {
    "info".to_string()
}

fn default_shutdown_timeout_secs() -> u64 {
    30
}

fn default_deploy_target() -> DeployTarget {
    DeployTarget::Native
}

impl Default for PachyConfig {
    fn default() -> Self {
        Self {
            name: None,
            host: default_host(),
            port: default_port(),
            watch_dirs: default_watch_dirs(),
            debug_mode: true,
            log_level: default_log_level(),
            shutdown_timeout_secs: default_shutdown_timeout_secs(),
            deploy_target: DeployTarget::Native,
        }
    }
}

impl PachyConfig {
    pub fn load(path: &Path) -> Result<Self> {
        let content = std::fs::read_to_string(path)
            .with_context(|| format!("failed to read config: {}", path.display()))?;
        let mut cfg: Self = toml::from_str(&content)
            .with_context(|| format!("invalid config at {}", path.display()))?;
        cfg.apply_env_overrides();
        Ok(cfg)
    }

    pub fn load_or_default(config_path: Option<&Path>) -> Result<Self> {
        let candidates: Vec<PathBuf> = match config_path {
            Some(p) => vec![p.to_path_buf()],
            None => vec![
                PathBuf::from("pachy.toml"),
                PathBuf::from("Pachy.toml"),
            ],
        };

        for path in &candidates {
            if path.exists() {
                return Self::load(path);
            }
        }

        let mut cfg = Self::default();
        cfg.apply_env_overrides();
        Ok(cfg)
    }

    /// Environment variables override pachy.toml values.
    ///
    /// PACHY_HOST, PACHY_PORT, PACHY_LOG_LEVEL, PACHY_DEBUG,
    /// PACHY_SHUTDOWN_TIMEOUT, PACHY_DEPLOY_TARGET
    fn apply_env_overrides(&mut self) {
        if let Ok(v) = std::env::var("PACHY_HOST") {
            self.host = v;
        }
        if let Ok(v) = std::env::var("PACHY_PORT") {
            if let Ok(p) = v.parse::<u16>() {
                self.port = p;
            }
        }
        if let Ok(v) = std::env::var("PACHY_LOG_LEVEL") {
            self.log_level = v;
        }
        if let Ok(v) = std::env::var("PACHY_DEBUG") {
            self.debug_mode = matches!(v.to_lowercase().as_str(), "1" | "true" | "yes");
        }
        if let Ok(v) = std::env::var("PACHY_SHUTDOWN_TIMEOUT") {
            if let Ok(t) = v.parse::<u64>() {
                self.shutdown_timeout_secs = t;
            }
        }
        if let Ok(v) = std::env::var("PACHY_DEPLOY_TARGET") {
            self.deploy_target = match v.to_lowercase().as_str() {
                "serverless" => DeployTarget::Serverless,
                "docker" => DeployTarget::Docker,
                _ => DeployTarget::Native,
            };
        }
    }

    pub fn addr(&self) -> String {
        format!("{}:{}", self.host, self.port)
    }

    /// The binary name to pass to `cargo build --bin`.
    /// Uses `name` from pachy.toml if set, otherwise falls back to the
    /// current directory name, then "app".
    pub fn bin_name(&self) -> String {
        if let Some(n) = &self.name {
            return n.clone();
        }
        // Try current directory name, but only if it looks like a valid bin name
        // (no spaces, not a framework root like "Pachycephalosaurus").
        // Fall back to "pachy" so `cargo build --bin pachy` always works from
        // the framework root.
        let dir_name = std::env::current_dir()
            .ok()
            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()));

        if let Some(name) = dir_name {
            // Check if a Cargo.toml in the current dir declares this bin.
            let cargo_toml = std::path::Path::new("Cargo.toml");
            if cargo_toml.exists() {
                if let Ok(content) = std::fs::read_to_string(cargo_toml) {
                    // If the Cargo.toml has [[bin]] with this name, use it.
                    if content.contains(&format!("name = \"{name}\"")) {
                        return name;
                    }
                }
            }
        }

        "pachy".to_string()
    }

    pub fn effective_log_filter(&self) -> String {
        // RUST_LOG takes final priority over everything.
        std::env::var("RUST_LOG").unwrap_or_else(|_| self.log_level.clone())
    }
}