1use crate::fetcher::{get_recipe_str, FetchError};
7use crate::menu::{list_menus_for_date as list_menus_for_date_internal, MenuError};
8use crate::model::{Metadata, RecipeEntry, RecipeEntryError, StepImageCollection};
9use crate::search::{search as search_internal, SearchError};
10use crate::tree::{build_tree as build_tree_internal, RecipeTree, TreeError};
11use camino::{Utf8Path, Utf8PathBuf};
12use std::sync::Arc;
13
14#[derive(Debug, Clone, uniffi::Error)]
16pub enum CooklangError {
17 NotFound { reason: String },
19 IoError { reason: String },
21 ParseError { reason: String },
23 InvalidPath { reason: String },
25 SearchError { reason: String },
27 TreeError { reason: String },
29 MenuError { reason: String },
31}
32
33impl std::fmt::Display for CooklangError {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 CooklangError::NotFound { reason } => write!(f, "Not found: {}", reason),
37 CooklangError::IoError { reason } => write!(f, "IO error: {}", reason),
38 CooklangError::ParseError { reason } => write!(f, "Parse error: {}", reason),
39 CooklangError::InvalidPath { reason } => write!(f, "Invalid path: {}", reason),
40 CooklangError::SearchError { reason } => write!(f, "Search error: {}", reason),
41 CooklangError::TreeError { reason } => write!(f, "Tree error: {}", reason),
42 CooklangError::MenuError { reason } => write!(f, "Menu error: {}", reason),
43 }
44 }
45}
46
47impl std::error::Error for CooklangError {}
48
49impl From<FetchError> for CooklangError {
50 fn from(e: FetchError) -> Self {
51 match e {
52 FetchError::IoError(e) => CooklangError::IoError {
53 reason: e.to_string(),
54 },
55 FetchError::RecipeEntryError(e) => e.into(),
56 FetchError::InvalidPath(p) => CooklangError::NotFound {
57 reason: format!("Recipe not found: {}", p),
58 },
59 }
60 }
61}
62
63impl From<RecipeEntryError> for CooklangError {
64 fn from(e: RecipeEntryError) -> Self {
65 match e {
66 RecipeEntryError::IoError(e) => CooklangError::IoError {
67 reason: e.to_string(),
68 },
69 RecipeEntryError::InvalidPath(p) => CooklangError::InvalidPath {
70 reason: p.to_string(),
71 },
72 RecipeEntryError::ParseError(msg) => CooklangError::ParseError { reason: msg },
73 RecipeEntryError::MetadataError(msg) => CooklangError::ParseError { reason: msg },
74 }
75 }
76}
77
78impl From<SearchError> for CooklangError {
79 fn from(e: SearchError) -> Self {
80 CooklangError::SearchError {
81 reason: e.to_string(),
82 }
83 }
84}
85
86impl From<TreeError> for CooklangError {
87 fn from(e: TreeError) -> Self {
88 CooklangError::TreeError {
89 reason: e.to_string(),
90 }
91 }
92}
93
94impl From<MenuError> for CooklangError {
95 fn from(e: MenuError) -> Self {
96 CooklangError::MenuError {
97 reason: e.to_string(),
98 }
99 }
100}
101
102#[derive(Debug, Clone, uniffi::Record)]
104pub struct MetadataEntry {
105 pub key: String,
106 pub value: String,
107}
108
109#[derive(Debug, Clone, uniffi::Record)]
111pub struct FfiMetadata {
112 pub title: Option<String>,
114 pub servings: Option<i64>,
116 pub tags: Vec<String>,
118 pub image_url: Option<String>,
120 pub raw_json: String,
122}
123
124impl From<&Metadata> for FfiMetadata {
125 fn from(m: &Metadata) -> Self {
126 let raw_json = serde_json::to_string(&m).unwrap_or_default();
128
129 FfiMetadata {
130 title: m.title().map(|s| s.to_string()),
131 servings: m.servings(),
132 tags: m.tags(),
133 image_url: m.image_url(),
134 raw_json,
135 }
136 }
137}
138
139#[derive(Debug, Clone, uniffi::Record)]
141pub struct StepImageEntry {
142 pub section: u32,
144 pub step: u32,
146 pub image_path: String,
148}
149
150#[derive(Debug, Clone, uniffi::Record)]
152pub struct FfiStepImages {
153 pub images: Vec<StepImageEntry>,
155 pub count: u32,
157}
158
159impl From<&StepImageCollection> for FfiStepImages {
160 fn from(c: &StepImageCollection) -> Self {
161 let mut images = Vec::new();
162
163 for (section_idx, steps) in &c.images {
164 for (step_idx, path) in steps {
165 let section = if *section_idx == 0 {
167 0 } else {
169 (*section_idx + 1) as u32 };
171 let step = (*step_idx + 1) as u32;
172
173 images.push(StepImageEntry {
174 section,
175 step,
176 image_path: path.clone(),
177 });
178 }
179 }
180
181 images.sort_by(|a, b| {
183 let section_cmp = a.section.cmp(&b.section);
184 if section_cmp == std::cmp::Ordering::Equal {
185 a.step.cmp(&b.step)
186 } else {
187 section_cmp
188 }
189 });
190
191 FfiStepImages {
192 count: images.len() as u32,
193 images,
194 }
195 }
196}
197
198#[derive(uniffi::Object)]
202pub struct FfiRecipeEntry {
203 inner: RecipeEntry,
204}
205
206#[uniffi::export]
207impl FfiRecipeEntry {
208 pub fn name(&self) -> Option<String> {
210 self.inner.name().clone()
211 }
212
213 pub fn path(&self) -> Option<String> {
215 self.inner.path().map(|p| p.to_string())
216 }
217
218 pub fn file_name(&self) -> Option<String> {
220 self.inner.file_name()
221 }
222
223 pub fn content(&self) -> Result<String, CooklangError> {
225 self.inner.content().map_err(|e| e.into())
226 }
227
228 pub fn metadata(&self) -> FfiMetadata {
230 FfiMetadata::from(self.inner.metadata())
231 }
232
233 pub fn tags(&self) -> Vec<String> {
235 self.inner.tags()
236 }
237
238 pub fn title_image(&self) -> Option<String> {
240 self.inner.title_image().clone()
241 }
242
243 pub fn step_images(&self) -> FfiStepImages {
245 FfiStepImages::from(self.inner.step_images())
246 }
247
248 pub fn is_menu(&self) -> bool {
250 self.inner.is_menu()
251 }
252
253 pub fn get_step_image(&self, section: u32, step: u32) -> Option<String> {
258 self.inner
259 .step_images()
260 .get(section as usize, step as usize)
261 .cloned()
262 }
263
264 pub fn get_metadata_value(&self, key: String) -> Option<String> {
266 self.inner
267 .metadata()
268 .get(&key)
269 .map(|v| serde_json::to_string(v).unwrap_or_default())
270 }
271
272 pub fn related_files(&self) -> Vec<String> {
277 self.inner
278 .related_files()
279 .into_iter()
280 .map(|p| p.to_string())
281 .collect()
282 }
283}
284
285impl FfiRecipeEntry {
286 fn new(entry: RecipeEntry) -> Self {
287 FfiRecipeEntry { inner: entry }
288 }
289}
290
291#[derive(Debug, Clone, uniffi::Record)]
293pub struct FfiTreeNode {
294 pub name: String,
296 pub path: String,
298 pub has_recipe: bool,
300 pub children: Vec<String>,
302}
303
304#[derive(uniffi::Object)]
306pub struct FfiRecipeTree {
307 inner: RecipeTree,
308}
309
310#[uniffi::export]
311impl FfiRecipeTree {
312 pub fn root(&self) -> FfiTreeNode {
314 tree_to_node(&self.inner)
315 }
316
317 pub fn all_nodes(&self) -> Vec<FfiTreeNode> {
319 let mut nodes = Vec::new();
320 collect_nodes(&self.inner, &mut nodes);
321 nodes
322 }
323
324 pub fn all_recipes(&self) -> Vec<Arc<FfiRecipeEntry>> {
326 let mut recipes = Vec::new();
327 collect_recipes(&self.inner, &mut recipes);
328 recipes
329 }
330
331 pub fn get_child(&self, name: String) -> Option<FfiTreeNode> {
333 self.inner.children.get(&name).map(tree_to_node)
334 }
335
336 pub fn recipe(&self) -> Option<Arc<FfiRecipeEntry>> {
338 self.inner
339 .recipe
340 .as_ref()
341 .map(|r| Arc::new(FfiRecipeEntry::new(r.clone())))
342 }
343
344 pub fn get_recipe_at_path(&self, path: Vec<String>) -> Option<Arc<FfiRecipeEntry>> {
346 let mut current = &self.inner;
347 for component in &path {
348 current = current.children.get(component)?;
349 }
350 current
351 .recipe
352 .as_ref()
353 .map(|r| Arc::new(FfiRecipeEntry::new(r.clone())))
354 }
355}
356
357fn tree_to_node(tree: &RecipeTree) -> FfiTreeNode {
358 FfiTreeNode {
359 name: tree.name.clone(),
360 path: tree.path.to_string(),
361 has_recipe: tree.recipe.is_some(),
362 children: tree.children.keys().cloned().collect(),
363 }
364}
365
366fn collect_nodes(tree: &RecipeTree, nodes: &mut Vec<FfiTreeNode>) {
367 nodes.push(tree_to_node(tree));
368 for child in tree.children.values() {
369 collect_nodes(child, nodes);
370 }
371}
372
373fn collect_recipes(tree: &RecipeTree, recipes: &mut Vec<Arc<FfiRecipeEntry>>) {
374 if let Some(recipe) = &tree.recipe {
375 recipes.push(Arc::new(FfiRecipeEntry::new(recipe.clone())));
376 }
377 for child in tree.children.values() {
378 collect_recipes(child, recipes);
379 }
380}
381
382#[uniffi::export]
398pub fn get_recipe(
399 base_dirs: Vec<String>,
400 name: String,
401) -> Result<Arc<FfiRecipeEntry>, CooklangError> {
402 let entry = get_recipe_str(base_dirs, &name)?;
403 Ok(Arc::new(FfiRecipeEntry::new(entry)))
404}
405
406#[uniffi::export]
418pub fn recipe_from_content(
419 content: String,
420 name: Option<String>,
421) -> Result<Arc<FfiRecipeEntry>, CooklangError> {
422 let entry = RecipeEntry::from_content(content, name)?;
423 Ok(Arc::new(FfiRecipeEntry::new(entry)))
424}
425
426#[uniffi::export]
434pub fn recipe_from_path(path: String) -> Result<Arc<FfiRecipeEntry>, CooklangError> {
435 let entry = RecipeEntry::from_path(path.into())?;
436 Ok(Arc::new(FfiRecipeEntry::new(entry)))
437}
438
439#[uniffi::export]
451pub fn search(base_dir: String, query: String) -> Result<Vec<Arc<FfiRecipeEntry>>, CooklangError> {
452 let results = search_internal(Utf8Path::new(&base_dir), &query)?;
453 Ok(results
454 .into_iter()
455 .map(|r| Arc::new(FfiRecipeEntry::new(r)))
456 .collect())
457}
458
459#[uniffi::export]
472pub fn list_menus_for_date(
473 base_dirs: Vec<String>,
474 date: String,
475) -> Result<Vec<Arc<FfiRecipeEntry>>, CooklangError> {
476 let dirs: Vec<Utf8PathBuf> = base_dirs.into_iter().map(Utf8PathBuf::from).collect();
477 let results = list_menus_for_date_internal(&dirs, &date)?;
478 Ok(results
479 .into_iter()
480 .map(|r| Arc::new(FfiRecipeEntry::new(r)))
481 .collect())
482}
483
484#[uniffi::export]
495pub fn build_tree(base_dir: String) -> Result<Arc<FfiRecipeTree>, CooklangError> {
496 let tree = build_tree_internal(&base_dir)?;
497 Ok(Arc::new(FfiRecipeTree { inner: tree }))
498}
499
500#[uniffi::export]
502pub fn library_version() -> String {
503 env!("CARGO_PKG_VERSION").to_string()
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use indoc::indoc;
510 use std::fs;
511 use tempfile::TempDir;
512
513 fn create_test_recipe(dir: &str, name: &str, content: &str) -> String {
514 let path = format!("{}/{}.cook", dir, name);
515 fs::write(&path, content).unwrap();
516 path
517 }
518
519 #[test]
520 fn test_recipe_from_content() {
521 let content = indoc! {r#"
522 ---
523 title: Test Recipe
524 servings: 4
525 tags: [breakfast, easy]
526 ---
527
528 Add @eggs{2} and mix"#};
529
530 let recipe = recipe_from_content(content.to_string(), None).unwrap();
531 assert_eq!(recipe.name(), Some("Test Recipe".to_string()));
532 assert_eq!(recipe.metadata().servings, Some(4));
533 assert_eq!(recipe.tags(), vec!["breakfast", "easy"]);
534 }
535
536 #[test]
537 fn test_search_recipes() {
538 let temp_dir = TempDir::new().unwrap();
539 let temp_path = temp_dir.path().to_str().unwrap();
540
541 create_test_recipe(
542 temp_path,
543 "pancakes",
544 indoc! {r#"
545 ---
546 title: Fluffy Pancakes
547 ---
548
549 Mix and cook"#},
550 );
551
552 create_test_recipe(
553 temp_path,
554 "waffles",
555 indoc! {r#"
556 ---
557 title: Crispy Waffles
558 ---
559
560 Make waffles"#},
561 );
562
563 let results = search(temp_path.to_string(), "pancakes".to_string()).unwrap();
564 assert_eq!(results.len(), 1);
565 assert_eq!(results[0].name(), Some("Fluffy Pancakes".to_string()));
566 }
567
568 #[test]
569 fn test_build_tree() {
570 let temp_dir = TempDir::new().unwrap();
571 let temp_path = temp_dir.path().to_str().unwrap();
572
573 let breakfast_dir = format!("{}/breakfast", temp_path);
575 fs::create_dir_all(&breakfast_dir).unwrap();
576
577 create_test_recipe(
578 &breakfast_dir,
579 "pancakes",
580 indoc! {r#"
581 ---
582 title: Pancakes
583 ---
584
585 Make pancakes"#},
586 );
587
588 let tree = build_tree(temp_path.to_string()).unwrap();
589 let nodes = tree.all_nodes();
590 assert!(nodes.len() >= 2); let recipes = tree.all_recipes();
593 assert_eq!(recipes.len(), 1);
594 }
595
596 #[test]
597 fn test_step_images_conversion() {
598 use std::collections::HashMap;
599
600 let mut collection = StepImageCollection::default();
601 collection.images.insert(0, HashMap::new());
602 collection
603 .images
604 .get_mut(&0)
605 .unwrap()
606 .insert(0, "/path/to/image1.jpg".to_string());
607 collection
608 .images
609 .get_mut(&0)
610 .unwrap()
611 .insert(2, "/path/to/image3.jpg".to_string());
612
613 let ffi_images = FfiStepImages::from(&collection);
614 assert_eq!(ffi_images.count, 2);
615 assert_eq!(ffi_images.images[0].section, 0);
616 assert_eq!(ffi_images.images[0].step, 1);
617 assert_eq!(ffi_images.images[1].step, 3);
618 }
619
620 #[test]
621 fn test_library_version() {
622 let version = library_version();
623 assert!(!version.is_empty());
624 assert_eq!(version, env!("CARGO_PKG_VERSION"));
625 }
626
627 #[test]
628 fn test_related_files_ffi() {
629 let temp_dir = TempDir::new().unwrap();
630 let temp_path = temp_dir.path().to_str().unwrap();
631
632 let sauces_dir = format!("{}/sauces", temp_path);
633 fs::create_dir_all(&sauces_dir).unwrap();
634
635 create_test_recipe(&sauces_dir, "Hollandaise", "Melt @butter{100%g}");
637 fs::write(format!("{}/Hollandaise.jpg", sauces_dir), b"").unwrap();
638
639 let path = create_test_recipe(
641 temp_path,
642 "EggsBenedict",
643 "Pour @./sauces/Hollandaise{150%g} over eggs.",
644 );
645
646 let recipe = recipe_from_path(path).unwrap();
647 let files = recipe.related_files();
648
649 assert_eq!(files.len(), 2);
650 assert!(files.iter().any(|f| f.ends_with("Hollandaise.cook")));
651 assert!(files.iter().any(|f| f.ends_with("Hollandaise.jpg")));
652 }
653}