cooklang_find/tree/
mod.rs1use crate::model::{RecipeEntry, RecipeEntryError};
7use camino::{Utf8Path, Utf8PathBuf};
8use glob::glob;
9use thiserror::Error;
10
11mod model;
12pub use model::RecipeTree;
13
14#[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
36pub fn build_tree<P: AsRef<Utf8Path>>(base_dir: P) -> Result<RecipeTree, TreeError> {
69 let base_dir = base_dir.as_ref();
70
71 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 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, };
102
103 let rel_path = path
105 .strip_prefix(base_dir)
106 .map_err(|_| TreeError::StripPrefixError(path.to_string()))?;
107
108 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 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 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 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 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 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 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}