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
172/// Configuration files registered for this exact project directory.
173///
174/// Unlike [`paths_for`], this does not include files registered for an
175/// ancestor: they belong to that project, not to this one.
176pub fn configs_for_dir(dir: &Path) -> Vec<PathBuf> {
177    let dir = normalize(dir);
178    entries()
179        .into_iter()
180        .filter(|e| e.dir == dir)
181        .flat_map(|e| e.config)
182        .collect()
183}
184
185pub fn project_dir(path: &Path) -> Option<PathBuf> {
186    let path = normalize(path);
187    entries()
188        .into_iter()
189        .rev()
190        .find(|e| e.config.contains(&path))
191        .map(|e| e.dir)
192}
193
194pub fn namespace_for_dir(dir: &Path) -> Option<String> {
195    let dir = normalize(dir);
196    entries()
197        .into_iter()
198        .find(|e| e.source == "registry" && e.dir == dir)
199        .map(|e| e.namespace)
200}
201
202/// Mutate under the same lock as the existing namespace and slug writers.
203pub fn add(namespace: &str, dir: &Path, file: &Path) -> Result<bool> {
204    let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
205    if let Some(parent) = path.parent() {
206        std::fs::create_dir_all(parent).into_diagnostic()?;
207    }
208    let _lock = xx::fslock::get(path, false).into_diagnostic()?;
209    let mut pt = if path.exists() {
210        PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
211    } else {
212        PitchforkToml::new(path.clone())
213    };
214    let dir = normalize(dir);
215    let file = normalize(file);
216    for (name, entry) in &pt.namespaces {
217        if (name == namespace && normalize(&entry.dir) != dir)
218            || (name != namespace
219                && (entry.config.contains(&file)
220                    || (!entry.config.is_empty() && normalize(&entry.dir) == dir)))
221        {
222            miette::bail!(
223                "external configuration conflicts with namespace '{name}' ({})",
224                entry.dir.display()
225            );
226        }
227    }
228    let entry = pt
229        .namespaces
230        .entry(namespace.to_string())
231        .or_insert_with(|| crate::pitchfork_toml::NamespaceEntry {
232            dir,
233            config: Vec::new(),
234        });
235    if entry.config.contains(&file) {
236        return Ok(false);
237    }
238    entry.config.push(file);
239    pt.write_unlocked()?;
240    Ok(true)
241}
242
243pub fn remove(file: &Path) -> Result<Option<String>> {
244    let path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
245    if !path.exists() {
246        return Ok(None);
247    }
248    let _lock = xx::fslock::get(path, false).into_diagnostic()?;
249    let mut pt = if path.exists() {
250        PitchforkToml::parse_str(&std::fs::read_to_string(path).into_diagnostic()?, path)?
251    } else {
252        PitchforkToml::new(path.clone())
253    };
254    let file = normalize(file);
255    let mut removed = None;
256    for (name, entry) in &mut pt.namespaces {
257        let before = entry.config.len();
258        entry.config.retain(|p| p != &file);
259        if before != entry.config.len() {
260            removed = Some(name.clone());
261        }
262    }
263    if removed.is_some() {
264        pt.write_unlocked()?;
265    }
266    Ok(removed)
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    #[test]
273    fn registry_paths_are_relative_to_project_and_old_entries_are_compatible() {
274        let tmp = tempfile::tempdir().unwrap();
275        let root = normalize(tmp.path());
276        let project = root.join("project");
277        let external = root.join("state/app.toml");
278        let project_str = project.to_string_lossy().into_owned();
279        let external_str = external.to_string_lossy().into_owned();
280        let doc = toml::toml! {
281            [namespaces.old]
282            dir = "/old"
283            [namespaces.app]
284            dir = (project_str)
285            config = ["generated.toml", (external_str)]
286        };
287        let entries = parse_entries(&toml::to_string(&doc).unwrap()).unwrap();
288        assert_eq!(entries.len(), 1);
289        assert_eq!(
290            entries[0].config,
291            vec![
292                normalize(&project.join("generated.toml")),
293                normalize(&external)
294            ]
295        );
296        let raw = NamespaceEntryRaw {
297            dir: "/old".into(),
298            config: vec![],
299        };
300        assert!(!toml::to_string(&raw).unwrap().contains("config"));
301    }
302}