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
71/// Whether the filesystem positively reports `path` as nonexistent. An I/O
72/// error (permissions, a flaky mount) is not "gone": callers delete on this.
73pub fn path_is_gone(path: &Path) -> bool {
74    matches!(std::fs::exists(path), Ok(false))
75}
76
77/// Whether the state file records a running daemon in `namespace`. A
78/// namespace with live daemons must keep its registration, or a new project
79/// registered under the name would find the old project's running daemons.
80pub 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        // Unknown state: err on the side of keeping the registration.
89        .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        // Avoid calling namespace_for_project_dir here: namespace discovery uses this registry.
171        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            // Last attachment wins, even if it is also in PITCHFORK_CONFIG.
197            paths.retain(|p| p != &path);
198            paths.push(path);
199        }
200    }
201    paths
202}
203
204/// Configuration files registered for this exact project directory.
205///
206/// Unlike [`paths_for`], this does not include files registered for an
207/// ancestor: they belong to that project, not to this one.
208pub 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
234/// How long a built label index answers lookups. Long enough that one hostname
235/// pass over every daemon reads the filesystem once, short enough that a moved
236/// checkout is picked up without a registry change.
237const LABEL_INDEX_TTL: std::time::Duration = std::time::Duration::from_secs(2);
238
239/// The hostname label a registration names for the project whose primary
240/// checkout is `primary`.
241///
242/// A pure lookup in the cached registry. It is what lets a tool register a
243/// project under a generated namespace (needed to keep daemon IDs unique) and
244/// still get the hostname it advertises.
245///
246/// A registration made from the primary checkout wins. Failing that, one made
247/// from a linked worktree of it counts too: a tool registers the checkout it
248/// runs in, which is often a worktree, and the project's hostnames must not
249/// change depending on which checkout registered first.
250pub fn label_for_checkout(primary: &Path) -> Option<String> {
251    // Refreshes the cache, and drops the index with it, when the registry changed.
252    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    // The index records where Git pointers led, which moving a checkout changes
260    // without touching the registry, so it only serves lookups for a moment.
261    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
275/// Every registered label, keyed by the primary checkout it names.
276///
277/// A registration made from the primary itself wins over one made from a linked
278/// worktree, and among worktrees the lowest path wins, so the result never
279/// depends on registry order.
280fn 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        // A registration for a subdirectory (a monorepo package, say) names
295        // that directory's project, not the repository around it.
296        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
314/// Mutate under the same lock as the existing namespace and slug writers.
315///
316/// A `label` replaces the one already recorded for the namespace; `None` leaves
317/// it alone. Returns whether anything was written.
318pub 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    // A registration for this namespace whose directory is gone (a deleted
332    // scratch project, say) must not block registering it at a new directory.
333    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
379/// Detach `file` from a registration, reporting whether it was attached.
380///
381/// A registration with no attachment left is not a registration: `entries()`
382/// drops it, so it neither names the project's namespace nor its label. The
383/// label goes with the last attachment so that it cannot silently come back
384/// when the directory is registered again without `--label`.
385fn 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    /// The label lives exactly as long as the registration: it survives while
458    /// any attachment remains and goes with the last one.
459    #[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        // Removing again is a no-op and cannot resurrect anything.
477        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    /// A real primary checkout with linked worktrees named `names`, so the Git
504    /// pointer files that `detect_checkout` follows are what the tests use.
505    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        // Another project is not claimed by it.
535        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}