1use crate::error::{ErrorCode, MitosisError, Result};
2use crate::paths::Home;
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7#[derive(Clone, Debug, PartialEq)]
8pub struct PgInstall {
9 pub bin_dir: PathBuf,
10 pub major: u32,
11 pub version: String,
12}
13
14impl PgInstall {
15 pub fn bin(&self, name: &str) -> PathBuf {
16 self.bin_dir.join(name)
17 }
18
19 pub fn pg_upgrade(&self) -> PathBuf {
21 self.bin("pg_upgrade")
22 }
23}
24
25pub fn parse_version(output: &str) -> Option<(u32, String)> {
27 let rest = output.split("(PostgreSQL)").nth(1)?.trim_start();
28 let version: String = rest
29 .chars()
30 .take_while(|c| c.is_ascii_digit() || *c == '.')
31 .collect();
32 let version = version.trim_end_matches('.').to_string();
33 let major = version.split('.').next()?.parse().ok()?;
34 Some((major, version))
35}
36
37pub(crate) fn version_key(version: &str) -> Vec<u32> {
38 version.split('.').filter_map(|p| p.parse().ok()).collect()
39}
40
41fn glob_dirs(parent: impl AsRef<Path>, prefix: &str, suffix: &str) -> Vec<PathBuf> {
43 let Ok(rd) = std::fs::read_dir(parent) else {
44 return vec![];
45 };
46 rd.flatten()
47 .filter(|e| e.file_name().to_string_lossy().starts_with(prefix))
48 .map(|e| e.path().join(suffix))
49 .collect()
50}
51
52fn candidates_in(root: &Path) -> Vec<PathBuf> {
54 let mut out = Vec::new();
55 for prefix in ["opt/homebrew/opt", "usr/local/opt"] {
56 let dir = root.join(prefix);
57 out.extend(glob_dirs(&dir, "postgresql@", "bin"));
58 out.push(dir.join("postgresql/bin"));
59 }
60 if cfg!(target_os = "macos") {
61 out.extend(glob_dirs(
62 root.join("Applications/Postgres.app/Contents/Versions"),
63 "",
64 "bin",
65 ));
66 }
67 if cfg!(target_os = "linux") {
68 out.extend(glob_dirs(root.join("usr/lib/postgresql"), "", "bin"));
70 out.extend(glob_dirs(root.join("usr"), "pgsql-", "bin"));
71 }
72 out
73}
74
75fn candidates() -> Vec<PathBuf> {
76 let mut out = candidates_in(Path::new("/"));
77 if let Ok(home) = std::env::var("HOME") {
78 out.extend(glob_dirs(
79 format!("{home}/.asdf/installs/postgres"),
80 "",
81 "bin",
82 ));
83 out.extend(glob_dirs(format!("{home}/.pgenv"), "pgsql-", "bin"));
84 }
85 if cfg!(target_os = "linux") {
86 if let Ok(o) = Command::new("pg_config").arg("--bindir").output() {
87 let dir = String::from_utf8_lossy(&o.stdout).trim().to_string();
88 if o.status.success() && !dir.is_empty() {
89 out.push(PathBuf::from(dir));
90 }
91 }
92 }
93 if let Some(path) = std::env::var_os("PATH") {
94 out.extend(std::env::split_paths(&path).filter(|d| d.join("postgres").is_file()));
95 }
96 out
97}
98
99fn probe(bin_dir: &Path) -> Option<PgInstall> {
100 let required = ["postgres", "initdb", "pg_ctl", "psql", "pg_isready"];
101 if !required.iter().all(|b| bin_dir.join(b).is_file()) {
102 return None;
103 }
104 let out = Command::new(bin_dir.join("postgres"))
105 .arg("--version")
106 .output()
107 .ok()?;
108 if !out.status.success() {
109 return None;
110 }
111 let (major, version) = parse_version(&String::from_utf8_lossy(&out.stdout))?;
112 Some(PgInstall {
113 bin_dir: bin_dir.to_path_buf(),
114 major,
115 version,
116 })
117}
118
119pub fn discover() -> Vec<PgInstall> {
121 discover_with(None)
122}
123
124pub fn discover_with(home: Option<&Home>) -> Vec<PgInstall> {
126 let mut seen = HashSet::new();
127 let mut probe_all = |dirs: Vec<PathBuf>| -> Vec<PgInstall> {
128 dirs.into_iter()
129 .filter_map(|dir| {
130 let canon = std::fs::canonicalize(dir.join("postgres")).ok()?;
132 if !seen.insert(canon) {
133 return None;
134 }
135 probe(&dir)
136 })
137 .collect()
138 };
139 let system = probe_all(candidates());
140 let managed = home
141 .map(|h| probe_all(managed_candidates(h)))
142 .unwrap_or_default();
143 merge(system, managed)
144}
145
146fn managed_candidates(home: &Home) -> Vec<PathBuf> {
147 let Ok(rd) = std::fs::read_dir(home.pg_dir()) else {
148 return vec![];
149 };
150 rd.flatten()
151 .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
152 .map(|e| e.path().join("bin"))
153 .collect()
154}
155
156fn merge(system: Vec<PgInstall>, managed: Vec<PgInstall>) -> Vec<PgInstall> {
158 let majors: HashSet<u32> = system.iter().map(|i| i.major).collect();
159 let mut installs = system;
160 installs.extend(managed.into_iter().filter(|m| !majors.contains(&m.major)));
161 installs.sort_by(|a, b| {
162 b.major
163 .cmp(&a.major)
164 .then_with(|| version_key(&b.version).cmp(&version_key(&a.version)))
165 });
166 installs
167}
168
169pub fn find_major(installs: &[PgInstall], major: u32) -> Result<&PgInstall> {
170 installs.iter().find(|i| i.major == major).ok_or_else(|| {
171 MitosisError::new(
172 ErrorCode::NoMatchingPostgres,
173 format!("no Postgres {major} found"),
174 )
175 .with_hint(install_hint(major))
176 })
177}
178
179pub const LATEST_PG_MAJOR: u32 = 18;
181
182pub fn install_hint(major: u32) -> String {
184 if cfg!(target_os = "macos") {
185 format!("brew install postgresql@{major}")
186 } else if Path::new("/usr/bin/dnf").exists() {
187 format!("sudo dnf install postgresql{major}-server")
188 } else {
189 format!("sudo apt install postgresql-{major}")
190 }
191}
192
193pub fn newest(installs: &[PgInstall]) -> Result<&PgInstall> {
194 installs.first().ok_or_else(|| {
195 MitosisError::new(ErrorCode::NoMatchingPostgres, "no Postgres found")
196 .with_hint(install_hint(LATEST_PG_MAJOR))
197 })
198}
199
200pub fn read_pg_version(data_dir: &Path) -> Result<u32> {
201 let path = data_dir.join("PG_VERSION");
202 let text = std::fs::read_to_string(&path).map_err(|e| {
203 MitosisError::new(ErrorCode::Internal, format!("read {}: {e}", path.display()))
204 })?;
205 text.trim().parse().map_err(|_| {
206 MitosisError::new(
207 ErrorCode::Internal,
208 format!("invalid PG_VERSION in {}", path.display()),
209 )
210 })
211}
212
213pub fn check_extensions(install: &PgInstall, extensions: &[String]) -> Result<()> {
214 let pg_config = install.bin("pg_config");
215 let out = Command::new(&pg_config).arg("--sharedir").output()?;
216 if !out.status.success() {
217 let stderr = String::from_utf8_lossy(&out.stderr);
218 let stderr = stderr.trim();
219 let detail = if stderr.is_empty() {
220 format!("{}, no output", out.status)
221 } else {
222 stderr.to_string()
223 };
224 return Err(MitosisError::new(
225 ErrorCode::Internal,
226 format!("{} --sharedir failed: {detail}", pg_config.display()),
227 ));
228 }
229 let share = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
230 let missing: Vec<&str> = extensions
231 .iter()
232 .map(String::as_str)
233 .filter(|n| {
234 *n != "plpgsql"
235 && !share
236 .join("extension")
237 .join(format!("{n}.control"))
238 .is_file()
239 })
240 .collect();
241 if missing.is_empty() {
242 return Ok(());
243 }
244 Err(MitosisError::new(
245 ErrorCode::MissingExtension,
246 format!(
247 "missing extensions for Postgres {}: {}",
248 install.major,
249 missing.join(", ")
250 ),
251 ))
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[cfg(target_os = "linux")]
259 #[test]
260 fn linux_candidates_include_apt_and_dnf_layouts() {
261 let root = tempfile::tempdir().unwrap();
262 std::fs::create_dir_all(root.path().join("usr/lib/postgresql/16/bin")).unwrap();
263 std::fs::create_dir_all(root.path().join("usr/pgsql-15/bin")).unwrap();
264 let c = candidates_in(root.path());
265 assert!(c.contains(&root.path().join("usr/lib/postgresql/16/bin")));
266 assert!(c.contains(&root.path().join("usr/pgsql-15/bin")));
267 }
268
269 #[test]
270 fn parses_versions() {
271 assert_eq!(
272 parse_version("postgres (PostgreSQL) 16.4"),
273 Some((16, "16.4".into()))
274 );
275 assert_eq!(
276 parse_version("postgres (PostgreSQL) 17.0 (Homebrew)"),
277 Some((17, "17.0".into()))
278 );
279 assert_eq!(
280 parse_version("postgres (PostgreSQL) 9.6.24"),
281 Some((9, "9.6.24".into()))
282 );
283 assert_eq!(parse_version("garbage"), None);
284 }
285
286 #[test]
287 fn version_sort_is_numeric() {
288 assert!(version_key("16.10") > version_key("16.9"));
289 }
290
291 #[test]
292 fn missing_postgres_errors() {
293 let e = find_major(&[], 16).unwrap_err();
294 assert_eq!(e.code, ErrorCode::NoMatchingPostgres);
295 assert_eq!(e.message, "no Postgres 16 found");
296 assert_eq!(e.hint, Some(install_hint(16)));
297 let e = newest(&[]).unwrap_err();
298 assert_eq!(e.code, ErrorCode::NoMatchingPostgres);
299 assert_eq!(e.hint, Some(install_hint(18)));
300 let want = if cfg!(target_os = "macos") {
301 "brew install postgresql@16"
302 } else if Path::new("/usr/bin/dnf").exists() {
303 "sudo dnf install postgresql16-server"
304 } else {
305 "sudo apt install postgresql-16"
306 };
307 assert_eq!(install_hint(16), want);
308 }
309
310 #[test]
311 fn find_major_and_newest_pick_first_match() {
312 let mk = |major, v: &str| PgInstall {
313 bin_dir: "/x".into(),
314 major,
315 version: v.into(),
316 };
317 let installs = vec![mk(17, "17.0"), mk(16, "16.4")];
318 assert_eq!(newest(&installs).unwrap().major, 17);
319 assert_eq!(find_major(&installs, 16).unwrap().version, "16.4");
320 }
321
322 #[test]
323 fn system_install_wins_same_major() {
324 let mk = |dir: &str, major, v: &str| PgInstall {
325 bin_dir: dir.into(),
326 major,
327 version: v.into(),
328 };
329 let system = vec![mk("/opt/homebrew/opt/postgresql@17/bin", 17, "17.2")];
330 let managed = vec![
331 mk("/h/pg/17.9/bin", 17, "17.9"),
332 mk("/h/pg/16.4/bin", 16, "16.4"),
333 mk("/h/pg/18.1/bin", 18, "18.1"),
334 ];
335 let all = merge(system, managed);
336 let got: Vec<_> = all.iter().map(|i| (i.major, i.version.as_str())).collect();
337 assert_eq!(got, vec![(18, "18.1"), (17, "17.2"), (16, "16.4")]);
338 assert_eq!(
339 find_major(&all, 17).unwrap().bin_dir,
340 PathBuf::from("/opt/homebrew/opt/postgresql@17/bin")
341 );
342 }
343
344 #[test]
345 fn reads_pg_version_file() {
346 let d = tempfile::tempdir().unwrap();
347 std::fs::write(d.path().join("PG_VERSION"), "16\n").unwrap();
348 assert_eq!(read_pg_version(d.path()).unwrap(), 16);
349 assert!(read_pg_version(&d.path().join("nope")).is_err());
350 }
351}