Skip to main content

nexql_tools/
detect.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 NexQL-OSS Team
3
4//! Auto-detection of Postgres connection candidates from environment, workspace files, and local settings.
5
6use 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    /// Secret-safe view for model-facing responses — never expose passwords or URL credentials.
35    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    /// Detect all connection candidates across environment, ~/.pgpass, and workspace root.
73    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    /// Detect from environment variables (DATABASE_URL, POSTGRES_URL, PG*).
91    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    /// Parse entries from ~/.pgpass (format: hostname:port:database:username:password).
149    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    /// Parse .env files under workspace root.
213    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    /// Parse docker-compose files under workspace root.
287    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}