1use std::fs;
2use std::fs::OpenOptions;
3use std::hash::{Hash, Hasher};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::DaemonWorkspaceConfig;
10
11#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13pub struct DaemonRegistryEntry {
14 pub workspace_root: String,
15 pub workspace_roots: Vec<String>,
16 pub project: Option<String>,
17 pub cache_dir: Option<String>,
18 pub live_refresh: Option<String>,
19 pub endpoint: String,
20 pub token: String,
21 pub pid: u32,
22 #[serde(default)]
23 pub state: DaemonRegistryState,
24}
25
26#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[serde(rename_all = "snake_case")]
29pub enum DaemonRegistryState {
30 Indexing,
31 #[default]
32 Ready,
33}
34
35pub fn registry_dir() -> PathBuf {
36 std::env::temp_dir().join("code-moniker-daemons")
37}
38
39pub fn canonical_workspace_root(root: impl AsRef<Path>) -> anyhow::Result<PathBuf> {
40 let root = root.as_ref();
41 root.canonicalize()
42 .map_err(|err| anyhow::anyhow!("cannot canonicalize {}: {err}", root.display()))
43}
44
45pub fn canonical_workspace_roots<I, P>(roots: I) -> anyhow::Result<Vec<PathBuf>>
46where
47 I: IntoIterator<Item = P>,
48 P: AsRef<Path>,
49{
50 let mut canonical = Vec::new();
51 for root in roots {
52 let root = canonical_workspace_root(root)?;
53 if !canonical.contains(&root) {
54 canonical.push(root);
55 }
56 }
57 if canonical.is_empty() {
58 canonical.push(canonical_workspace_root(".")?);
59 }
60 Ok(canonical)
61}
62
63pub fn daemon_workspace_config<I, P>(
64 roots: I,
65 project: Option<String>,
66 cache_dir: Option<PathBuf>,
67 live_refresh: Option<String>,
68) -> anyhow::Result<DaemonWorkspaceConfig>
69where
70 I: IntoIterator<Item = P>,
71 P: AsRef<Path>,
72{
73 Ok(DaemonWorkspaceConfig {
74 roots: canonical_workspace_roots(roots)?
75 .into_iter()
76 .map(|root| root.display().to_string())
77 .collect(),
78 project,
79 cache_dir: cache_dir
80 .map(normalize_path)
81 .transpose()?
82 .map(|path| path.display().to_string()),
83 live_refresh,
84 })
85}
86
87pub fn canonical_workspace_config(
88 config: DaemonWorkspaceConfig,
89) -> anyhow::Result<DaemonWorkspaceConfig> {
90 daemon_workspace_config(
91 config.roots.iter().map(PathBuf::from),
92 config.project,
93 config.cache_dir.map(PathBuf::from),
94 config.live_refresh,
95 )
96}
97
98pub fn config_from_roots<I, P>(roots: I) -> anyhow::Result<DaemonWorkspaceConfig>
99where
100 I: IntoIterator<Item = P>,
101 P: AsRef<Path>,
102{
103 daemon_workspace_config(roots, None, None, Some("on-demand".to_string()))
104}
105
106pub fn registry_path_for_root(root: impl AsRef<Path>) -> anyhow::Result<PathBuf> {
107 registry_path_for_roots([root])
108}
109
110pub fn registry_path_for_roots<I, P>(roots: I) -> anyhow::Result<PathBuf>
111where
112 I: IntoIterator<Item = P>,
113 P: AsRef<Path>,
114{
115 registry_path_for_config(&config_from_roots(roots)?)
116}
117
118pub fn registry_path_for_config(config: &DaemonWorkspaceConfig) -> anyhow::Result<PathBuf> {
119 let config = canonical_workspace_config(config.clone())?;
120 Ok(registry_dir().join(format!("{}.json", stable_config_hash(&config))))
121}
122
123pub fn read_registry_entry(
124 config: &DaemonWorkspaceConfig,
125) -> anyhow::Result<Option<DaemonRegistryEntry>> {
126 let path = registry_path_for_config(config)?;
127 match fs::read_to_string(&path) {
128 Ok(text) => Ok(serde_json::from_str(&text).ok()),
129 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
130 Err(err) => Err(err.into()),
131 }
132}
133
134pub fn write_registry_entry(
135 config: &DaemonWorkspaceConfig,
136 entry: &DaemonRegistryEntry,
137) -> anyhow::Result<()> {
138 fs::create_dir_all(registry_dir())?;
139 atomic_write_registry_entry(®istry_path_for_config(config)?, entry)?;
140 Ok(())
141}
142
143pub fn claim_registry_entry(
144 config: &DaemonWorkspaceConfig,
145 entry: &DaemonRegistryEntry,
146) -> anyhow::Result<bool> {
147 fs::create_dir_all(registry_dir())?;
148 let path = registry_path_for_config(config)?;
149 let text = serde_json::to_vec_pretty(entry)?;
150 match OpenOptions::new().write(true).create_new(true).open(path) {
151 Ok(mut file) => {
152 file.write_all(&text)?;
153 file.sync_all()?;
154 Ok(true)
155 }
156 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
157 Err(error) => Err(error.into()),
158 }
159}
160
161pub fn update_registry_entry_if_own(
162 config: &DaemonWorkspaceConfig,
163 entry: &DaemonRegistryEntry,
164) -> anyhow::Result<bool> {
165 let path = registry_path_for_config(config)?;
166 let current = fs::read_to_string(&path)
167 .ok()
168 .and_then(|text| serde_json::from_str::<DaemonRegistryEntry>(&text).ok());
169 let owned = current
170 .map(|current| current.token == entry.token && current.pid == entry.pid)
171 .unwrap_or(false);
172 if owned {
173 atomic_write_registry_entry(&path, entry)?;
174 }
175 Ok(owned)
176}
177
178fn atomic_write_registry_entry(path: &Path, entry: &DaemonRegistryEntry) -> anyhow::Result<()> {
179 let temp = path.with_extension(format!("{}.tmp", entry.token));
180 let text = serde_json::to_vec_pretty(entry)?;
181 {
182 let mut file = OpenOptions::new()
183 .write(true)
184 .create_new(true)
185 .open(&temp)?;
186 file.write_all(&text)?;
187 file.sync_all()?;
188 }
189 fs::rename(temp, path)?;
190 Ok(())
191}
192
193pub fn remove_registry_entry_if_own(path: &Path, own: &DaemonRegistryEntry) {
197 let current = fs::read_to_string(path)
198 .ok()
199 .and_then(|text| serde_json::from_str::<DaemonRegistryEntry>(&text).ok());
200 let owned = current
201 .map(|entry| entry.token == own.token && entry.pid == own.pid)
202 .unwrap_or(false);
203 if owned {
204 let _ = fs::remove_file(path);
205 }
206}
207
208pub fn list_registry_files() -> anyhow::Result<Vec<(PathBuf, DaemonRegistryEntry)>> {
209 let dir = registry_dir();
210 if !dir.exists() {
211 return Ok(Vec::new());
212 }
213 let mut entries = Vec::new();
214 for entry in fs::read_dir(&dir)? {
215 let entry = entry?;
216 if entry.path().extension().and_then(|ext| ext.to_str()) != Some("json") {
217 continue;
218 }
219 let text = fs::read_to_string(entry.path())?;
220 if let Ok(registry) = serde_json::from_str::<DaemonRegistryEntry>(&text) {
221 entries.push((entry.path(), registry));
222 }
223 }
224 entries.sort_by(|(_, a), (_, b)| a.workspace_root.cmp(&b.workspace_root));
225 Ok(entries)
226}
227
228pub fn pid_is_alive(pid: u32) -> bool {
229 #[cfg(unix)]
230 {
231 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
234 let errno = (result != 0)
235 .then(|| std::io::Error::last_os_error().raw_os_error())
236 .flatten();
237 kill_result_means_alive(result, errno)
238 }
239 #[cfg(not(unix))]
240 {
241 let _ = pid;
242 true
243 }
244}
245
246#[cfg(unix)]
247fn kill_result_means_alive(result: i32, errno: Option<i32>) -> bool {
248 result == 0 || errno != Some(libc::ESRCH)
249}
250
251pub fn list_registry_entries() -> anyhow::Result<Vec<DaemonRegistryEntry>> {
252 let mut entries = Vec::new();
253 for (path, entry) in list_registry_files()? {
254 if pid_is_alive(entry.pid) {
255 entries.push(entry);
256 } else {
257 remove_registry_entry_if_own(&path, &entry);
258 }
259 }
260 entries.sort_by(|a, b| a.workspace_root.cmp(&b.workspace_root));
261 Ok(entries)
262}
263
264pub fn config_roots(config: &DaemonWorkspaceConfig) -> Vec<PathBuf> {
265 config.roots.iter().map(PathBuf::from).collect()
266}
267
268pub fn workspace_label(roots: &[PathBuf]) -> String {
269 if roots.len() == 1 {
270 roots[0].display().to_string()
271 } else {
272 roots
273 .iter()
274 .map(|root| root.display().to_string())
275 .collect::<Vec<_>>()
276 .join(";")
277 }
278}
279
280fn normalize_path(path: PathBuf) -> anyhow::Result<PathBuf> {
281 if path.is_absolute() {
282 Ok(path)
283 } else {
284 Ok(std::env::current_dir()?.join(path))
285 }
286}
287
288fn stable_config_hash(config: &DaemonWorkspaceConfig) -> String {
293 let mut hasher = StableHasher::default();
294 for root in &config.roots {
295 root.hash(&mut hasher);
296 0xff_u8.hash(&mut hasher);
297 }
298 config.project.hash(&mut hasher);
299 0xfe_u8.hash(&mut hasher);
300 config.cache_dir.hash(&mut hasher);
301 format!("{:016x}", hasher.finish())
302}
303
304#[derive(Default)]
305struct StableHasher(u64);
306
307impl Hasher for StableHasher {
308 fn finish(&self) -> u64 {
309 self.0
310 }
311
312 fn write(&mut self, bytes: &[u8]) {
313 let mut hash = if self.0 == 0 {
314 0xcbf29ce484222325
315 } else {
316 self.0
317 };
318 for byte in bytes {
319 hash ^= u64::from(*byte);
320 hash = hash.wrapping_mul(0x100000001b3);
321 }
322 self.0 = hash;
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn entry(token: &str, pid: u32) -> DaemonRegistryEntry {
331 DaemonRegistryEntry {
332 workspace_root: "/tmp/ws".to_string(),
333 workspace_roots: vec!["/tmp/ws".to_string()],
334 project: None,
335 cache_dir: None,
336 live_refresh: None,
337 endpoint: "127.0.0.1:1".to_string(),
338 token: token.to_string(),
339 pid,
340 state: DaemonRegistryState::Ready,
341 }
342 }
343
344 #[test]
345 fn registry_identity_ignores_the_refresh_mode() {
346 let base = DaemonWorkspaceConfig {
347 roots: vec!["/tmp/ws".to_string()],
348 project: None,
349 cache_dir: None,
350 live_refresh: Some("auto".to_string()),
351 };
352 let mut on_demand = base.clone();
353 on_demand.live_refresh = Some("on-demand".to_string());
354 assert_eq!(
355 stable_config_hash(&base),
356 stable_config_hash(&on_demand),
357 "one workspace must map to one registry slot, whatever the refresh mode"
358 );
359
360 let mut other_project = base.clone();
361 other_project.project = Some("api".to_string());
362 assert_ne!(
363 stable_config_hash(&base),
364 stable_config_hash(&other_project),
365 "what gets indexed still separates registry slots"
366 );
367 }
368
369 #[test]
370 fn shutdown_removal_spares_a_successor_entry() {
371 let dir = tempfile::tempdir().expect("tempdir");
372 let path = dir.path().join("ws.json");
373 let old = entry("old-token", 111);
374 let new = entry("new-token", 222);
375
376 fs::write(&path, serde_json::to_string(&new).expect("json")).expect("write");
377 remove_registry_entry_if_own(&path, &old);
378 assert!(path.exists(), "the successor's entry must survive");
379
380 remove_registry_entry_if_own(&path, &new);
381 assert!(!path.exists(), "the owner removes its own entry");
382
383 remove_registry_entry_if_own(&path, &new);
384 }
385
386 #[test]
387 fn legacy_registry_entries_default_to_ready() {
388 let mut value = serde_json::to_value(entry("legacy", 111)).expect("json");
389 value.as_object_mut().expect("object").remove("state");
390 let decoded: DaemonRegistryEntry = serde_json::from_value(value).expect("legacy entry");
391 assert_eq!(decoded.state, DaemonRegistryState::Ready);
392 }
393
394 #[test]
395 fn atomic_registry_update_replaces_a_complete_entry() {
396 let dir = tempfile::tempdir().expect("tempdir");
397 let path = dir.path().join("workspace.json");
398 let indexing = DaemonRegistryEntry {
399 state: DaemonRegistryState::Indexing,
400 ..entry("same-daemon", 111)
401 };
402 atomic_write_registry_entry(&path, &indexing).expect("write indexing entry");
403 let ready = DaemonRegistryEntry {
404 state: DaemonRegistryState::Ready,
405 ..indexing.clone()
406 };
407 atomic_write_registry_entry(&path, &ready).expect("write ready entry");
408 let read: DaemonRegistryEntry =
409 serde_json::from_str(&fs::read_to_string(path).expect("read entry")).expect("json");
410 assert_eq!(read, ready);
411 }
412
413 #[cfg(unix)]
414 #[test]
415 fn permission_denied_pid_is_alive_but_missing_pid_is_dead() {
416 assert!(kill_result_means_alive(-1, Some(libc::EPERM)));
417 assert!(!kill_result_means_alive(-1, Some(libc::ESRCH)));
418 assert!(kill_result_means_alive(0, None));
419 }
420}