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