pitchfork_cli/
extra_configs.rs1use 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 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
37pub 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 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 paths.retain(|p| p != &path);
170 paths.push(path);
171 }
172 }
173 paths
174}
175
176pub 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
206pub 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
219pub 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
273fn 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 #[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 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}