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};
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/// The configuration bundle every command operates against.
12///
13/// [`Context::new`] performs no filesystem access. Ambient configuration
14/// discovery is opt-in through [`Context::discover`], so a caller that already
15/// knows its configuration — an editor holding an unsaved buffer, say — never
16/// has the user's `~/.config` read behind its back.
17#[derive(Debug, Clone)]
18pub struct Context {
19    base_path: Utf8PathBuf,
20    aisle: ConfigSource,
21    pantry: ConfigSource,
22}
23
24impl Context {
25    /// A context with no aisle or pantry configuration. Touches nothing.
26    pub fn new(base_path: Utf8PathBuf) -> Self {
27        Self {
28            base_path,
29            aisle: ConfigSource::None,
30            pantry: ConfigSource::None,
31        }
32    }
33
34    /// A context with aisle and pantry resolved using CookCLI's search order:
35    /// `<base>/config/<name>` first, then the platform configuration directory
36    /// ([`global_config_path`] — `~/.config/cook/<name>` on Linux, the platform
37    /// equivalent elsewhere).
38    ///
39    /// This is the only constructor that reads ambient state, and it is
40    /// explicitly opted into.
41    ///
42    /// Two things to know before relying on it:
43    ///
44    /// - **It reports no errors.** A configuration directory that cannot be
45    ///   resolved at all — no home directory, or a non-UTF-8 path — is treated
46    ///   as one fewer place to look, exactly as the CLI treats it. An unset
47    ///   [`ConfigSource`] therefore does not distinguish "the user has no
48    ///   config file" from "this machine has no home directory". Call
49    ///   [`global_config_path`] directly if you need to tell those apart.
50    /// - **It stats, it does not read.** Discovery only checks that each
51    ///   candidate is a file; the contents are read later by
52    ///   [`ConfigSource::read`]. A `Context` held across an editing session can
53    ///   therefore name a file that has since been deleted, which surfaces as a
54    ///   [`CoreError::Io`] from whichever command reads it rather than from
55    ///   here. Re-run `discover` if the configuration may have changed.
56    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        // A global path that cannot be determined at all is simply one fewer
68        // place to look, exactly as in the CLI. This is part of `discover`'s
69        // documented contract.
70        Self::search(base_path, name, global_config_path(name).ok().as_deref())
71    }
72
73    /// The search order, with the global candidate passed in.
74    ///
75    /// Injecting it keeps the ordering testable without the result depending
76    /// on what the machine running the tests has in its home directory.
77    /// `global_config_path` supplies it in production. Resolving it eagerly,
78    /// where the CLI resolves it lazily, makes no observable difference: it
79    /// works out the configuration directory without touching the filesystem,
80    /// and the trace output is unchanged either way.
81    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    /// Replace the aisle configuration, whatever discovery found.
102    pub fn with_aisle(mut self, source: ConfigSource) -> Self {
103        self.aisle = source;
104        self
105    }
106
107    /// Replace the pantry configuration, whatever discovery found.
108    pub fn with_pantry(mut self, source: ConfigSource) -> Self {
109        self.pantry = source;
110        self
111    }
112
113    /// The directory recipe paths and searches are resolved against.
114    ///
115    /// Returned exactly as it was supplied. Unlike the CLI, which canonicalises
116    /// it and rejects a non-directory before building a `Context`, core neither
117    /// resolves nor validates it — so a relative path is interpreted against
118    /// the *process* working directory, which for an in-process editor
119    /// integration is the editor's, not the project's. Pass an absolute path
120    /// unless you mean that.
121    pub fn base_path(&self) -> &Utf8Path {
122        &self.base_path
123    }
124
125    /// The aisle configuration to categorise shopping list ingredients with.
126    pub fn aisle(&self) -> &ConfigSource {
127        &self.aisle
128    }
129
130    /// The pantry configuration to filter already-stocked ingredients with.
131    pub fn pantry(&self) -> &ConfigSource {
132        &self.pantry
133    }
134}
135
136/// Resolve `name` inside the platform configuration directory for `cook`, e.g.
137/// `~/.config/cook/aisle.conf` on Linux.
138///
139/// The path is returned whether or not anything exists there.
140///
141/// # Errors
142///
143/// [`CoreError::Config`] if there is no home directory to resolve against, or
144/// if the platform configuration directory is not valid UTF-8. Both carry no
145/// path, because the failure is that no path could be built.
146pub 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    /// Config files are planted where discovery *would* find them, so this
168    /// fails if `new` ever grows a filesystem lookup. Asserting only that the
169    /// sources come back unset would pass even if `new` called `discover`.
170    #[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        // Asserted as properties rather than a fixed suffix, because the shape
240        // of the prefix is the platform's: `~/.config/cook/aisle.conf` on
241        // Linux and `…/Application Support/cook/aisle.conf` on macOS put the
242        // app name immediately before the file, but on Windows `directories`
243        // yields `…\Roaming\cook\config`, so the last component before the
244        // file is `config`. What holds everywhere is that the file is named
245        // last, somewhere under a directory belonging to `cook`.
246        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    // The search order is exercised through `Context::search`, which takes the
272    // global candidate as a parameter. Going through `discover` instead would
273    // make these depend on whether the machine running them happens to have a
274    // `~/.config/cook/aisle.conf`.
275    #[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}