Skip to main content

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}