cufflink-cli 0.20.0

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;

#[derive(Debug, Deserialize)]
pub struct ProjectConfig {
    #[serde(default)]
    pub service: ServiceConfig,
    #[serde(default)]
    pub environments: HashMap<String, EnvironmentConfig>,
    /// Directory containing Cufflink.toml (set after loading, not from TOML)
    #[serde(skip)]
    pub project_dir: PathBuf,
}

#[derive(Debug, Deserialize, Default)]
pub struct ServiceConfig {
    /// Service name (required for web mode, optional for Rust services which extract from manifest)
    pub name: Option<String>,
    /// Service mode: "crud", "wasm", "web" (defaults to Rust build if not specified)
    pub mode: Option<String>,
    /// Default environment name when --env is not specified
    pub default_env: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct EnvironmentConfig {
    pub api_url: String,
    pub tenant: String,
    /// Name of the environment variable that holds the API key for this env
    pub api_key_env: Option<String>,
    /// Inline API key (for local dev only — don't commit to version control)
    pub api_key: Option<String>,
    /// Keycloak base URL for login (e.g., "https://auth.staging.example.com")
    pub keycloak_url: Option<String>,
    /// Keycloak realm name (e.g., "acme")
    pub keycloak_realm: Option<String>,
    /// Keycloak client ID for device auth (defaults to "cufflink-cli")
    pub keycloak_client_id: Option<String>,
    /// Non-secret config key-value pairs to sync on deploy
    #[serde(default)]
    pub config: HashMap<String, String>,
    /// Secret config mappings: CONFIG_KEY = "securestore_secret_name"
    #[serde(default)]
    pub secrets: HashMap<String, String>,
}

impl ProjectConfig {
    /// The project a `Cufflink.toml` in `dir` declares. Its environment
    /// `dev` is the development stack's unless it declares one.
    pub(crate) fn parsed(content: &str, dir: &std::path::Path) -> Result<Self, toml::de::Error> {
        let mut config: ProjectConfig = toml::from_str(content)?;
        config.project_dir = dir.to_path_buf();
        config
            .environments
            .entry(crate::commands::dev::ENV.to_string())
            .or_insert_with(crate::commands::dev::environment);
        Ok(config)
    }

    /// Search for Cufflink.toml starting from cwd, walking up to filesystem root.
    pub fn find_and_load() -> eyre::Result<Option<Self>> {
        let cwd = std::env::current_dir()?;
        let mut dir = cwd.as_path();
        loop {
            let candidate = dir.join("Cufflink.toml");
            if candidate.exists() {
                let content = std::fs::read_to_string(&candidate)?;
                return Self::parsed(&content, dir)
                    .map(Some)
                    .map_err(|e| eyre::eyre!("Failed to parse {}: {}", candidate.display(), e));
            }
            match dir.parent() {
                Some(parent) => dir = parent,
                None => return Ok(None),
            }
        }
    }

    pub fn get_env(&self, name: &str) -> eyre::Result<&EnvironmentConfig> {
        self.environments.get(name).ok_or_else(|| {
            let available: Vec<&str> = self.environments.keys().map(|k| k.as_str()).collect();
            eyre::eyre!(
                "Environment '{}' not found in Cufflink.toml. Available: {:?}",
                name,
                available
            )
        })
    }

    pub fn resolve_env(&self, env_arg: Option<&str>) -> eyre::Result<Option<&EnvironmentConfig>> {
        let env_name = env_arg
            .map(|s| s.to_string())
            .or_else(|| self.service.default_env.clone());
        match env_name {
            Some(name) => Ok(Some(self.get_env(&name)?)),
            None => Ok(None),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    #[test]
    fn a_project_has_the_development_stack_as_dev_unless_it_declares_one() {
        let undeclared = ProjectConfig::parsed(
            "[service]\nname = \"notes\"\ndefault_env = \"staging\"\n\n\
             [environments.staging]\napi_url = \"https://api.staging.test\"\ntenant = \"acme\"\n",
            Path::new("/work/notes"),
        )
        .expect("a project");
        assert_eq!(undeclared.project_dir, Path::new("/work/notes"));
        let dev = undeclared.get_env("dev").expect("dev");
        assert_eq!(
            (dev.api_url.as_str(), dev.tenant.as_str()),
            ("http://localhost:8080", "dev")
        );
        let default = undeclared
            .resolve_env(None)
            .expect("resolved")
            .expect("staging");
        assert_eq!(default.api_url, "https://api.staging.test");

        let declared = ProjectConfig::parsed(
            "[environments.dev]\napi_url = \"http://localhost:9999\"\ntenant = \"mine\"\n",
            Path::new("/work/notes"),
        )
        .expect("a project");
        let dev = declared.get_env("dev").expect("dev");
        assert_eq!(
            (
                dev.api_url.as_str(),
                dev.tenant.as_str(),
                dev.keycloak_realm.as_deref()
            ),
            ("http://localhost:9999", "mine", None)
        );
        assert!(ProjectConfig::parsed("", Path::new("/"))
            .expect("empty")
            .resolve_env(None)
            .expect("none")
            .is_none());
    }
}