Skip to main content

rustauth_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 rustauth_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    config_loaded: bool,
64) -> DiagnosticReport {
65    let production = production_override || config.project.production;
66    let workspace = inspect(cwd).ok();
67    let mut findings = Vec::new();
68
69    if config_loaded {
70        findings.push(info(
71            "config.loaded",
72            "Loaded RustAuth CLI configuration from rustauth.toml.",
73        ));
74        if config.database_adapter().is_none() {
75            findings.push(error(
76                "database.adapter_missing",
77                "database.adapter is required in rustauth.toml; set it explicitly \
78                 (e.g. sqlx, diesel, tokio-postgres, deadpool-postgres).",
79            ));
80        }
81        inspect_plugin_cli_features(&mut findings, config);
82    } else {
83        findings.push(warn(
84            "config.missing",
85            "No rustauth.toml found; using defaults. Run `rustauth init` to create one.",
86        ));
87    }
88    inspect_workspace(&mut findings, workspace.as_ref(), config);
89    inspect_integration_patterns(&mut findings, cwd);
90    inspect_security(&mut findings, config, production);
91    inspect_database(&mut findings, config, production).await;
92
93    DiagnosticReport {
94        workspace_root: workspace
95            .as_ref()
96            .map(|info| info.root.display().to_string()),
97        target_package: workspace
98            .as_ref()
99            .and_then(|info| info.packages.first())
100            .map(|package| package.name.clone()),
101        rustauth_version: env!("CARGO_PKG_VERSION").to_owned(),
102        rust: command_version("rustc").unwrap_or_else(|_| "not available".to_owned()),
103        cargo: command_version("cargo").unwrap_or_else(|_| "not available".to_owned()),
104        config: redact_config(config),
105        findings,
106    }
107}
108
109pub fn redact_config(config: &CliConfig) -> RedactedConfig {
110    let mut project = BTreeMap::new();
111    project.insert(
112        "framework".to_owned(),
113        serde_json::Value::String(config.project.framework.clone().unwrap_or_default()),
114    );
115    project.insert(
116        "base_url".to_owned(),
117        serde_json::Value::String(config.project.base_url.clone()),
118    );
119    project.insert(
120        "base_path".to_owned(),
121        serde_json::Value::String(config.project.base_path.clone()),
122    );
123    project.insert(
124        "production".to_owned(),
125        serde_json::Value::Bool(config.project.production),
126    );
127
128    let mut database = BTreeMap::new();
129    database.insert(
130        "adapter".to_owned(),
131        serde_json::Value::String(config.database_adapter().unwrap_or_default().to_owned()),
132    );
133    database.insert(
134        "provider".to_owned(),
135        serde_json::Value::String(config.database.provider.clone().unwrap_or_default()),
136    );
137    database.insert(
138        "normalized_provider".to_owned(),
139        serde_json::Value::String(normalized_provider(config.database.provider.as_deref())),
140    );
141    database.insert(
142        "migration_support".to_owned(),
143        serde_json::Value::Bool(db::supports_sql_migrations(config)),
144    );
145    database.insert(
146        "url_env".to_owned(),
147        serde_json::Value::String(config.database.url_env.clone()),
148    );
149    database.insert(
150        "database_url".to_owned(),
151        serde_json::Value::String("[REDACTED]".to_owned()),
152    );
153
154    let mut security = BTreeMap::new();
155    security.insert(
156        "secret_env".to_owned(),
157        serde_json::Value::String(config.security.secret_env.clone()),
158    );
159    security.insert(
160        "secret".to_owned(),
161        serde_json::Value::String("[REDACTED]".to_owned()),
162    );
163
164    RedactedConfig {
165        project,
166        database,
167        security,
168        plugins: config.plugins.enabled.clone(),
169    }
170}
171
172fn inspect_plugin_cli_features(findings: &mut Vec<Finding>, config: &CliConfig) {
173    for id in &config.plugins.enabled {
174        if let Some(feature) = crate::plugins::required_cargo_feature(id) {
175            if !crate::plugins::is_cargo_feature_enabled(feature) {
176                findings.push(error(
177                    "plugins.cli_feature_disabled",
178                    &format!(
179                        "Plugin `{id}` is enabled in rustauth.toml, but this rustauth CLI binary \
180                         was compiled without the `{feature}` Cargo feature."
181                    ),
182                ));
183            }
184        }
185        if !crate::plugins::supports_schema_planning(id)
186            && !crate::plugins::is_schema_planning_exception(id)
187        {
188            findings.push(warn(
189                "plugins.schema_unknown",
190                &format!(
191                    "Plugin `{id}` is enabled but the CLI cannot plan schema for it. \
192                     App-configured plugins such as additional-fields need manual migration \
193                     alignment — see docs/database-migrations.md."
194                ),
195            ));
196        }
197    }
198}
199
200fn inspect_adapter_dependency_alignment(
201    findings: &mut Vec<Finding>,
202    workspace: &WorkspaceInfo,
203    config: &CliConfig,
204) {
205    match config.database_adapter() {
206        Some("sqlx") => {
207            if !cfg!(feature = "sqlx") {
208                findings.push(cli_adapter_feature_disabled_finding("sqlx"));
209            } else if !package_has_dependency(workspace, "rustauth-sqlx") {
210                findings.push(adapter_dependency_mismatch_finding("sqlx", "rustauth-sqlx"));
211            }
212        }
213        Some("tokio-postgres") => {
214            if !cfg!(feature = "tokio-postgres") {
215                findings.push(cli_adapter_feature_disabled_finding("tokio-postgres"));
216            } else if !package_has_dependency(workspace, "rustauth-tokio-postgres") {
217                findings.push(adapter_dependency_mismatch_finding(
218                    "tokio-postgres",
219                    "rustauth-tokio-postgres",
220                ));
221            }
222        }
223        Some("deadpool-postgres") => {
224            if !cfg!(feature = "deadpool-postgres") {
225                findings.push(cli_adapter_feature_disabled_finding("deadpool-postgres"));
226            } else if !package_has_dependency(workspace, "rustauth-deadpool-postgres") {
227                findings.push(adapter_dependency_mismatch_finding(
228                    "deadpool-postgres",
229                    "rustauth-deadpool-postgres",
230                ));
231            }
232        }
233        Some("diesel") => {
234            if !cfg!(feature = "diesel") {
235                findings.push(cli_adapter_feature_disabled_finding("diesel"));
236            } else if !package_has_dependency(workspace, "rustauth-diesel") {
237                findings.push(adapter_dependency_mismatch_finding(
238                    "diesel",
239                    "rustauth-diesel",
240                ));
241            }
242        }
243        _ => {}
244    }
245}
246
247fn cli_adapter_feature_disabled_finding(adapter: &str) -> Finding {
248    error(
249        "database.cli_feature_disabled",
250        &format!(
251            "Config uses the {adapter} adapter, but this rustauth CLI binary was compiled \
252             without the `{adapter}` Cargo feature."
253        ),
254    )
255}
256
257fn adapter_dependency_mismatch_finding(adapter: &str, crate_name: &str) -> Finding {
258    error(
259        "database.adapter_mismatch",
260        &format!(
261            "Config uses the {adapter} adapter, but {crate_name} was not detected in dependencies."
262        ),
263    )
264}
265
266fn inspect_workspace(
267    findings: &mut Vec<Finding>,
268    workspace: Option<&WorkspaceInfo>,
269    config: &CliConfig,
270) {
271    let Some(workspace) = workspace else {
272        findings.push(warn(
273            "workspace.metadata",
274            "Cargo metadata could not be loaded from this directory.",
275        ));
276        return;
277    };
278    findings.push(info(
279        "workspace.root",
280        &format!("Workspace root: {}", workspace.root.display()),
281    ));
282    for framework in &workspace.detected_frameworks {
283        findings.push(info(
284            "framework.detected",
285            &format!("Detected framework: {}", framework.name),
286        ));
287    }
288    inspect_adapter_dependency_alignment(findings, workspace, config);
289    if !db::supports_sql_migrations(config)
290        && config.database.provider.as_deref().is_some_and(|provider| {
291            matches!(
292                provider,
293                "sqlite" | "sqlite3" | "postgres" | "postgresql" | "pg" | "mysql"
294            )
295        })
296    {
297        findings.push(warn(
298            "database.adapter_provider_mismatch",
299            "database.provider is SQL-compatible but database.adapter does not support CLI migrations.",
300        ));
301    }
302    if workspace.detected_databases.len() > 1 && config.database.provider.is_none() {
303        findings.push(warn(
304            "database.multiple_adapters",
305            "Multiple database integrations were detected; configure database.provider explicitly.",
306        ));
307    }
308}
309
310fn inspect_integration_patterns(findings: &mut Vec<Finding>, cwd: &std::path::Path) {
311    let src = cwd.join("src");
312    if !src.is_dir() {
313        return;
314    }
315    let mut legacy_router = false;
316    let mut double_nest = false;
317    walk_rs_sources(&src, &mut |contents| {
318        if contents.contains("rustauth_axum::router(") {
319            legacy_router = true;
320        }
321        if (contents.contains(".mount_router(") || contents.contains(".mount_at_base_path("))
322            && contents.contains(".nest(")
323        {
324            double_nest = true;
325        }
326    });
327    if legacy_router {
328        findings.push(warn(
329            "integration.legacy_router",
330            "Detected rustauth_axum::router(); prefer Arc<RustAuth> + mount_routes() + Router::nest.",
331        ));
332    }
333    if double_nest {
334        findings.push(warn(
335            "integration.double_nest",
336            "Detected mount_at_base_path() (or deprecated mount_router()) and .nest() in the same source tree; avoid nesting twice on the same prefix.",
337        ));
338    }
339}
340
341fn walk_rs_sources(dir: &std::path::Path, visit: &mut dyn FnMut(&str)) {
342    let entries = match std::fs::read_dir(dir) {
343        Ok(entries) => entries,
344        Err(_) => return,
345    };
346    for entry in entries.flatten() {
347        let path = entry.path();
348        if path.is_dir() {
349            walk_rs_sources(&path, visit);
350        } else if path.extension().is_some_and(|ext| ext == "rs") {
351            if let Ok(contents) = std::fs::read_to_string(&path) {
352                visit(&contents);
353            }
354        }
355    }
356}
357
358fn inspect_security(findings: &mut Vec<Finding>, config: &CliConfig, production: bool) {
359    let secret = std::env::var(&config.security.secret_env).unwrap_or_default();
360    let assessment = assess_secret(&secret, production);
361    match assessment.severity {
362        SecretSeverity::Ok => findings.push(info("security.secret", &assessment.message)),
363        SecretSeverity::Warning => findings.push(warn("security.secret", &assessment.message)),
364        SecretSeverity::Error => findings.push(error("security.secret", &assessment.message)),
365    }
366    if production && !config.project.base_url.starts_with("https://") {
367        findings.push(error(
368            "security.base_url_https",
369            "base_url must use HTTPS in production.",
370        ));
371    }
372    if production && is_localhost_url(&config.project.base_url) {
373        findings.push(warn(
374            "security.localhost",
375            "base_url points to localhost while production checks are enabled.",
376        ));
377    }
378}
379
380async fn inspect_database(findings: &mut Vec<Finding>, config: &CliConfig, production: bool) {
381    if !db::supports_sql_migrations(config) {
382        findings.push(warn(
383            "database.migrations_unsupported",
384            "CLI migration checks are skipped for this database adapter/provider.",
385        ));
386        return;
387    }
388    if production && std::env::var(&config.database.url_env).is_err() {
389        findings.push(error(
390            "database.url",
391            &format!("{} is required in production.", config.database.url_env),
392        ));
393        return;
394    }
395    if std::env::var(&config.database.url_env).is_err() {
396        findings.push(warn(
397            "database.url",
398            &format!(
399                "{} is not set; database checks were skipped.",
400                config.database.url_env
401            ),
402        ));
403        return;
404    }
405    match db::plan(config, false).await {
406        Ok(plan) => {
407            if !plan.plan.warnings.is_empty() {
408                findings.push(error(
409                    "database.schema_type_mismatch",
410                    "Database schema has type mismatches.",
411                ));
412            }
413            if !plan.plan.is_empty() {
414                findings.push(warn(
415                    "database.pending_schema",
416                    "Database schema has pending RustAuth changes.",
417                ));
418            } else {
419                findings.push(info("database.schema", "Database schema is up to date."));
420            }
421        }
422        Err(db_error) => findings.push(error("database.connection", &db_error.to_string())),
423    }
424}
425
426fn normalized_provider(provider: Option<&str>) -> String {
427    match provider {
428        Some("postgresql" | "pg") => "postgres".to_owned(),
429        Some("sqlite3") => "sqlite".to_owned(),
430        Some(provider) => provider.to_owned(),
431        None => String::new(),
432    }
433}
434
435fn is_localhost_url(value: &str) -> bool {
436    Url::parse(value)
437        .ok()
438        .and_then(|url| url.host_str().map(str::to_owned))
439        .is_some_and(|host| host == "localhost" || host == "127.0.0.1" || host == "::1")
440}
441
442fn info(code: &str, message: &str) -> Finding {
443    finding(Severity::Info, code, message)
444}
445
446fn warn(code: &str, message: &str) -> Finding {
447    finding(Severity::Warn, code, message)
448}
449
450fn error(code: &str, message: &str) -> Finding {
451    finding(Severity::Error, code, message)
452}
453
454fn finding(severity: Severity, code: &str, message: &str) -> Finding {
455    Finding {
456        severity,
457        code: code.to_owned(),
458        message: message.to_owned(),
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::config::CliConfig;
466
467    fn config_with_plugins(ids: &[&str]) -> CliConfig {
468        let mut config = CliConfig::default();
469        config.plugins.enabled = ids.iter().map(|id| (*id).to_owned()).collect();
470        config
471    }
472
473    fn finding_codes(findings: &[Finding]) -> Vec<&str> {
474        findings
475            .iter()
476            .map(|finding| finding.code.as_str())
477            .collect()
478    }
479
480    #[test]
481    fn magic_link_enabled_does_not_report_cli_feature_disabled() {
482        let mut findings = Vec::new();
483        inspect_plugin_cli_features(&mut findings, &config_with_plugins(&["magic-link"]));
484        assert!(
485            !finding_codes(&findings).contains(&"plugins.cli_feature_disabled"),
486            "magic-link has no enterprise CLI feature requirement"
487        );
488    }
489
490    #[cfg(feature = "passkey")]
491    #[test]
492    fn passkey_with_feature_enabled_does_not_report_cli_feature_disabled() {
493        let mut findings = Vec::new();
494        inspect_plugin_cli_features(&mut findings, &config_with_plugins(&["passkey"]));
495        assert!(
496            !finding_codes(&findings).contains(&"plugins.cli_feature_disabled"),
497            "passkey should succeed when the passkey feature is enabled"
498        );
499    }
500
501    #[cfg(not(feature = "passkey"))]
502    #[test]
503    fn passkey_without_feature_reports_cli_feature_disabled() {
504        let mut findings = Vec::new();
505        inspect_plugin_cli_features(&mut findings, &config_with_plugins(&["passkey"]));
506        assert!(
507            finding_codes(&findings).contains(&"plugins.cli_feature_disabled"),
508            "passkey requires the passkey Cargo feature"
509        );
510    }
511}