Skip to main content

cooklang_find/fetcher/
mod.rs

1//! Recipe fetching functionality.
2//!
3//! This module provides functions for finding and loading recipe files
4//! from the filesystem. It supports searching multiple directories and
5//! automatically handles both .cook and .menu file extensions.
6
7use crate::model::{RecipeEntry, RecipeEntryError};
8use camino::{Utf8Path, Utf8PathBuf};
9use thiserror::Error;
10
11/// Errors that can occur when fetching recipes.
12#[derive(Error, Debug)]
13pub enum FetchError {
14    #[error("Failed to read recipe file: {0}")]
15    IoError(#[from] std::io::Error),
16
17    #[error("Failed to parse recipe: {0}")]
18    RecipeEntryError(#[from] RecipeEntryError),
19
20    #[error("Invalid recipe path: {0}")]
21    InvalidPath(Utf8PathBuf),
22}
23
24/// Searches for and loads a recipe by name from the specified directories.
25///
26/// This function searches through the provided base directories in order,
27/// looking for a recipe file that matches the given name. It supports:
28/// - Direct file paths with extensions (e.g., "recipe.cook", "menu.menu")
29/// - Names without extensions (automatically tries .cook and .menu)
30///
31/// # Arguments
32///
33/// * `base_dirs` - An iterator of directory paths to search in order
34/// * `name` - The recipe name or path to search for
35///
36/// # Returns
37///
38/// Returns the first matching `RecipeEntry` found, or a `FetchError` if no
39/// matching recipe is found in any of the directories.
40///
41/// # Examples
42///
43/// ```no_run
44/// use cooklang_find::get_recipe;
45/// use camino::Utf8PathBuf;
46///
47/// // Search for "pancakes.cook" or "pancakes.menu" in multiple directories
48/// let dirs = vec![Utf8PathBuf::from("./recipes"), Utf8PathBuf::from("./meals")];
49/// let recipe = get_recipe(dirs, Utf8PathBuf::from("pancakes"))?;
50/// # Ok::<(), Box<dyn std::error::Error>>(())
51/// ```
52pub fn get_recipe<P: AsRef<Utf8Path>>(
53    base_dirs: impl IntoIterator<Item = P>,
54    name: P,
55) -> Result<RecipeEntry, FetchError> {
56    let name = name.as_ref();
57
58    for base_dir in base_dirs {
59        if name.extension().is_some() {
60            // If the name already has an extension, use it as-is
61            let recipe_path = base_dir.as_ref().join(name);
62            if recipe_path.exists() {
63                return RecipeEntry::from_path(recipe_path).map_err(FetchError::RecipeEntryError);
64            }
65        } else {
66            // Try both .cook and .menu extensions
67            let cook_path = base_dir.as_ref().join(format!("{name}.cook"));
68            if cook_path.exists() {
69                return RecipeEntry::from_path(cook_path).map_err(FetchError::RecipeEntryError);
70            }
71
72            let menu_path = base_dir.as_ref().join(format!("{name}.menu"));
73            if menu_path.exists() {
74                return RecipeEntry::from_path(menu_path).map_err(FetchError::RecipeEntryError);
75            }
76        }
77    }
78
79    Err(FetchError::InvalidPath(name.to_path_buf()))
80}
81
82/// Convenience function to search for recipes using string paths.
83///
84/// This is a wrapper around `get_recipe` that accepts string references
85/// instead of `Utf8Path` types, making it easier to use with string literals.
86///
87/// # Arguments
88///
89/// * `base_dirs` - An iterator of directory path strings to search
90/// * `name` - The recipe name to search for
91///
92/// # Examples
93///
94/// ```no_run
95/// use cooklang_find::get_recipe_str;
96///
97/// // Search using string paths
98/// let recipe = get_recipe_str(vec!["./recipes", "./meals"], "pancakes")?;
99/// # Ok::<(), Box<dyn std::error::Error>>(())
100/// ```
101pub fn get_recipe_str(
102    base_dirs: impl IntoIterator<Item = impl AsRef<str>>,
103    name: &str,
104) -> Result<RecipeEntry, FetchError> {
105    let base_dirs: Vec<Utf8PathBuf> = base_dirs
106        .into_iter()
107        .map(|s| Utf8PathBuf::from(s.as_ref()))
108        .collect();
109    let name = Utf8PathBuf::from(name);
110    get_recipe(base_dirs, name)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use indoc::indoc;
117    use std::fs;
118    use tempfile::TempDir;
119
120    fn create_test_recipe(dir: &Utf8Path, name: &str, content: &str) -> Utf8PathBuf {
121        let path = if name.ends_with(".cook") {
122            dir.join(name)
123        } else {
124            dir.join(format!("{name}.cook"))
125        };
126        fs::write(&path, content).unwrap();
127        path
128    }
129
130    #[test]
131    fn test_get_recipe_found() {
132        let temp_dir = TempDir::new().unwrap();
133        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
134        create_test_recipe(
135            &temp_dir_path,
136            "pancakes",
137            indoc! {r#"
138                ---
139                servings: 4
140                ---
141
142                Make pancakes"#},
143        );
144
145        let result = get_recipe([&temp_dir_path], &Utf8PathBuf::from("pancakes")).unwrap();
146        assert_eq!(result.name().as_ref().unwrap(), "pancakes");
147    }
148
149    #[test]
150    fn test_get_recipe_not_found() {
151        let temp_dir = TempDir::new().unwrap();
152        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
153        let result = get_recipe([&temp_dir_path], &Utf8PathBuf::from("nonexistent"));
154        assert!(matches!(result, Err(FetchError::InvalidPath(_))));
155    }
156
157    #[test]
158    fn test_get_recipe_multiple_directories() {
159        let dir1 = TempDir::new().unwrap();
160        let dir2 = TempDir::new().unwrap();
161        let dir1_path = Utf8PathBuf::from_path_buf(dir1.path().to_path_buf()).unwrap();
162        let dir2_path = Utf8PathBuf::from_path_buf(dir2.path().to_path_buf()).unwrap();
163
164        create_test_recipe(
165            &dir2_path,
166            "pancakes",
167            indoc! {r#"
168                ---
169                servings: 4
170                ---
171
172                Make pancakes"#},
173        );
174
175        let result = get_recipe([&dir1_path, &dir2_path], &Utf8PathBuf::from("pancakes")).unwrap();
176        assert_eq!(result.name().as_ref().unwrap(), "pancakes");
177    }
178
179    #[test]
180    fn test_get_recipe_first_directory_priority() {
181        let dir1 = TempDir::new().unwrap();
182        let dir2 = TempDir::new().unwrap();
183        let dir1_path = Utf8PathBuf::from_path_buf(dir1.path().to_path_buf()).unwrap();
184        let dir2_path = Utf8PathBuf::from_path_buf(dir2.path().to_path_buf()).unwrap();
185
186        create_test_recipe(
187            &dir1_path,
188            "pancakes",
189            indoc! {r#"
190                ---
191                servings: 2
192                ---
193
194                Dir1 pancakes"#},
195        );
196        create_test_recipe(
197            &dir2_path,
198            "pancakes",
199            indoc! {r#"
200                ---
201                servings: 4
202                ---
203
204                Dir2 pancakes"#},
205        );
206
207        let result = get_recipe([&dir1_path, &dir2_path], &Utf8PathBuf::from("pancakes")).unwrap();
208        assert_eq!(result.name().as_ref().unwrap(), "pancakes");
209        assert!(result.path().unwrap().starts_with(&dir1_path)); // Should find the recipe in the first directory
210    }
211
212    #[test]
213    fn test_get_recipe_invalid_directory() {
214        let result = get_recipe(
215            [Utf8PathBuf::from("/nonexistent/directory")],
216            Utf8PathBuf::from("recipe"),
217        );
218        assert!(matches!(result, Err(FetchError::InvalidPath(_))));
219    }
220
221    #[test]
222    fn test_get_recipe_empty_directories() {
223        let result = get_recipe(
224            std::iter::empty::<Utf8PathBuf>(),
225            Utf8PathBuf::from("recipe"),
226        );
227        assert!(matches!(result, Err(FetchError::InvalidPath(_))));
228    }
229
230    #[test]
231    fn test_get_recipe_with_subdirectories() {
232        let temp_dir = TempDir::new().unwrap();
233        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
234        let sub_dir = temp_dir_path.join("breakfast");
235        fs::create_dir_all(&sub_dir).unwrap();
236
237        create_test_recipe(
238            &sub_dir,
239            "pancakes",
240            indoc! {r#"
241                ---
242                servings: 4
243                ---
244
245                Make pancakes"#},
246        );
247
248        // Should not find recipe in subdirectory when searching base directory
249        let result = get_recipe([&temp_dir_path], &Utf8PathBuf::from("pancakes"));
250        assert!(matches!(result, Err(FetchError::InvalidPath(_))));
251
252        // Should find recipe when searching subdirectory directly
253        let result = get_recipe([&sub_dir], &Utf8PathBuf::from("pancakes")).unwrap();
254        assert_eq!(result.name().as_ref().unwrap(), "pancakes");
255    }
256
257    #[test]
258    fn test_get_recipe_with_existing_extension() {
259        let temp_dir = TempDir::new().unwrap();
260        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
261        create_test_recipe(
262            &temp_dir_path,
263            "pancakes.cook",
264            indoc! {r#"
265                ---
266                servings: 4
267                ---
268
269                Make pancakes"#},
270        );
271
272        // Should find recipe when name already includes .cook extension
273        let result = get_recipe([&temp_dir_path], &Utf8PathBuf::from("pancakes.cook")).unwrap();
274        assert_eq!(result.name().as_ref().unwrap(), "pancakes");
275    }
276
277    #[test]
278    fn test_get_recipe_with_menu_extension() {
279        let temp_dir = TempDir::new().unwrap();
280        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
281
282        // Create a .menu file
283        let menu_path = temp_dir_path.join("weekly.menu");
284        fs::write(
285            &menu_path,
286            indoc! {r#"
287            ---
288            title: Weekly Menu
289            ---
290
291            Menu content here"#},
292        )
293        .unwrap();
294
295        // Should find file when name includes .menu extension
296        let result = get_recipe([&temp_dir_path], &Utf8PathBuf::from("weekly.menu")).unwrap();
297        assert_eq!(result.path(), Some(&menu_path));
298    }
299}