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}