1use 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 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
130fn 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}