1use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct DetectedCandidate {
11 pub source: String,
12 pub url: Option<String>,
13 pub host: Option<String>,
14 pub port: Option<u16>,
15 pub dbname: Option<String>,
16 pub user: Option<String>,
17 pub password: Option<String>,
18 pub sslmode: Option<String>,
19 pub is_complete: bool,
20}
21
22impl DetectedCandidate {
23 pub fn check_complete(&mut self) {
24 if self.url.is_some() {
25 self.is_complete = true;
26 return;
27 }
28 self.is_complete = self.host.is_some()
29 && self.port.is_some()
30 && self.dbname.is_some()
31 && self.user.is_some();
32 }
33
34 pub fn redacted_json(&self) -> serde_json::Value {
36 use serde_json::json;
37 let redacted_url = self.url.as_ref().map(|u| redact_url_credentials(u));
38 json!({
39 "source": self.source,
40 "url": redacted_url,
41 "host": self.host,
42 "port": self.port,
43 "dbname": self.dbname,
44 "user": self.user,
45 "password": self.password.as_ref().map(|_| "<redacted>"),
46 "sslmode": self.sslmode,
47 "isComplete": self.is_complete,
48 })
49 }
50}
51
52fn redact_url_credentials(url: &str) -> String {
53 let Some(scheme_end) = url.find("://") else {
54 return url.to_string();
55 };
56 let Some(at_idx) = url[scheme_end + 3..].find('@') else {
57 return url.to_string();
58 };
59 let cred_start = scheme_end + 3;
60 let cred_end = cred_start + at_idx;
61 let creds = &url[cred_start..cred_end];
62 let Some(colon) = creds.find(':') else {
63 return url.to_string();
64 };
65 let user = &creds[..colon];
66 format!("{}://{}@{}", &url[..scheme_end], user, &url[cred_end + 1..])
67}
68
69pub struct ConnectionDetector;
70
71impl ConnectionDetector {
72 pub fn detect_all(workspace_root: Option<&Path>) -> Vec<DetectedCandidate> {
74 let mut candidates = Vec::new();
75
76 if let Some(cand) = Self::detect_env_vars() {
77 candidates.push(cand);
78 }
79
80 candidates.extend(Self::detect_pgpass());
81
82 if let Some(root) = workspace_root {
83 candidates.extend(Self::detect_dotenv(root));
84 candidates.extend(Self::detect_docker_compose(root));
85 }
86
87 candidates
88 }
89
90 pub fn detect_env_vars() -> Option<DetectedCandidate> {
92 if let Ok(url) = std::env::var("DATABASE_URL").or_else(|_| std::env::var("POSTGRES_URL"))
93 && !url.trim().is_empty()
94 {
95 let mut cand = DetectedCandidate {
96 source: "environment (DATABASE_URL/POSTGRES_URL)".into(),
97 url: Some(url),
98 host: None,
99 port: None,
100 dbname: None,
101 user: None,
102 password: None,
103 sslmode: None,
104 is_complete: true,
105 };
106 cand.check_complete();
107 return Some(cand);
108 }
109
110 let host = std::env::var("PGHOST")
111 .ok()
112 .filter(|s| !s.trim().is_empty());
113 let port = std::env::var("PGPORT")
114 .ok()
115 .and_then(|s| s.parse::<u16>().ok());
116 let dbname = std::env::var("PGDATABASE")
117 .ok()
118 .filter(|s| !s.trim().is_empty());
119 let user = std::env::var("PGUSER")
120 .ok()
121 .filter(|s| !s.trim().is_empty());
122 let password = std::env::var("PGPASSWORD")
123 .ok()
124 .filter(|s| !s.trim().is_empty());
125 let sslmode = std::env::var("PGSSLMODE")
126 .ok()
127 .filter(|s| !s.trim().is_empty());
128
129 if host.is_some() || dbname.is_some() || user.is_some() {
130 let mut cand = DetectedCandidate {
131 source: "environment (PG*)".into(),
132 url: None,
133 host,
134 port: port.or(Some(5432)),
135 dbname,
136 user,
137 password,
138 sslmode,
139 is_complete: false,
140 };
141 cand.check_complete();
142 return Some(cand);
143 }
144
145 None
146 }
147
148 pub fn detect_pgpass() -> Vec<DetectedCandidate> {
150 let mut candidates = Vec::new();
151 let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
152 let Some(home_dir) = home else {
153 return candidates;
154 };
155 let pgpass_path = home_dir.join(".pgpass");
156 let Ok(content) = std::fs::read_to_string(&pgpass_path) else {
157 return candidates;
158 };
159
160 for line in content.lines() {
161 let line = line.trim();
162 if line.is_empty() || line.starts_with('#') {
163 continue;
164 }
165 let parts: Vec<&str> = line.split(':').collect();
166 if parts.len() == 5 {
167 let host = if parts[0] != "*" {
168 Some(parts[0].to_string())
169 } else {
170 Some("127.0.0.1".to_string())
171 };
172 let port = if parts[1] != "*" {
173 parts[1].parse::<u16>().ok()
174 } else {
175 Some(5432)
176 };
177 let dbname = if parts[2] != "*" {
178 Some(parts[2].to_string())
179 } else {
180 None
181 };
182 let user = if parts[3] != "*" {
183 Some(parts[3].to_string())
184 } else {
185 None
186 };
187 let password = if parts[4] != "*" {
188 Some(parts[4].to_string())
189 } else {
190 None
191 };
192
193 let mut cand = DetectedCandidate {
194 source: "~/.pgpass".into(),
195 url: None,
196 host,
197 port,
198 dbname,
199 user,
200 password,
201 sslmode: None,
202 is_complete: false,
203 };
204 cand.check_complete();
205 candidates.push(cand);
206 }
207 }
208
209 candidates
210 }
211
212 pub fn detect_dotenv(root: &Path) -> Vec<DetectedCandidate> {
214 let mut candidates = Vec::new();
215 let env_files = [".env", ".env.local", ".env.development"];
216
217 for filename in env_files {
218 let path = root.join(filename);
219 let Ok(content) = std::fs::read_to_string(&path) else {
220 continue;
221 };
222
223 let mut url = None;
224 let mut host = None;
225 let mut port = None;
226 let mut dbname = None;
227 let mut user = None;
228 let mut password = None;
229 let mut sslmode = None;
230
231 for line in content.lines() {
232 let line = line.trim();
233 if line.is_empty() || line.starts_with('#') {
234 continue;
235 }
236 if let Some((k, v)) = line.split_once('=') {
237 let k = k.trim();
238 let v = v.trim().trim_matches('"').trim_matches('\'');
239 match k {
240 "DATABASE_URL" | "POSTGRES_URL" if !v.is_empty() => {
241 url = Some(v.to_string())
242 }
243 "PGHOST" | "DB_HOST" | "POSTGRES_HOST" if !v.is_empty() => {
244 host = Some(v.to_string())
245 }
246 "PGPORT" | "DB_PORT" | "POSTGRES_PORT" if !v.is_empty() => {
247 port = v.parse::<u16>().ok()
248 }
249 "PGDATABASE" | "DB_NAME" | "POSTGRES_DB" if !v.is_empty() => {
250 dbname = Some(v.to_string())
251 }
252 "PGUSER" | "DB_USER" | "POSTGRES_USER" if !v.is_empty() => {
253 user = Some(v.to_string())
254 }
255 "PGPASSWORD" | "DB_PASSWORD" | "POSTGRES_PASSWORD" if !v.is_empty() => {
256 password = Some(v.to_string())
257 }
258 "PGSSLMODE" | "DB_SSLMODE" if !v.is_empty() => {
259 sslmode = Some(v.to_string())
260 }
261 _ => {}
262 }
263 }
264 }
265
266 if url.is_some() || host.is_some() || dbname.is_some() {
267 let mut cand = DetectedCandidate {
268 source: format!("workspace {filename}"),
269 url,
270 host,
271 port: port.or(Some(5432)),
272 dbname,
273 user,
274 password,
275 sslmode,
276 is_complete: false,
277 };
278 cand.check_complete();
279 candidates.push(cand);
280 }
281 }
282
283 candidates
284 }
285
286 pub fn detect_docker_compose(root: &Path) -> Vec<DetectedCandidate> {
288 let mut candidates = Vec::new();
289 let compose_files = ["docker-compose.yml", "docker-compose.yaml", "compose.yaml"];
290
291 for filename in compose_files {
292 let path = root.join(filename);
293 let Ok(content) = std::fs::read_to_string(&path) else {
294 continue;
295 };
296
297 if !content.contains("postgres") && !content.contains("postgresql") {
298 continue;
299 }
300
301 let mut dbname = None;
302 let mut user = None;
303 let mut password = None;
304 let mut port = Some(5432);
305
306 for line in content.lines() {
307 let line = line.trim();
308 if line.contains("POSTGRES_DB=") || line.contains("POSTGRES_DB:") {
309 dbname = line
310 .split(&['=', ':'][..])
311 .nth(1)
312 .map(|s| s.trim().trim_matches('"').to_string());
313 } else if line.contains("POSTGRES_USER=") || line.contains("POSTGRES_USER:") {
314 user = line
315 .split(&['=', ':'][..])
316 .nth(1)
317 .map(|s| s.trim().trim_matches('"').to_string());
318 } else if line.contains("POSTGRES_PASSWORD=") || line.contains("POSTGRES_PASSWORD:")
319 {
320 password = line
321 .split(&['=', ':'][..])
322 .nth(1)
323 .map(|s| s.trim().trim_matches('"').to_string());
324 } else if line.contains("5433:5432") {
325 port = Some(5433);
326 }
327 }
328
329 let mut cand = DetectedCandidate {
330 source: format!("workspace {filename}"),
331 url: None,
332 host: Some("127.0.0.1".into()),
333 port,
334 dbname,
335 user,
336 password,
337 sslmode: Some("disable".into()),
338 is_complete: false,
339 };
340 cand.check_complete();
341 candidates.push(cand);
342 }
343
344 candidates
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn parses_dotenv_candidate() {
354 let temp_dir = tempfile::tempdir().unwrap();
355 let env_path = temp_dir.path().join(".env");
356 std::fs::write(
357 &env_path,
358 r#"
359POSTGRES_HOST=127.0.0.1
360POSTGRES_PORT=5432
361POSTGRES_DB=testdb
362POSTGRES_USER=testuser
363POSTGRES_PASSWORD=secret
364"#,
365 )
366 .unwrap();
367
368 let cands = ConnectionDetector::detect_dotenv(temp_dir.path());
369 assert_eq!(cands.len(), 1);
370 let cand = &cands[0];
371 assert_eq!(cand.host.as_deref(), Some("127.0.0.1"));
372 assert_eq!(cand.dbname.as_deref(), Some("testdb"));
373 assert_eq!(cand.user.as_deref(), Some("testuser"));
374 assert!(cand.is_complete);
375 }
376}