cookcli_core/context.rs
1//! Resolved configuration for a set of recipe operations.
2
3use crate::{ConfigSource, CoreError};
4use camino::{Utf8Path, Utf8PathBuf};
5use std::ffi::OsStr;
6
7const APP_NAME: &str = "cook";
8
9/// Environment variable that replaces the platform configuration directory.
10///
11/// See [`global_config_path`]. Set it to point every global lookup — aisle,
12/// pantry, `session.json`, `sync.db` — at one directory of your choosing.
13pub const CONFIG_DIR_ENV: &str = "COOK_CONFIG_DIR";
14pub(crate) const LOCAL_CONFIG_DIR: &str = "config";
15const AUTO_AISLE: &str = "aisle.conf";
16pub(crate) const AUTO_PANTRY: &str = "pantry.conf";
17
18/// The configuration bundle every command operates against.
19///
20/// [`Context::new`] performs no filesystem access. Ambient configuration
21/// discovery is opt-in through [`Context::discover`], so a caller that already
22/// knows its configuration — an editor holding an unsaved buffer, say — never
23/// has the user's `~/.config` read behind its back.
24#[derive(Debug, Clone)]
25pub struct Context {
26 base_path: Utf8PathBuf,
27 aisle: ConfigSource,
28 pantry: ConfigSource,
29}
30
31impl Context {
32 /// A context with no aisle or pantry configuration. Touches nothing.
33 pub fn new(base_path: Utf8PathBuf) -> Self {
34 Self {
35 base_path,
36 aisle: ConfigSource::None,
37 pantry: ConfigSource::None,
38 }
39 }
40
41 /// A context with aisle and pantry resolved using CookCLI's search order:
42 /// `<base>/config/<name>` first, then the global configuration directory
43 /// ([`global_config_path`] — `$COOK_CONFIG_DIR` when set, otherwise
44 /// `~/.config/cook/<name>` on Linux and the platform equivalent
45 /// elsewhere).
46 ///
47 /// This is the only constructor that reads ambient state, and it is
48 /// explicitly opted into.
49 ///
50 /// Two things to know before relying on it:
51 ///
52 /// - **It reports no errors.** A configuration directory that cannot be
53 /// resolved at all — no home directory, or a non-UTF-8 path — is treated
54 /// as one fewer place to look, exactly as the CLI treats it. An unset
55 /// [`ConfigSource`] therefore does not distinguish "the user has no
56 /// config file" from "this machine has no home directory". Call
57 /// [`global_config_path`] directly if you need to tell those apart.
58 /// - **It stats, it does not read.** Discovery only checks that each
59 /// candidate is a file; the contents are read later by
60 /// [`ConfigSource::read`]. A `Context` held across an editing session can
61 /// therefore name a file that has since been deleted, which surfaces as a
62 /// [`CoreError::Io`] from whichever command reads it rather than from
63 /// here. Re-run `discover` if the configuration may have changed.
64 pub fn discover(base_path: Utf8PathBuf) -> Self {
65 let aisle = Self::discover_one(&base_path, AUTO_AISLE);
66 let pantry = Self::discover_one(&base_path, AUTO_PANTRY);
67 Self {
68 base_path,
69 aisle,
70 pantry,
71 }
72 }
73
74 fn discover_one(base_path: &Utf8Path, name: &str) -> ConfigSource {
75 // A global path that cannot be determined at all is simply one fewer
76 // place to look, exactly as in the CLI. This is part of `discover`'s
77 // documented contract.
78 Self::search(base_path, name, global_config_path(name).ok().as_deref())
79 }
80
81 /// The search order, with the global candidate passed in.
82 ///
83 /// Injecting it keeps the ordering testable without the result depending
84 /// on what the machine running the tests has in its home directory.
85 /// `global_config_path` supplies it in production. Resolving it eagerly,
86 /// where the CLI resolves it lazily, makes no observable difference: it
87 /// works out the configuration directory without touching the filesystem,
88 /// and the trace output is unchanged either way.
89 fn search(base_path: &Utf8Path, name: &str, global: Option<&Utf8Path>) -> ConfigSource {
90 let local = base_path.join(LOCAL_CONFIG_DIR).join(name);
91 tracing::trace!("checking local config file: {local}");
92 if local.is_file() {
93 return ConfigSource::Path(local);
94 }
95
96 match global {
97 Some(global) => {
98 tracing::trace!("checking global config file: {global}");
99 if global.is_file() {
100 ConfigSource::Path(global.to_owned())
101 } else {
102 ConfigSource::None
103 }
104 }
105 None => ConfigSource::None,
106 }
107 }
108
109 /// Replace the aisle configuration, whatever discovery found.
110 pub fn with_aisle(mut self, source: ConfigSource) -> Self {
111 self.aisle = source;
112 self
113 }
114
115 /// Replace the pantry configuration, whatever discovery found.
116 pub fn with_pantry(mut self, source: ConfigSource) -> Self {
117 self.pantry = source;
118 self
119 }
120
121 /// The directory recipe paths and searches are resolved against.
122 ///
123 /// Returned exactly as it was supplied. Unlike the CLI, which canonicalises
124 /// it and rejects a non-directory before building a `Context`, core neither
125 /// resolves nor validates it — so a relative path is interpreted against
126 /// the *process* working directory, which for an in-process editor
127 /// integration is the editor's, not the project's. Pass an absolute path
128 /// unless you mean that.
129 pub fn base_path(&self) -> &Utf8Path {
130 &self.base_path
131 }
132
133 /// The aisle configuration to categorise shopping list ingredients with.
134 pub fn aisle(&self) -> &ConfigSource {
135 &self.aisle
136 }
137
138 /// The pantry configuration to filter already-stocked ingredients with.
139 pub fn pantry(&self) -> &ConfigSource {
140 &self.pantry
141 }
142}
143
144/// Resolve `name` inside the global configuration directory for `cook`.
145///
146/// That directory is [`CONFIG_DIR_ENV`] (`COOK_CONFIG_DIR`) when it is set to
147/// a non-empty value, and otherwise the platform's own — `~/.config/cook` on
148/// Linux, `~/Library/Application Support/cook` on macOS,
149/// `%APPDATA%\cook\config` on Windows.
150///
151/// The override is the only portable way to redirect these lookups. The
152/// platform directory is resolved by `directories`, which on Windows asks the
153/// Known Folder API and so ignores `HOME` and `XDG_CONFIG_HOME` entirely —
154/// setting those isolates a test on Unix and silently does nothing on Windows,
155/// where a `cook server` under test would then find the developer's real
156/// `session.json` and sync database. Tests that spawn `cook` set
157/// `COOK_CONFIG_DIR` instead.
158///
159/// The path is returned whether or not anything exists there; nothing is
160/// created.
161///
162/// # Errors
163///
164/// [`CoreError::Config`] if there is no home directory to resolve against, or
165/// if the resolved directory — the override included — is not valid UTF-8.
166/// Both carry no path, because the failure is that no path could be built.
167pub fn global_config_path(name: &str) -> Result<Utf8PathBuf, CoreError> {
168 global_config_path_in(std::env::var_os(CONFIG_DIR_ENV).as_deref(), name)
169}
170
171/// [`global_config_path`] with the override supplied rather than read.
172///
173/// Injecting it keeps the resolution testable: `std::env::set_var` mutates
174/// process-wide state that the rest of the test binary's threads share, so a
175/// test that set `COOK_CONFIG_DIR` for itself would be setting it for whatever
176/// else happened to be running.
177fn global_config_path_in(
178 override_dir: Option<&OsStr>,
179 name: &str,
180) -> Result<Utf8PathBuf, CoreError> {
181 // An empty value is treated as unset, as the XDG variables it stands in
182 // for are: exporting `COOK_CONFIG_DIR=` should not silently resolve the
183 // configuration to the process working directory.
184 if let Some(dir) = override_dir.filter(|dir| !dir.is_empty()) {
185 let dir =
186 Utf8Path::from_path(std::path::Path::new(dir)).ok_or_else(|| CoreError::Config {
187 path: None,
188 message: format!(
189 "{CONFIG_DIR_ENV} is not valid utf-8, and cook only supports utf-8 paths"
190 ),
191 })?;
192 return Ok(dir.join(name));
193 }
194
195 let dirs =
196 directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| CoreError::Config {
197 path: None,
198 message: format!("could not determine the home directory to locate {name}"),
199 })?;
200 let config = Utf8Path::from_path(dirs.config_dir()).ok_or_else(|| CoreError::Config {
201 path: None,
202 message: format!(
203 "the configuration directory holding {name} is not valid utf-8, \
204 and cook only supports utf-8 paths"
205 ),
206 })?;
207 Ok(config.join(name))
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::ConfigSource;
214
215 /// Config files are planted where discovery *would* find them, so this
216 /// fails if `new` ever grows a filesystem lookup. Asserting only that the
217 /// sources come back unset would pass even if `new` called `discover`.
218 #[test]
219 fn new_touches_nothing() {
220 let dir = tempfile::TempDir::new().unwrap();
221 let base = utf8(&dir);
222 write(&base.join("config").join("aisle.conf"), "[produce]\nleek");
223 write(
224 &base.join("config").join("pantry.conf"),
225 "[freezer]\npeas = \"1kg\"",
226 );
227
228 let ctx = Context::new(base.clone());
229 assert!(ctx.aisle().is_unset(), "new must not discover local config");
230 assert!(
231 ctx.pantry().is_unset(),
232 "new must not discover local config"
233 );
234 assert_eq!(ctx.base_path(), base);
235 }
236
237 #[test]
238 fn with_aisle_overrides() {
239 let ctx = Context::new(Utf8PathBuf::from("/tmp"))
240 .with_aisle(ConfigSource::Inline("[produce]\nleek".to_string()));
241 assert_eq!(
242 ctx.aisle().read().unwrap().as_deref(),
243 Some("[produce]\nleek")
244 );
245 assert!(
246 ctx.pantry().is_unset(),
247 "with_aisle must not set the pantry"
248 );
249 }
250
251 #[test]
252 fn with_pantry_overrides() {
253 let ctx = Context::new(Utf8PathBuf::from("/tmp")).with_pantry(ConfigSource::Inline(
254 "[freezer]\npeas = \"1kg\"".to_string(),
255 ));
256 assert_eq!(
257 ctx.pantry().read().unwrap().as_deref(),
258 Some("[freezer]\npeas = \"1kg\"")
259 );
260 assert!(ctx.aisle().is_unset(), "with_pantry must not set the aisle");
261 }
262
263 #[test]
264 fn discover_finds_local_config() {
265 let dir = tempfile::TempDir::new().unwrap();
266 let base = utf8(&dir);
267 write(&base.join("config").join("aisle.conf"), "[produce]\nleek");
268 write(
269 &base.join("config").join("pantry.conf"),
270 "[freezer]\npeas = \"1kg\"",
271 );
272
273 let ctx = Context::discover(base.clone());
274
275 assert_eq!(
276 ctx.aisle().path(),
277 Some(base.join("config").join("aisle.conf").as_path())
278 );
279 assert_eq!(
280 ctx.pantry().path(),
281 Some(base.join("config").join("pantry.conf").as_path())
282 );
283 }
284
285 #[test]
286 fn global_config_path_joins_the_app_name() {
287 // Asserted as properties rather than a fixed suffix, because the shape
288 // of the prefix is the platform's: `~/.config/cook/aisle.conf` on
289 // Linux and `…/Application Support/cook/aisle.conf` on macOS put the
290 // app name immediately before the file, but on Windows `directories`
291 // yields `…\Roaming\cook\config`, so the last component before the
292 // file is `config`. What holds everywhere is that the file is named
293 // last, somewhere under a directory belonging to `cook`.
294 //
295 // Resolved with the override explicitly absent, so the assertion still
296 // describes the platform directory on a machine that happens to export
297 // `COOK_CONFIG_DIR`.
298 let path = global_config_path_in(None, "aisle.conf").expect("a home directory");
299 assert_eq!(
300 path.file_name(),
301 Some("aisle.conf"),
302 "the name asked for must be the last component: {path}"
303 );
304 assert!(
305 path.components().any(|c| c.as_str() == APP_NAME),
306 "expected a `{APP_NAME}` component in {path}"
307 );
308 assert!(
309 path.is_absolute(),
310 "the platform config directory is absolute: {path}"
311 );
312 }
313
314 // The override is exercised through `global_config_path_in`, which takes
315 // the environment value as a parameter. `std::env::set_var` would reach
316 // every other thread in the test binary, and these run in parallel with
317 // `global_config_path_joins_the_app_name` above, which asserts the
318 // *un*-overridden result.
319
320 #[test]
321 fn config_dir_env_replaces_the_platform_directory() {
322 let dir = Utf8PathBuf::from("/somewhere/else");
323 let path =
324 global_config_path_in(Some(OsStr::new(dir.as_str())), "session.json").expect("a path");
325 assert_eq!(path, dir.join("session.json"));
326 }
327
328 /// The point of the override: no component of the platform directory —
329 /// `~/.config`, `Application Support`, `%APPDATA%` — survives it. Without
330 /// this, a test setting `COOK_CONFIG_DIR` could still be reading the
331 /// developer's real `session.json`.
332 #[test]
333 fn config_dir_env_leaves_nothing_of_the_platform_directory() {
334 let dir = Utf8PathBuf::from("/somewhere/isolated");
335 let overridden =
336 global_config_path_in(Some(OsStr::new(dir.as_str())), "sync.db").expect("a path");
337 let platform = global_config_path_in(None, "sync.db").expect("a home directory");
338 assert_eq!(overridden, dir.join("sync.db"));
339 assert_ne!(overridden, platform);
340 }
341
342 /// `COOK_CONFIG_DIR=` means "unset", not "the working directory".
343 #[test]
344 fn an_empty_config_dir_env_falls_back_to_the_platform_directory() {
345 let empty = global_config_path_in(Some(OsStr::new("")), "aisle.conf").expect("a path");
346 let unset = global_config_path_in(None, "aisle.conf").expect("a home directory");
347 assert_eq!(empty, unset);
348 }
349
350 /// Discovery honours the override through the same call the CLI makes, so
351 /// a global `aisle.conf` placed there is found when there is no local one.
352 #[test]
353 fn a_config_dir_env_aisle_is_discoverable() {
354 let dir = tempfile::TempDir::new().unwrap();
355 let base = utf8(&dir);
356 let config = base.join("isolated");
357 write(&config.join("aisle.conf"), "[produce]\nleek");
358
359 let global =
360 global_config_path_in(Some(OsStr::new(config.as_str())), "aisle.conf").expect("a path");
361 let found = Context::search(&base, "aisle.conf", Some(&global));
362
363 assert_eq!(found, ConfigSource::Path(config.join("aisle.conf")));
364 }
365
366 #[cfg(unix)]
367 #[test]
368 fn a_non_utf8_config_dir_env_is_an_error() {
369 use std::os::unix::ffi::OsStrExt;
370
371 let err = global_config_path_in(Some(OsStr::from_bytes(b"/tmp/\xff")), "session.json")
372 .expect_err("non-utf-8 override");
373 assert!(
374 err.to_string().contains(CONFIG_DIR_ENV),
375 "the message should name the variable at fault: {err}"
376 );
377 }
378
379 fn write(path: &Utf8Path, text: &str) {
380 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
381 std::fs::write(path, text).unwrap();
382 }
383
384 fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
385 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
386 }
387
388 // The search order is exercised through `Context::search`, which takes the
389 // global candidate as a parameter. Going through `discover` instead would
390 // make these depend on whether the machine running them happens to have a
391 // `~/.config/cook/aisle.conf`.
392 #[test]
393 fn local_config_wins_over_global() {
394 let dir = tempfile::TempDir::new().unwrap();
395 let base = utf8(&dir);
396 let local = base.join("config").join("aisle.conf");
397 let global = base.join("global").join("aisle.conf");
398 write(&local, "[produce]\nleek");
399 write(&global, "[dairy]\nmilk");
400
401 let found = Context::search(&base, "aisle.conf", Some(&global));
402 assert_eq!(found, ConfigSource::Path(local));
403 }
404
405 #[test]
406 fn global_config_is_used_when_there_is_no_local_one() {
407 let dir = tempfile::TempDir::new().unwrap();
408 let base = utf8(&dir);
409 let global = base.join("global").join("pantry.conf");
410 write(&global, "[freezer]\npeas = \"1kg\"");
411
412 let found = Context::search(&base, "pantry.conf", Some(&global));
413 assert_eq!(found, ConfigSource::Path(global));
414 }
415
416 #[test]
417 fn absent_everywhere_is_unset() {
418 let dir = tempfile::TempDir::new().unwrap();
419 let base = utf8(&dir);
420 let global = base.join("global").join("pantry.conf");
421
422 assert!(Context::search(&base, "pantry.conf", Some(&global)).is_unset());
423 assert!(Context::search(&base, "pantry.conf", None).is_unset());
424 }
425}