cooklang-find 0.6.0

Library for finding and managing Cooklang recipes in the filesystem
Documentation
//! Recipe tree building for directory hierarchies.
//!
//! This module provides functionality to build hierarchical tree structures
//! that represent the organization of recipe files within a directory tree.

use crate::model::{RecipeEntry, RecipeEntryError};
use camino::{Utf8Path, Utf8PathBuf};
use glob::glob;
use thiserror::Error;

mod model;
pub use model::RecipeTree;

/// Errors that can occur when building a recipe tree.
#[derive(Error, Debug)]
pub enum TreeError {
    #[error("Directory does not exist: {0}")]
    DirectoryNotFound(String),

    #[error("Path is not a directory: {0}")]
    NotADirectory(String),

    #[error("Failed to read directory: {0}")]
    GlobError(#[from] glob::GlobError),

    #[error("Failed to create glob pattern: {0}")]
    PatternError(#[from] glob::PatternError),

    #[error("Failed to process recipe: {0}")]
    RecipeEntryError(#[from] RecipeEntryError),

    #[error("Failed to strip prefix from path: {0}")]
    StripPrefixError(String),
}

/// Builds a hierarchical tree structure of all recipes in a directory.
///
/// This function recursively scans the specified directory and all its
/// subdirectories for .cook and .menu files, organizing them into a tree
/// structure that mirrors the filesystem hierarchy.
///
/// # Arguments
///
/// * `base_dir` - The root directory to build the tree from
///
/// # Returns
///
/// Returns a `RecipeTree` representing the directory structure with all
/// recipes loaded, or a `TreeError` if the operation fails.
///
/// # Examples
///
/// ```no_run
/// use cooklang_find::build_tree;
/// use camino::Utf8Path;
///
/// // Build a tree of all recipes in a directory
/// let tree = build_tree(Utf8Path::new("./recipes"))?;
///
/// // Access recipes in the tree
/// for (name, node) in &tree.children {
///     if let Some(recipe) = &node.recipe {
///         println!("Found recipe: {}", name);
///     }
/// }
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn build_tree<P: AsRef<Utf8Path>>(base_dir: P) -> Result<RecipeTree, TreeError> {
    let base_dir = base_dir.as_ref();

    // Check if directory exists
    if !base_dir.exists() {
        return Err(TreeError::DirectoryNotFound(base_dir.to_string()));
    }
    if !base_dir.is_dir() {
        return Err(TreeError::NotADirectory(base_dir.to_string()));
    }

    let base_name = base_dir
        .file_name()
        .map(|n| n.to_string())
        .unwrap_or_else(|| String::from("./"));

    let mut root = RecipeTree::new(base_name, base_dir.to_path_buf());

    // First, find all .cook and .menu files in this directory and subdirectories
    let patterns = vec![
        base_dir.join("**/*.cook").to_string(),
        base_dir.join("**/*.menu").to_string(),
    ];

    for pattern in patterns {
        for entry in glob(&pattern)? {
            let path = entry?;
            let path = Utf8PathBuf::from_path_buf(path).map_err(|_| {
                TreeError::StripPrefixError("Path contains invalid UTF-8".to_string())
            })?;
            let recipe = match RecipeEntry::from_path(path.clone()) {
                Ok(r) => r,
                Err(_) => continue, // Skip files whose content isn't available (e.g. iCloud)
            };

            // Calculate the relative path from the base directory
            let rel_path = path
                .strip_prefix(base_dir)
                .map_err(|_| TreeError::StripPrefixError(path.to_string()))?;

            // Build the tree structure
            let mut current = &mut root;
            let components: Vec<_> = rel_path
                .parent()
                .map(|p| p.components().collect())
                .unwrap_or_default();

            // Create directory nodes
            for component in components {
                let name = component.to_string();
                let path = current.path.join(&name);
                current = current
                    .children
                    .entry(name.clone())
                    .or_insert_with(|| RecipeTree::new(name, path));
            }

            // Add the recipe as a leaf node
            let name = recipe.name().clone().unwrap();

            current.children.insert(
                name.clone(),
                RecipeTree::new_with_recipe(name, path, recipe),
            );
        }
    }

    Ok(root)
}

#[cfg(test)]
mod tests {
    use super::*;
    use indoc::indoc;
    use std::fs;
    use tempfile::TempDir;

    fn create_test_recipe(dir: &Utf8Path, name: &str, content: &str) -> Utf8PathBuf {
        let path = dir.join(format!("{name}.cook"));
        fs::write(&path, content).unwrap();
        path
    }

    fn create_test_image(dir: &Utf8Path, name: &str, ext: &str) -> Utf8PathBuf {
        let path = dir.join(format!("{name}.{ext}"));
        fs::write(&path, "dummy image content").unwrap();
        path
    }

    #[test]
    fn test_empty_directory() {
        let temp_dir = TempDir::new().unwrap();
        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
        let tree = build_tree(&temp_dir_path).unwrap();

        assert_eq!(tree.name, temp_dir_path.file_name().unwrap().to_string());
        assert_eq!(tree.path, temp_dir_path);
        assert!(tree.recipe.is_none());
        assert!(tree.children.is_empty());
    }

    #[test]
    fn test_single_recipe() {
        let temp_dir = TempDir::new().unwrap();
        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
        create_test_recipe(
            &temp_dir_path,
            "pancakes",
            indoc! {r#"
                ---
                servings: 4
                ---

                Make pancakes"#},
        );

        let tree = build_tree(&temp_dir_path).unwrap();

        assert_eq!(tree.children.len(), 1);
        let recipe_node = tree.children.get("pancakes").unwrap();
        assert_eq!(recipe_node.name, "pancakes");
        assert!(recipe_node.recipe.is_some());
        assert!(recipe_node.children.is_empty());
    }

    #[test]
    fn test_recipe_with_image() {
        let temp_dir = TempDir::new().unwrap();
        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
        create_test_recipe(
            &temp_dir_path,
            "pancakes",
            indoc! {r#"
                ---
                servings: 4
                ---

                Make pancakes"#},
        );
        create_test_image(&temp_dir_path, "pancakes", "jpg");

        let tree = build_tree(&temp_dir_path).unwrap();

        let recipe_node = tree.children.get("pancakes").unwrap();
        assert!(recipe_node.recipe.as_ref().unwrap().title_image().is_some());
    }

    #[test]
    fn test_nested_directories() {
        let temp_dir = TempDir::new().unwrap();
        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();

        // Create nested directory structure
        let breakfast_dir = temp_dir_path.join("breakfast");
        let dessert_dir = temp_dir_path.join("dessert");
        fs::create_dir_all(&breakfast_dir).unwrap();
        fs::create_dir_all(&dessert_dir).unwrap();

        // Add recipes
        create_test_recipe(
            &breakfast_dir,
            "pancakes",
            indoc! {r#"
                ---
                servings: 4
                ---

                Make pancakes"#},
        );
        create_test_recipe(
            &breakfast_dir,
            "waffles",
            indoc! {r#"
                ---
                servings: 2
                ---

                Make waffles"#},
        );
        create_test_recipe(
            &dessert_dir,
            "cake",
            indoc! {r#"
                ---
                servings: 8
                ---

                Bake cake"#},
        );

        let tree = build_tree(&temp_dir_path).unwrap();

        assert_eq!(tree.children.len(), 2);

        // Check breakfast directory
        let breakfast = tree.children.get("breakfast").unwrap();
        assert_eq!(breakfast.name, "breakfast");
        assert!(breakfast.recipe.is_none());
        assert_eq!(breakfast.children.len(), 2);
        assert!(breakfast.children.contains_key("pancakes"));
        assert!(breakfast.children.contains_key("waffles"));

        // Check dessert directory
        let dessert = tree.children.get("dessert").unwrap();
        assert_eq!(dessert.name, "dessert");
        assert!(dessert.recipe.is_none());
        assert_eq!(dessert.children.len(), 1);
        assert!(dessert.children.contains_key("cake"));
    }

    #[test]
    fn test_deeply_nested_recipe() {
        let temp_dir = TempDir::new().unwrap();
        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
        let deep_path = temp_dir_path.join("a/b/c/d");
        fs::create_dir_all(&deep_path).unwrap();

        create_test_recipe(
            &deep_path,
            "deep_recipe",
            indoc! {r#"
                ---
                servings: 1
                ---

                Deep recipe"#},
        );

        let tree = build_tree(&temp_dir_path).unwrap();

        let a = tree.children.get("a").unwrap();
        let b = a.children.get("b").unwrap();
        let c = b.children.get("c").unwrap();
        let d = c.children.get("d").unwrap();
        let recipe = d.children.get("deep_recipe").unwrap();

        assert!(recipe.recipe.is_some());
        assert_eq!(recipe.name, "deep_recipe");
    }

    #[test]
    fn test_invalid_directory() {
        let result = build_tree(Utf8Path::new("/nonexistent/directory"));
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("Directory does not exist"));
    }

    #[test]
    fn test_recipe_tree_new() {
        let tree = RecipeTree::new("test".to_string(), Utf8PathBuf::from("/test/path"));

        assert_eq!(tree.name, "test");
        assert_eq!(tree.path, Utf8PathBuf::from("/test/path"));
        assert!(tree.recipe.is_none());
        assert!(tree.children.is_empty());
    }

    #[test]
    fn test_recipe_tree_new_with_recipe() {
        let temp_dir = TempDir::new().unwrap();
        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
        let recipe_path = create_test_recipe(
            &temp_dir_path,
            "test_recipe",
            indoc! {r#"
                ---
                servings: 4
                ---

                Test recipe"#},
        );

        let recipe = RecipeEntry::from_path(recipe_path.clone()).unwrap();
        let tree = RecipeTree::new_with_recipe("test_recipe".to_string(), recipe_path, recipe);

        assert_eq!(tree.name, "test_recipe");
        assert!(tree.recipe.is_some());
        assert!(tree.children.is_empty());
    }
}