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::{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 source: &'static str,
19}
20
21#[derive(Default, Deserialize)]
22struct Registrations {
23    #[serde(default)]
24    namespaces: IndexMap<String, NamespaceEntryRaw>,
25}
26
27#[derive(Default)]
28struct Cache {
29    initialized: bool,
30    meta: Option<(SystemTime, u64)>,
31    entries: Vec<Entry>,
32}
33static CACHE: Lazy<Mutex<Cache>> = Lazy::new(|| Mutex::new(Cache::default()));
34
35/// Canonicalize existing paths and normalize missing paths for unregistering.
36pub fn normalize(path: &Path) -> PathBuf {
37    if let Ok(path) = path.canonicalize() {
38        return dunce::simplified(&path).to_path_buf();
39    }
40    let absolute = if path.is_absolute() {
41        path.to_path_buf()
42    } else {
43        env::CWD.join(path)
44    };
45    if let (Some(parent), Some(name)) = (absolute.parent(), absolute.file_name())
46        && let Ok(parent) = parent.canonicalize()
47    {
48        return dunce::simplified(&parent).join(name);
49    }
50    let mut result = PathBuf::new();
51    for part in absolute.components() {
52        match part {
53            Component::CurDir => {}
54            Component::ParentDir => {
55                result.pop();
56            }
57            part => result.push(part.as_os_str()),
58        }
59    }
60    result
61}
62
63pub fn resolve_path(dir: &Path, path: &str) -> PathBuf {
64    let path = env::expand_tilde(path);
65    normalize(&if path.is_absolute() {
66        path
67    } else {
68        dir.join(path)
69    })
70}
71
72fn parse_entries(content: &str) -> Result<Vec<Entry>> {
73    let raw: Registrations = toml::from_str(content).into_diagnostic()?;
74    Ok(raw
75        .namespaces
76        .into_iter()
77        .filter(|(_, e)| !e.config.is_empty())
78        .map(|(namespace, entry)| {
79            let dir = normalize(&env::expand_tilde(entry.dir));
80            let config = entry.config.iter().map(|p| resolve_path(&dir, p)).collect();
81            Entry {
82                namespace,
83                dir,
84                config,
85                source: "registry",
86            }
87        })
88        .collect())
89}
90
91pub fn entries() -> Vec<Entry> {
92    let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
93    let meta = current_meta(&env::PITCHFORK_GLOBAL_CONFIG_USER);
94    if !cache.initialized || cache.meta != meta {
95        cache.entries = if meta.is_some() {
96            std::fs::read_to_string(&*env::PITCHFORK_GLOBAL_CONFIG_USER)
97                .into_diagnostic()
98                .and_then(|s| parse_entries(&s))
99                .unwrap_or_else(|e| {
100                    warn!("cannot read external configuration registry: {e}");
101                    Vec::new()
102                })
103        } else {
104            Vec::new()
105        };
106        cache.meta = meta;
107        cache.initialized = true;
108    }
109    let mut entries = cache.entries.clone();
110    drop(cache);
111    if let Some(value) = std::env::var_os("PITCHFORK_CONFIG") {
112        let cwd = normalize(&env::CWD);
113        let dir = xx::file::find_up_all(
114            &cwd,
115            &[
116                "pitchfork.local.toml",
117                "pitchfork.toml",
118                ".config/pitchfork.local.toml",
119                ".config/pitchfork.toml",
120            ],
121        )
122        .into_iter()
123        .next()
124        .and_then(|path| {
125            let parent = path.parent()?;
126            Some(
127                if parent.file_name().is_some_and(|name| name == ".config") {
128                    parent.parent()?.to_path_buf()
129                } else {
130                    parent.to_path_buf()
131                },
132            )
133        })
134        .unwrap_or_else(|| cwd.clone());
135        let config = std::env::split_paths(&value)
136            .filter(|p| !p.as_os_str().is_empty())
137            .map(|p| resolve_path(&cwd, &p.to_string_lossy()))
138            .collect();
139        // Avoid calling namespace_for_project_dir here: namespace discovery uses this registry.
140        entries.push(Entry {
141            namespace: String::new(),
142            dir,
143            config,
144            source: "env",
145        });
146    }
147    entries
148}
149
150pub fn invalidate() {
151    *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Cache::default();
152}
153
154pub fn paths_for(cwd: &Path) -> Vec<PathBuf> {
155    let cwd = normalize(cwd);
156    let mut entries: Vec<_> = entries()
157        .into_iter()
158        .filter(|e| cwd.starts_with(&e.dir))
159        .collect();
160    entries.sort_by_key(|e| (e.source == "env", e.dir.components().count()));
161    let mut paths = Vec::new();
162    for entry in entries {
163        for path in entry.config {
164            // Last attachment wins, even if it is also in PITCHFORK_CONFIG.
165            paths.retain(|p| p != &path);
166            paths.push(path);
167        }
168    }
169    paths
170}
171
172pub fn project_dir(path: &Path) -> Option<PathBuf> {
173    let path = normalize(path);
174    entries()
175        .into_iter()
176        .rev()
177        .find(|e| e.config.contains(&path))
178        .map(|e| e.dir)
179}
180
181pub fn namespace_for_dir(dir: &Path) -> Option<String> {
182    let dir = normalize(dir);
183    entries()
184        .into_iter()
185        .find(|e| e.source == "registry" && e.dir == dir)
186        .map(|e| e.namespace)
187}
188
189/// Mutate under the same lock as the existing namespace and slug writers.
190pub fn add(namespace: &str, dir: &Path, file: &Path) -> Result<bool> {
191    let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
192    if let Some(parent) = path.parent() {
193        std::fs::create_dir_all(parent).into_diagnostic()?;
194    }
195    let _lock = xx::fslock::get(path, false).into_diagnostic()?;
196    let mut pt = if path.exists() {
197        PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
198    } else {
199        PitchforkToml::new(path.clone())
200    };
201    let dir = normalize(dir);
202    let file = normalize(file);
203    for (name, entry) in &pt.namespaces {
204        if (name == namespace && normalize(&entry.dir) != dir)
205            || (name != namespace
206                && (entry.config.contains(&file)
207                    || (!entry.config.is_empty() && normalize(&entry.dir) == dir)))
208        {
209            miette::bail!(
210                "external configuration conflicts with namespace '{name}' ({})",
211                entry.dir.display()
212            );
213        }
214    }
215    let entry = pt
216        .namespaces
217        .entry(namespace.to_string())
218        .or_insert_with(|| crate::pitchfork_toml::NamespaceEntry {
219            dir,
220            config: Vec::new(),
221        });
222    if entry.config.contains(&file) {
223        return Ok(false);
224    }
225    entry.config.push(file);
226    pt.write_unlocked()?;
227    Ok(true)
228}
229
230pub fn remove(file: &Path) -> Result<Option<String>> {
231    let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
232    if !path.exists() {
233        return Ok(None);
234    }
235    let _lock = xx::fslock::get(path, false).into_diagnostic()?;
236    let mut pt = if path.exists() {
237        PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
238    } else {
239        PitchforkToml::new(path.clone())
240    };
241    let file = normalize(file);
242    let mut removed = None;
243    for (name, entry) in &mut pt.namespaces {
244        let before = entry.config.len();
245        entry.config.retain(|p| p != &file);
246        if before != entry.config.len() {
247            removed = Some(name.clone());
248        }
249    }
250    if removed.is_some() {
251        pt.write_unlocked()?;
252    }
253    Ok(removed)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    #[test]
260    fn registry_paths_are_relative_to_project_and_old_entries_are_compatible() {
261        let tmp = tempfile::tempdir().unwrap();
262        let root = normalize(tmp.path());
263        let project = root.join("project");
264        let external = root.join("state/app.toml");
265        let project_str = project.to_string_lossy().into_owned();
266        let external_str = external.to_string_lossy().into_owned();
267        let doc = toml::toml! {
268            [namespaces.old]
269            dir = "/old"
270            [namespaces.app]
271            dir = (project_str)
272            config = ["generated.toml", (external_str)]
273        };
274        let entries = parse_entries(&toml::to_string(&doc).unwrap()).unwrap();
275        assert_eq!(entries.len(), 1);
276        assert_eq!(
277            entries[0].config,
278            vec![
279                normalize(&project.join("generated.toml")),
280                normalize(&external)
281            ]
282        );
283        let raw = NamespaceEntryRaw {
284            dir: "/old".into(),
285            config: vec![],
286        };
287        assert!(!toml::to_string(&raw).unwrap().contains("config"));
288    }
289}