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}