openauth-cli 0.0.4

Command-line tools for OpenAuth.
Documentation
use std::collections::BTreeMap;

use serde::Serialize;
use url::Url;

use crate::config::CliConfig;
use crate::db;
use crate::secret::{assess_secret, SecretSeverity};
use crate::workspace::{command_version, inspect, package_has_dependency, WorkspaceInfo};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    Info,
    Warn,
    Error,
}

#[derive(Debug, Clone, Serialize)]
pub struct Finding {
    pub severity: Severity,
    pub code: String,
    pub message: String,
}

#[derive(Debug, Serialize)]
pub struct DiagnosticReport {
    pub workspace_root: Option<String>,
    pub rust: String,
    pub cargo: String,
    pub config: RedactedConfig,
    pub findings: Vec<Finding>,
}

#[derive(Debug, Serialize)]
pub struct RedactedConfig {
    pub project: BTreeMap<String, serde_json::Value>,
    pub database: BTreeMap<String, serde_json::Value>,
    pub security: BTreeMap<String, serde_json::Value>,
    pub plugins: Vec<String>,
}

impl DiagnosticReport {
    pub fn has_errors(&self) -> bool {
        self.findings
            .iter()
            .any(|finding| finding.severity == Severity::Error)
    }

    pub fn has_warnings(&self) -> bool {
        self.findings
            .iter()
            .any(|finding| finding.severity == Severity::Warn)
    }
}

pub async fn doctor(
    cwd: &std::path::Path,
    config: &CliConfig,
    production_override: bool,
) -> DiagnosticReport {
    let production = production_override || config.project.production;
    let workspace = inspect(cwd).ok();
    let mut findings = Vec::new();

    findings.push(info(
        "config.loaded",
        "Loaded OpenAuth CLI configuration from openauth.toml.",
    ));
    inspect_workspace(&mut findings, workspace.as_ref(), config);
    inspect_security(&mut findings, config, production);
    inspect_database(&mut findings, config, production).await;

    DiagnosticReport {
        workspace_root: workspace.map(|info| info.root.display().to_string()),
        rust: command_version("rustc").unwrap_or_else(|_| "not available".to_owned()),
        cargo: command_version("cargo").unwrap_or_else(|_| "not available".to_owned()),
        config: redact_config(config),
        findings,
    }
}

pub fn redact_config(config: &CliConfig) -> RedactedConfig {
    let mut project = BTreeMap::new();
    project.insert(
        "framework".to_owned(),
        serde_json::Value::String(config.project.framework.clone().unwrap_or_default()),
    );
    project.insert(
        "base_url".to_owned(),
        serde_json::Value::String(config.project.base_url.clone()),
    );
    project.insert(
        "base_path".to_owned(),
        serde_json::Value::String(config.project.base_path.clone()),
    );
    project.insert(
        "production".to_owned(),
        serde_json::Value::Bool(config.project.production),
    );

    let mut database = BTreeMap::new();
    database.insert(
        "adapter".to_owned(),
        serde_json::Value::String(config.database.adapter.clone()),
    );
    database.insert(
        "provider".to_owned(),
        serde_json::Value::String(config.database.provider.clone().unwrap_or_default()),
    );
    database.insert(
        "url_env".to_owned(),
        serde_json::Value::String(config.database.url_env.clone()),
    );
    database.insert(
        "database_url".to_owned(),
        serde_json::Value::String("[REDACTED]".to_owned()),
    );

    let mut security = BTreeMap::new();
    security.insert(
        "secret_env".to_owned(),
        serde_json::Value::String(config.security.secret_env.clone()),
    );
    security.insert(
        "secret".to_owned(),
        serde_json::Value::String("[REDACTED]".to_owned()),
    );

    RedactedConfig {
        project,
        database,
        security,
        plugins: config.plugins.enabled.clone(),
    }
}

fn inspect_workspace(
    findings: &mut Vec<Finding>,
    workspace: Option<&WorkspaceInfo>,
    config: &CliConfig,
) {
    let Some(workspace) = workspace else {
        findings.push(warn(
            "workspace.metadata",
            "Cargo metadata could not be loaded from this directory.",
        ));
        return;
    };
    findings.push(info(
        "workspace.root",
        &format!("Workspace root: {}", workspace.root.display()),
    ));
    for framework in &workspace.detected_frameworks {
        findings.push(info(
            "framework.detected",
            &format!("Detected framework: {}", framework.name),
        ));
    }
    if config.database.adapter == "sqlx" && !package_has_dependency(workspace, "openauth-sqlx") {
        findings.push(error(
            "database.adapter_mismatch",
            "Config uses the sqlx adapter, but openauth-sqlx was not detected in dependencies.",
        ));
    }
    if workspace.detected_databases.len() > 1 && config.database.provider.is_none() {
        findings.push(warn(
            "database.multiple_adapters",
            "Multiple database integrations were detected; configure database.provider explicitly.",
        ));
    }
}

fn inspect_security(findings: &mut Vec<Finding>, config: &CliConfig, production: bool) {
    let secret = std::env::var(&config.security.secret_env).unwrap_or_default();
    let assessment = assess_secret(&secret, production);
    match assessment.severity {
        SecretSeverity::Ok => findings.push(info("security.secret", &assessment.message)),
        SecretSeverity::Warning => findings.push(warn("security.secret", &assessment.message)),
        SecretSeverity::Error => findings.push(error("security.secret", &assessment.message)),
    }
    if production && !config.project.base_url.starts_with("https://") {
        findings.push(error(
            "security.base_url_https",
            "base_url must use HTTPS in production.",
        ));
    }
    if production && is_localhost_url(&config.project.base_url) {
        findings.push(warn(
            "security.localhost",
            "base_url points to localhost while production checks are enabled.",
        ));
    }
}

async fn inspect_database(findings: &mut Vec<Finding>, config: &CliConfig, production: bool) {
    if production && std::env::var(&config.database.url_env).is_err() {
        findings.push(error(
            "database.url",
            &format!("{} is required in production.", config.database.url_env),
        ));
        return;
    }
    if std::env::var(&config.database.url_env).is_err() {
        findings.push(warn(
            "database.url",
            &format!(
                "{} is not set; database checks were skipped.",
                config.database.url_env
            ),
        ));
        return;
    }
    match db::plan(config, false).await {
        Ok(plan) => {
            if !plan.plan.warnings.is_empty() {
                findings.push(error(
                    "database.schema_type_mismatch",
                    "Database schema has type mismatches.",
                ));
            }
            if !plan.plan.is_empty() {
                findings.push(warn(
                    "database.pending_schema",
                    "Database schema has pending OpenAuth changes.",
                ));
            } else {
                findings.push(info("database.schema", "Database schema is up to date."));
            }
        }
        Err(db_error) => findings.push(error("database.connection", &db_error.to_string())),
    }
}

fn is_localhost_url(value: &str) -> bool {
    Url::parse(value)
        .ok()
        .and_then(|url| url.host_str().map(str::to_owned))
        .is_some_and(|host| host == "localhost" || host == "127.0.0.1" || host == "::1")
}

fn info(code: &str, message: &str) -> Finding {
    finding(Severity::Info, code, message)
}

fn warn(code: &str, message: &str) -> Finding {
    finding(Severity::Warn, code, message)
}

fn error(code: &str, message: &str) -> Finding {
    finding(Severity::Error, code, message)
}

fn finding(severity: Severity, code: &str, message: &str) -> Finding {
    Finding {
        severity,
        code: code.to_owned(),
        message: message.to_owned(),
    }
}