Skip to main content

cookcli_core/
source.rs

1//! Input sources. Core never touches the filesystem unless handed a path.
2
3use crate::CoreError;
4use camino::{Utf8Path, Utf8PathBuf};
5
6/// Where a recipe comes from.
7///
8/// `Content` exists so editors can pass an unsaved buffer straight in — the
9/// case a path-only API cannot serve.
10#[non_exhaustive]
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum RecipeSource {
13    /// A path or bare recipe name, resolved through `cooklang-find`.
14    Path(Utf8PathBuf),
15    /// In-memory recipe text.
16    Content {
17        /// The recipe source text.
18        text: String,
19        /// The name to use in diagnostics and titles.
20        name: String,
21    },
22}
23
24/// Where an aisle or pantry configuration comes from.
25#[non_exhaustive]
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub enum ConfigSource {
28    /// Read the configuration from this file.
29    Path(Utf8PathBuf),
30    /// Use this configuration text directly.
31    Inline(String),
32    /// No configuration. Commands fall back to their unconfigured behaviour.
33    #[default]
34    None,
35}
36
37impl ConfigSource {
38    /// Read the configuration text, if any.
39    ///
40    /// Returns `Ok(None)` for [`ConfigSource::None`].
41    ///
42    /// # Errors
43    ///
44    /// [`CoreError::Io`] if a path-backed source cannot be read.
45    pub fn read(&self) -> Result<Option<String>, CoreError> {
46        match self {
47            ConfigSource::None => Ok(None),
48            ConfigSource::Inline(text) => Ok(Some(text.clone())),
49            ConfigSource::Path(path) => match std::fs::read_to_string(path) {
50                Ok(text) => Ok(Some(text)),
51                Err(source) => Err(CoreError::Io {
52                    path: path.clone(),
53                    source,
54                }),
55            },
56        }
57    }
58
59    /// True when this source carries no configuration at all.
60    pub fn is_unset(&self) -> bool {
61        matches!(self, ConfigSource::None)
62    }
63
64    /// The path this source reads from, when it is path-backed.
65    pub fn path(&self) -> Option<&Utf8Path> {
66        match self {
67            ConfigSource::Path(p) => Some(p),
68            _ => None,
69        }
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn none_reads_as_none() {
79        assert!(ConfigSource::None.read().unwrap().is_none());
80        assert!(ConfigSource::None.is_unset());
81    }
82
83    #[test]
84    fn inline_reads_its_text() {
85        let source = ConfigSource::Inline("[produce]\ntomato".to_string());
86        assert_eq!(source.read().unwrap().as_deref(), Some("[produce]\ntomato"));
87        assert!(!source.is_unset());
88        assert_eq!(source.path(), None);
89    }
90
91    #[test]
92    fn path_reads_from_disk() {
93        let dir = tempfile::TempDir::new().unwrap();
94        let path = dir.path().join("aisle.conf");
95        std::fs::write(&path, "[dairy]\nmilk").unwrap();
96        let utf8 = camino::Utf8PathBuf::from_path_buf(path).unwrap();
97
98        let source = ConfigSource::Path(utf8.clone());
99        assert_eq!(source.read().unwrap().as_deref(), Some("[dairy]\nmilk"));
100        assert_eq!(source.path(), Some(utf8.as_path()));
101    }
102
103    #[test]
104    fn missing_path_is_an_io_error_naming_the_path() {
105        let missing = camino::Utf8PathBuf::from("/nonexistent/aisle.conf");
106        let source = ConfigSource::Path(missing.clone());
107
108        match source.read() {
109            Err(CoreError::Io { path, source }) => {
110                assert_eq!(path, missing);
111                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
112            }
113            other => panic!("expected CoreError::Io, got {other:?}"),
114        }
115    }
116}