1use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicBool, Ordering};
10
11use crate::client::DaemonError;
12
13static CLI_PROCESS: AtomicBool = AtomicBool::new(false);
14
15pub fn mark_cli_process() {
20 CLI_PROCESS.store(true, Ordering::SeqCst);
21}
22
23pub fn is_cli_process() -> bool {
25 CLI_PROCESS.load(Ordering::SeqCst)
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ResolveSource {
31 EnvOverride,
33 SelfAsCli,
35 Path,
37 WellKnown,
39 Explicit,
41}
42
43pub fn should_replace_daemon(
49 daemon_version: &str,
50 own_version: &str,
51 source: ResolveSource,
52) -> bool {
53 source == ResolveSource::SelfAsCli && daemon_version != own_version
54}
55
56const STACKLESS_BIN_ENV: &str = "STACKLESS_BIN";
57
58pub fn resolve_daemon_bin() -> Result<(PathBuf, ResolveSource), DaemonError> {
60 let override_bin = std::env::var_os(STACKLESS_BIN_ENV).map(PathBuf::from);
61 let self_exe = std::env::current_exe().map_err(|err| DaemonError::Spawn {
62 detail: format!("cannot resolve current executable: {err}"),
63 })?;
64 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
65 .map(|p| std::env::split_paths(&p).collect())
66 .unwrap_or_default();
67 let home = std::env::var_os("HOME").map(PathBuf::from);
68 resolve_daemon_bin_from(
69 override_bin.as_deref(),
70 &self_exe,
71 is_cli_process(),
72 &path_dirs,
73 home.as_deref(),
74 )
75}
76
77pub fn resolve_daemon_bin_from(
79 override_bin: Option<&Path>,
80 self_exe: &Path,
81 is_cli: bool,
82 path_dirs: &[PathBuf],
83 home: Option<&Path>,
84) -> Result<(PathBuf, ResolveSource), DaemonError> {
85 if let Some(path) = override_bin {
86 if is_executable(path) {
87 return Ok((path.to_path_buf(), ResolveSource::EnvOverride));
88 }
89 return Err(DaemonError::BinaryNotFound {
90 detail: format!(
91 "{STACKLESS_BIN_ENV}={path} is set but is not an executable file",
92 path = path.display()
93 ),
94 });
95 }
96
97 if is_cli && is_executable(self_exe) {
98 return Ok((self_exe.to_path_buf(), ResolveSource::SelfAsCli));
99 }
100
101 for dir in path_dirs {
102 let candidate = dir.join("stackless");
103 if is_executable(&candidate) {
104 return Ok((candidate, ResolveSource::Path));
105 }
106 }
107
108 for dir in well_known_dirs(home) {
109 let candidate = dir.join("stackless");
110 if is_executable(&candidate) {
111 return Ok((candidate, ResolveSource::WellKnown));
112 }
113 }
114
115 Err(DaemonError::BinaryNotFound {
116 detail: format!(
117 "Client::system() needs the stackless CLI to run the operator daemon. \
118 Install it (https://github.com/snowmead/stackless#install), ensure \
119 `stackless` is on PATH, or set {STACKLESS_BIN_ENV}. \
120 For hermetic tests use Client::builder().paths(...) / TestContext \
121 (feature test-support)."
122 ),
123 })
124}
125
126fn well_known_dirs(home: Option<&Path>) -> Vec<PathBuf> {
127 let mut dirs = Vec::new();
128 if let Some(home) = home {
129 dirs.push(home.join(".cargo").join("bin"));
130 }
131 dirs.push(PathBuf::from("/usr/local/bin"));
132 dirs.push(PathBuf::from("/opt/homebrew/bin"));
133 dirs
134}
135
136fn is_executable(path: &Path) -> bool {
137 use std::os::unix::fs::PermissionsExt;
138 let Ok(meta) = std::fs::metadata(path) else {
139 return false;
140 };
141 meta.is_file() && meta.permissions().mode() & 0o111 != 0
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use std::os::unix::fs::PermissionsExt;
148
149 fn touch_exe(dir: &Path, name: &str) -> PathBuf {
150 let path = dir.join(name);
151 std::fs::write(&path, b"#!/bin/sh\n").unwrap();
152 let mut perms = std::fs::metadata(&path).unwrap().permissions();
153 perms.set_mode(0o755);
154 std::fs::set_permissions(&path, perms).unwrap();
155 path
156 }
157
158 #[test]
159 fn override_wins_when_executable() {
160 let dir = tempfile::tempdir().unwrap();
161 let bin = touch_exe(dir.path(), "custom-stackless");
162 let (resolved, source) =
163 resolve_daemon_bin_from(Some(&bin), &dir.path().join("consumer"), false, &[], None)
164 .unwrap();
165 assert_eq!(resolved, bin);
166 assert_eq!(source, ResolveSource::EnvOverride);
167 }
168
169 #[test]
170 fn bad_override_is_hard_error() {
171 let dir = tempfile::tempdir().unwrap();
172 let missing = dir.path().join("missing");
173 let err = resolve_daemon_bin_from(Some(&missing), &dir.path().join("x"), false, &[], None)
174 .unwrap_err();
175 assert!(matches!(err, DaemonError::BinaryNotFound { .. }));
176 assert!(err.to_string().contains(STACKLESS_BIN_ENV));
177 }
178
179 #[test]
180 fn cli_marker_uses_self() {
181 let dir = tempfile::tempdir().unwrap();
182 let self_exe = touch_exe(dir.path(), "stackless");
183 let (resolved, source) = resolve_daemon_bin_from(None, &self_exe, true, &[], None).unwrap();
184 assert_eq!(resolved, self_exe);
185 assert_eq!(source, ResolveSource::SelfAsCli);
186 }
187
188 #[test]
189 fn unmarked_self_never_returned_even_if_named_stackless() {
190 let dir = tempfile::tempdir().unwrap();
191 let self_exe = touch_exe(dir.path(), "stackless");
192 let path_dir = tempfile::tempdir().unwrap();
193 let on_path = touch_exe(path_dir.path(), "stackless");
194 let (resolved, source) = resolve_daemon_bin_from(
195 None,
196 &self_exe,
197 false,
198 &[path_dir.path().to_path_buf()],
199 None,
200 )
201 .unwrap();
202 assert_eq!(resolved, on_path);
203 assert_eq!(source, ResolveSource::Path);
204 assert_ne!(resolved, self_exe);
205 }
206
207 #[test]
208 fn unmarked_with_nothing_errors() {
209 let dir = tempfile::tempdir().unwrap();
210 let self_exe = touch_exe(dir.path(), "jinttai-e2e");
211 let err = resolve_daemon_bin_from(None, &self_exe, false, &[], None).unwrap_err();
212 assert!(matches!(err, DaemonError::BinaryNotFound { .. }));
213 assert!(!err.to_string().contains("jinttai-e2e daemon run"));
214 }
215
216 #[test]
217 fn well_known_cargo_bin() {
218 let home = tempfile::tempdir().unwrap();
219 let cargo_bin = home.path().join(".cargo").join("bin");
220 std::fs::create_dir_all(&cargo_bin).unwrap();
221 let bin = touch_exe(&cargo_bin, "stackless");
222 let self_exe = home.path().join("consumer");
223 let (resolved, source) =
224 resolve_daemon_bin_from(None, &self_exe, false, &[], Some(home.path())).unwrap();
225 assert_eq!(resolved, bin);
226 assert_eq!(source, ResolveSource::WellKnown);
227 }
228
229 #[test]
230 fn should_replace_only_self_as_cli() {
231 assert!(should_replace_daemon(
232 "0.1.4",
233 "0.1.5",
234 ResolveSource::SelfAsCli
235 ));
236 assert!(!should_replace_daemon(
237 "0.1.4",
238 "0.1.5",
239 ResolveSource::Path
240 ));
241 assert!(!should_replace_daemon(
242 "0.1.4",
243 "0.1.5",
244 ResolveSource::EnvOverride
245 ));
246 assert!(!should_replace_daemon(
247 "0.1.4",
248 "0.1.5",
249 ResolveSource::WellKnown
250 ));
251 assert!(!should_replace_daemon(
252 "0.1.4",
253 "0.1.5",
254 ResolveSource::Explicit
255 ));
256 assert!(!should_replace_daemon(
257 "0.1.5",
258 "0.1.5",
259 ResolveSource::SelfAsCli
260 ));
261 }
262}