knishio-cli 0.1.4

KnishIO validator orchestration CLI — Docker control, cell management, benchmarks, and health checks
//! Configuration loading with layered resolution:
//! config file → env vars → CLI flags (highest priority).

use anyhow::Result;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;

use crate::detect::Accel;
use crate::output;

const CONFIG_FILENAME: &str = "knishio.toml";

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Config {
    pub validator: ValidatorConfig,
    pub docker: DockerConfig,
    pub database: DatabaseConfig,
    /// Optional: force this accel, skipping auto-detection. Mostly for CI /
    /// reproducible rigs.  Accepts the same names as the CLI `--accel` flag.
    pub default_accel: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ValidatorConfig {
    pub url: String,
    pub insecure_tls: bool,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct DockerConfig {
    /// Back-compat single file. Ignored when `accel` is driving file
    /// selection (i.e. all new code paths).
    pub compose_file: String,
    pub postgres_container: String,
    pub validator_container: String,
    /// Per-accel overlay chains. Keys match `Accel::config_key()`.
    /// Empty/missing keys fall back to baked defaults (see `default_accel_map`).
    pub accel: HashMap<String, AccelProfile>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AccelProfile {
    /// Compose filenames (in layering order) for `docker compose -f a -f b …`.
    pub files: Vec<String>,
    /// When true, the validator runs natively on the host rather than in a
    /// container. `start` still brings up whatever's in `files` (typically
    /// just Postgres) and then emits a native-run hint block.
    pub native_validator: bool,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct DatabaseConfig {
    pub user: String,
    pub name: String,
}

// ── Defaults ────────────────────────────────────────────────

impl Default for Config {
    fn default() -> Self {
        Self {
            validator: ValidatorConfig::default(),
            docker: DockerConfig::default(),
            database: DatabaseConfig::default(),
            default_accel: None,
        }
    }
}

impl Default for ValidatorConfig {
    fn default() -> Self {
        Self {
            url: "https://localhost:8080".into(),
            insecure_tls: false,
        }
    }
}

impl Default for DockerConfig {
    fn default() -> Self {
        Self {
            compose_file: "docker-compose.standalone.yml".into(),
            postgres_container: "knishio-postgres".into(),
            validator_container: "knishio-validator".into(),
            accel: default_accel_map(),
        }
    }
}

impl Default for AccelProfile {
    fn default() -> Self {
        Self {
            files: Vec::new(),
            native_validator: false,
        }
    }
}

impl Default for DatabaseConfig {
    fn default() -> Self {
        Self {
            user: "knishio".into(),
            name: "knishio".into(),
        }
    }
}

/// Baked-in defaults for every accel profile. Written out verbatim in
/// `knishio.toml`'s template so operators can see + override them.
fn default_accel_map() -> HashMap<String, AccelProfile> {
    let mut m = HashMap::new();
    m.insert(
        "cpu".into(),
        AccelProfile {
            files: vec!["docker-compose.standalone.yml".into()],
            native_validator: false,
        },
    );
    m.insert(
        "cuda".into(),
        AccelProfile {
            files: vec![
                "docker-compose.standalone.yml".into(),
                "docker-compose.cuda.yml".into(),
            ],
            native_validator: false,
        },
    );
    m.insert(
        "dmr".into(),
        AccelProfile {
            files: vec![
                "docker-compose.standalone.yml".into(),
                "docker-compose.dmr.yml".into(),
            ],
            native_validator: false,
        },
    );
    m.insert(
        "metal-native".into(),
        AccelProfile {
            files: vec!["docker-compose.metal.yml".into()],
            native_validator: true,
        },
    );
    m.insert(
        "rocm".into(),
        AccelProfile {
            files: vec![
                "docker-compose.standalone.yml".into(),
                "docker-compose.rocm.yml".into(),
            ],
            native_validator: false,
        },
    );
    m.insert(
        "vulkan".into(),
        AccelProfile {
            files: vec![
                "docker-compose.standalone.yml".into(),
                "docker-compose.vulkan.yml".into(),
            ],
            native_validator: false,
        },
    );
    m
}

// ── Loading ─────────────────────────────────────────────────

impl Config {
    /// Load config from file (if found), then apply env var overrides.
    pub fn load(search_start: &Path) -> Self {
        let mut config = match find_config_file(search_start) {
            Some(path) => match Self::from_file(&path) {
                Ok(cfg) => {
                    output::info(&format!("Config loaded from {}", path.display()));
                    cfg
                }
                Err(e) => {
                    output::warn(&format!("Failed to parse {}: {}", path.display(), e));
                    Config::default()
                }
            },
            None => Config::default(),
        };

        // Ensure baked defaults are present for any accel profile the config
        // file didn't override explicitly.
        for (k, v) in default_accel_map() {
            config.docker.accel.entry(k).or_insert(v);
        }

        config.apply_env_overrides();
        config
    }

    fn from_file(path: &Path) -> Result<Self> {
        let content = std::fs::read_to_string(path)?;
        let config: Config = toml::from_str(&content)?;
        Ok(config)
    }

    fn apply_env_overrides(&mut self) {
        if let Ok(val) = std::env::var("KNISHIO_URL") {
            self.validator.url = val;
        }
        if let Ok(val) = std::env::var("KNISHIO_PG_CONTAINER") {
            self.docker.postgres_container = val;
        }
        if let Ok(val) = std::env::var("KNISHIO_VALIDATOR_CONTAINER") {
            self.docker.validator_container = val;
        }
        if let Ok(val) = std::env::var("KNISHIO_DB_USER") {
            self.database.user = val;
        }
        if let Ok(val) = std::env::var("KNISHIO_DB_NAME") {
            self.database.name = val;
        }
        if let Ok(val) = std::env::var("KNISHIO_INSECURE_TLS") {
            self.validator.insecure_tls =
                val.eq_ignore_ascii_case("true") || val == "1";
        }
        if let Ok(val) = std::env::var("KNISHIO_ACCEL") {
            self.default_accel = Some(val);
        }
    }

    /// Apply CLI flag override for the validator URL.
    /// Only overrides if the user explicitly passed --url (not the default).
    pub fn with_url_override(mut self, cli_url: &str) -> Self {
        // clap always provides a value (default or explicit), so we check
        // if it differs from the compiled-in default to detect explicit use.
        // This isn't perfect but covers the common case.
        let clap_default = "https://localhost:8080";
        if cli_url != clap_default || self.validator.url == clap_default {
            self.validator.url = cli_url.to_string();
        }
        self
    }

    /// Look up the overlay file list for the given accel, falling back to CPU
    /// (with a warning emitted by the caller) if the profile has no `files`.
    pub fn accel_files(&self, accel: Accel) -> &[String] {
        self.docker
            .accel
            .get(accel.config_key())
            .map(|p| p.files.as_slice())
            .unwrap_or(&[])
    }

    /// Whether this accel wants the validator to run natively on the host.
    pub fn accel_is_native(&self, accel: Accel) -> bool {
        self.docker
            .accel
            .get(accel.config_key())
            .map(|p| p.native_validator)
            .unwrap_or(false)
    }
}

/// Walk up from `start` looking for knishio.toml.
fn find_config_file(start: &Path) -> Option<std::path::PathBuf> {
    let mut dir = start.to_path_buf();
    loop {
        let candidate = dir.join(CONFIG_FILENAME);
        if candidate.exists() {
            return Some(candidate);
        }

        let candidate = dir.join("knishio-validator-rust").join(CONFIG_FILENAME);
        if candidate.exists() {
            return Some(candidate);
        }

        let candidate = dir
            .join("servers")
            .join("knishio-validator-rust")
            .join(CONFIG_FILENAME);
        if candidate.exists() {
            return Some(candidate);
        }

        if !dir.pop() {
            break;
        }
    }
    None
}