1use crate::Result;
3use crate::env;
4use crate::pitchfork_toml::{NamespaceEntry, NamespaceEntryRaw, PitchforkToml, current_meta};
5use indexmap::IndexMap;
6use miette::IntoDiagnostic;
7use once_cell::sync::Lazy;
8use serde::{Deserialize, Serialize};
9use std::path::{Component, Path, PathBuf};
10use std::sync::Mutex;
11use std::time::SystemTime;
12
13#[derive(Clone, Debug, Serialize)]
14pub struct Entry {
15 pub namespace: String,
16 pub dir: PathBuf,
17 pub config: Vec<PathBuf>,
18 pub label: Option<String>,
20 pub source: &'static str,
21}
22
23#[derive(Default, Deserialize)]
24struct Registrations {
25 #[serde(default)]
26 namespaces: IndexMap<String, NamespaceEntryRaw>,
27}
28
29#[derive(Default)]
30struct Cache {
31 initialized: bool,
32 meta: Option<(SystemTime, u64)>,
33 entries: Vec<Entry>,
34 label_index: Option<(
37 std::time::Instant,
38 std::collections::HashMap<PathBuf, String>,
39 )>,
40}
41static CACHE: Lazy<Mutex<Cache>> = Lazy::new(|| Mutex::new(Cache::default()));
42
43pub fn normalize(path: &Path) -> PathBuf {
45 if let Ok(path) = path.canonicalize() {
46 return dunce::simplified(&path).to_path_buf();
47 }
48 let absolute = if path.is_absolute() {
49 path.to_path_buf()
50 } else {
51 env::CWD.join(path)
52 };
53 if let (Some(parent), Some(name)) = (absolute.parent(), absolute.file_name())
54 && let Ok(parent) = parent.canonicalize()
55 {
56 return dunce::simplified(&parent).join(name);
57 }
58 let mut result = PathBuf::new();
59 for part in absolute.components() {
60 match part {
61 Component::CurDir => {}
62 Component::ParentDir => {
63 result.pop();
64 }
65 part => result.push(part.as_os_str()),
66 }
67 }
68 result
69}
70
71pub fn path_is_gone(path: &Path) -> bool {
74 matches!(std::fs::exists(path), Ok(false))
75}
76
77pub fn namespace_has_running_daemon(namespace: &str) -> bool {
81 crate::state_file::StateFile::read(&*env::PITCHFORK_STATE_FILE)
82 .map(|state| {
83 state
84 .daemons
85 .iter()
86 .any(|(id, d)| d.pid.is_some() && id.namespace() == namespace)
87 })
88 .unwrap_or(true)
90}
91
92pub fn resolve_path(dir: &Path, path: &str) -> PathBuf {
93 let path = env::expand_tilde(path);
94 normalize(&if path.is_absolute() {
95 path
96 } else {
97 dir.join(path)
98 })
99}
100
101fn parse_entries(content: &str) -> Result<Vec<Entry>> {
102 let raw: Registrations = toml::from_str(content).into_diagnostic()?;
103 Ok(raw
104 .namespaces
105 .into_iter()
106 .filter(|(_, e)| !e.config.is_empty())
107 .map(|(namespace, entry)| {
108 let dir = normalize(&env::expand_tilde(entry.dir));
109 let config = entry.config.iter().map(|p| resolve_path(&dir, p)).collect();
110 Entry {
111 namespace,
112 dir,
113 config,
114 label: entry.label,
115 source: "registry",
116 }
117 })
118 .collect())
119}
120
121pub fn entries() -> Vec<Entry> {
122 let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
123 let meta = current_meta(&env::PITCHFORK_GLOBAL_CONFIG_USER);
124 if !cache.initialized || cache.meta != meta {
125 cache.entries = if meta.is_some() {
126 std::fs::read_to_string(&*env::PITCHFORK_GLOBAL_CONFIG_USER)
127 .into_diagnostic()
128 .and_then(|s| parse_entries(&s))
129 .unwrap_or_else(|e| {
130 warn!("cannot read external configuration registry: {e}");
131 Vec::new()
132 })
133 } else {
134 Vec::new()
135 };
136 cache.meta = meta;
137 cache.initialized = true;
138 cache.label_index = None;
139 }
140 let mut entries = cache.entries.clone();
141 drop(cache);
142 if let Some(value) = std::env::var_os("PITCHFORK_CONFIG") {
143 let cwd = normalize(&env::CWD);
144 let dir = xx::file::find_up_all(
145 &cwd,
146 &[
147 "pitchfork.local.toml",
148 "pitchfork.toml",
149 ".config/pitchfork.local.toml",
150 ".config/pitchfork.toml",
151 ],
152 )
153 .into_iter()
154 .next()
155 .and_then(|path| {
156 let parent = path.parent()?;
157 Some(
158 if parent.file_name().is_some_and(|name| name == ".config") {
159 parent.parent()?.to_path_buf()
160 } else {
161 parent.to_path_buf()
162 },
163 )
164 })
165 .unwrap_or_else(|| cwd.clone());
166 let config = std::env::split_paths(&value)
167 .filter(|p| !p.as_os_str().is_empty())
168 .map(|p| resolve_path(&cwd, &p.to_string_lossy()))
169 .collect();
170 entries.push(Entry {
172 namespace: String::new(),
173 dir,
174 config,
175 label: None,
176 source: "env",
177 });
178 }
179 entries
180}
181
182pub fn invalidate() {
183 *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Cache::default();
184}
185
186pub fn paths_for(cwd: &Path) -> Vec<PathBuf> {
187 let cwd = normalize(cwd);
188 let mut entries: Vec<_> = entries()
189 .into_iter()
190 .filter(|e| cwd.starts_with(&e.dir))
191 .collect();
192 entries.sort_by_key(|e| (e.source == "env", e.dir.components().count()));
193 let mut paths = Vec::new();
194 for entry in entries {
195 for path in entry.config {
196 paths.retain(|p| p != &path);
198 paths.push(path);
199 }
200 }
201 paths
202}
203
204pub fn configs_for_dir(dir: &Path) -> Vec<PathBuf> {
209 let dir = normalize(dir);
210 entries()
211 .into_iter()
212 .filter(|e| e.dir == dir)
213 .flat_map(|e| e.config)
214 .collect()
215}
216
217pub fn project_dir(path: &Path) -> Option<PathBuf> {
218 let path = normalize(path);
219 entries()
220 .into_iter()
221 .rev()
222 .find(|e| e.config.contains(&path))
223 .map(|e| e.dir)
224}
225
226pub fn namespace_for_dir(dir: &Path) -> Option<String> {
227 let dir = normalize(dir);
228 entries()
229 .into_iter()
230 .find(|e| e.source == "registry" && e.dir == dir)
231 .map(|e| e.namespace)
232}
233
234const LABEL_INDEX_TTL: std::time::Duration = std::time::Duration::from_secs(2);
238
239pub fn label_for_checkout(primary: &Path) -> Option<String> {
251 drop(entries());
253 let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
254 let Cache {
255 entries,
256 label_index,
257 ..
258 } = &mut *cache;
259 if label_index
262 .as_ref()
263 .is_none_or(|(built, _)| built.elapsed() > LABEL_INDEX_TTL)
264 {
265 *label_index = Some((
266 std::time::Instant::now(),
267 label_index_of(entries, crate::proxy::hostname::detect_checkout),
268 ));
269 }
270 label_index
271 .as_ref()
272 .and_then(|(_, index)| index.get(&normalize(primary)).cloned())
273}
274
275fn label_index_of(
281 entries: &[Entry],
282 checkout_of: impl Fn(&Path) -> crate::proxy::hostname::Checkout,
283) -> std::collections::HashMap<PathBuf, String> {
284 let mut chosen: std::collections::HashMap<PathBuf, (bool, &Path, &str)> = Default::default();
285 for entry in entries {
286 let Some(label) = entry
287 .label
288 .as_deref()
289 .filter(|_| entry.source == "registry")
290 else {
291 continue;
292 };
293 let checkout = checkout_of(&entry.dir);
294 if checkout.root() != entry.dir {
297 continue;
298 }
299 let primary = checkout.primary.clone();
300 let candidate = (checkout.worktree.is_some(), entry.dir.as_path(), label);
301 match chosen.get(&primary) {
302 Some(best) if (best.0, best.1) <= (candidate.0, candidate.1) => {}
303 _ => {
304 chosen.insert(primary, candidate);
305 }
306 }
307 }
308 chosen
309 .into_iter()
310 .map(|(primary, (_, _, label))| (primary, label.to_string()))
311 .collect()
312}
313
314pub fn add(namespace: &str, dir: &Path, file: &Path, label: Option<&str>) -> Result<bool> {
319 let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
320 if let Some(parent) = path.parent() {
321 std::fs::create_dir_all(parent).into_diagnostic()?;
322 }
323 let _lock = xx::fslock::get(path, false).into_diagnostic()?;
324 let mut pt = if path.exists() {
325 PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
326 } else {
327 PitchforkToml::new(path.clone())
328 };
329 let dir = normalize(dir);
330 let file = normalize(file);
331 let mut replaced_stale = false;
334 if let Some(old) = pt.namespaces.get(namespace)
335 && normalize(&old.dir) != dir
336 && path_is_gone(&old.dir)
337 && !namespace_has_running_daemon(namespace)
338 {
339 pt.namespaces.shift_remove(namespace);
340 replaced_stale = true;
341 }
342 for (name, entry) in &pt.namespaces {
343 if (name == namespace && normalize(&entry.dir) != dir)
344 || (name != namespace
345 && (entry.config.contains(&file)
346 || (!entry.config.is_empty() && normalize(&entry.dir) == dir)))
347 {
348 miette::bail!(
349 "external configuration conflicts with namespace '{name}' ({})",
350 entry.dir.display()
351 );
352 }
353 }
354 let entry = pt
355 .namespaces
356 .entry(namespace.to_string())
357 .or_insert_with(|| crate::pitchfork_toml::NamespaceEntry {
358 dir,
359 config: Vec::new(),
360 label: None,
361 });
362 let mut changed = replaced_stale;
363 if let Some(label) = label
364 && entry.label.as_deref() != Some(label)
365 {
366 entry.label = Some(label.to_string());
367 changed = true;
368 }
369 if !entry.config.contains(&file) {
370 entry.config.push(file);
371 changed = true;
372 }
373 if changed {
374 pt.write_unlocked()?;
375 }
376 Ok(changed)
377}
378
379fn detach_file(entry: &mut NamespaceEntry, file: &Path) -> bool {
386 let before = entry.config.len();
387 entry.config.retain(|p| p != file);
388 let detached = before != entry.config.len();
389 if detached && entry.config.is_empty() {
390 entry.label = None;
391 }
392 detached
393}
394
395pub fn remove(file: &Path) -> Result<Option<String>> {
396 let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
397 if !path.exists() {
398 return Ok(None);
399 }
400 let _lock = xx::fslock::get(path, false).into_diagnostic()?;
401 let mut pt = if path.exists() {
402 PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
403 } else {
404 PitchforkToml::new(path.clone())
405 };
406 let file = normalize(file);
407 let mut removed = None;
408 for (name, entry) in &mut pt.namespaces {
409 if detach_file(entry, &file) {
410 removed = Some(name.clone());
411 }
412 }
413 if removed.is_some() {
414 pt.write_unlocked()?;
415 }
416 Ok(removed)
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 #[test]
423 fn registry_paths_are_relative_to_project_and_old_entries_are_compatible() {
424 let tmp = tempfile::tempdir().unwrap();
425 let root = normalize(tmp.path());
426 let project = root.join("project");
427 let external = root.join("state/app.toml");
428 let project_str = project.to_string_lossy().into_owned();
429 let external_str = external.to_string_lossy().into_owned();
430 let doc = toml::toml! {
431 [namespaces.old]
432 dir = "/old"
433 [namespaces.app]
434 dir = (project_str)
435 config = ["generated.toml", (external_str)]
436 };
437 let entries = parse_entries(&toml::to_string(&doc).unwrap()).unwrap();
438 assert_eq!(entries.len(), 1);
439 assert_eq!(
440 entries[0].config,
441 vec![
442 normalize(&project.join("generated.toml")),
443 normalize(&external)
444 ]
445 );
446 let raw = NamespaceEntryRaw {
447 dir: "/old".into(),
448 config: vec![],
449 label: None,
450 };
451 let text = toml::to_string(&raw).unwrap();
452 assert!(!text.contains("config"));
453 assert!(!text.contains("label"));
454 assert_eq!(entries[0].label, None);
455 }
456
457 #[test]
460 fn detach_file_drops_label_with_last_attachment() {
461 let a = PathBuf::from("/gen/a.toml");
462 let b = PathBuf::from("/gen/b.toml");
463 let mut entry = NamespaceEntry {
464 dir: PathBuf::from("/shop"),
465 config: vec![a.clone(), b.clone()],
466 label: Some("shop".into()),
467 };
468 assert!(!detach_file(&mut entry, Path::new("/gen/none.toml")));
469 assert_eq!(entry.label.as_deref(), Some("shop"));
470 assert!(detach_file(&mut entry, &a));
471 assert_eq!(entry.config, vec![b.clone()]);
472 assert_eq!(entry.label.as_deref(), Some("shop"));
473 assert!(detach_file(&mut entry, &b));
474 assert!(entry.config.is_empty());
475 assert_eq!(entry.label, None);
476 assert!(!detach_file(&mut entry, &b));
478 }
479
480 #[test]
481 fn registry_label_parses_and_round_trips() {
482 let doc = "[namespaces.shop-528f92b13a6784f0]\ndir = \"/shop\"\n\
483 config = [\"/gen/pitchfork.toml\"]\nlabel = \"shop\"\n";
484 let entries = parse_entries(doc).unwrap();
485 assert_eq!(entries.len(), 1);
486 assert_eq!(entries[0].namespace, "shop-528f92b13a6784f0");
487 assert_eq!(entries[0].label.as_deref(), Some("shop"));
488 let raw: Registrations = toml::from_str(doc).unwrap();
489 let again = toml::to_string(&raw.namespaces["shop-528f92b13a6784f0"]).unwrap();
490 assert!(again.contains("label = \"shop\""), "{again}");
491 }
492
493 fn labelled(dir: &Path, label: Option<&str>) -> Entry {
494 Entry {
495 namespace: format!("ns-{}", dir.display()),
496 dir: normalize(dir),
497 config: vec![],
498 label: label.map(String::from),
499 source: "registry",
500 }
501 }
502
503 fn repo_with_worktrees(names: &[&str]) -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
506 let temp = tempfile::tempdir().unwrap();
507 let repo = temp.path().join("shop");
508 std::fs::create_dir_all(repo.join(".git")).unwrap();
509 let worktrees = names
510 .iter()
511 .map(|name| {
512 let admin = repo.join(".git/worktrees").join(name);
513 std::fs::create_dir_all(&admin).unwrap();
514 let wt = temp.path().join(name);
515 std::fs::create_dir_all(&wt).unwrap();
516 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
517 wt
518 })
519 .collect();
520 (temp, repo, worktrees)
521 }
522
523 fn pick(entries: &[Entry], primary: &Path) -> Option<String> {
524 label_index_of(entries, crate::proxy::hostname::detect_checkout)
525 .get(&normalize(primary))
526 .cloned()
527 }
528
529 #[test]
530 fn label_registered_from_a_linked_worktree_names_the_project() {
531 let (temp, repo, worktrees) = repo_with_worktrees(&["feature"]);
532 let entries = vec![labelled(&worktrees[0], Some("shop-web"))];
533 assert_eq!(pick(&entries, &repo).as_deref(), Some("shop-web"));
534 let other = temp.path().join("other");
536 std::fs::create_dir_all(other.join(".git")).unwrap();
537 assert_eq!(pick(&entries, &other), None);
538 }
539
540 #[test]
541 fn primary_checkout_label_beats_a_worktree_label() {
542 let (_temp, repo, worktrees) = repo_with_worktrees(&["feature"]);
543 let entries = vec![
544 labelled(&worktrees[0], Some("from-worktree")),
545 labelled(&repo, Some("from-primary")),
546 ];
547 assert_eq!(pick(&entries, &repo).as_deref(), Some("from-primary"));
548 }
549
550 #[test]
551 fn worktree_labels_are_chosen_independently_of_registry_order() {
552 let (_temp, repo, worktrees) = repo_with_worktrees(&["a", "b"]);
553 let a = labelled(&worktrees[0], Some("label-a"));
554 let b = labelled(&worktrees[1], Some("label-b"));
555 for entries in [vec![a.clone(), b.clone()], vec![b, a]] {
556 assert_eq!(pick(&entries, &repo).as_deref(), Some("label-a"));
557 }
558 }
559
560 #[test]
561 fn a_label_registered_for_a_subdirectory_does_not_name_the_repository() {
562 let (_temp, repo, worktrees) = repo_with_worktrees(&["feature"]);
563 let package = repo.join("packages/web");
564 let wt_package = worktrees[0].join("packages/web");
565 std::fs::create_dir_all(&package).unwrap();
566 std::fs::create_dir_all(&wt_package).unwrap();
567 let entries = vec![
568 labelled(&package, Some("web-package")),
569 labelled(&wt_package, Some("web-package-wt")),
570 ];
571 assert_eq!(pick(&entries, &repo), None);
572 }
573
574 #[test]
575 fn unlabelled_registrations_name_nothing() {
576 let (_temp, repo, worktrees) = repo_with_worktrees(&["feature"]);
577 let entries = vec![labelled(&worktrees[0], None)];
578 assert_eq!(pick(&entries, &repo), None);
579 }
580}