systemprompt-config 0.4.0

Profile-based configuration for systemprompt.io AI governance infrastructure. Bootstraps profiles, secrets, and credentials with zero environment-variable fallback.
Documentation
use super::types::{DeployEnvironment, EnvironmentConfig};
use anyhow::{Result, anyhow};
use regex::Regex;
use std::path::Path;
use systemprompt_logging::CliService;

#[derive(Debug, Clone, Copy)]
pub struct ConfigValidator;

impl ConfigValidator {
    pub fn validate(config: &EnvironmentConfig) -> Result<ValidationReport> {
        let mut report = ValidationReport::new();

        CliService::section(&format!(
            "Validating Environment: {}",
            config.environment.as_str()
        ));

        Self::check_unresolved_variables(config, &mut report);
        Self::check_required_variables(config, &mut report);
        Self::check_empty_values(config, &mut report);
        Self::check_url_formats(config, &mut report);
        Self::check_port_values(config, &mut report);
        Self::check_environment_specific(config, &mut report);

        CliService::section("Validation Summary");

        if report.errors.is_empty() {
            CliService::success("Validation PASSED");
        } else {
            CliService::error("Validation FAILED");
            CliService::info("Errors:");
            for error in &report.errors {
                CliService::info(&format!("  - {error}"));
            }
        }

        if !report.warnings.is_empty() {
            CliService::info("Warnings:");
            for warning in &report.warnings {
                CliService::info(&format!("  - {warning}"));
            }
        }

        if report.errors.is_empty() {
            Ok(report)
        } else {
            Err(anyhow!("{} validation error(s)", report.errors.len()))
        }
    }

    fn check_unresolved_variables(config: &EnvironmentConfig, report: &mut ValidationReport) {
        let Ok(var_regex) = Regex::new(r"\$\{[^}]+\}") else {
            report.add_error("Internal error: Invalid unresolved variable regex".to_string());
            return;
        };
        let mut unresolved = Vec::new();

        for (key, value) in &config.variables {
            if var_regex.is_match(value) {
                unresolved.push(format!("{key} = {value}"));
            }
        }

        if unresolved.is_empty() {
            CliService::success("No unresolved variables found");
        } else {
            CliService::error("Found unresolved variables:");
            for u in &unresolved {
                CliService::info(&format!("    {u}"));
                report.add_error(format!("Unresolved variable: {u}"));
            }
        }
    }

    fn check_required_variables(config: &EnvironmentConfig, report: &mut ValidationReport) {
        let required_vars = vec![
            "SERVICE_NAME",
            "SYSTEM_PATH",
            "DATABASE_URL",
            "HOST",
            "PORT",
            "API_SERVER_URL",
            "JWT_SECRET",
            "JWT_ISSUER",
        ];

        let mut missing = Vec::new();

        for var in &required_vars {
            let is_missing_or_empty = config.variables.get(*var).is_none_or(String::is_empty);
            if is_missing_or_empty {
                missing.push(*var);
            }
        }

        if missing.is_empty() {
            CliService::success("All required variables present");
        } else {
            CliService::error("Required variables missing:");
            for m in &missing {
                CliService::info(&format!("    {m}"));
                report.add_error(format!("Required variable missing: {m}"));
            }
        }
    }

    fn check_empty_values(config: &EnvironmentConfig, report: &mut ValidationReport) {
        let critical_vars = vec!["DATABASE_URL", "JWT_SECRET"];

        let mut empty = Vec::new();

        for var in &critical_vars {
            if let Some(value) = config.variables.get(*var) {
                if value.is_empty() || value == "''" || value == "\"\"" {
                    empty.push(*var);
                }
            }
        }

        if empty.is_empty() {
            CliService::success("All critical variables have values");
        } else {
            CliService::error("Critical variables are empty:");
            for e in &empty {
                CliService::info(&format!("    {e}"));
                report.add_error(format!("Critical variable is empty: {e}"));
            }
        }
    }

    fn check_url_formats(config: &EnvironmentConfig, report: &mut ValidationReport) {
        let url_vars = vec!["DATABASE_URL", "API_SERVER_URL", "API_EXTERNAL_URL"];

        let Ok(url_regex) = Regex::new(r"^(https?|postgresql|mysql)://.*$") else {
            report.add_error("Internal error: Invalid URL regex".to_string());
            return;
        };

        let mut invalid = Vec::new();

        for url_var in &url_vars {
            if let Some(url) = config.variables.get(*url_var) {
                if !url.is_empty() && !url_regex.is_match(url) {
                    invalid.push(format!("{url_var} = {url}"));
                }
            }
        }

        if invalid.is_empty() {
            CliService::success("All URL formats are valid");
        } else {
            CliService::error("Invalid URL formats:");
            for i in &invalid {
                CliService::info(&format!("    {i}"));
                report.add_error(format!("Invalid URL format: {i}"));
            }
        }
    }

    fn check_port_values(config: &EnvironmentConfig, report: &mut ValidationReport) {
        if let Some(port_str) = config.variables.get("PORT") {
            if let Ok(port) = port_str.parse::<u16>() {
                if port == 0 {
                    CliService::error(&format!("Invalid port number: {} (must be 1-65535)", port));
                    report.add_error(format!("Invalid port number: {port}"));
                } else {
                    CliService::success(&format!("Port number is valid: {port}"));
                }
            } else {
                CliService::error(&format!("Port is not a valid number: {port_str}"));
                report.add_error(format!("Port is not a valid number: {port_str}"));
            }
        } else {
            CliService::warning("PORT not explicitly set, will use default");
            report.add_warning("PORT not explicitly set".to_string());
        }
    }

    fn check_environment_specific(config: &EnvironmentConfig, report: &mut ValidationReport) {
        match config.environment {
            DeployEnvironment::Production => {
                if let Some(use_https) = config.variables.get("USE_HTTPS") {
                    if use_https != "true" {
                        CliService::warning("Production environment should have USE_HTTPS=true");
                        report.add_warning("Production should have USE_HTTPS=true".to_string());
                    }
                }

                if let Some(rust_log) = config.variables.get("RUST_LOG") {
                    if rust_log == "debug" {
                        CliService::warning(
                            "Production environment should not have RUST_LOG=debug",
                        );
                        report.add_warning("Production should not have RUST_LOG=debug".to_string());
                    }
                }

                CliService::success(&format!(
                    "Environment-specific checks passed for: {}",
                    config.environment.as_str()
                ));
            },
            _ => {
                CliService::success(&format!(
                    "Environment-specific checks passed for: {}",
                    config.environment.as_str()
                ));
            },
        }
    }

    pub fn check_file_permissions(path: &Path, report: &mut ValidationReport) -> Result<()> {
        if !path.exists() {
            return Ok(());
        }

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let metadata = std::fs::metadata(path)?;
            let permissions = metadata.permissions();
            let mode = permissions.mode();
            let perms_octal = format!("{:o}", mode & 0o777);

            if perms_octal == "644" || perms_octal == "600" {
                CliService::success(".env file has appropriate permissions");
            } else {
                CliService::warning(&format!(
                    ".env file permissions may expose secrets: {}",
                    perms_octal
                ));
                report.add_warning(format!(
                    ".env file permissions may expose secrets: {}",
                    perms_octal
                ));
            }
        }

        #[cfg(not(unix))]
        {
            CliService::warning("File permission check skipped (non-Unix system)");
        }

        Ok(())
    }
}

#[derive(Debug)]
pub struct ValidationReport {
    pub errors: Vec<String>,
    pub warnings: Vec<String>,
}

impl ValidationReport {
    pub const fn new() -> Self {
        Self {
            errors: Vec::new(),
            warnings: Vec::new(),
        }
    }

    pub fn add_error(&mut self, error: String) {
        self.errors.push(error);
    }

    pub fn add_warning(&mut self, warning: String) {
        self.warnings.push(warning);
    }

    pub fn is_valid(&self) -> bool {
        self.errors.is_empty()
    }
}

impl Default for ValidationReport {
    fn default() -> Self {
        Self::new()
    }
}