use crate::model::{RecipeEntry, RecipeEntryError};
use camino::{Utf8Path, Utf8PathBuf};
use glob::glob;
use thiserror::Error;
mod model;
pub use model::RecipeTree;
#[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),
}
pub fn build_tree<P: AsRef<Utf8Path>>(base_dir: P) -> Result<RecipeTree, TreeError> {
let base_dir = base_dir.as_ref();
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());
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, };
let rel_path = path
.strip_prefix(base_dir)
.map_err(|_| TreeError::StripPrefixError(path.to_string()))?;
let mut current = &mut root;
let components: Vec<_> = rel_path
.parent()
.map(|p| p.components().collect())
.unwrap_or_default();
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));
}
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();
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();
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);
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"));
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());
}
}