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 resolve_path(dir: &Path, path: &str) -> PathBuf {
72 let path = env::expand_tilde(path);
73 normalize(&if path.is_absolute() {
74 path
75 } else {
76 dir.join(path)
77 })
78}
79
80fn parse_entries(content: &str) -> Result<Vec<Entry>> {
81 let raw: Registrations = toml::from_str(content).into_diagnostic()?;
82 Ok(raw
83 .namespaces
84 .into_iter()
85 .filter(|(_, e)| !e.config.is_empty())
86 .map(|(namespace, entry)| {
87 let dir = normalize(&env::expand_tilde(entry.dir));
88 let config = entry.config.iter().map(|p| resolve_path(&dir, p)).collect();
89 Entry {
90 namespace,
91 dir,
92 config,
93 label: entry.label,
94 source: "registry",
95 }
96 })
97 .collect())
98}
99
100pub fn entries() -> Vec<Entry> {
101 let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
102 let meta = current_meta(&env::PITCHFORK_GLOBAL_CONFIG_USER);
103 if !cache.initialized || cache.meta != meta {
104 cache.entries = if meta.is_some() {
105 std::fs::read_to_string(&*env::PITCHFORK_GLOBAL_CONFIG_USER)
106 .into_diagnostic()
107 .and_then(|s| parse_entries(&s))
108 .unwrap_or_else(|e| {
109 warn!("cannot read external configuration registry: {e}");
110 Vec::new()
111 })
112 } else {
113 Vec::new()
114 };
115 cache.meta = meta;
116 cache.initialized = true;
117 cache.label_index = None;
118 }
119 let mut entries = cache.entries.clone();
120 drop(cache);
121 if let Some(value) = std::env::var_os("PITCHFORK_CONFIG") {
122 let cwd = normalize(&env::CWD);
123 let dir = xx::file::find_up_all(
124 &cwd,
125 &[
126 "pitchfork.local.toml",
127 "pitchfork.toml",
128 ".config/pitchfork.local.toml",
129 ".config/pitchfork.toml",
130 ],
131 )
132 .into_iter()
133 .next()
134 .and_then(|path| {
135 let parent = path.parent()?;
136 Some(
137 if parent.file_name().is_some_and(|name| name == ".config") {
138 parent.parent()?.to_path_buf()
139 } else {
140 parent.to_path_buf()
141 },
142 )
143 })
144 .unwrap_or_else(|| cwd.clone());
145 let config = std::env::split_paths(&value)
146 .filter(|p| !p.as_os_str().is_empty())
147 .map(|p| resolve_path(&cwd, &p.to_string_lossy()))
148 .collect();
149 entries.push(Entry {
151 namespace: String::new(),
152 dir,
153 config,
154 label: None,
155 source: "env",
156 });
157 }
158 entries
159}
160
161pub fn invalidate() {
162 *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Cache::default();
163}
164
165pub fn paths_for(cwd: &Path) -> Vec<PathBuf> {
166 let cwd = normalize(cwd);
167 let mut entries: Vec<_> = entries()
168 .into_iter()
169 .filter(|e| cwd.starts_with(&e.dir))
170 .collect();
171 entries.sort_by_key(|e| (e.source == "env", e.dir.components().count()));
172 let mut paths = Vec::new();
173 for entry in entries {
174 for path in entry.config {
175 paths.retain(|p| p != &path);
177 paths.push(path);
178 }
179 }
180 paths
181}
182
183pub fn configs_for_dir(dir: &Path) -> Vec<PathBuf> {
188 let dir = normalize(dir);
189 entries()
190 .into_iter()
191 .filter(|e| e.dir == dir)
192 .flat_map(|e| e.config)
193 .collect()
194}
195
196pub fn project_dir(path: &Path) -> Option<PathBuf> {
197 let path = normalize(path);
198 entries()
199 .into_iter()
200 .rev()
201 .find(|e| e.config.contains(&path))
202 .map(|e| e.dir)
203}
204
205pub fn namespace_for_dir(dir: &Path) -> Option<String> {
206 let dir = normalize(dir);
207 entries()
208 .into_iter()
209 .find(|e| e.source == "registry" && e.dir == dir)
210 .map(|e| e.namespace)
211}
212
213const LABEL_INDEX_TTL: std::time::Duration = std::time::Duration::from_secs(2);
217
218pub fn label_for_checkout(primary: &Path) -> Option<String> {
230 drop(entries());
232 let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
233 let Cache {
234 entries,
235 label_index,
236 ..
237 } = &mut *cache;
238 if label_index
241 .as_ref()
242 .is_none_or(|(built, _)| built.elapsed() > LABEL_INDEX_TTL)
243 {
244 *label_index = Some((
245 std::time::Instant::now(),
246 label_index_of(entries, crate::proxy::hostname::detect_checkout),
247 ));
248 }
249 label_index
250 .as_ref()
251 .and_then(|(_, index)| index.get(&normalize(primary)).cloned())
252}
253
254fn label_index_of(
260 entries: &[Entry],
261 checkout_of: impl Fn(&Path) -> crate::proxy::hostname::Checkout,
262) -> std::collections::HashMap<PathBuf, String> {
263 let mut chosen: std::collections::HashMap<PathBuf, (bool, &Path, &str)> = Default::default();
264 for entry in entries {
265 let Some(label) = entry
266 .label
267 .as_deref()
268 .filter(|_| entry.source == "registry")
269 else {
270 continue;
271 };
272 let checkout = checkout_of(&entry.dir);
273 if checkout.root() != entry.dir {
276 continue;
277 }
278 let primary = checkout.primary.clone();
279 let candidate = (checkout.worktree.is_some(), entry.dir.as_path(), label);
280 match chosen.get(&primary) {
281 Some(best) if (best.0, best.1) <= (candidate.0, candidate.1) => {}
282 _ => {
283 chosen.insert(primary, candidate);
284 }
285 }
286 }
287 chosen
288 .into_iter()
289 .map(|(primary, (_, _, label))| (primary, label.to_string()))
290 .collect()
291}
292
293pub fn add(namespace: &str, dir: &Path, file: &Path, label: Option<&str>) -> Result<bool> {
298 let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
299 if let Some(parent) = path.parent() {
300 std::fs::create_dir_all(parent).into_diagnostic()?;
301 }
302 let _lock = xx::fslock::get(path, false).into_diagnostic()?;
303 let mut pt = if path.exists() {
304 PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
305 } else {
306 PitchforkToml::new(path.clone())
307 };
308 let dir = normalize(dir);
309 let file = normalize(file);
310 for (name, entry) in &pt.namespaces {
311 if (name == namespace && normalize(&entry.dir) != dir)
312 || (name != namespace
313 && (entry.config.contains(&file)
314 || (!entry.config.is_empty() && normalize(&entry.dir) == dir)))
315 {
316 miette::bail!(
317 "external configuration conflicts with namespace '{name}' ({})",
318 entry.dir.display()
319 );
320 }
321 }
322 let entry = pt
323 .namespaces
324 .entry(namespace.to_string())
325 .or_insert_with(|| crate::pitchfork_toml::NamespaceEntry {
326 dir,
327 config: Vec::new(),
328 label: None,
329 });
330 let mut changed = false;
331 if let Some(label) = label
332 && entry.label.as_deref() != Some(label)
333 {
334 entry.label = Some(label.to_string());
335 changed = true;
336 }
337 if !entry.config.contains(&file) {
338 entry.config.push(file);
339 changed = true;
340 }
341 if changed {
342 pt.write_unlocked()?;
343 }
344 Ok(changed)
345}
346
347fn detach_file(entry: &mut NamespaceEntry, file: &Path) -> bool {
354 let before = entry.config.len();
355 entry.config.retain(|p| p != file);
356 let detached = before != entry.config.len();
357 if detached && entry.config.is_empty() {
358 entry.label = None;
359 }
360 detached
361}
362
363pub fn remove(file: &Path) -> Result<Option<String>> {
364 let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
365 if !path.exists() {
366 return Ok(None);
367 }
368 let _lock = xx::fslock::get(path, false).into_diagnostic()?;
369 let mut pt = if path.exists() {
370 PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
371 } else {
372 PitchforkToml::new(path.clone())
373 };
374 let file = normalize(file);
375 let mut removed = None;
376 for (name, entry) in &mut pt.namespaces {
377 if detach_file(entry, &file) {
378 removed = Some(name.clone());
379 }
380 }
381 if removed.is_some() {
382 pt.write_unlocked()?;
383 }
384 Ok(removed)
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 #[test]
391 fn registry_paths_are_relative_to_project_and_old_entries_are_compatible() {
392 let tmp = tempfile::tempdir().unwrap();
393 let root = normalize(tmp.path());
394 let project = root.join("project");
395 let external = root.join("state/app.toml");
396 let project_str = project.to_string_lossy().into_owned();
397 let external_str = external.to_string_lossy().into_owned();
398 let doc = toml::toml! {
399 [namespaces.old]
400 dir = "/old"
401 [namespaces.app]
402 dir = (project_str)
403 config = ["generated.toml", (external_str)]
404 };
405 let entries = parse_entries(&toml::to_string(&doc).unwrap()).unwrap();
406 assert_eq!(entries.len(), 1);
407 assert_eq!(
408 entries[0].config,
409 vec![
410 normalize(&project.join("generated.toml")),
411 normalize(&external)
412 ]
413 );
414 let raw = NamespaceEntryRaw {
415 dir: "/old".into(),
416 config: vec![],
417 label: None,
418 };
419 let text = toml::to_string(&raw).unwrap();
420 assert!(!text.contains("config"));
421 assert!(!text.contains("label"));
422 assert_eq!(entries[0].label, None);
423 }
424
425 #[test]
428 fn detach_file_drops_label_with_last_attachment() {
429 let a = PathBuf::from("/gen/a.toml");
430 let b = PathBuf::from("/gen/b.toml");
431 let mut entry = NamespaceEntry {
432 dir: PathBuf::from("/shop"),
433 config: vec![a.clone(), b.clone()],
434 label: Some("shop".into()),
435 };
436 assert!(!detach_file(&mut entry, Path::new("/gen/none.toml")));
437 assert_eq!(entry.label.as_deref(), Some("shop"));
438 assert!(detach_file(&mut entry, &a));
439 assert_eq!(entry.config, vec![b.clone()]);
440 assert_eq!(entry.label.as_deref(), Some("shop"));
441 assert!(detach_file(&mut entry, &b));
442 assert!(entry.config.is_empty());
443 assert_eq!(entry.label, None);
444 assert!(!detach_file(&mut entry, &b));
446 }
447
448 #[test]
449 fn registry_label_parses_and_round_trips() {
450 let doc = "[namespaces.shop-528f92b13a6784f0]\ndir = \"/shop\"\n\
451 config = [\"/gen/pitchfork.toml\"]\nlabel = \"shop\"\n";
452 let entries = parse_entries(doc).unwrap();
453 assert_eq!(entries.len(), 1);
454 assert_eq!(entries[0].namespace, "shop-528f92b13a6784f0");
455 assert_eq!(entries[0].label.as_deref(), Some("shop"));
456 let raw: Registrations = toml::from_str(doc).unwrap();
457 let again = toml::to_string(&raw.namespaces["shop-528f92b13a6784f0"]).unwrap();
458 assert!(again.contains("label = \"shop\""), "{again}");
459 }
460
461 fn labelled(dir: &Path, label: Option<&str>) -> Entry {
462 Entry {
463 namespace: format!("ns-{}", dir.display()),
464 dir: normalize(dir),
465 config: vec![],
466 label: label.map(String::from),
467 source: "registry",
468 }
469 }
470
471 fn repo_with_worktrees(names: &[&str]) -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
474 let temp = tempfile::tempdir().unwrap();
475 let repo = temp.path().join("shop");
476 std::fs::create_dir_all(repo.join(".git")).unwrap();
477 let worktrees = names
478 .iter()
479 .map(|name| {
480 let admin = repo.join(".git/worktrees").join(name);
481 std::fs::create_dir_all(&admin).unwrap();
482 let wt = temp.path().join(name);
483 std::fs::create_dir_all(&wt).unwrap();
484 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
485 wt
486 })
487 .collect();
488 (temp, repo, worktrees)
489 }
490
491 fn pick(entries: &[Entry], primary: &Path) -> Option<String> {
492 label_index_of(entries, crate::proxy::hostname::detect_checkout)
493 .get(&normalize(primary))
494 .cloned()
495 }
496
497 #[test]
498 fn label_registered_from_a_linked_worktree_names_the_project() {
499 let (temp, repo, worktrees) = repo_with_worktrees(&["feature"]);
500 let entries = vec![labelled(&worktrees[0], Some("shop-web"))];
501 assert_eq!(pick(&entries, &repo).as_deref(), Some("shop-web"));
502 let other = temp.path().join("other");
504 std::fs::create_dir_all(other.join(".git")).unwrap();
505 assert_eq!(pick(&entries, &other), None);
506 }
507
508 #[test]
509 fn primary_checkout_label_beats_a_worktree_label() {
510 let (_temp, repo, worktrees) = repo_with_worktrees(&["feature"]);
511 let entries = vec![
512 labelled(&worktrees[0], Some("from-worktree")),
513 labelled(&repo, Some("from-primary")),
514 ];
515 assert_eq!(pick(&entries, &repo).as_deref(), Some("from-primary"));
516 }
517
518 #[test]
519 fn worktree_labels_are_chosen_independently_of_registry_order() {
520 let (_temp, repo, worktrees) = repo_with_worktrees(&["a", "b"]);
521 let a = labelled(&worktrees[0], Some("label-a"));
522 let b = labelled(&worktrees[1], Some("label-b"));
523 for entries in [vec![a.clone(), b.clone()], vec![b, a]] {
524 assert_eq!(pick(&entries, &repo).as_deref(), Some("label-a"));
525 }
526 }
527
528 #[test]
529 fn a_label_registered_for_a_subdirectory_does_not_name_the_repository() {
530 let (_temp, repo, worktrees) = repo_with_worktrees(&["feature"]);
531 let package = repo.join("packages/web");
532 let wt_package = worktrees[0].join("packages/web");
533 std::fs::create_dir_all(&package).unwrap();
534 std::fs::create_dir_all(&wt_package).unwrap();
535 let entries = vec![
536 labelled(&package, Some("web-package")),
537 labelled(&wt_package, Some("web-package-wt")),
538 ];
539 assert_eq!(pick(&entries, &repo), None);
540 }
541
542 #[test]
543 fn unlabelled_registrations_name_nothing() {
544 let (_temp, repo, worktrees) = repo_with_worktrees(&["feature"]);
545 let entries = vec![labelled(&worktrees[0], None)];
546 assert_eq!(pick(&entries, &repo), None);
547 }
548}