Skip to main content

cooklang_find/
ffi.rs

1//! UniFFI bindings for cross-platform support (iOS, Android).
2//!
3//! This module provides FFI-safe types and functions for use with UniFFI.
4//! Complex types are converted to simpler representations suitable for FFI.
5
6use 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/// FFI-safe error type that wraps all possible errors.
15#[derive(Debug, Clone, uniffi::Error)]
16pub enum CooklangError {
17    /// Recipe not found
18    NotFound { reason: String },
19    /// IO error (file not found, permission denied, etc.)
20    IoError { reason: String },
21    /// Failed to parse recipe or metadata
22    ParseError { reason: String },
23    /// Invalid path provided
24    InvalidPath { reason: String },
25    /// Search operation failed
26    SearchError { reason: String },
27    /// Tree operation failed
28    TreeError { reason: String },
29    /// Menu listing operation failed
30    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/// A key-value pair for metadata entries.
103#[derive(Debug, Clone, uniffi::Record)]
104pub struct MetadataEntry {
105    pub key: String,
106    pub value: String,
107}
108
109/// FFI-safe representation of recipe metadata.
110#[derive(Debug, Clone, uniffi::Record)]
111pub struct FfiMetadata {
112    /// Recipe title if present
113    pub title: Option<String>,
114    /// Number of servings if present
115    pub servings: Option<i64>,
116    /// List of tags
117    pub tags: Vec<String>,
118    /// Primary image URL if present
119    pub image_url: Option<String>,
120    /// All metadata as JSON string for complex access
121    pub raw_json: String,
122}
123
124impl From<&Metadata> for FfiMetadata {
125    fn from(m: &Metadata) -> Self {
126        // Convert the internal data to JSON for complex access
127        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/// A step image entry mapping section and step to an image path.
140#[derive(Debug, Clone, uniffi::Record)]
141pub struct StepImageEntry {
142    /// Section number (0 for linear recipes, 1+ for sectioned recipes)
143    pub section: u32,
144    /// Step number (1-indexed)
145    pub step: u32,
146    /// Path to the image
147    pub image_path: String,
148}
149
150/// FFI-safe representation of step images.
151#[derive(Debug, Clone, uniffi::Record)]
152pub struct FfiStepImages {
153    /// List of all step images
154    pub images: Vec<StepImageEntry>,
155    /// Total count of images
156    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                // Convert back from zero-indexed storage to one-indexed API
166                let section = if *section_idx == 0 {
167                    0 // Linear recipe
168                } else {
169                    (*section_idx + 1) as u32 // Sectioned recipe
170                };
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        // Sort by section then step for predictable ordering
182        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/// FFI-safe representation of a recipe entry.
199///
200/// This is the main type for representing recipes across the FFI boundary.
201#[derive(uniffi::Object)]
202pub struct FfiRecipeEntry {
203    inner: RecipeEntry,
204}
205
206#[uniffi::export]
207impl FfiRecipeEntry {
208    /// Returns the name of the recipe.
209    pub fn name(&self) -> Option<String> {
210        self.inner.name().clone()
211    }
212
213    /// Returns the file path if this recipe is backed by a file.
214    pub fn path(&self) -> Option<String> {
215        self.inner.path().map(|p| p.to_string())
216    }
217
218    /// Returns the file name if this recipe is backed by a file.
219    pub fn file_name(&self) -> Option<String> {
220        self.inner.file_name()
221    }
222
223    /// Returns the full content of the recipe.
224    pub fn content(&self) -> Result<String, CooklangError> {
225        self.inner.content().map_err(|e| e.into())
226    }
227
228    /// Returns the recipe's metadata.
229    pub fn metadata(&self) -> FfiMetadata {
230        FfiMetadata::from(self.inner.metadata())
231    }
232
233    /// Returns the recipe's tags.
234    pub fn tags(&self) -> Vec<String> {
235        self.inner.tags()
236    }
237
238    /// Returns the URL or path to the recipe's title image.
239    pub fn title_image(&self) -> Option<String> {
240        self.inner.title_image().clone()
241    }
242
243    /// Returns all step images for the recipe.
244    pub fn step_images(&self) -> FfiStepImages {
245        FfiStepImages::from(self.inner.step_images())
246    }
247
248    /// Returns true if this is a menu file (.menu) rather than a recipe (.cook).
249    pub fn is_menu(&self) -> bool {
250        self.inner.is_menu()
251    }
252
253    /// Gets a step image by section and step number.
254    ///
255    /// For linear recipes (no sections), use section = 0.
256    /// Steps are one-indexed (first step is 1).
257    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    /// Gets a specific metadata value by key as a JSON string.
265    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    /// Returns all file paths related to this recipe.
273    ///
274    /// Includes images, referenced recipe files, and recursively
275    /// related files of referenced recipes.
276    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/// FFI-safe representation of a tree node.
292#[derive(Debug, Clone, uniffi::Record)]
293pub struct FfiTreeNode {
294    /// Name of the node (directory or recipe name)
295    pub name: String,
296    /// Full path to this node
297    pub path: String,
298    /// True if this node has a recipe
299    pub has_recipe: bool,
300    /// Names of child nodes
301    pub children: Vec<String>,
302}
303
304/// FFI-safe representation of a recipe tree.
305#[derive(uniffi::Object)]
306pub struct FfiRecipeTree {
307    inner: RecipeTree,
308}
309
310#[uniffi::export]
311impl FfiRecipeTree {
312    /// Returns the root node information.
313    pub fn root(&self) -> FfiTreeNode {
314        tree_to_node(&self.inner)
315    }
316
317    /// Returns all nodes in the tree as a flat list.
318    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    /// Returns all recipes in the tree.
325    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    /// Gets a child node by name from the root.
332    pub fn get_child(&self, name: String) -> Option<FfiTreeNode> {
333        self.inner.children.get(&name).map(tree_to_node)
334    }
335
336    /// Gets the recipe at the root level if present.
337    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    /// Gets a recipe by path components (e.g., ["breakfast", "pancakes"]).
345    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// ============================================================================
383// Exported FFI Functions
384// ============================================================================
385
386/// Loads a recipe by name from the specified directories.
387///
388/// Searches through the provided directories in order for a recipe file
389/// matching the given name. Automatically handles .cook and .menu extensions.
390///
391/// # Arguments
392/// * `base_dirs` - List of directory paths to search
393/// * `name` - Recipe name to search for (with or without extension)
394///
395/// # Returns
396/// The recipe if found, or an error.
397#[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/// Creates a recipe from file content.
407///
408/// Useful for creating recipes from sources other than files,
409/// such as network responses or programmatically generated content.
410///
411/// # Arguments
412/// * `content` - The full recipe content including any YAML frontmatter
413/// * `name` - Optional name for the recipe
414///
415/// # Returns
416/// The recipe entry, or an error if parsing fails.
417#[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/// Creates a recipe from a file path.
427///
428/// # Arguments
429/// * `path` - The path to the recipe file
430///
431/// # Returns
432/// The recipe entry, or an error if loading fails.
433#[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/// Searches for recipes matching a query string.
440///
441/// Performs full-text search across recipe filenames and contents
442/// in the specified directory and subdirectories.
443///
444/// # Arguments
445/// * `base_dir` - Root directory to search in
446/// * `query` - Search query (can contain multiple space-separated terms)
447///
448/// # Returns
449/// List of matching recipes sorted by relevance.
450#[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/// Lists menu files that have a section header containing the given date.
460///
461/// Only `.menu` files are scanned; a file is included if any of its section
462/// headers contains the `date` substring. The date is matched literally — the
463/// caller supplies it (for example, the host app's local "today" or "tomorrow").
464///
465/// # Arguments
466/// * `base_dirs` - Root directories to scan
467/// * `date` - The date string to match (e.g. "2026-06-24")
468///
469/// # Returns
470/// List of matching menu recipes.
471#[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/// Builds a hierarchical tree of all recipes in a directory.
485///
486/// Recursively scans the directory for .cook and .menu files,
487/// organizing them into a tree structure mirroring the filesystem.
488///
489/// # Arguments
490/// * `base_dir` - Root directory to build the tree from
491///
492/// # Returns
493/// The recipe tree, or an error.
494#[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/// Returns the library version.
501#[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        // Create nested structure
574        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); // At least root and breakfast directory
591
592        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        // Referenced recipe with image
636        create_test_recipe(&sauces_dir, "Hollandaise", "Melt @butter{100%g}");
637        fs::write(format!("{}/Hollandaise.jpg", sauces_dir), b"").unwrap();
638
639        // Main recipe
640        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}