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}
35static CACHE: Lazy<Mutex<Cache>> = Lazy::new(|| Mutex::new(Cache::default()));
36
37/// Canonicalize existing paths and normalize missing paths for unregistering.
38pub fn normalize(path: &Path) -> PathBuf {
39    if let Ok(path) = path.canonicalize() {
40        return dunce::simplified(&path).to_path_buf();
41    }
42    let absolute = if path.is_absolute() {
43        path.to_path_buf()
44    } else {
45        env::CWD.join(path)
46    };
47    if let (Some(parent), Some(name)) = (absolute.parent(), absolute.file_name())
48        && let Ok(parent) = parent.canonicalize()
49    {
50        return dunce::simplified(&parent).join(name);
51    }
52    let mut result = PathBuf::new();
53    for part in absolute.components() {
54        match part {
55            Component::CurDir => {}
56            Component::ParentDir => {
57                result.pop();
58            }
59            part => result.push(part.as_os_str()),
60        }
61    }
62    result
63}
64
65pub fn resolve_path(dir: &Path, path: &str) -> PathBuf {
66    let path = env::expand_tilde(path);
67    normalize(&if path.is_absolute() {
68        path
69    } else {
70        dir.join(path)
71    })
72}
73
74fn parse_entries(content: &str) -> Result<Vec<Entry>> {
75    let raw: Registrations = toml::from_str(content).into_diagnostic()?;
76    Ok(raw
77        .namespaces
78        .into_iter()
79        .filter(|(_, e)| !e.config.is_empty())
80        .map(|(namespace, entry)| {
81            let dir = normalize(&env::expand_tilde(entry.dir));
82            let config = entry.config.iter().map(|p| resolve_path(&dir, p)).collect();
83            Entry {
84                namespace,
85                dir,
86                config,
87                label: entry.label,
88                source: "registry",
89            }
90        })
91        .collect())
92}
93
94pub fn entries() -> Vec<Entry> {
95    let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
96    let meta = current_meta(&env::PITCHFORK_GLOBAL_CONFIG_USER);
97    if !cache.initialized || cache.meta != meta {
98        cache.entries = if meta.is_some() {
99            std::fs::read_to_string(&*env::PITCHFORK_GLOBAL_CONFIG_USER)
100                .into_diagnostic()
101                .and_then(|s| parse_entries(&s))
102                .unwrap_or_else(|e| {
103                    warn!("cannot read external configuration registry: {e}");
104                    Vec::new()
105                })
106        } else {
107            Vec::new()
108        };
109        cache.meta = meta;
110        cache.initialized = true;
111    }
112    let mut entries = cache.entries.clone();
113    drop(cache);
114    if let Some(value) = std::env::var_os("PITCHFORK_CONFIG") {
115        let cwd = normalize(&env::CWD);
116        let dir = xx::file::find_up_all(
117            &cwd,
118            &[
119                "pitchfork.local.toml",
120                "pitchfork.toml",
121                ".config/pitchfork.local.toml",
122                ".config/pitchfork.toml",
123            ],
124        )
125        .into_iter()
126        .next()
127        .and_then(|path| {
128            let parent = path.parent()?;
129            Some(
130                if parent.file_name().is_some_and(|name| name == ".config") {
131                    parent.parent()?.to_path_buf()
132                } else {
133                    parent.to_path_buf()
134                },
135            )
136        })
137        .unwrap_or_else(|| cwd.clone());
138        let config = std::env::split_paths(&value)
139            .filter(|p| !p.as_os_str().is_empty())
140            .map(|p| resolve_path(&cwd, &p.to_string_lossy()))
141            .collect();
142        // Avoid calling namespace_for_project_dir here: namespace discovery uses this registry.
143        entries.push(Entry {
144            namespace: String::new(),
145            dir,
146            config,
147            label: None,
148            source: "env",
149        });
150    }
151    entries
152}
153
154pub fn invalidate() {
155    *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Cache::default();
156}
157
158pub fn paths_for(cwd: &Path) -> Vec<PathBuf> {
159    let cwd = normalize(cwd);
160    let mut entries: Vec<_> = entries()
161        .into_iter()
162        .filter(|e| cwd.starts_with(&e.dir))
163        .collect();
164    entries.sort_by_key(|e| (e.source == "env", e.dir.components().count()));
165    let mut paths = Vec::new();
166    for entry in entries {
167        for path in entry.config {
168            // Last attachment wins, even if it is also in PITCHFORK_CONFIG.
169            paths.retain(|p| p != &path);
170            paths.push(path);
171        }
172    }
173    paths
174}
175
176/// Configuration files registered for this exact project directory.
177///
178/// Unlike [`paths_for`], this does not include files registered for an
179/// ancestor: they belong to that project, not to this one.
180pub fn configs_for_dir(dir: &Path) -> Vec<PathBuf> {
181    let dir = normalize(dir);
182    entries()
183        .into_iter()
184        .filter(|e| e.dir == dir)
185        .flat_map(|e| e.config)
186        .collect()
187}
188
189pub fn project_dir(path: &Path) -> Option<PathBuf> {
190    let path = normalize(path);
191    entries()
192        .into_iter()
193        .rev()
194        .find(|e| e.config.contains(&path))
195        .map(|e| e.dir)
196}
197
198pub fn namespace_for_dir(dir: &Path) -> Option<String> {
199    let dir = normalize(dir);
200    entries()
201        .into_iter()
202        .find(|e| e.source == "registry" && e.dir == dir)
203        .map(|e| e.namespace)
204}
205
206/// The hostname label a registration names for the project at `dir`.
207///
208/// A pure lookup in the cached registry. It is what lets a tool register a
209/// project under a generated namespace (needed to keep daemon IDs unique) and
210/// still get the hostname it advertises.
211pub fn label_for_dir(dir: &Path) -> Option<String> {
212    let dir = normalize(dir);
213    entries()
214        .into_iter()
215        .find(|e| e.source == "registry" && e.dir == dir)
216        .and_then(|e| e.label)
217}
218
219/// Mutate under the same lock as the existing namespace and slug writers.
220///
221/// A `label` replaces the one already recorded for the namespace; `None` leaves
222/// it alone. Returns whether anything was written.
223pub fn add(namespace: &str, dir: &Path, file: &Path, label: Option<&str>) -> Result<bool> {
224    let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
225    if let Some(parent) = path.parent() {
226        std::fs::create_dir_all(parent).into_diagnostic()?;
227    }
228    let _lock = xx::fslock::get(path, false).into_diagnostic()?;
229    let mut pt = if path.exists() {
230        PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
231    } else {
232        PitchforkToml::new(path.clone())
233    };
234    let dir = normalize(dir);
235    let file = normalize(file);
236    for (name, entry) in &pt.namespaces {
237        if (name == namespace && normalize(&entry.dir) != dir)
238            || (name != namespace
239                && (entry.config.contains(&file)
240                    || (!entry.config.is_empty() && normalize(&entry.dir) == dir)))
241        {
242            miette::bail!(
243                "external configuration conflicts with namespace '{name}' ({})",
244                entry.dir.display()
245            );
246        }
247    }
248    let entry = pt
249        .namespaces
250        .entry(namespace.to_string())
251        .or_insert_with(|| crate::pitchfork_toml::NamespaceEntry {
252            dir,
253            config: Vec::new(),
254            label: None,
255        });
256    let mut changed = false;
257    if let Some(label) = label
258        && entry.label.as_deref() != Some(label)
259    {
260        entry.label = Some(label.to_string());
261        changed = true;
262    }
263    if !entry.config.contains(&file) {
264        entry.config.push(file);
265        changed = true;
266    }
267    if changed {
268        pt.write_unlocked()?;
269    }
270    Ok(changed)
271}
272
273/// Detach `file` from a registration, reporting whether it was attached.
274///
275/// A registration with no attachment left is not a registration: `entries()`
276/// drops it, so it neither names the project's namespace nor its label. The
277/// label goes with the last attachment so that it cannot silently come back
278/// when the directory is registered again without `--label`.
279fn detach_file(entry: &mut NamespaceEntry, file: &Path) -> bool {
280    let before = entry.config.len();
281    entry.config.retain(|p| p != file);
282    let detached = before != entry.config.len();
283    if detached && entry.config.is_empty() {
284        entry.label = None;
285    }
286    detached
287}
288
289pub fn remove(file: &Path) -> Result<Option<String>> {
290    let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
291    if !path.exists() {
292        return Ok(None);
293    }
294    let _lock = xx::fslock::get(path, false).into_diagnostic()?;
295    let mut pt = if path.exists() {
296        PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
297    } else {
298        PitchforkToml::new(path.clone())
299    };
300    let file = normalize(file);
301    let mut removed = None;
302    for (name, entry) in &mut pt.namespaces {
303        if detach_file(entry, &file) {
304            removed = Some(name.clone());
305        }
306    }
307    if removed.is_some() {
308        pt.write_unlocked()?;
309    }
310    Ok(removed)
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    #[test]
317    fn registry_paths_are_relative_to_project_and_old_entries_are_compatible() {
318        let tmp = tempfile::tempdir().unwrap();
319        let root = normalize(tmp.path());
320        let project = root.join("project");
321        let external = root.join("state/app.toml");
322        let project_str = project.to_string_lossy().into_owned();
323        let external_str = external.to_string_lossy().into_owned();
324        let doc = toml::toml! {
325            [namespaces.old]
326            dir = "/old"
327            [namespaces.app]
328            dir = (project_str)
329            config = ["generated.toml", (external_str)]
330        };
331        let entries = parse_entries(&toml::to_string(&doc).unwrap()).unwrap();
332        assert_eq!(entries.len(), 1);
333        assert_eq!(
334            entries[0].config,
335            vec![
336                normalize(&project.join("generated.toml")),
337                normalize(&external)
338            ]
339        );
340        let raw = NamespaceEntryRaw {
341            dir: "/old".into(),
342            config: vec![],
343            label: None,
344        };
345        let text = toml::to_string(&raw).unwrap();
346        assert!(!text.contains("config"));
347        assert!(!text.contains("label"));
348        assert_eq!(entries[0].label, None);
349    }
350
351    /// The label lives exactly as long as the registration: it survives while
352    /// any attachment remains and goes with the last one.
353    #[test]
354    fn detach_file_drops_label_with_last_attachment() {
355        let a = PathBuf::from("/gen/a.toml");
356        let b = PathBuf::from("/gen/b.toml");
357        let mut entry = NamespaceEntry {
358            dir: PathBuf::from("/shop"),
359            config: vec![a.clone(), b.clone()],
360            label: Some("shop".into()),
361        };
362        assert!(!detach_file(&mut entry, Path::new("/gen/none.toml")));
363        assert_eq!(entry.label.as_deref(), Some("shop"));
364        assert!(detach_file(&mut entry, &a));
365        assert_eq!(entry.config, vec![b.clone()]);
366        assert_eq!(entry.label.as_deref(), Some("shop"));
367        assert!(detach_file(&mut entry, &b));
368        assert!(entry.config.is_empty());
369        assert_eq!(entry.label, None);
370        // Removing again is a no-op and cannot resurrect anything.
371        assert!(!detach_file(&mut entry, &b));
372    }
373
374    #[test]
375    fn registry_label_parses_and_round_trips() {
376        let doc = "[namespaces.shop-528f92b13a6784f0]\ndir = \"/shop\"\n\
377                   config = [\"/gen/pitchfork.toml\"]\nlabel = \"shop\"\n";
378        let entries = parse_entries(doc).unwrap();
379        assert_eq!(entries.len(), 1);
380        assert_eq!(entries[0].namespace, "shop-528f92b13a6784f0");
381        assert_eq!(entries[0].label.as_deref(), Some("shop"));
382        let raw: Registrations = toml::from_str(doc).unwrap();
383        let again = toml::to_string(&raw.namespaces["shop-528f92b13a6784f0"]).unwrap();
384        assert!(again.contains("label = \"shop\""), "{again}");
385    }
386}