pitchfork_cli/
extra_configs.rs1use 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
35pub 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 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 paths.retain(|p| p != &path);
166 paths.push(path);
167 }
168 }
169 paths
170}
171
172pub 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
202pub 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}