Skip to main content

pitchfork_cli/
extra_configs.rs

1//! External files are associated with a project, never with their storage directory.
2use 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    /// Hostname label the registration names for the project, if any.
19    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    /// Registered labels keyed by the primary checkout they name, built from
35    /// `entries` on first use so a hostname lookup never walks the filesystem.
36    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
43/// Canonicalize existing paths and normalize missing paths for unregistering.
44pub 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        // Avoid calling namespace_for_project_dir here: namespace discovery uses this registry.
150        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            // Last attachment wins, even if it is also in PITCHFORK_CONFIG.
176            paths.retain(|p| p != &path);
177            paths.push(path);
178        }
179    }
180    paths
181}
182
183/// Configuration files registered for this exact project directory.
184///
185/// Unlike [`paths_for`], this does not include files registered for an
186/// ancestor: they belong to that project, not to this one.
187pub 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
213/// How long a built label index answers lookups. Long enough that one hostname
214/// pass over every daemon reads the filesystem once, short enough that a moved
215/// checkout is picked up without a registry change.
216const LABEL_INDEX_TTL: std::time::Duration = std::time::Duration::from_secs(2);
217
218/// The hostname label a registration names for the project whose primary
219/// checkout is `primary`.
220///
221/// A pure lookup in the cached registry. It is what lets a tool register a
222/// project under a generated namespace (needed to keep daemon IDs unique) and
223/// still get the hostname it advertises.
224///
225/// A registration made from the primary checkout wins. Failing that, one made
226/// from a linked worktree of it counts too: a tool registers the checkout it
227/// runs in, which is often a worktree, and the project's hostnames must not
228/// change depending on which checkout registered first.
229pub fn label_for_checkout(primary: &Path) -> Option<String> {
230    // Refreshes the cache, and drops the index with it, when the registry changed.
231    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    // The index records where Git pointers led, which moving a checkout changes
239    // without touching the registry, so it only serves lookups for a moment.
240    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
254/// Every registered label, keyed by the primary checkout it names.
255///
256/// A registration made from the primary itself wins over one made from a linked
257/// worktree, and among worktrees the lowest path wins, so the result never
258/// depends on registry order.
259fn 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        // A registration for a subdirectory (a monorepo package, say) names
274        // that directory's project, not the repository around it.
275        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
293/// Mutate under the same lock as the existing namespace and slug writers.
294///
295/// A `label` replaces the one already recorded for the namespace; `None` leaves
296/// it alone. Returns whether anything was written.
297pub 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
347/// Detach `file` from a registration, reporting whether it was attached.
348///
349/// A registration with no attachment left is not a registration: `entries()`
350/// drops it, so it neither names the project's namespace nor its label. The
351/// label goes with the last attachment so that it cannot silently come back
352/// when the directory is registered again without `--label`.
353fn 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    /// The label lives exactly as long as the registration: it survives while
426    /// any attachment remains and goes with the last one.
427    #[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        // Removing again is a no-op and cannot resurrect anything.
445        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    /// A real primary checkout with linked worktrees named `names`, so the Git
472    /// pointer files that `detect_checkout` follows are what the tests use.
473    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        // Another project is not claimed by it.
503        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}