Skip to main content

openauth_cli/
diagnostics.rs

1use std::collections::BTreeMap;
2
3use serde::Serialize;
4use url::Url;
5
6use crate::config::CliConfig;
7use crate::db;
8use crate::secret::{assess_secret, SecretSeverity};
9use crate::workspace::{command_version, inspect, package_has_dependency, WorkspaceInfo};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Severity {
14    Info,
15    Warn,
16    Error,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct Finding {
21    pub severity: Severity,
22    pub code: String,
23    pub message: String,
24}
25
26#[derive(Debug, Serialize)]
27pub struct DiagnosticReport {
28    pub workspace_root: Option<String>,
29    pub rust: String,
30    pub cargo: String,
31    pub config: RedactedConfig,
32    pub findings: Vec<Finding>,
33}
34
35#[derive(Debug, Serialize)]
36pub struct RedactedConfig {
37    pub project: BTreeMap<String, serde_json::Value>,
38    pub database: BTreeMap<String, serde_json::Value>,
39    pub security: BTreeMap<String, serde_json::Value>,
40    pub plugins: Vec<String>,
41}
42
43impl DiagnosticReport {
44    pub fn has_errors(&self) -> bool {
45        self.findings
46            .iter()
47            .any(|finding| finding.severity == Severity::Error)
48    }
49
50    pub fn has_warnings(&self) -> bool {
51        self.findings
52            .iter()
53            .any(|finding| finding.severity == Severity::Warn)
54    }
55}
56
57pub async fn doctor(
58    cwd: &std::path::Path,
59    config: &CliConfig,
60    production_override: bool,
61) -> DiagnosticReport {
62    let production = production_override || config.project.production;
63    let workspace = inspect(cwd).ok();
64    let mut findings = Vec::new();
65
66    findings.push(info(
67        "config.loaded",
68        "Loaded OpenAuth CLI configuration from openauth.toml.",
69    ));
70    inspect_workspace(&mut findings, workspace.as_ref(), config);
71    inspect_security(&mut findings, config, production);
72    inspect_database(&mut findings, config, production).await;
73
74    DiagnosticReport {
75        workspace_root: workspace.map(|info| info.root.display().to_string()),
76        rust: command_version("rustc").unwrap_or_else(|_| "not available".to_owned()),
77        cargo: command_version("cargo").unwrap_or_else(|_| "not available".to_owned()),
78        config: redact_config(config),
79        findings,
80    }
81}
82
83pub fn redact_config(config: &CliConfig) -> RedactedConfig {
84    let mut project = BTreeMap::new();
85    project.insert(
86        "framework".to_owned(),
87        serde_json::Value::String(config.project.framework.clone().unwrap_or_default()),
88    );
89    project.insert(
90        "base_url".to_owned(),
91        serde_json::Value::String(config.project.base_url.clone()),
92    );
93    project.insert(
94        "base_path".to_owned(),
95        serde_json::Value::String(config.project.base_path.clone()),
96    );
97    project.insert(
98        "production".to_owned(),
99        serde_json::Value::Bool(config.project.production),
100    );
101
102    let mut database = BTreeMap::new();
103    database.insert(
104        "adapter".to_owned(),
105        serde_json::Value::String(config.database.adapter.clone()),
106    );
107    database.insert(
108        "provider".to_owned(),
109        serde_json::Value::String(config.database.provider.clone().unwrap_or_default()),
110    );
111    database.insert(
112        "url_env".to_owned(),
113        serde_json::Value::String(config.database.url_env.clone()),
114    );
115    database.insert(
116        "database_url".to_owned(),
117        serde_json::Value::String("[REDACTED]".to_owned()),
118    );
119
120    let mut security = BTreeMap::new();
121    security.insert(
122        "secret_env".to_owned(),
123        serde_json::Value::String(config.security.secret_env.clone()),
124    );
125    security.insert(
126        "secret".to_owned(),
127        serde_json::Value::String("[REDACTED]".to_owned()),
128    );
129
130    RedactedConfig {
131        project,
132        database,
133        security,
134        plugins: config.plugins.enabled.clone(),
135    }
136}
137
138fn inspect_workspace(
139    findings: &mut Vec<Finding>,
140    workspace: Option<&WorkspaceInfo>,
141    config: &CliConfig,
142) {
143    let Some(workspace) = workspace else {
144        findings.push(warn(
145            "workspace.metadata",
146            "Cargo metadata could not be loaded from this directory.",
147        ));
148        return;
149    };
150    findings.push(info(
151        "workspace.root",
152        &format!("Workspace root: {}", workspace.root.display()),
153    ));
154    for framework in &workspace.detected_frameworks {
155        findings.push(info(
156            "framework.detected",
157            &format!("Detected framework: {}", framework.name),
158        ));
159    }
160    if config.database.adapter == "sqlx" && !package_has_dependency(workspace, "openauth-sqlx") {
161        findings.push(error(
162            "database.adapter_mismatch",
163            "Config uses the sqlx adapter, but openauth-sqlx was not detected in dependencies.",
164        ));
165    }
166    if workspace.detected_databases.len() > 1 && config.database.provider.is_none() {
167        findings.push(warn(
168            "database.multiple_adapters",
169            "Multiple database integrations were detected; configure database.provider explicitly.",
170        ));
171    }
172}
173
174fn inspect_security(findings: &mut Vec<Finding>, config: &CliConfig, production: bool) {
175    let secret = std::env::var(&config.security.secret_env).unwrap_or_default();
176    let assessment = assess_secret(&secret, production);
177    match assessment.severity {
178        SecretSeverity::Ok => findings.push(info("security.secret", &assessment.message)),
179        SecretSeverity::Warning => findings.push(warn("security.secret", &assessment.message)),
180        SecretSeverity::Error => findings.push(error("security.secret", &assessment.message)),
181    }
182    if production && !config.project.base_url.starts_with("https://") {
183        findings.push(error(
184            "security.base_url_https",
185            "base_url must use HTTPS in production.",
186        ));
187    }
188    if production && is_localhost_url(&config.project.base_url) {
189        findings.push(warn(
190            "security.localhost",
191            "base_url points to localhost while production checks are enabled.",
192        ));
193    }
194}
195
196async fn inspect_database(findings: &mut Vec<Finding>, config: &CliConfig, production: bool) {
197    if production && std::env::var(&config.database.url_env).is_err() {
198        findings.push(error(
199            "database.url",
200            &format!("{} is required in production.", config.database.url_env),
201        ));
202        return;
203    }
204    if std::env::var(&config.database.url_env).is_err() {
205        findings.push(warn(
206            "database.url",
207            &format!(
208                "{} is not set; database checks were skipped.",
209                config.database.url_env
210            ),
211        ));
212        return;
213    }
214    match db::plan(config, false).await {
215        Ok(plan) => {
216            if !plan.plan.warnings.is_empty() {
217                findings.push(error(
218                    "database.schema_type_mismatch",
219                    "Database schema has type mismatches.",
220                ));
221            }
222            if !plan.plan.is_empty() {
223                findings.push(warn(
224                    "database.pending_schema",
225                    "Database schema has pending OpenAuth changes.",
226                ));
227            } else {
228                findings.push(info("database.schema", "Database schema is up to date."));
229            }
230        }
231        Err(db_error) => findings.push(error("database.connection", &db_error.to_string())),
232    }
233}
234
235fn is_localhost_url(value: &str) -> bool {
236    Url::parse(value)
237        .ok()
238        .and_then(|url| url.host_str().map(str::to_owned))
239        .is_some_and(|host| host == "localhost" || host == "127.0.0.1" || host == "::1")
240}
241
242fn info(code: &str, message: &str) -> Finding {
243    finding(Severity::Info, code, message)
244}
245
246fn warn(code: &str, message: &str) -> Finding {
247    finding(Severity::Warn, code, message)
248}
249
250fn error(code: &str, message: &str) -> Finding {
251    finding(Severity::Error, code, message)
252}
253
254fn finding(severity: Severity, code: &str, message: &str) -> Finding {
255    Finding {
256        severity,
257        code: code.to_owned(),
258        message: message.to_owned(),
259    }
260}