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 rust: String,
30 pub cargo: String,
31 pub config: RedactedConfig,
32 pub findings: Vec<Finding>,
33}
34
35#[derive(Debug, Serialize)]
36pub struct RedactedConfig {
37 pub project: BTreeMap<String, serde_json::Value>,
38 pub database: BTreeMap<String, serde_json::Value>,
39 pub security: BTreeMap<String, serde_json::Value>,
40 pub plugins: Vec<String>,
41}
42
43impl DiagnosticReport {
44 pub fn has_errors(&self) -> bool {
45 self.findings
46 .iter()
47 .any(|finding| finding.severity == Severity::Error)
48 }
49
50 pub fn has_warnings(&self) -> bool {
51 self.findings
52 .iter()
53 .any(|finding| finding.severity == Severity::Warn)
54 }
55}
56
57pub async fn doctor(
58 cwd: &std::path::Path,
59 config: &CliConfig,
60 production_override: bool,
61) -> DiagnosticReport {
62 let production = production_override || config.project.production;
63 let workspace = inspect(cwd).ok();
64 let mut findings = Vec::new();
65
66 findings.push(info(
67 "config.loaded",
68 "Loaded OpenAuth CLI configuration from openauth.toml.",
69 ));
70 inspect_workspace(&mut findings, workspace.as_ref(), config);
71 inspect_security(&mut findings, config, production);
72 inspect_database(&mut findings, config, production).await;
73
74 DiagnosticReport {
75 workspace_root: workspace.map(|info| info.root.display().to_string()),
76 rust: command_version("rustc").unwrap_or_else(|_| "not available".to_owned()),
77 cargo: command_version("cargo").unwrap_or_else(|_| "not available".to_owned()),
78 config: redact_config(config),
79 findings,
80 }
81}
82
83pub fn redact_config(config: &CliConfig) -> RedactedConfig {
84 let mut project = BTreeMap::new();
85 project.insert(
86 "framework".to_owned(),
87 serde_json::Value::String(config.project.framework.clone().unwrap_or_default()),
88 );
89 project.insert(
90 "base_url".to_owned(),
91 serde_json::Value::String(config.project.base_url.clone()),
92 );
93 project.insert(
94 "base_path".to_owned(),
95 serde_json::Value::String(config.project.base_path.clone()),
96 );
97 project.insert(
98 "production".to_owned(),
99 serde_json::Value::Bool(config.project.production),
100 );
101
102 let mut database = BTreeMap::new();
103 database.insert(
104 "adapter".to_owned(),
105 serde_json::Value::String(config.database.adapter.clone()),
106 );
107 database.insert(
108 "provider".to_owned(),
109 serde_json::Value::String(config.database.provider.clone().unwrap_or_default()),
110 );
111 database.insert(
112 "url_env".to_owned(),
113 serde_json::Value::String(config.database.url_env.clone()),
114 );
115 database.insert(
116 "database_url".to_owned(),
117 serde_json::Value::String("[REDACTED]".to_owned()),
118 );
119
120 let mut security = BTreeMap::new();
121 security.insert(
122 "secret_env".to_owned(),
123 serde_json::Value::String(config.security.secret_env.clone()),
124 );
125 security.insert(
126 "secret".to_owned(),
127 serde_json::Value::String("[REDACTED]".to_owned()),
128 );
129
130 RedactedConfig {
131 project,
132 database,
133 security,
134 plugins: config.plugins.enabled.clone(),
135 }
136}
137
138fn inspect_workspace(
139 findings: &mut Vec<Finding>,
140 workspace: Option<&WorkspaceInfo>,
141 config: &CliConfig,
142) {
143 let Some(workspace) = workspace else {
144 findings.push(warn(
145 "workspace.metadata",
146 "Cargo metadata could not be loaded from this directory.",
147 ));
148 return;
149 };
150 findings.push(info(
151 "workspace.root",
152 &format!("Workspace root: {}", workspace.root.display()),
153 ));
154 for framework in &workspace.detected_frameworks {
155 findings.push(info(
156 "framework.detected",
157 &format!("Detected framework: {}", framework.name),
158 ));
159 }
160 if config.database.adapter == "sqlx" && !package_has_dependency(workspace, "openauth-sqlx") {
161 findings.push(error(
162 "database.adapter_mismatch",
163 "Config uses the sqlx adapter, but openauth-sqlx was not detected in dependencies.",
164 ));
165 }
166 if workspace.detected_databases.len() > 1 && config.database.provider.is_none() {
167 findings.push(warn(
168 "database.multiple_adapters",
169 "Multiple database integrations were detected; configure database.provider explicitly.",
170 ));
171 }
172}
173
174fn inspect_security(findings: &mut Vec<Finding>, config: &CliConfig, production: bool) {
175 let secret = std::env::var(&config.security.secret_env).unwrap_or_default();
176 let assessment = assess_secret(&secret, production);
177 match assessment.severity {
178 SecretSeverity::Ok => findings.push(info("security.secret", &assessment.message)),
179 SecretSeverity::Warning => findings.push(warn("security.secret", &assessment.message)),
180 SecretSeverity::Error => findings.push(error("security.secret", &assessment.message)),
181 }
182 if production && !config.project.base_url.starts_with("https://") {
183 findings.push(error(
184 "security.base_url_https",
185 "base_url must use HTTPS in production.",
186 ));
187 }
188 if production && is_localhost_url(&config.project.base_url) {
189 findings.push(warn(
190 "security.localhost",
191 "base_url points to localhost while production checks are enabled.",
192 ));
193 }
194}
195
196async fn inspect_database(findings: &mut Vec<Finding>, config: &CliConfig, production: bool) {
197 if production && std::env::var(&config.database.url_env).is_err() {
198 findings.push(error(
199 "database.url",
200 &format!("{} is required in production.", config.database.url_env),
201 ));
202 return;
203 }
204 if std::env::var(&config.database.url_env).is_err() {
205 findings.push(warn(
206 "database.url",
207 &format!(
208 "{} is not set; database checks were skipped.",
209 config.database.url_env
210 ),
211 ));
212 return;
213 }
214 match db::plan(config, false).await {
215 Ok(plan) => {
216 if !plan.plan.warnings.is_empty() {
217 findings.push(error(
218 "database.schema_type_mismatch",
219 "Database schema has type mismatches.",
220 ));
221 }
222 if !plan.plan.is_empty() {
223 findings.push(warn(
224 "database.pending_schema",
225 "Database schema has pending OpenAuth changes.",
226 ));
227 } else {
228 findings.push(info("database.schema", "Database schema is up to date."));
229 }
230 }
231 Err(db_error) => findings.push(error("database.connection", &db_error.to_string())),
232 }
233}
234
235fn is_localhost_url(value: &str) -> bool {
236 Url::parse(value)
237 .ok()
238 .and_then(|url| url.host_str().map(str::to_owned))
239 .is_some_and(|host| host == "localhost" || host == "127.0.0.1" || host == "::1")
240}
241
242fn info(code: &str, message: &str) -> Finding {
243 finding(Severity::Info, code, message)
244}
245
246fn warn(code: &str, message: &str) -> Finding {
247 finding(Severity::Warn, code, message)
248}
249
250fn error(code: &str, message: &str) -> Finding {
251 finding(Severity::Error, code, message)
252}
253
254fn finding(severity: Severity, code: &str, message: &str) -> Finding {
255 Finding {
256 severity,
257 code: code.to_owned(),
258 message: message.to_owned(),
259 }
260}