prospero_core/
discovery.rs1use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use sha2::{Digest, Sha256};
13use tokio::net::UnixStream;
14
15use crate::caliband::client::CalibandClient;
16use crate::error::{CoreError, Result};
17
18pub fn hash16(path: &Path) -> String {
20 let mut hasher = Sha256::new();
21 hasher.update(path.to_string_lossy().as_bytes());
22 let digest = hasher.finalize();
23 hex::encode(digest)[..16].to_string()
24}
25
26#[derive(Debug, Clone, Default)]
29pub struct DiscoveryEnv {
30 pub caliban_daemon_runtime_dir: Option<PathBuf>,
32 pub xdg_runtime_dir: Option<PathBuf>,
34 pub tmpdir: Option<PathBuf>,
36}
37
38impl DiscoveryEnv {
39 pub fn from_process() -> Self {
41 Self {
42 caliban_daemon_runtime_dir: std::env::var_os("CALIBAN_DAEMON_RUNTIME_DIR")
43 .map(Into::into),
44 xdg_runtime_dir: std::env::var_os("XDG_RUNTIME_DIR").map(Into::into),
45 tmpdir: std::env::var_os("TMPDIR").map(Into::into),
46 }
47 }
48
49 fn socket_base_dir(&self) -> PathBuf {
51 if let Some(dir) = &self.caliban_daemon_runtime_dir {
52 dir.clone()
53 } else if let Some(xdg) = &self.xdg_runtime_dir {
54 xdg.join("caliban")
55 } else {
56 self.tmpdir
57 .clone()
58 .unwrap_or_else(|| PathBuf::from("/tmp"))
59 .join("caliban-daemon")
60 }
61 }
62}
63
64pub fn control_socket_path(workspace_root_canonical: &Path, env: &DiscoveryEnv) -> PathBuf {
66 env.socket_base_dir()
67 .join(format!("{}.sock", hash16(workspace_root_canonical)))
68}
69
70pub fn canonical_root(workspace_root: &Path) -> Result<PathBuf> {
78 workspace_root.canonicalize().map_err(|e| {
79 CoreError::Discovery(format!(
80 "cannot canonicalize {}: {e}",
81 workspace_root.display()
82 ))
83 })
84}
85
86pub fn resolve_socket(workspace_root: &Path, env: &DiscoveryEnv) -> Result<PathBuf> {
88 Ok(control_socket_path(&canonical_root(workspace_root)?, env))
89}
90
91#[derive(Debug, Clone)]
93pub struct EnsureConfig {
94 pub autostart: bool,
96 pub caliband_bin: String,
98 pub startup_timeout: Duration,
100 pub env: std::collections::BTreeMap<String, String>,
102}
103
104impl Default for EnsureConfig {
105 fn default() -> Self {
106 Self {
107 autostart: true,
108 caliband_bin: "caliband".to_string(),
109 startup_timeout: Duration::from_secs(10),
110 env: std::collections::BTreeMap::new(),
111 }
112 }
113}
114
115pub async fn ensure_caliband(
119 workspace_root: &Path,
120 env: &DiscoveryEnv,
121 cfg: &EnsureConfig,
122) -> Result<CalibandClient> {
123 let canonical = canonical_root(workspace_root)?;
127 let socket = control_socket_path(&canonical, env);
128
129 if UnixStream::connect(&socket).await.is_ok() {
130 return Ok(CalibandClient::new(socket));
131 }
132
133 if !cfg.autostart {
134 return Err(CoreError::Discovery(format!(
135 "no caliband reachable at {} and autostart is disabled",
136 socket.display()
137 )));
138 }
139
140 tokio::process::Command::new(&cfg.caliband_bin)
141 .arg("--workspace-root")
142 .arg(&canonical)
143 .envs(&cfg.env)
144 .spawn()
145 .map_err(|e| CoreError::Discovery(format!("failed to spawn {} : {e}", cfg.caliband_bin)))?;
146
147 let deadline = tokio::time::Instant::now() + cfg.startup_timeout;
149 loop {
150 if UnixStream::connect(&socket).await.is_ok() {
151 return Ok(CalibandClient::new(socket));
152 }
153 if tokio::time::Instant::now() >= deadline {
154 return Err(CoreError::Discovery(format!(
155 "caliband did not come up at {} within {:?}",
156 socket.display(),
157 cfg.startup_timeout
158 )));
159 }
160 tokio::time::sleep(Duration::from_millis(50)).await;
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn hash16_is_stable_and_16_chars() {
170 let h = hash16(Path::new("/home/u/dev/prospero"));
171 assert_eq!(h.len(), 16);
172 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
173 assert_eq!(h, hash16(Path::new("/home/u/dev/prospero")));
175 assert_ne!(h, hash16(Path::new("/home/u/dev/caliban")));
177 }
178
179 #[test]
180 fn runtime_dir_env_takes_priority() {
181 let env = DiscoveryEnv {
182 caliban_daemon_runtime_dir: Some("/run/cal".into()),
183 xdg_runtime_dir: Some("/run/user/1000".into()),
184 tmpdir: Some("/tmp".into()),
185 };
186 let p = control_socket_path(Path::new("/repo"), &env);
187 assert_eq!(
188 p,
189 PathBuf::from(format!("/run/cal/{}.sock", hash16(Path::new("/repo"))))
190 );
191 }
192
193 #[test]
194 fn xdg_runtime_dir_used_when_no_override() {
195 let env = DiscoveryEnv {
196 caliban_daemon_runtime_dir: None,
197 xdg_runtime_dir: Some("/run/user/1000".into()),
198 tmpdir: Some("/tmp".into()),
199 };
200 let p = control_socket_path(Path::new("/repo"), &env);
201 assert_eq!(
202 p,
203 PathBuf::from(format!(
204 "/run/user/1000/caliban/{}.sock",
205 hash16(Path::new("/repo"))
206 ))
207 );
208 }
209
210 #[test]
211 fn tmpdir_fallback_when_nothing_else() {
212 let env = DiscoveryEnv {
213 caliban_daemon_runtime_dir: None,
214 xdg_runtime_dir: None,
215 tmpdir: Some("/var/tmp".into()),
216 };
217 let p = control_socket_path(Path::new("/repo"), &env);
218 assert_eq!(
219 p,
220 PathBuf::from(format!(
221 "/var/tmp/caliban-daemon/{}.sock",
222 hash16(Path::new("/repo"))
223 ))
224 );
225 }
226
227 #[cfg(unix)]
228 #[test]
229 fn resolve_socket_canonicalizes_symlinked_roots() {
230 use std::os::unix::fs::symlink;
231 let real = tempfile::tempdir().unwrap();
235 let real_canon = real.path().canonicalize().unwrap();
236 let scratch = tempfile::tempdir().unwrap();
237 let link = scratch.path().join("link");
238 symlink(&real_canon, &link).unwrap();
239 assert_ne!(link, real_canon, "symlink path must differ from canonical");
240
241 let env = DiscoveryEnv {
242 tmpdir: Some("/var/tmp".into()),
243 ..Default::default()
244 };
245 assert_eq!(
246 resolve_socket(&link, &env).unwrap(),
247 resolve_socket(&real_canon, &env).unwrap(),
248 "a symlinked root and its canonical form must resolve to one socket"
249 );
250 }
251
252 #[cfg(unix)]
253 #[tokio::test]
254 async fn ensure_caliband_spawns_caliband_with_the_canonical_root() {
255 use std::os::unix::fs::{PermissionsExt, symlink};
256 let real = tempfile::tempdir().unwrap();
261 let real_canon = real.path().canonicalize().unwrap();
262 let scratch = tempfile::tempdir().unwrap();
263 let link = scratch.path().join("link");
264 symlink(&real_canon, &link).unwrap();
265 assert_ne!(link, real_canon);
266
267 let recorded = scratch.path().join("recorded-root");
270 let script = scratch.path().join("fake-caliband.sh");
271 std::fs::write(
272 &script,
273 format!("#!/bin/sh\nprintf '%s' \"$2\" > '{}'\n", recorded.display()),
274 )
275 .unwrap();
276 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
277
278 let cfg = EnsureConfig {
279 autostart: true,
280 caliband_bin: script.to_string_lossy().into_owned(),
281 startup_timeout: Duration::from_millis(300),
282 env: std::collections::BTreeMap::new(),
283 };
284 let env = DiscoveryEnv {
285 tmpdir: Some(scratch.path().to_path_buf()),
286 ..Default::default()
287 };
288
289 let _ = ensure_caliband(&link, &env, &cfg).await;
292
293 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
298 let got = loop {
299 match std::fs::read_to_string(&recorded) {
300 Ok(contents) if !contents.is_empty() => break contents,
303 _ if tokio::time::Instant::now() >= deadline => panic!(
304 "fake caliband never recorded its root at {}",
305 recorded.display()
306 ),
307 _ => tokio::time::sleep(Duration::from_millis(10)).await,
308 }
309 };
310 assert_eq!(
311 got,
312 real_canon.to_string_lossy(),
313 "caliband must be spawned with the canonical root, not the symlinked path"
314 );
315 }
316}