Skip to main content

libguix/
discover.rs

1//! Resolve `guix` binary once and cache. `guix pull` rewrites
2//! `~/.config/guix/current` — re-resolving mid-session yanks the binary.
3
4use std::env;
5use std::path::{Path, PathBuf};
6
7use crate::cmd::cmd;
8use crate::error::GuixError;
9#[allow(unused_imports)]
10use crate::trace_warn;
11
12pub const MIN_GUIX_VERSION_DATE: &str = "2025-05-01";
13
14#[derive(Debug, Clone)]
15pub struct Discovered {
16    pub binary: PathBuf,
17    pub version: String,
18}
19
20pub fn resolve_binary() -> Result<PathBuf, GuixError> {
21    let candidates = candidate_paths();
22    for c in &candidates {
23        if c.is_file() && is_executable(c) {
24            return Ok(c.clone());
25        }
26    }
27    Err(GuixError::Spawn(std::io::Error::new(
28        std::io::ErrorKind::NotFound,
29        format!(
30            "could not find a `guix` binary in any of: {}",
31            candidates
32                .iter()
33                .map(|p| p.display().to_string())
34                .collect::<Vec<_>>()
35                .join(", ")
36        ),
37    )))
38}
39
40fn candidate_paths() -> Vec<PathBuf> {
41    let mut v = Vec::new();
42    if let Some(p) = env::var_os("GUIX_PROFILE") {
43        v.push(PathBuf::from(p).join("bin/guix"));
44    }
45    if let Some(home) = dirs_home() {
46        v.push(home.join(".config/guix/current/bin/guix"));
47    }
48    v.push(PathBuf::from("/run/current-system/profile/bin/guix"));
49    if let Some(path) = env::var_os("PATH") {
50        for entry in env::split_paths(&path) {
51            v.push(entry.join("guix"));
52        }
53    }
54    v
55}
56
57fn dirs_home() -> Option<PathBuf> {
58    env::var_os("HOME").map(PathBuf::from)
59}
60
61#[cfg(unix)]
62fn is_executable(p: &Path) -> bool {
63    use std::os::unix::fs::PermissionsExt;
64    p.metadata()
65        .map(|m| m.permissions().mode() & 0o111 != 0)
66        .unwrap_or(false)
67}
68
69#[cfg(not(unix))]
70fn is_executable(p: &Path) -> bool {
71    p.is_file()
72}
73
74pub async fn discover() -> Result<Discovered, GuixError> {
75    let binary = resolve_binary()?;
76    let out = cmd(&binary)
77        .arg("--version")
78        .output()
79        .await
80        .map_err(GuixError::Spawn)?;
81    if !out.status.success() {
82        return Err(GuixError::NonZeroExit {
83            code: out.status.code().unwrap_or(-1),
84            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
85        });
86    }
87    let first_line = String::from_utf8_lossy(&out.stdout)
88        .lines()
89        .next()
90        .unwrap_or("")
91        .to_owned();
92
93    // Release strings get a 1.4.0 floor; commit-hash builds (development
94    // Guix) pass — we can't date them without git. Malformed warns + passes.
95    if let Some(version_token) = first_line.split_whitespace().last() {
96        if looks_like_release_version(version_token) {
97            match release_version_at_least(version_token, "1.4.0") {
98                Some(true) => {}
99                Some(false) => {
100                    return Err(GuixError::VersionUnsupported {
101                        found: version_token.to_owned(),
102                        min: format!("1.4.0 or commit build (anchor date {MIN_GUIX_VERSION_DATE})"),
103                    });
104                }
105                None => {
106                    trace_warn!(
107                        target: "libguix::discover",
108                        "could not parse guix version {:?}; assuming compatible",
109                        version_token
110                    );
111                }
112            }
113        }
114    }
115
116    Ok(Discovered {
117        binary,
118        version: first_line,
119    })
120}
121
122fn looks_like_release_version(s: &str) -> bool {
123    s.chars()
124        .next()
125        .map(|c| c.is_ascii_digit())
126        .unwrap_or(false)
127        && s.contains('.')
128}
129
130/// Returns `None` if `found` has a non-numeric component — caller warns + passes.
131fn release_version_at_least(found: &str, min: &str) -> Option<bool> {
132    fn parse_strict(v: &str) -> Option<Vec<u32>> {
133        v.split('.').map(|p| p.parse::<u32>().ok()).collect()
134    }
135    fn parse_lenient(v: &str) -> Vec<u32> {
136        v.split('.').filter_map(|p| p.parse::<u32>().ok()).collect()
137    }
138    let a = parse_strict(found)?;
139    let b = parse_lenient(min);
140    Some(a >= b)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn release_compare() {
149        assert_eq!(release_version_at_least("1.4.0", "1.4.0"), Some(true));
150        assert_eq!(release_version_at_least("1.4.1", "1.4.0"), Some(true));
151        assert_eq!(release_version_at_least("2.0.0", "1.4.0"), Some(true));
152        assert_eq!(release_version_at_least("1.3.0", "1.4.0"), Some(false));
153    }
154
155    #[test]
156    fn release_compare_malformed_returns_none() {
157        assert_eq!(release_version_at_least("1.foo", "1.4.0"), None);
158        assert_eq!(release_version_at_least("foo.bar", "1.4.0"), None);
159    }
160
161    #[test]
162    fn looks_like_release() {
163        assert!(looks_like_release_version("1.4.0"));
164        assert!(!looks_like_release_version("fc27102e8acb19"));
165    }
166}