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}