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