1use crate::{ConfigSource, CoreError};
4use camino::{Utf8Path, Utf8PathBuf};
5
6const APP_NAME: &str = "cook";
7pub(crate) const LOCAL_CONFIG_DIR: &str = "config";
8const AUTO_AISLE: &str = "aisle.conf";
9pub(crate) const AUTO_PANTRY: &str = "pantry.conf";
10
11#[derive(Debug, Clone)]
18pub struct Context {
19 base_path: Utf8PathBuf,
20 aisle: ConfigSource,
21 pantry: ConfigSource,
22}
23
24impl Context {
25 pub fn new(base_path: Utf8PathBuf) -> Self {
27 Self {
28 base_path,
29 aisle: ConfigSource::None,
30 pantry: ConfigSource::None,
31 }
32 }
33
34 pub fn discover(base_path: Utf8PathBuf) -> Self {
57 let aisle = Self::discover_one(&base_path, AUTO_AISLE);
58 let pantry = Self::discover_one(&base_path, AUTO_PANTRY);
59 Self {
60 base_path,
61 aisle,
62 pantry,
63 }
64 }
65
66 fn discover_one(base_path: &Utf8Path, name: &str) -> ConfigSource {
67 Self::search(base_path, name, global_config_path(name).ok().as_deref())
71 }
72
73 fn search(base_path: &Utf8Path, name: &str, global: Option<&Utf8Path>) -> ConfigSource {
82 let local = base_path.join(LOCAL_CONFIG_DIR).join(name);
83 tracing::trace!("checking local config file: {local}");
84 if local.is_file() {
85 return ConfigSource::Path(local);
86 }
87
88 match global {
89 Some(global) => {
90 tracing::trace!("checking global config file: {global}");
91 if global.is_file() {
92 ConfigSource::Path(global.to_owned())
93 } else {
94 ConfigSource::None
95 }
96 }
97 None => ConfigSource::None,
98 }
99 }
100
101 pub fn with_aisle(mut self, source: ConfigSource) -> Self {
103 self.aisle = source;
104 self
105 }
106
107 pub fn with_pantry(mut self, source: ConfigSource) -> Self {
109 self.pantry = source;
110 self
111 }
112
113 pub fn base_path(&self) -> &Utf8Path {
122 &self.base_path
123 }
124
125 pub fn aisle(&self) -> &ConfigSource {
127 &self.aisle
128 }
129
130 pub fn pantry(&self) -> &ConfigSource {
132 &self.pantry
133 }
134}
135
136pub fn global_config_path(name: &str) -> Result<Utf8PathBuf, CoreError> {
147 let dirs =
148 directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| CoreError::Config {
149 path: None,
150 message: format!("could not determine the home directory to locate {name}"),
151 })?;
152 let config = Utf8Path::from_path(dirs.config_dir()).ok_or_else(|| CoreError::Config {
153 path: None,
154 message: format!(
155 "the configuration directory holding {name} is not valid utf-8, \
156 and cook only supports utf-8 paths"
157 ),
158 })?;
159 Ok(config.join(name))
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::ConfigSource;
166
167 #[test]
171 fn new_touches_nothing() {
172 let dir = tempfile::TempDir::new().unwrap();
173 let base = utf8(&dir);
174 write(&base.join("config").join("aisle.conf"), "[produce]\nleek");
175 write(
176 &base.join("config").join("pantry.conf"),
177 "[freezer]\npeas = \"1kg\"",
178 );
179
180 let ctx = Context::new(base.clone());
181 assert!(ctx.aisle().is_unset(), "new must not discover local config");
182 assert!(
183 ctx.pantry().is_unset(),
184 "new must not discover local config"
185 );
186 assert_eq!(ctx.base_path(), base);
187 }
188
189 #[test]
190 fn with_aisle_overrides() {
191 let ctx = Context::new(Utf8PathBuf::from("/tmp"))
192 .with_aisle(ConfigSource::Inline("[produce]\nleek".to_string()));
193 assert_eq!(
194 ctx.aisle().read().unwrap().as_deref(),
195 Some("[produce]\nleek")
196 );
197 assert!(
198 ctx.pantry().is_unset(),
199 "with_aisle must not set the pantry"
200 );
201 }
202
203 #[test]
204 fn with_pantry_overrides() {
205 let ctx = Context::new(Utf8PathBuf::from("/tmp")).with_pantry(ConfigSource::Inline(
206 "[freezer]\npeas = \"1kg\"".to_string(),
207 ));
208 assert_eq!(
209 ctx.pantry().read().unwrap().as_deref(),
210 Some("[freezer]\npeas = \"1kg\"")
211 );
212 assert!(ctx.aisle().is_unset(), "with_pantry must not set the aisle");
213 }
214
215 #[test]
216 fn discover_finds_local_config() {
217 let dir = tempfile::TempDir::new().unwrap();
218 let base = utf8(&dir);
219 write(&base.join("config").join("aisle.conf"), "[produce]\nleek");
220 write(
221 &base.join("config").join("pantry.conf"),
222 "[freezer]\npeas = \"1kg\"",
223 );
224
225 let ctx = Context::discover(base.clone());
226
227 assert_eq!(
228 ctx.aisle().path(),
229 Some(base.join("config").join("aisle.conf").as_path())
230 );
231 assert_eq!(
232 ctx.pantry().path(),
233 Some(base.join("config").join("pantry.conf").as_path())
234 );
235 }
236
237 #[test]
238 fn global_config_path_joins_the_app_name() {
239 let path = global_config_path("aisle.conf").expect("a home directory");
247 assert_eq!(
248 path.file_name(),
249 Some("aisle.conf"),
250 "the name asked for must be the last component: {path}"
251 );
252 assert!(
253 path.components().any(|c| c.as_str() == APP_NAME),
254 "expected a `{APP_NAME}` component in {path}"
255 );
256 assert!(
257 path.is_absolute(),
258 "the platform config directory is absolute: {path}"
259 );
260 }
261
262 fn write(path: &Utf8Path, text: &str) {
263 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
264 std::fs::write(path, text).unwrap();
265 }
266
267 fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
268 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
269 }
270
271 #[test]
276 fn local_config_wins_over_global() {
277 let dir = tempfile::TempDir::new().unwrap();
278 let base = utf8(&dir);
279 let local = base.join("config").join("aisle.conf");
280 let global = base.join("global").join("aisle.conf");
281 write(&local, "[produce]\nleek");
282 write(&global, "[dairy]\nmilk");
283
284 let found = Context::search(&base, "aisle.conf", Some(&global));
285 assert_eq!(found, ConfigSource::Path(local));
286 }
287
288 #[test]
289 fn global_config_is_used_when_there_is_no_local_one() {
290 let dir = tempfile::TempDir::new().unwrap();
291 let base = utf8(&dir);
292 let global = base.join("global").join("pantry.conf");
293 write(&global, "[freezer]\npeas = \"1kg\"");
294
295 let found = Context::search(&base, "pantry.conf", Some(&global));
296 assert_eq!(found, ConfigSource::Path(global));
297 }
298
299 #[test]
300 fn absent_everywhere_is_unset() {
301 let dir = tempfile::TempDir::new().unwrap();
302 let base = utf8(&dir);
303 let global = base.join("global").join("pantry.conf");
304
305 assert!(Context::search(&base, "pantry.conf", Some(&global)).is_unset());
306 assert!(Context::search(&base, "pantry.conf", None).is_unset());
307 }
308}