Skip to main content

cooklang_find/tree/
mod.rs

1//! Recipe tree building for directory hierarchies.
2//!
3//! This module provides functionality to build hierarchical tree structures
4//! that represent the organization of recipe files within a directory tree.
5
6use crate::model::{RecipeEntry, RecipeEntryError};
7use camino::{Utf8Path, Utf8PathBuf};
8use glob::glob;
9use thiserror::Error;
10
11mod model;
12pub use model::RecipeTree;
13
14/// Errors that can occur when building a recipe tree.
15#[derive(Error, Debug)]
16pub enum TreeError {
17    #[error("Directory does not exist: {0}")]
18    DirectoryNotFound(String),
19
20    #[error("Path is not a directory: {0}")]
21    NotADirectory(String),
22
23    #[error("Failed to read directory: {0}")]
24    GlobError(#[from] glob::GlobError),
25
26    #[error("Failed to create glob pattern: {0}")]
27    PatternError(#[from] glob::PatternError),
28
29    #[error("Failed to process recipe: {0}")]
30    RecipeEntryError(#[from] RecipeEntryError),
31
32    #[error("Failed to strip prefix from path: {0}")]
33    StripPrefixError(String),
34}
35
36/// Builds a hierarchical tree structure of all recipes in a directory.
37///
38/// This function recursively scans the specified directory and all its
39/// subdirectories for .cook and .menu files, organizing them into a tree
40/// structure that mirrors the filesystem hierarchy.
41///
42/// # Arguments
43///
44/// * `base_dir` - The root directory to build the tree from
45///
46/// # Returns
47///
48/// Returns a `RecipeTree` representing the directory structure with all
49/// recipes loaded, or a `TreeError` if the operation fails.
50///
51/// # Examples
52///
53/// ```no_run
54/// use cooklang_find::build_tree;
55/// use camino::Utf8Path;
56///
57/// // Build a tree of all recipes in a directory
58/// let tree = build_tree(Utf8Path::new("./recipes"))?;
59///
60/// // Access recipes in the tree
61/// for (name, node) in &tree.children {
62///     if let Some(recipe) = &node.recipe {
63///         println!("Found recipe: {}", name);
64///     }
65/// }
66/// # Ok::<(), Box<dyn std::error::Error>>(())
67/// ```
68pub fn build_tree<P: AsRef<Utf8Path>>(base_dir: P) -> Result<RecipeTree, TreeError> {
69    let base_dir = base_dir.as_ref();
70
71    // Check if directory exists
72    if !base_dir.exists() {
73        return Err(TreeError::DirectoryNotFound(base_dir.to_string()));
74    }
75    if !base_dir.is_dir() {
76        return Err(TreeError::NotADirectory(base_dir.to_string()));
77    }
78
79    let base_name = base_dir
80        .file_name()
81        .map(|n| n.to_string())
82        .unwrap_or_else(|| String::from("./"));
83
84    let mut root = RecipeTree::new(base_name, base_dir.to_path_buf());
85
86    // First, find all .cook and .menu files in this directory and subdirectories
87    let patterns = vec![
88        base_dir.join("**/*.cook").to_string(),
89        base_dir.join("**/*.menu").to_string(),
90    ];
91
92    for pattern in patterns {
93        for entry in glob(&pattern)? {
94            let path = entry?;
95            let path = Utf8PathBuf::from_path_buf(path).map_err(|_| {
96                TreeError::StripPrefixError("Path contains invalid UTF-8".to_string())
97            })?;
98            let recipe = match RecipeEntry::from_path(path.clone()) {
99                Ok(r) => r,
100                Err(_) => continue, // Skip files whose content isn't available (e.g. iCloud)
101            };
102
103            // Calculate the relative path from the base directory
104            let rel_path = path
105                .strip_prefix(base_dir)
106                .map_err(|_| TreeError::StripPrefixError(path.to_string()))?;
107
108            // Build the tree structure
109            let mut current = &mut root;
110            let components: Vec<_> = rel_path
111                .parent()
112                .map(|p| p.components().collect())
113                .unwrap_or_default();
114
115            // Create directory nodes
116            for component in components {
117                let name = component.to_string();
118                let path = current.path.join(&name);
119                current = current
120                    .children
121                    .entry(name.clone())
122                    .or_insert_with(|| RecipeTree::new(name, path));
123            }
124
125            // Add the recipe as a leaf node
126            let name = recipe.name().clone().unwrap();
127
128            current.children.insert(
129                name.clone(),
130                RecipeTree::new_with_recipe(name, path, recipe),
131            );
132        }
133    }
134
135    Ok(root)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use indoc::indoc;
142    use std::fs;
143    use tempfile::TempDir;
144
145    fn create_test_recipe(dir: &Utf8Path, name: &str, content: &str) -> Utf8PathBuf {
146        let path = dir.join(format!("{name}.cook"));
147        fs::write(&path, content).unwrap();
148        path
149    }
150
151    fn create_test_image(dir: &Utf8Path, name: &str, ext: &str) -> Utf8PathBuf {
152        let path = dir.join(format!("{name}.{ext}"));
153        fs::write(&path, "dummy image content").unwrap();
154        path
155    }
156
157    #[test]
158    fn test_empty_directory() {
159        let temp_dir = TempDir::new().unwrap();
160        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
161        let tree = build_tree(&temp_dir_path).unwrap();
162
163        assert_eq!(tree.name, temp_dir_path.file_name().unwrap().to_string());
164        assert_eq!(tree.path, temp_dir_path);
165        assert!(tree.recipe.is_none());
166        assert!(tree.children.is_empty());
167    }
168
169    #[test]
170    fn test_single_recipe() {
171        let temp_dir = TempDir::new().unwrap();
172        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
173        create_test_recipe(
174            &temp_dir_path,
175            "pancakes",
176            indoc! {r#"
177                ---
178                servings: 4
179                ---
180
181                Make pancakes"#},
182        );
183
184        let tree = build_tree(&temp_dir_path).unwrap();
185
186        assert_eq!(tree.children.len(), 1);
187        let recipe_node = tree.children.get("pancakes").unwrap();
188        assert_eq!(recipe_node.name, "pancakes");
189        assert!(recipe_node.recipe.is_some());
190        assert!(recipe_node.children.is_empty());
191    }
192
193    #[test]
194    fn test_recipe_with_image() {
195        let temp_dir = TempDir::new().unwrap();
196        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
197        create_test_recipe(
198            &temp_dir_path,
199            "pancakes",
200            indoc! {r#"
201                ---
202                servings: 4
203                ---
204
205                Make pancakes"#},
206        );
207        create_test_image(&temp_dir_path, "pancakes", "jpg");
208
209        let tree = build_tree(&temp_dir_path).unwrap();
210
211        let recipe_node = tree.children.get("pancakes").unwrap();
212        assert!(recipe_node.recipe.as_ref().unwrap().title_image().is_some());
213    }
214
215    #[test]
216    fn test_nested_directories() {
217        let temp_dir = TempDir::new().unwrap();
218        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
219
220        // Create nested directory structure
221        let breakfast_dir = temp_dir_path.join("breakfast");
222        let dessert_dir = temp_dir_path.join("dessert");
223        fs::create_dir_all(&breakfast_dir).unwrap();
224        fs::create_dir_all(&dessert_dir).unwrap();
225
226        // Add recipes
227        create_test_recipe(
228            &breakfast_dir,
229            "pancakes",
230            indoc! {r#"
231                ---
232                servings: 4
233                ---
234
235                Make pancakes"#},
236        );
237        create_test_recipe(
238            &breakfast_dir,
239            "waffles",
240            indoc! {r#"
241                ---
242                servings: 2
243                ---
244
245                Make waffles"#},
246        );
247        create_test_recipe(
248            &dessert_dir,
249            "cake",
250            indoc! {r#"
251                ---
252                servings: 8
253                ---
254
255                Bake cake"#},
256        );
257
258        let tree = build_tree(&temp_dir_path).unwrap();
259
260        assert_eq!(tree.children.len(), 2);
261
262        // Check breakfast directory
263        let breakfast = tree.children.get("breakfast").unwrap();
264        assert_eq!(breakfast.name, "breakfast");
265        assert!(breakfast.recipe.is_none());
266        assert_eq!(breakfast.children.len(), 2);
267        assert!(breakfast.children.contains_key("pancakes"));
268        assert!(breakfast.children.contains_key("waffles"));
269
270        // Check dessert directory
271        let dessert = tree.children.get("dessert").unwrap();
272        assert_eq!(dessert.name, "dessert");
273        assert!(dessert.recipe.is_none());
274        assert_eq!(dessert.children.len(), 1);
275        assert!(dessert.children.contains_key("cake"));
276    }
277
278    #[test]
279    fn test_deeply_nested_recipe() {
280        let temp_dir = TempDir::new().unwrap();
281        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
282        let deep_path = temp_dir_path.join("a/b/c/d");
283        fs::create_dir_all(&deep_path).unwrap();
284
285        create_test_recipe(
286            &deep_path,
287            "deep_recipe",
288            indoc! {r#"
289                ---
290                servings: 1
291                ---
292
293                Deep recipe"#},
294        );
295
296        let tree = build_tree(&temp_dir_path).unwrap();
297
298        let a = tree.children.get("a").unwrap();
299        let b = a.children.get("b").unwrap();
300        let c = b.children.get("c").unwrap();
301        let d = c.children.get("d").unwrap();
302        let recipe = d.children.get("deep_recipe").unwrap();
303
304        assert!(recipe.recipe.is_some());
305        assert_eq!(recipe.name, "deep_recipe");
306    }
307
308    #[test]
309    fn test_invalid_directory() {
310        let result = build_tree(Utf8Path::new("/nonexistent/directory"));
311        assert!(result.is_err());
312        assert!(result
313            .unwrap_err()
314            .to_string()
315            .contains("Directory does not exist"));
316    }
317
318    #[test]
319    fn test_recipe_tree_new() {
320        let tree = RecipeTree::new("test".to_string(), Utf8PathBuf::from("/test/path"));
321
322        assert_eq!(tree.name, "test");
323        assert_eq!(tree.path, Utf8PathBuf::from("/test/path"));
324        assert!(tree.recipe.is_none());
325        assert!(tree.children.is_empty());
326    }
327
328    #[test]
329    fn test_recipe_tree_new_with_recipe() {
330        let temp_dir = TempDir::new().unwrap();
331        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
332        let recipe_path = create_test_recipe(
333            &temp_dir_path,
334            "test_recipe",
335            indoc! {r#"
336                ---
337                servings: 4
338                ---
339
340                Test recipe"#},
341        );
342
343        let recipe = RecipeEntry::from_path(recipe_path.clone()).unwrap();
344        let tree = RecipeTree::new_with_recipe("test_recipe".to_string(), recipe_path, recipe);
345
346        assert_eq!(tree.name, "test_recipe");
347        assert!(tree.recipe.is_some());
348        assert!(tree.children.is_empty());
349    }
350}