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 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
189pub 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}