cooklang-find 0.6.0

Library for finding and managing Cooklang recipes in the filesystem
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
//! UniFFI bindings for cross-platform support (iOS, Android).
//!
//! This module provides FFI-safe types and functions for use with UniFFI.
//! Complex types are converted to simpler representations suitable for FFI.

use crate::fetcher::{get_recipe_str, FetchError};
use crate::menu::{list_menus_for_date as list_menus_for_date_internal, MenuError};
use crate::model::{Metadata, RecipeEntry, RecipeEntryError, StepImageCollection};
use crate::search::{search as search_internal, SearchError};
use crate::tree::{build_tree as build_tree_internal, RecipeTree, TreeError};
use camino::{Utf8Path, Utf8PathBuf};
use std::sync::Arc;

/// FFI-safe error type that wraps all possible errors.
#[derive(Debug, Clone, uniffi::Error)]
pub enum CooklangError {
    /// Recipe not found
    NotFound { reason: String },
    /// IO error (file not found, permission denied, etc.)
    IoError { reason: String },
    /// Failed to parse recipe or metadata
    ParseError { reason: String },
    /// Invalid path provided
    InvalidPath { reason: String },
    /// Search operation failed
    SearchError { reason: String },
    /// Tree operation failed
    TreeError { reason: String },
    /// Menu listing operation failed
    MenuError { reason: String },
}

impl std::fmt::Display for CooklangError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CooklangError::NotFound { reason } => write!(f, "Not found: {}", reason),
            CooklangError::IoError { reason } => write!(f, "IO error: {}", reason),
            CooklangError::ParseError { reason } => write!(f, "Parse error: {}", reason),
            CooklangError::InvalidPath { reason } => write!(f, "Invalid path: {}", reason),
            CooklangError::SearchError { reason } => write!(f, "Search error: {}", reason),
            CooklangError::TreeError { reason } => write!(f, "Tree error: {}", reason),
            CooklangError::MenuError { reason } => write!(f, "Menu error: {}", reason),
        }
    }
}

impl std::error::Error for CooklangError {}

impl From<FetchError> for CooklangError {
    fn from(e: FetchError) -> Self {
        match e {
            FetchError::IoError(e) => CooklangError::IoError {
                reason: e.to_string(),
            },
            FetchError::RecipeEntryError(e) => e.into(),
            FetchError::InvalidPath(p) => CooklangError::NotFound {
                reason: format!("Recipe not found: {}", p),
            },
        }
    }
}

impl From<RecipeEntryError> for CooklangError {
    fn from(e: RecipeEntryError) -> Self {
        match e {
            RecipeEntryError::IoError(e) => CooklangError::IoError {
                reason: e.to_string(),
            },
            RecipeEntryError::InvalidPath(p) => CooklangError::InvalidPath {
                reason: p.to_string(),
            },
            RecipeEntryError::ParseError(msg) => CooklangError::ParseError { reason: msg },
            RecipeEntryError::MetadataError(msg) => CooklangError::ParseError { reason: msg },
        }
    }
}

impl From<SearchError> for CooklangError {
    fn from(e: SearchError) -> Self {
        CooklangError::SearchError {
            reason: e.to_string(),
        }
    }
}

impl From<TreeError> for CooklangError {
    fn from(e: TreeError) -> Self {
        CooklangError::TreeError {
            reason: e.to_string(),
        }
    }
}

impl From<MenuError> for CooklangError {
    fn from(e: MenuError) -> Self {
        CooklangError::MenuError {
            reason: e.to_string(),
        }
    }
}

/// A key-value pair for metadata entries.
#[derive(Debug, Clone, uniffi::Record)]
pub struct MetadataEntry {
    pub key: String,
    pub value: String,
}

/// FFI-safe representation of recipe metadata.
#[derive(Debug, Clone, uniffi::Record)]
pub struct FfiMetadata {
    /// Recipe title if present
    pub title: Option<String>,
    /// Number of servings if present
    pub servings: Option<i64>,
    /// List of tags
    pub tags: Vec<String>,
    /// Primary image URL if present
    pub image_url: Option<String>,
    /// All metadata as JSON string for complex access
    pub raw_json: String,
}

impl From<&Metadata> for FfiMetadata {
    fn from(m: &Metadata) -> Self {
        // Convert the internal data to JSON for complex access
        let raw_json = serde_json::to_string(&m).unwrap_or_default();

        FfiMetadata {
            title: m.title().map(|s| s.to_string()),
            servings: m.servings(),
            tags: m.tags(),
            image_url: m.image_url(),
            raw_json,
        }
    }
}

/// A step image entry mapping section and step to an image path.
#[derive(Debug, Clone, uniffi::Record)]
pub struct StepImageEntry {
    /// Section number (0 for linear recipes, 1+ for sectioned recipes)
    pub section: u32,
    /// Step number (1-indexed)
    pub step: u32,
    /// Path to the image
    pub image_path: String,
}

/// FFI-safe representation of step images.
#[derive(Debug, Clone, uniffi::Record)]
pub struct FfiStepImages {
    /// List of all step images
    pub images: Vec<StepImageEntry>,
    /// Total count of images
    pub count: u32,
}

impl From<&StepImageCollection> for FfiStepImages {
    fn from(c: &StepImageCollection) -> Self {
        let mut images = Vec::new();

        for (section_idx, steps) in &c.images {
            for (step_idx, path) in steps {
                // Convert back from zero-indexed storage to one-indexed API
                let section = if *section_idx == 0 {
                    0 // Linear recipe
                } else {
                    (*section_idx + 1) as u32 // Sectioned recipe
                };
                let step = (*step_idx + 1) as u32;

                images.push(StepImageEntry {
                    section,
                    step,
                    image_path: path.clone(),
                });
            }
        }

        // Sort by section then step for predictable ordering
        images.sort_by(|a, b| {
            let section_cmp = a.section.cmp(&b.section);
            if section_cmp == std::cmp::Ordering::Equal {
                a.step.cmp(&b.step)
            } else {
                section_cmp
            }
        });

        FfiStepImages {
            count: images.len() as u32,
            images,
        }
    }
}

/// FFI-safe representation of a recipe entry.
///
/// This is the main type for representing recipes across the FFI boundary.
#[derive(uniffi::Object)]
pub struct FfiRecipeEntry {
    inner: RecipeEntry,
}

#[uniffi::export]
impl FfiRecipeEntry {
    /// Returns the name of the recipe.
    pub fn name(&self) -> Option<String> {
        self.inner.name().clone()
    }

    /// Returns the file path if this recipe is backed by a file.
    pub fn path(&self) -> Option<String> {
        self.inner.path().map(|p| p.to_string())
    }

    /// Returns the file name if this recipe is backed by a file.
    pub fn file_name(&self) -> Option<String> {
        self.inner.file_name()
    }

    /// Returns the full content of the recipe.
    pub fn content(&self) -> Result<String, CooklangError> {
        self.inner.content().map_err(|e| e.into())
    }

    /// Returns the recipe's metadata.
    pub fn metadata(&self) -> FfiMetadata {
        FfiMetadata::from(self.inner.metadata())
    }

    /// Returns the recipe's tags.
    pub fn tags(&self) -> Vec<String> {
        self.inner.tags()
    }

    /// Returns the URL or path to the recipe's title image.
    pub fn title_image(&self) -> Option<String> {
        self.inner.title_image().clone()
    }

    /// Returns all step images for the recipe.
    pub fn step_images(&self) -> FfiStepImages {
        FfiStepImages::from(self.inner.step_images())
    }

    /// Returns true if this is a menu file (.menu) rather than a recipe (.cook).
    pub fn is_menu(&self) -> bool {
        self.inner.is_menu()
    }

    /// Gets a step image by section and step number.
    ///
    /// For linear recipes (no sections), use section = 0.
    /// Steps are one-indexed (first step is 1).
    pub fn get_step_image(&self, section: u32, step: u32) -> Option<String> {
        self.inner
            .step_images()
            .get(section as usize, step as usize)
            .cloned()
    }

    /// Gets a specific metadata value by key as a JSON string.
    pub fn get_metadata_value(&self, key: String) -> Option<String> {
        self.inner
            .metadata()
            .get(&key)
            .map(|v| serde_json::to_string(v).unwrap_or_default())
    }

    /// Returns all file paths related to this recipe.
    ///
    /// Includes images, referenced recipe files, and recursively
    /// related files of referenced recipes.
    pub fn related_files(&self) -> Vec<String> {
        self.inner
            .related_files()
            .into_iter()
            .map(|p| p.to_string())
            .collect()
    }
}

impl FfiRecipeEntry {
    fn new(entry: RecipeEntry) -> Self {
        FfiRecipeEntry { inner: entry }
    }
}

/// FFI-safe representation of a tree node.
#[derive(Debug, Clone, uniffi::Record)]
pub struct FfiTreeNode {
    /// Name of the node (directory or recipe name)
    pub name: String,
    /// Full path to this node
    pub path: String,
    /// True if this node has a recipe
    pub has_recipe: bool,
    /// Names of child nodes
    pub children: Vec<String>,
}

/// FFI-safe representation of a recipe tree.
#[derive(uniffi::Object)]
pub struct FfiRecipeTree {
    inner: RecipeTree,
}

#[uniffi::export]
impl FfiRecipeTree {
    /// Returns the root node information.
    pub fn root(&self) -> FfiTreeNode {
        tree_to_node(&self.inner)
    }

    /// Returns all nodes in the tree as a flat list.
    pub fn all_nodes(&self) -> Vec<FfiTreeNode> {
        let mut nodes = Vec::new();
        collect_nodes(&self.inner, &mut nodes);
        nodes
    }

    /// Returns all recipes in the tree.
    pub fn all_recipes(&self) -> Vec<Arc<FfiRecipeEntry>> {
        let mut recipes = Vec::new();
        collect_recipes(&self.inner, &mut recipes);
        recipes
    }

    /// Gets a child node by name from the root.
    pub fn get_child(&self, name: String) -> Option<FfiTreeNode> {
        self.inner.children.get(&name).map(tree_to_node)
    }

    /// Gets the recipe at the root level if present.
    pub fn recipe(&self) -> Option<Arc<FfiRecipeEntry>> {
        self.inner
            .recipe
            .as_ref()
            .map(|r| Arc::new(FfiRecipeEntry::new(r.clone())))
    }

    /// Gets a recipe by path components (e.g., ["breakfast", "pancakes"]).
    pub fn get_recipe_at_path(&self, path: Vec<String>) -> Option<Arc<FfiRecipeEntry>> {
        let mut current = &self.inner;
        for component in &path {
            current = current.children.get(component)?;
        }
        current
            .recipe
            .as_ref()
            .map(|r| Arc::new(FfiRecipeEntry::new(r.clone())))
    }
}

fn tree_to_node(tree: &RecipeTree) -> FfiTreeNode {
    FfiTreeNode {
        name: tree.name.clone(),
        path: tree.path.to_string(),
        has_recipe: tree.recipe.is_some(),
        children: tree.children.keys().cloned().collect(),
    }
}

fn collect_nodes(tree: &RecipeTree, nodes: &mut Vec<FfiTreeNode>) {
    nodes.push(tree_to_node(tree));
    for child in tree.children.values() {
        collect_nodes(child, nodes);
    }
}

fn collect_recipes(tree: &RecipeTree, recipes: &mut Vec<Arc<FfiRecipeEntry>>) {
    if let Some(recipe) = &tree.recipe {
        recipes.push(Arc::new(FfiRecipeEntry::new(recipe.clone())));
    }
    for child in tree.children.values() {
        collect_recipes(child, recipes);
    }
}

// ============================================================================
// Exported FFI Functions
// ============================================================================

/// Loads a recipe by name from the specified directories.
///
/// Searches through the provided directories in order for a recipe file
/// matching the given name. Automatically handles .cook and .menu extensions.
///
/// # Arguments
/// * `base_dirs` - List of directory paths to search
/// * `name` - Recipe name to search for (with or without extension)
///
/// # Returns
/// The recipe if found, or an error.
#[uniffi::export]
pub fn get_recipe(
    base_dirs: Vec<String>,
    name: String,
) -> Result<Arc<FfiRecipeEntry>, CooklangError> {
    let entry = get_recipe_str(base_dirs, &name)?;
    Ok(Arc::new(FfiRecipeEntry::new(entry)))
}

/// Creates a recipe from file content.
///
/// Useful for creating recipes from sources other than files,
/// such as network responses or programmatically generated content.
///
/// # Arguments
/// * `content` - The full recipe content including any YAML frontmatter
/// * `name` - Optional name for the recipe
///
/// # Returns
/// The recipe entry, or an error if parsing fails.
#[uniffi::export]
pub fn recipe_from_content(
    content: String,
    name: Option<String>,
) -> Result<Arc<FfiRecipeEntry>, CooklangError> {
    let entry = RecipeEntry::from_content(content, name)?;
    Ok(Arc::new(FfiRecipeEntry::new(entry)))
}

/// Creates a recipe from a file path.
///
/// # Arguments
/// * `path` - The path to the recipe file
///
/// # Returns
/// The recipe entry, or an error if loading fails.
#[uniffi::export]
pub fn recipe_from_path(path: String) -> Result<Arc<FfiRecipeEntry>, CooklangError> {
    let entry = RecipeEntry::from_path(path.into())?;
    Ok(Arc::new(FfiRecipeEntry::new(entry)))
}

/// Searches for recipes matching a query string.
///
/// Performs full-text search across recipe filenames and contents
/// in the specified directory and subdirectories.
///
/// # Arguments
/// * `base_dir` - Root directory to search in
/// * `query` - Search query (can contain multiple space-separated terms)
///
/// # Returns
/// List of matching recipes sorted by relevance.
#[uniffi::export]
pub fn search(base_dir: String, query: String) -> Result<Vec<Arc<FfiRecipeEntry>>, CooklangError> {
    let results = search_internal(Utf8Path::new(&base_dir), &query)?;
    Ok(results
        .into_iter()
        .map(|r| Arc::new(FfiRecipeEntry::new(r)))
        .collect())
}

/// Lists menu files that have a section header containing the given date.
///
/// Only `.menu` files are scanned; a file is included if any of its section
/// headers contains the `date` substring. The date is matched literally — the
/// caller supplies it (for example, the host app's local "today" or "tomorrow").
///
/// # Arguments
/// * `base_dirs` - Root directories to scan
/// * `date` - The date string to match (e.g. "2026-06-24")
///
/// # Returns
/// List of matching menu recipes.
#[uniffi::export]
pub fn list_menus_for_date(
    base_dirs: Vec<String>,
    date: String,
) -> Result<Vec<Arc<FfiRecipeEntry>>, CooklangError> {
    let dirs: Vec<Utf8PathBuf> = base_dirs.into_iter().map(Utf8PathBuf::from).collect();
    let results = list_menus_for_date_internal(&dirs, &date)?;
    Ok(results
        .into_iter()
        .map(|r| Arc::new(FfiRecipeEntry::new(r)))
        .collect())
}

/// Builds a hierarchical tree of all recipes in a directory.
///
/// Recursively scans the directory for .cook and .menu files,
/// organizing them into a tree structure mirroring the filesystem.
///
/// # Arguments
/// * `base_dir` - Root directory to build the tree from
///
/// # Returns
/// The recipe tree, or an error.
#[uniffi::export]
pub fn build_tree(base_dir: String) -> Result<Arc<FfiRecipeTree>, CooklangError> {
    let tree = build_tree_internal(&base_dir)?;
    Ok(Arc::new(FfiRecipeTree { inner: tree }))
}

/// Returns the library version.
#[uniffi::export]
pub fn library_version() -> String {
    env!("CARGO_PKG_VERSION").to_string()
}

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

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

    #[test]
    fn test_recipe_from_content() {
        let content = indoc! {r#"
            ---
            title: Test Recipe
            servings: 4
            tags: [breakfast, easy]
            ---

            Add @eggs{2} and mix"#};

        let recipe = recipe_from_content(content.to_string(), None).unwrap();
        assert_eq!(recipe.name(), Some("Test Recipe".to_string()));
        assert_eq!(recipe.metadata().servings, Some(4));
        assert_eq!(recipe.tags(), vec!["breakfast", "easy"]);
    }

    #[test]
    fn test_search_recipes() {
        let temp_dir = TempDir::new().unwrap();
        let temp_path = temp_dir.path().to_str().unwrap();

        create_test_recipe(
            temp_path,
            "pancakes",
            indoc! {r#"
            ---
            title: Fluffy Pancakes
            ---

            Mix and cook"#},
        );

        create_test_recipe(
            temp_path,
            "waffles",
            indoc! {r#"
            ---
            title: Crispy Waffles
            ---

            Make waffles"#},
        );

        let results = search(temp_path.to_string(), "pancakes".to_string()).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].name(), Some("Fluffy Pancakes".to_string()));
    }

    #[test]
    fn test_build_tree() {
        let temp_dir = TempDir::new().unwrap();
        let temp_path = temp_dir.path().to_str().unwrap();

        // Create nested structure
        let breakfast_dir = format!("{}/breakfast", temp_path);
        fs::create_dir_all(&breakfast_dir).unwrap();

        create_test_recipe(
            &breakfast_dir,
            "pancakes",
            indoc! {r#"
            ---
            title: Pancakes
            ---

            Make pancakes"#},
        );

        let tree = build_tree(temp_path.to_string()).unwrap();
        let nodes = tree.all_nodes();
        assert!(nodes.len() >= 2); // At least root and breakfast directory

        let recipes = tree.all_recipes();
        assert_eq!(recipes.len(), 1);
    }

    #[test]
    fn test_step_images_conversion() {
        use std::collections::HashMap;

        let mut collection = StepImageCollection::default();
        collection.images.insert(0, HashMap::new());
        collection
            .images
            .get_mut(&0)
            .unwrap()
            .insert(0, "/path/to/image1.jpg".to_string());
        collection
            .images
            .get_mut(&0)
            .unwrap()
            .insert(2, "/path/to/image3.jpg".to_string());

        let ffi_images = FfiStepImages::from(&collection);
        assert_eq!(ffi_images.count, 2);
        assert_eq!(ffi_images.images[0].section, 0);
        assert_eq!(ffi_images.images[0].step, 1);
        assert_eq!(ffi_images.images[1].step, 3);
    }

    #[test]
    fn test_library_version() {
        let version = library_version();
        assert!(!version.is_empty());
        assert_eq!(version, env!("CARGO_PKG_VERSION"));
    }

    #[test]
    fn test_related_files_ffi() {
        let temp_dir = TempDir::new().unwrap();
        let temp_path = temp_dir.path().to_str().unwrap();

        let sauces_dir = format!("{}/sauces", temp_path);
        fs::create_dir_all(&sauces_dir).unwrap();

        // Referenced recipe with image
        create_test_recipe(&sauces_dir, "Hollandaise", "Melt @butter{100%g}");
        fs::write(format!("{}/Hollandaise.jpg", sauces_dir), b"").unwrap();

        // Main recipe
        let path = create_test_recipe(
            temp_path,
            "EggsBenedict",
            "Pour @./sauces/Hollandaise{150%g} over eggs.",
        );

        let recipe = recipe_from_path(path).unwrap();
        let files = recipe.related_files();

        assert_eq!(files.len(), 2);
        assert!(files.iter().any(|f| f.ends_with("Hollandaise.cook")));
        assert!(files.iter().any(|f| f.ends_with("Hollandaise.jpg")));
    }
}