Skip to main content

mitosis/pg/
binaries.rs

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    /// Not required by `discover`; callers check it exists before an upgrade.
20    pub fn pg_upgrade(&self) -> PathBuf {
21        self.bin("pg_upgrade")
22    }
23}
24
25/// Parses `postgres --version` output, e.g. "postgres (PostgreSQL) 16.4" -> (16, "16.4").
26pub 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
41/// Expands `<parent>/<prefix>*/<suffix>`; missing parents yield nothing.
42fn 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
52/// The fixed system folders, under `root` (`/` in real use; a temp folder in tests).
53fn 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        // apt/PGDG: /usr/lib/postgresql/<N>/bin; dnf/PGDG: /usr/pgsql-<N>/bin.
69        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
119/// System installs only; the daemon uses `discover_with` so managed builds count too.
120pub fn discover() -> Vec<PgInstall> {
121    discover_with(None)
122}
123
124/// System installs plus, given a home, the builds `mitosis pg install` put in `<home>/pg/`.
125pub 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                // By the real `postgres` file: Homebrew links one install under several folders.
131                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
156/// A system install of a major wins over a managed one of the same major; newest first.
157fn 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
179/// Newest Postgres major; used only for the install hint when none is found.
180pub const LATEST_PG_MAJOR: u32 = 18;
181
182/// The package-manager command that installs Postgres `major` on this OS.
183pub 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}