Skip to main content

cooklang_find/model/
recipe_entry.rs

1use super::metadata::{extract_and_parse_metadata, Metadata};
2use camino::{Utf8Path, Utf8PathBuf};
3use glob::glob;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::collections::HashSet;
8use std::fs::File;
9use std::io::{BufRead, BufReader};
10use std::path::Path;
11use std::sync::OnceLock;
12use thiserror::Error;
13
14/// Represents the complete collection of step images for a recipe.
15///
16/// Images are discovered based on the Cooklang naming convention:
17/// - `RecipeName.N.ext`: Stored at \[0\]\[N-1\] (section 0 = linear/no section)
18/// - `RecipeName.S.N.ext`: Stored at \[S-1\]\[N-1\] (section S, step N)
19///
20/// File numbering is one-indexed (Recipe.1.jpg = first step)
21/// Internal HashMap keys are zero-indexed
22/// Section 0 is reserved for linear recipes without sections.
23///
24/// Supported extensions: jpg, jpeg, png, webp
25#[derive(Debug, Clone, Serialize, Default)]
26pub struct StepImageCollection {
27    /// Two-dimensional map: section_index -> step_index -> image_path
28    /// - Section 0: steps for linear recipes (Recipe.N.ext stored at \[0\]\[N-1\])
29    /// - Section 1+: steps within sections (Recipe.S.N.ext stored at \[S-1\]\[N-1\])
30    ///
31    /// HashMap keys are zero-indexed
32    pub images: HashMap<usize, HashMap<usize, String>>,
33}
34
35impl StepImageCollection {
36    /// Returns true if there are any images in the collection
37    pub fn is_empty(&self) -> bool {
38        self.images.is_empty()
39    }
40
41    /// Returns total count of all images across all sections
42    pub fn count(&self) -> usize {
43        self.images.values().map(|steps| steps.len()).sum()
44    }
45
46    /// Gets an image for a specific section and step.
47    ///
48    /// # Arguments
49    /// * `section` - Section number (0 for linear recipes, 1+ for sectioned recipes, one-indexed for sections)
50    /// * `step` - One-indexed step number (1 = first step)
51    ///
52    /// # Returns
53    /// Image path if found, None otherwise
54    ///
55    /// # Examples
56    /// ```
57    /// # use cooklang_find::StepImageCollection;
58    /// # let images = StepImageCollection::default();
59    /// // Get step 3 in linear recipe (Recipe.3.jpg stored at [0][2])
60    /// let img = images.get(0, 3);
61    ///
62    /// // Get section 2, step 4 (Recipe.2.4.jpg stored at [1][3])
63    /// let img = images.get(2, 4);
64    /// ```
65    pub fn get(&self, section: usize, step: usize) -> Option<&String> {
66        if step == 0 {
67            return None; // Steps are one-indexed
68        }
69        // For section 0 (linear recipes), use section index 0
70        // For section 1+, convert to zero-indexed (section - 1)
71        let section_idx = if section == 0 { 0 } else { section - 1 };
72        self.images.get(&section_idx)?.get(&(step - 1))
73    }
74}
75
76/// Represents the source of a recipe.
77///
78/// A recipe can come from either:
79/// - A file path on the filesystem
80/// - Direct content (e.g., from stdin or programmatically created)
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(tag = "source_type")]
83pub enum RecipeSource {
84    Path {
85        path: Utf8PathBuf,
86    },
87    Content {
88        content: String,
89        name: Option<String>,
90    },
91}
92
93/// Represents a single recipe or menu entry.
94///
95/// This structure encapsulates all information about a recipe including:
96/// - Its source (file path or content)
97/// - Metadata extracted from YAML frontmatter
98/// - Cached computed values like name and title image
99///
100/// # Examples
101///
102/// ```no_run
103/// use cooklang_find::RecipeEntry;
104/// use camino::Utf8PathBuf;
105///
106/// // Load a recipe from a file
107/// let recipe = RecipeEntry::from_path(Utf8PathBuf::from("recipes/pancakes.cook"))?;
108///
109/// // Access recipe information
110/// let name = recipe.name();
111/// let metadata = recipe.metadata();
112/// let content = recipe.content()?;
113/// # Ok::<(), Box<dyn std::error::Error>>(())
114/// ```
115#[derive(Debug, Serialize, Deserialize)]
116pub struct RecipeEntry {
117    /// Source of the recipe (path or content)
118    source: RecipeSource,
119    /// Cached metadata
120    metadata: Metadata,
121
122    /// Cached name of the recipe (from file stem, title, or provided name)
123    #[serde(skip)]
124    name: OnceLock<Option<String>>,
125    /// Optional path or URL to the title image
126    #[serde(skip)]
127    title_image: OnceLock<Option<String>>,
128    /// Cached step and section images
129    #[serde(skip)]
130    step_images: OnceLock<StepImageCollection>,
131    /// Whether this is a menu file (*.menu) rather than a regular recipe
132    #[serde(skip)]
133    is_menu: OnceLock<bool>,
134}
135
136impl Clone for RecipeEntry {
137    fn clone(&self) -> Self {
138        RecipeEntry {
139            source: self.source.clone(),
140            metadata: self.metadata.clone(),
141            // Reset cached fields - they will be recomputed on demand
142            name: OnceLock::new(),
143            title_image: OnceLock::new(),
144            step_images: OnceLock::new(),
145            is_menu: OnceLock::new(),
146        }
147    }
148}
149
150impl RecipeEntry {
151    /// Creates a new `RecipeEntry` from a file path.
152    ///
153    /// Reads the recipe file, extracts metadata from YAML frontmatter,
154    /// and creates a fully initialized recipe entry.
155    ///
156    /// # Arguments
157    ///
158    /// * `path` - The path to the recipe file (.cook or .menu)
159    ///
160    /// # Errors
161    ///
162    /// Returns `RecipeEntryError` if:
163    /// - The file cannot be read
164    /// - The metadata cannot be parsed
165    pub fn from_path(path: Utf8PathBuf) -> Result<Self, RecipeEntryError> {
166        let file = File::open(&path).map_err(RecipeEntryError::IoError)?;
167        let reader = BufReader::new(file);
168
169        let metadata = extract_and_parse_metadata(
170            reader.lines().map(|r| r.map_err(RecipeEntryError::IoError)),
171        )?;
172
173        Ok(RecipeEntry {
174            source: RecipeSource::Path { path },
175            metadata,
176            name: OnceLock::new(),
177            title_image: OnceLock::new(),
178            step_images: OnceLock::new(),
179            is_menu: OnceLock::new(),
180        })
181    }
182
183    /// Creates a new `RecipeEntry` from string content.
184    ///
185    /// This method is useful for creating recipes from sources other than files,
186    /// such as stdin, network responses, or programmatically generated content.
187    ///
188    /// # Arguments
189    ///
190    /// * `content` - The full recipe content including any YAML frontmatter
191    /// * `name` - Optional name for the recipe (used if no title in metadata)
192    ///
193    /// # Errors
194    ///
195    /// Returns `RecipeEntryError` if the metadata cannot be parsed.
196    pub fn from_content(content: String, name: Option<String>) -> Result<Self, RecipeEntryError> {
197        let metadata = extract_and_parse_metadata(
198            content
199                .lines()
200                .map(|line| Ok::<_, RecipeEntryError>(line.to_string())),
201        )?;
202
203        Ok(RecipeEntry {
204            source: RecipeSource::Content { content, name },
205            metadata,
206            name: OnceLock::new(),
207            title_image: OnceLock::new(),
208            step_images: OnceLock::new(),
209            is_menu: OnceLock::new(),
210        })
211    }
212
213    /// Returns the name of the recipe.
214    ///
215    /// The name is determined in the following priority order:
216    /// 1. Title from metadata (if present)
217    /// 2. File stem (for path-based recipes)
218    /// 3. Provided name (for content-based recipes)
219    ///
220    /// The result is cached after the first call.
221    pub fn name(&self) -> &Option<String> {
222        self.name.get_or_init(|| {
223            if let Some(title) = self.metadata.title() {
224                Some(title.to_string())
225            } else {
226                match &self.source {
227                    RecipeSource::Path { path } => Some(path.file_stem()?.to_string()),
228                    RecipeSource::Content { name, .. } => name.clone(),
229                }
230            }
231        })
232    }
233
234    /// Returns the URL or path to the recipe's title image.
235    ///
236    /// The image is determined in the following priority order:
237    /// 1. Image URL from metadata (image, images, picture, or pictures fields)
238    /// 2. Image file with same stem as recipe (for path-based recipes)
239    ///
240    /// Supported image extensions: jpg, jpeg, png, webp
241    ///
242    /// The result is cached after the first call.
243    pub fn title_image(&self) -> &Option<String> {
244        self.title_image.get_or_init(|| {
245            // First check metadata for image URLs
246            if let Some(url) = self.metadata.image_url() {
247                return Some(url);
248            }
249
250            // For path-based recipes, check for file-based images
251            match &self.source {
252                RecipeSource::Path { path } => find_title_image(path).map(|p| p.to_string()),
253                RecipeSource::Content { .. } => None,
254            }
255        })
256    }
257
258    /// Returns the full content of the recipe.
259    ///
260    /// For path-based recipes, this reads the file from disk.
261    /// For content-based recipes, this returns the stored content.
262    ///
263    /// # Errors
264    ///
265    /// Returns `RecipeEntryError::IoError` if the file cannot be read
266    /// (only applicable for path-based recipes).
267    pub fn content(&self) -> Result<String, RecipeEntryError> {
268        match &self.source {
269            RecipeSource::Path { path } => {
270                std::fs::read_to_string(path).map_err(RecipeEntryError::IoError)
271            }
272            RecipeSource::Content { content, .. } => Ok(content.clone()),
273        }
274    }
275
276    /// Returns a reference to the recipe's metadata.
277    ///
278    /// The metadata contains all fields from the YAML frontmatter,
279    /// providing access to both standard fields (title, servings, tags)
280    /// and any custom fields defined in the recipe.
281    pub fn metadata(&self) -> &Metadata {
282        &self.metadata
283    }
284
285    /// Returns the file path if this recipe is backed by a file.
286    ///
287    /// Returns `None` for recipes created from content.
288    pub fn path(&self) -> Option<&Utf8PathBuf> {
289        match &self.source {
290            RecipeSource::Path { path } => Some(path),
291            RecipeSource::Content { .. } => None,
292        }
293    }
294
295    /// Returns the file name if this recipe is backed by a file.
296    ///
297    /// Returns `None` for recipes created from content.
298    pub fn file_name(&self) -> Option<String> {
299        match &self.source {
300            RecipeSource::Path { path } => Some(path.file_name()?.to_string()),
301            RecipeSource::Content { .. } => None,
302        }
303    }
304
305    /// Returns the recipe's tags from metadata.
306    ///
307    /// Tags can be defined in metadata as:
308    /// - A comma-separated string: `tags: "breakfast, easy, vegetarian"`
309    /// - An array: `tags: [breakfast, easy, vegetarian]`
310    ///
311    /// Returns an empty vector if no tags are defined.
312    pub fn tags(&self) -> Vec<String> {
313        self.metadata.tags()
314    }
315
316    /// Checks if this entry represents a menu file.
317    ///
318    /// Returns `true` if the file has a .menu extension,
319    /// `false` otherwise (including content-based recipes).
320    pub fn is_menu(&self) -> bool {
321        *self.is_menu.get_or_init(|| match &self.source {
322            RecipeSource::Path { path } => path.extension() == Some("menu"),
323            RecipeSource::Content { .. } => false,
324        })
325    }
326
327    /// Returns all step and section images for the recipe.
328    ///
329    /// Images follow the Cooklang naming convention (one-indexed):
330    /// - `RecipeName.N.ext`: Step N in linear recipe (stored at section 0)
331    /// - `RecipeName.S.N.ext`: Section S, step N within section
332    ///
333    /// All step and section numbers are one-indexed (first step/section is 1).
334    ///
335    /// Supported extensions: jpg, jpeg, png, webp (in priority order)
336    ///
337    /// The result is cached after the first call.
338    ///
339    /// # Returns
340    ///
341    /// Reference to StepImageCollection containing all discovered images.
342    /// For content-based recipes, returns an empty collection.
343    ///
344    /// # Examples
345    ///
346    /// ```no_run
347    /// use cooklang_find::RecipeEntry;
348    /// use camino::Utf8PathBuf;
349    ///
350    /// let recipe = RecipeEntry::from_path(Utf8PathBuf::from("recipes/pasta.cook"))?;
351    /// let images = recipe.step_images();
352    ///
353    /// // Access linear step image (Pasta.3.jpg)
354    /// if let Some(img) = images.get(0, 3) {
355    ///     println!("Step 3 image: {}", img);
356    /// }
357    ///
358    /// // Access section-step image (Pasta.2.4.jpg)
359    /// if let Some(img) = images.get(2, 4) {
360    ///     println!("Section 2, Step 4 image: {}", img);
361    /// }
362    ///
363    /// // Direct HashMap access for iteration
364    /// if let Some(section_steps) = images.images.get(&1) {
365    ///     for (step_idx, img_path) in section_steps {
366    ///         println!("Section 2, Step {}: {}", step_idx + 1, img_path);
367    ///     }
368    /// }
369    /// # Ok::<(), Box<dyn std::error::Error>>(())
370    /// ```
371    pub fn step_images(&self) -> &StepImageCollection {
372        self.step_images.get_or_init(|| match &self.source {
373            RecipeSource::Path { path } => find_step_images(path),
374            RecipeSource::Content { .. } => StepImageCollection::default(),
375        })
376    }
377
378    /// Returns all file paths related to this recipe.
379    ///
380    /// Includes:
381    /// - Title image (if any)
382    /// - Step/section images
383    /// - Referenced recipe .cook files (detected via `@./path` or `@../path` syntax)
384    /// - Recursively: related files of referenced recipes
385    ///
386    /// Returns an empty Vec for content-based recipes.
387    /// Missing referenced files are silently skipped.
388    /// Cycles are detected and broken automatically.
389    pub fn related_files(&self) -> Vec<Utf8PathBuf> {
390        let path = match &self.source {
391            RecipeSource::Path { path } => path,
392            RecipeSource::Content { .. } => return Vec::new(),
393        };
394        let mut visited = HashSet::new();
395        let mut result = Vec::new();
396        collect_related_files(path, &mut visited, &mut result);
397        result
398    }
399}
400
401/// Errors that can occur when working with recipe entries.
402#[derive(Error, Debug)]
403pub enum RecipeEntryError {
404    #[error("Failed to read recipe file: {0}")]
405    IoError(#[from] std::io::Error),
406
407    #[error("Failed to get file stem from path: {0}")]
408    InvalidPath(Utf8PathBuf),
409
410    #[error("Failed to parse recipe: {0}")]
411    ParseError(String),
412
413    #[error("Failed to parse recipe metadata: {0}")]
414    MetadataError(String),
415}
416
417fn find_title_image(path: &Utf8Path) -> Option<Utf8PathBuf> {
418    // Look for an image with the same stem
419    let possible_image_extensions = ["jpg", "jpeg", "png", "webp"];
420    possible_image_extensions.iter().find_map(|ext| {
421        let image_path = path.with_extension(ext);
422        if image_path.exists() {
423            Some(image_path)
424        } else {
425            None
426        }
427    })
428}
429
430/// Discovers all step and section images for a recipe file.
431///
432/// Uses glob patterns to find images matching these patterns:
433/// - `Recipe.N.ext` (where N is 1+, one-indexed) → stored at [0][N-1]
434/// - `Recipe.S.N.ext` (where S and N are 1+, one-indexed) → stored at [S-1][N-1]
435///
436/// Images are discovered purely by filename pattern. No recipe parsing required.
437///
438/// # Arguments
439///
440/// * `path` - Path to the recipe file
441///
442/// # Returns
443///
444/// StepImageCollection containing all discovered images
445fn find_step_images(path: &Utf8Path) -> StepImageCollection {
446    let mut collection = StepImageCollection::default();
447    let stem = match path.file_stem() {
448        Some(s) => s,
449        None => return collection,
450    };
451    let dir = path.parent().unwrap_or(path);
452    let extensions = ["jpg", "jpeg", "png", "webp"];
453
454    // Build glob pattern for images: Recipe.*.ext and Recipe.*.*.ext
455    // Pattern matches: Recipe.1.jpg, Recipe.2.4.png, etc.
456    for ext in &extensions {
457        let pattern = dir.join(format!("{}.*.{}", stem, ext));
458        let pattern_str = pattern.as_str();
459
460        if let Ok(entries) = glob(pattern_str) {
461            for entry in entries.flatten() {
462                if let Some(numbers) = parse_image_numbers(&entry, stem, ext) {
463                    let entry_str = entry.to_string_lossy().to_string();
464
465                    match numbers.len() {
466                        // Single number: Recipe.N.ext
467                        1 => {
468                            let step_num = numbers[0]; // One-indexed from filename
469                                                       // Store in section 0 for linear recipes
470                                                       // Recipe.1.ext -> [0][0], Recipe.3.ext -> [0][2]
471                            collection
472                                .images
473                                .entry(0)
474                                .or_insert_with(HashMap::new)
475                                .entry(step_num - 1) // Convert to zero-indexed
476                                .or_insert(entry_str);
477                        }
478                        // Two numbers: Recipe.S.N.ext
479                        2 => {
480                            let (section_num, step_num) = (numbers[0], numbers[1]); // One-indexed
481                                                                                    // Recipe.2.4.ext -> [1][3]
482                            collection
483                                .images
484                                .entry(section_num - 1) // Convert to zero-indexed
485                                .or_insert_with(HashMap::new)
486                                .entry(step_num - 1) // Convert to zero-indexed
487                                .or_insert(entry_str);
488                        }
489                        _ => {} // Ignore invalid patterns
490                    }
491                }
492            }
493        }
494    }
495
496    collection
497}
498
499/// Parses step/section numbers from an image filename.
500///
501/// Examples:
502/// - "Recipe.3.jpg" -> Some(vec![3])
503/// - "Recipe.2.4.jpg" -> Some(vec![2, 4])
504/// - "Recipe.invalid.jpg" -> None
505///
506/// # Arguments
507///
508/// * `path` - Path to the image file
509/// * `stem` - Recipe file stem (e.g., "Recipe")
510/// * `ext` - Image extension (e.g., "jpg")
511///
512/// # Returns
513///
514/// Vector of one-indexed numbers if valid, None otherwise
515fn parse_image_numbers(path: &Path, stem: &str, ext: &str) -> Option<Vec<usize>> {
516    let filename = path.file_name()?.to_str()?;
517
518    // Remove the stem and extension to get just the number part(s)
519    // Example: "Recipe.2.4.jpg" -> ".2.4."
520    let without_stem = filename.strip_prefix(stem)?;
521    let without_ext = without_stem.strip_suffix(&format!(".{}", ext))?;
522
523    // Split by dots and parse numbers
524    // Example: ".2.4." -> ["", "2", "4", ""]
525    let numbers: Vec<usize> = without_ext
526        .split('.')
527        .filter(|s| !s.is_empty())
528        .filter_map(|s| s.parse::<usize>().ok())
529        .collect();
530
531    // Only accept 1 or 2 numbers, and they must be >= 1 (one-indexed)
532    if !numbers.is_empty() && numbers.len() <= 2 && numbers.iter().all(|&n| n >= 1) {
533        Some(numbers)
534    } else {
535        None
536    }
537}
538
539/// Extracts recipe references from Cooklang content.
540///
541/// Looks for ingredient references that are relative file paths,
542/// matching patterns like `@./path/to/Recipe` or `@../path/to/Recipe`
543/// with optional quantity `{...}`.
544///
545/// Returns deduplicated list of referenced paths (without extension).
546fn extract_recipe_references(content: &str) -> Vec<String> {
547    static RE: OnceLock<Regex> = OnceLock::new();
548    let re = RE.get_or_init(|| Regex::new(r"@(\.\.?/[^\s\{},.)]+)").unwrap());
549    let mut seen = HashSet::new();
550    let mut refs = Vec::new();
551    for cap in re.captures_iter(content) {
552        let path = cap[1].to_string();
553        if seen.insert(path.clone()) {
554            refs.push(path);
555        }
556    }
557    refs
558}
559
560/// Recursively collects all files related to a recipe.
561///
562/// Adds image paths and referenced recipe paths to `result`.
563/// Uses `visited` to prevent cycles and deduplication.
564fn collect_related_files(
565    recipe_path: &Utf8Path,
566    visited: &mut HashSet<Utf8PathBuf>,
567    result: &mut Vec<Utf8PathBuf>,
568) {
569    // Canonicalize and mark as visited to prevent cycles
570    let canonical = match std::fs::canonicalize(recipe_path) {
571        Ok(p) => Utf8PathBuf::from_path_buf(p)
572            .unwrap_or_else(|p| Utf8PathBuf::from(p.to_string_lossy().into_owned())),
573        Err(_) => recipe_path.to_path_buf(),
574    };
575    if !visited.insert(canonical) {
576        return;
577    }
578
579    // Collect title image
580    if let Some(image_path) = find_title_image(recipe_path) {
581        result.push(image_path);
582    }
583
584    // Collect step images
585    let step_images = find_step_images(recipe_path);
586    for steps in step_images.images.values() {
587        for image_path in steps.values() {
588            result.push(Utf8PathBuf::from(image_path));
589        }
590    }
591
592    // Read content and extract recipe references
593    let content = match std::fs::read_to_string(recipe_path) {
594        Ok(c) => c,
595        Err(_) => return,
596    };
597
598    let dir = recipe_path.parent().unwrap_or(recipe_path);
599    for ref_path_str in extract_recipe_references(&content) {
600        // Resolve relative to recipe's directory
601        let ref_path = dir.join(&ref_path_str);
602
603        // Try with .cook extension if no extension present
604        let candidates = if ref_path.extension().is_some() {
605            vec![ref_path]
606        } else {
607            vec![ref_path.with_extension("cook")]
608        };
609
610        for candidate in candidates {
611            let canonical_candidate = match std::fs::canonicalize(&candidate) {
612                Ok(p) => Utf8PathBuf::from_path_buf(p)
613                    .unwrap_or_else(|p| Utf8PathBuf::from(p.to_string_lossy().into_owned())),
614                Err(_) => candidate.clone(),
615            };
616            if candidate.exists() && !visited.contains(&canonical_candidate) {
617                result.push(candidate.clone());
618                collect_related_files(&candidate, visited, result);
619            }
620        }
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use indoc::indoc;
628    use std::fs::File;
629    use std::io::Write;
630    use tempfile::TempDir;
631
632    fn create_test_recipe(dir: &Utf8Path, name: &str, content: &str) -> Utf8PathBuf {
633        let recipe_path = dir.join(format!("{name}.cook"));
634        let mut file = File::create(&recipe_path).unwrap();
635        write!(file, "{content}").unwrap();
636        recipe_path
637    }
638
639    fn create_test_image(dir: &Utf8Path, name: &str, ext: &str) -> Utf8PathBuf {
640        let image_path = dir.join(format!("{name}.{ext}"));
641        File::create(&image_path).unwrap();
642        image_path
643    }
644
645    #[test]
646    fn test_recipe_creation() {
647        let temp_dir = TempDir::new().unwrap();
648        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
649        let recipe_path = create_test_recipe(
650            &temp_dir_path,
651            "test_recipe",
652            indoc! {r#"
653                ---
654                servings: 4
655                ---
656
657                Test recipe content"#},
658        );
659
660        let recipe = RecipeEntry::from_path(recipe_path.clone()).unwrap();
661        assert_eq!(recipe.name().as_ref().unwrap(), "test_recipe");
662        assert_eq!(recipe.path(), Some(&recipe_path));
663        assert_eq!(recipe.file_name().as_ref().unwrap(), "test_recipe.cook");
664        assert!(recipe.title_image().is_none());
665    }
666
667    #[test]
668    fn test_recipe_name_from_title() {
669        let temp_dir = TempDir::new().unwrap();
670        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
671        let recipe_path = create_test_recipe(
672            &temp_dir_path,
673            "test_recipe",
674            indoc! {r#"
675                ---
676                title: My Special Recipe
677                servings: 4
678                ---
679
680                Test recipe content"#},
681        );
682
683        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
684        assert_eq!(recipe.name().as_ref().unwrap(), "My Special Recipe");
685    }
686
687    #[test]
688    fn test_recipe_with_title_image() {
689        let temp_dir = TempDir::new().unwrap();
690        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
691        let recipe_path = create_test_recipe(
692            &temp_dir_path,
693            "test_recipe",
694            indoc! {r#"
695                ---
696                servings: 4
697                ---
698
699                Test recipe content"#},
700        );
701        let image_path = create_test_image(&temp_dir_path, "test_recipe", "jpg");
702
703        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
704        assert_eq!(
705            recipe.title_image().as_ref().unwrap(),
706            &image_path.to_string()
707        );
708    }
709
710    #[test]
711    fn test_recipe_content() {
712        let temp_dir = TempDir::new().unwrap();
713        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
714        let content = indoc! {r#"
715            ---
716            servings: 4
717            ---
718
719            Test recipe content"#};
720        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", content);
721
722        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
723        assert_eq!(recipe.content().unwrap(), content);
724    }
725
726    #[test]
727    fn test_recipe_metadata() {
728        let temp_dir = TempDir::new().unwrap();
729        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
730        let content = indoc! {r#"
731            ---
732            servings: 4
733            time: 30 min
734            cuisine: Italian
735            ---
736
737            Test recipe content"#};
738        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", content);
739
740        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
741        let metadata = &recipe.metadata;
742
743        assert_eq!(metadata.get("servings").unwrap().as_i64().unwrap(), 4);
744        assert_eq!(metadata.get("time").unwrap().as_str().unwrap(), "30 min");
745        assert_eq!(
746            metadata.get("cuisine").unwrap().as_str().unwrap(),
747            "Italian"
748        );
749    }
750
751    #[test]
752    fn test_recipe_content_access() {
753        let temp_dir = TempDir::new().unwrap();
754        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
755        let content = indoc! {r#"
756            ---
757            servings: 4
758            ---
759
760            Add @salt{1%tsp} and @pepper{1%tsp}"#};
761        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", content);
762
763        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
764
765        // Test that content is accessible
766        assert_eq!(recipe.content().unwrap(), content);
767
768        // Test that metadata is parsed
769        assert_eq!(recipe.metadata().servings().unwrap(), 4);
770    }
771
772    #[test]
773    fn test_recipe_equality() {
774        let temp_dir = TempDir::new().unwrap();
775        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
776        let path1 = create_test_recipe(
777            &temp_dir_path,
778            "recipe1",
779            indoc! {r#"
780                ---
781                servings: 4
782                ---
783
784                Test recipe content"#},
785        );
786        let path2 = create_test_recipe(
787            &temp_dir_path,
788            "recipe2",
789            indoc! {r#"
790                ---
791                servings: 4
792                ---
793
794                Test recipe content"#},
795        );
796
797        let recipe1 = RecipeEntry::from_path(path1.clone()).unwrap();
798        let recipe2 = RecipeEntry::from_path(path1).unwrap();
799        let recipe3 = RecipeEntry::from_path(path2).unwrap();
800
801        // Compare paths
802        assert_eq!(recipe1.path(), recipe2.path());
803        assert_ne!(recipe1.path(), recipe3.path());
804    }
805
806    #[test]
807    fn test_invalid_recipe_path() {
808        let temp_dir = TempDir::new().unwrap();
809        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
810        let invalid_path = temp_dir_path.join("nonexistent.cook");
811
812        let result = RecipeEntry::from_path(invalid_path);
813        assert!(result.is_err());
814    }
815
816    #[test]
817    fn test_find_title_image_no_image() {
818        let temp_dir = TempDir::new().unwrap();
819        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
820        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
821        assert!(find_title_image(&recipe_path).is_none());
822    }
823
824    #[test]
825    fn test_find_title_image_all_extensions() {
826        let temp_dir = TempDir::new().unwrap();
827        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
828        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
829
830        // Test each supported extension
831        for ext in ["jpg", "jpeg", "png", "webp"] {
832            // Clean up any previous test images
833            for old_ext in ["jpg", "jpeg", "png", "webp"] {
834                let _ = std::fs::remove_file(recipe_path.with_extension(old_ext));
835            }
836
837            let image_path = create_test_image(&temp_dir_path, "test_recipe", ext);
838            let found = find_title_image(&recipe_path);
839
840            assert!(found.is_some(), "Failed to find image with extension {ext}");
841            assert_eq!(found.unwrap(), image_path);
842        }
843    }
844
845    #[test]
846    fn test_find_title_image_multiple_images() {
847        let temp_dir = TempDir::new().unwrap();
848        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
849        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
850
851        // Create images with different extensions
852        let jpg_path = create_test_image(&temp_dir_path, "test_recipe", "jpg");
853        let _png_path = create_test_image(&temp_dir_path, "test_recipe", "png");
854        let _webp_path = create_test_image(&temp_dir_path, "test_recipe", "webp");
855
856        // Should return the first matching extension (jpg)
857        let found_image = find_title_image(&recipe_path);
858        assert!(found_image.is_some());
859        assert_eq!(found_image.unwrap(), jpg_path);
860    }
861
862    #[test]
863    fn test_recipe_from_content() {
864        let content = indoc! {r#"
865            ---
866            title: Test Recipe
867            servings: 4
868            ---
869
870            Test recipe content from string"#};
871
872        let recipe =
873            RecipeEntry::from_content(content.to_string(), Some("my_recipe".to_string())).unwrap();
874        assert_eq!(recipe.name().as_ref().unwrap(), "Test Recipe"); // Title takes precedence
875        assert!(recipe.path().is_none());
876        assert!(recipe.title_image().is_none());
877        assert_eq!(recipe.content().unwrap(), content);
878        assert_eq!(recipe.metadata().servings().unwrap(), 4);
879    }
880
881    #[test]
882    fn test_recipe_with_metadata_image() {
883        let content = indoc! {r#"
884            ---
885            title: Test Recipe
886            image: https://example.com/recipe.jpg
887            ---
888
889            Test recipe content"#};
890
891        let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
892        assert_eq!(
893            recipe.title_image().as_ref().unwrap(),
894            "https://example.com/recipe.jpg"
895        );
896    }
897
898    #[test]
899    fn test_recipe_with_metadata_images_array() {
900        let content = indoc! {r#"
901            ---
902            title: Test Recipe
903            images:
904              - https://example.com/recipe1.jpg
905              - https://example.com/recipe2.jpg
906            ---
907
908            Test recipe content"#};
909
910        let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
911        // Should return the first image from the array
912        assert_eq!(
913            recipe.title_image().as_ref().unwrap(),
914            "https://example.com/recipe1.jpg"
915        );
916    }
917
918    #[test]
919    fn test_recipe_with_metadata_picture() {
920        let content = indoc! {r#"
921            ---
922            title: Test Recipe
923            picture: https://example.com/pic.png
924            ---
925
926            Test recipe content"#};
927
928        let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
929        assert_eq!(
930            recipe.title_image().as_ref().unwrap(),
931            "https://example.com/pic.png"
932        );
933    }
934
935    #[test]
936    fn test_recipe_with_metadata_pictures_array() {
937        let content = indoc! {r#"
938            ---
939            title: Test Recipe
940            pictures:
941              - https://example.com/pic1.png
942              - https://example.com/pic2.png
943            ---
944
945            Test recipe content"#};
946
947        let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
948        assert_eq!(
949            recipe.title_image().as_ref().unwrap(),
950            "https://example.com/pic1.png"
951        );
952    }
953
954    #[test]
955    fn test_recipe_from_content_no_title() {
956        let content = indoc! {r#"
957            ---
958            servings: 2
959            ---
960
961            Test recipe content"#};
962
963        let recipe =
964            RecipeEntry::from_content(content.to_string(), Some("content_recipe".to_string()))
965                .unwrap();
966        assert_eq!(recipe.name().as_ref().unwrap(), "content_recipe");
967        assert!(recipe.path().is_none());
968    }
969
970    #[test]
971    fn test_recipe_from_content_no_name() {
972        let content = "Just recipe content";
973
974        let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
975        assert!(recipe.name().is_none());
976        assert!(recipe.path().is_none());
977        assert!(recipe.file_name().is_none());
978    }
979
980    #[test]
981    #[ignore]
982    fn test_find_title_image_case_sensitivity() {
983        let temp_dir = TempDir::new().unwrap();
984        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
985        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
986
987        // Create an image with uppercase extension
988        let image_path = temp_dir_path.join("test_recipe.JPG");
989        File::create(&image_path).unwrap();
990        let found_image = find_title_image(&recipe_path);
991
992        // Should find the image with uppercase extension
993        assert!(found_image.is_some());
994    }
995
996    // ========== Tests for StepImageCollection ==========
997
998    #[test]
999    fn test_step_image_collection_empty() {
1000        let collection = StepImageCollection::default();
1001        assert!(collection.is_empty());
1002        assert_eq!(collection.count(), 0);
1003        assert_eq!(collection.get(0, 1), None);
1004    }
1005
1006    #[test]
1007    fn test_step_image_collection_get_zero_step() {
1008        let mut collection = StepImageCollection::default();
1009        collection
1010            .images
1011            .entry(0)
1012            .or_insert_with(HashMap::new)
1013            .insert(0, "test.jpg".to_string());
1014
1015        // Step 0 should return None (steps are one-indexed)
1016        assert_eq!(collection.get(0, 0), None);
1017    }
1018
1019    #[test]
1020    fn test_recipe_with_linear_step_images() {
1021        let temp_dir = TempDir::new().unwrap();
1022        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1023        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
1024
1025        // Create step images: Recipe.1.jpg, Recipe.3.jpg, Recipe.5.jpg
1026        create_test_image(&temp_dir_path, "test_recipe.1", "jpg");
1027        create_test_image(&temp_dir_path, "test_recipe.3", "jpg");
1028        create_test_image(&temp_dir_path, "test_recipe.5", "jpg");
1029
1030        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1031        let images = recipe.step_images();
1032
1033        assert!(!images.is_empty());
1034        assert_eq!(images.count(), 3);
1035
1036        // Verify images are stored at correct indices (one-indexed access, zero-indexed storage)
1037        assert!(images.get(0, 1).is_some()); // Recipe.1.jpg at [0][0]
1038        assert!(images.get(0, 2).is_none()); // No Recipe.2.jpg
1039        assert!(images.get(0, 3).is_some()); // Recipe.3.jpg at [0][2]
1040        assert!(images.get(0, 5).is_some()); // Recipe.5.jpg at [0][4]
1041
1042        // Verify actual paths
1043        let img1 = images.get(0, 1).unwrap();
1044        assert!(img1.contains("test_recipe.1.jpg"));
1045    }
1046
1047    #[test]
1048    fn test_recipe_with_section_step_images() {
1049        let temp_dir = TempDir::new().unwrap();
1050        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1051        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
1052
1053        // Create section-step images: Recipe.2.4.jpg, Recipe.1.1.jpg
1054        create_test_image(&temp_dir_path, "test_recipe.2.4", "jpg");
1055        create_test_image(&temp_dir_path, "test_recipe.1.1", "jpg");
1056
1057        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1058        let images = recipe.step_images();
1059
1060        assert!(!images.is_empty());
1061        assert_eq!(images.count(), 2);
1062
1063        // Recipe.2.4.jpg should be at section 2, step 4 (stored at [1][3])
1064        assert!(images.get(2, 4).is_some());
1065        let img = images.get(2, 4).unwrap();
1066        assert!(img.contains("test_recipe.2.4.jpg"));
1067
1068        // Recipe.1.1.jpg should be at section 1, step 1 (stored at [0][0])
1069        assert!(images.get(1, 1).is_some());
1070        let img = images.get(1, 1).unwrap();
1071        assert!(img.contains("test_recipe.1.1.jpg"));
1072    }
1073
1074    #[test]
1075    fn test_recipe_with_mixed_image_types() {
1076        let temp_dir = TempDir::new().unwrap();
1077        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1078        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
1079
1080        // Create mixed images: title, linear step, and section-step
1081        create_test_image(&temp_dir_path, "test_recipe", "jpg"); // title
1082        create_test_image(&temp_dir_path, "test_recipe.2", "jpg"); // linear step 2
1083        create_test_image(&temp_dir_path, "test_recipe.2.4", "jpg"); // section 2, step 4
1084
1085        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1086
1087        // Title image should work
1088        assert!(recipe.title_image().is_some());
1089
1090        // Step images should work
1091        let images = recipe.step_images();
1092        assert_eq!(images.count(), 2);
1093
1094        // Recipe.2.jpg is stored at [0][1] (section 0 = linear)
1095        assert!(images.get(0, 2).is_some());
1096
1097        // Recipe.2.4.jpg is stored at [1][3] (section 2, step 4)
1098        assert!(images.get(2, 4).is_some());
1099    }
1100
1101    #[test]
1102    fn test_recipe_step_images_all_extensions() {
1103        let temp_dir = TempDir::new().unwrap();
1104        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1105        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
1106
1107        // Create images with different extensions
1108        create_test_image(&temp_dir_path, "test_recipe.1", "jpg");
1109        create_test_image(&temp_dir_path, "test_recipe.2", "jpeg");
1110        create_test_image(&temp_dir_path, "test_recipe.3", "png");
1111        create_test_image(&temp_dir_path, "test_recipe.4", "webp");
1112
1113        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1114        let images = recipe.step_images();
1115
1116        assert_eq!(images.count(), 4);
1117        assert!(images.get(0, 1).unwrap().ends_with(".jpg"));
1118        assert!(images.get(0, 2).unwrap().ends_with(".jpeg"));
1119        assert!(images.get(0, 3).unwrap().ends_with(".png"));
1120        assert!(images.get(0, 4).unwrap().ends_with(".webp"));
1121    }
1122
1123    #[test]
1124    fn test_recipe_step_image_extension_priority() {
1125        let temp_dir = TempDir::new().unwrap();
1126        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1127        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
1128
1129        // Create multiple extensions for same step - jpg should take priority
1130        create_test_image(&temp_dir_path, "test_recipe.1", "jpg");
1131        create_test_image(&temp_dir_path, "test_recipe.1", "png");
1132        create_test_image(&temp_dir_path, "test_recipe.1", "webp");
1133
1134        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1135        let images = recipe.step_images();
1136
1137        assert_eq!(images.count(), 1);
1138        assert!(images.get(0, 1).unwrap().ends_with(".jpg"));
1139    }
1140
1141    #[test]
1142    fn test_recipe_from_content_no_step_images() {
1143        let content = indoc! {r#"
1144            ---
1145            servings: 4
1146            ---
1147
1148            Test recipe content"#};
1149
1150        let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
1151        let images = recipe.step_images();
1152
1153        assert!(images.is_empty());
1154        assert_eq!(images.count(), 0);
1155    }
1156
1157    #[test]
1158    fn test_recipe_no_step_images() {
1159        let temp_dir = TempDir::new().unwrap();
1160        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1161        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
1162
1163        // Only create title image, no step images
1164        create_test_image(&temp_dir_path, "test_recipe", "jpg");
1165
1166        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1167        let images = recipe.step_images();
1168
1169        assert!(images.is_empty());
1170        assert_eq!(images.count(), 0);
1171    }
1172
1173    #[test]
1174    fn test_recipe_step_images_with_gaps() {
1175        let temp_dir = TempDir::new().unwrap();
1176        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1177        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
1178
1179        // Create non-consecutive step images
1180        create_test_image(&temp_dir_path, "test_recipe.1", "jpg");
1181        create_test_image(&temp_dir_path, "test_recipe.7", "jpg");
1182        create_test_image(&temp_dir_path, "test_recipe.15", "jpg");
1183
1184        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1185        let images = recipe.step_images();
1186
1187        assert_eq!(images.count(), 3);
1188        assert!(images.get(0, 1).is_some());
1189        assert!(images.get(0, 2).is_none());
1190        assert!(images.get(0, 7).is_some());
1191        assert!(images.get(0, 15).is_some());
1192    }
1193
1194    #[test]
1195    fn test_direct_hashmap_iteration() {
1196        let temp_dir = TempDir::new().unwrap();
1197        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1198        let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
1199
1200        create_test_image(&temp_dir_path, "test_recipe.1", "jpg");
1201        create_test_image(&temp_dir_path, "test_recipe.2", "jpg");
1202
1203        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1204        let images = recipe.step_images();
1205
1206        // Test direct HashMap access
1207        if let Some(section_steps) = images.images.get(&0) {
1208            assert_eq!(section_steps.len(), 2);
1209            assert!(section_steps.contains_key(&0)); // Recipe.1.jpg
1210            assert!(section_steps.contains_key(&1)); // Recipe.2.jpg
1211        } else {
1212            panic!("Section 0 should exist");
1213        }
1214    }
1215
1216    #[test]
1217    fn test_parse_image_numbers_valid() {
1218        use std::path::PathBuf;
1219
1220        // Test single number
1221        let path = PathBuf::from("Recipe.3.jpg");
1222        let result = parse_image_numbers(&path, "Recipe", "jpg");
1223        assert_eq!(result, Some(vec![3]));
1224
1225        // Test two numbers
1226        let path = PathBuf::from("Recipe.2.4.jpg");
1227        let result = parse_image_numbers(&path, "Recipe", "jpg");
1228        assert_eq!(result, Some(vec![2, 4]));
1229    }
1230
1231    #[test]
1232    fn test_parse_image_numbers_invalid() {
1233        use std::path::PathBuf;
1234
1235        // Invalid: zero
1236        let path = PathBuf::from("Recipe.0.jpg");
1237        let result = parse_image_numbers(&path, "Recipe", "jpg");
1238        assert_eq!(result, None);
1239
1240        // Invalid: non-numeric
1241        let path = PathBuf::from("Recipe.invalid.jpg");
1242        let result = parse_image_numbers(&path, "Recipe", "jpg");
1243        assert_eq!(result, None);
1244
1245        // Invalid: three numbers
1246        let path = PathBuf::from("Recipe.1.2.3.jpg");
1247        let result = parse_image_numbers(&path, "Recipe", "jpg");
1248        assert_eq!(result, None);
1249    }
1250
1251    // ========== Tests for extract_recipe_references ==========
1252
1253    #[test]
1254    fn test_extract_recipe_references_simple() {
1255        let content = "Pour @./sauces/Hollandaise{150%g} over the eggs.";
1256        let refs = extract_recipe_references(content);
1257        assert_eq!(refs, vec!["./sauces/Hollandaise"]);
1258    }
1259
1260    #[test]
1261    fn test_extract_recipe_references_multiple() {
1262        let content = "Serve @./sauces/Hollandaise{150%g} with @./sides/Asparagus{200%g}.";
1263        let refs = extract_recipe_references(content);
1264        assert_eq!(refs.len(), 2);
1265        assert!(refs.contains(&"./sauces/Hollandaise".to_string()));
1266        assert!(refs.contains(&"./sides/Asparagus".to_string()));
1267    }
1268
1269    #[test]
1270    fn test_extract_recipe_references_no_refs() {
1271        let content = "Add @salt{1%tsp} and @pepper{1%tsp}.";
1272        let refs = extract_recipe_references(content);
1273        assert!(refs.is_empty());
1274    }
1275
1276    #[test]
1277    fn test_extract_recipe_references_no_quantity() {
1278        let content = "Serve with @./sauces/Hollandaise over eggs.";
1279        let refs = extract_recipe_references(content);
1280        assert_eq!(refs, vec!["./sauces/Hollandaise"]);
1281    }
1282
1283    #[test]
1284    fn test_extract_recipe_references_deduplicates() {
1285        let content = "Use @./base/Stock{100%ml} twice and @./base/Stock{200%ml} again.";
1286        let refs = extract_recipe_references(content);
1287        assert_eq!(refs, vec!["./base/Stock"]);
1288    }
1289
1290    // ========== Tests for related_files ==========
1291
1292    #[test]
1293    fn test_related_files_empty() {
1294        let temp_dir = TempDir::new().unwrap();
1295        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1296        let recipe_path = create_test_recipe(&temp_dir_path, "simple", "Just a recipe");
1297
1298        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1299        let files = recipe.related_files();
1300        assert!(files.is_empty());
1301    }
1302
1303    #[test]
1304    fn test_related_files_with_title_image() {
1305        let temp_dir = TempDir::new().unwrap();
1306        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1307        let recipe_path = create_test_recipe(&temp_dir_path, "pasta", "Make pasta");
1308        let image_path = create_test_image(&temp_dir_path, "pasta", "jpg");
1309
1310        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1311        let files = recipe.related_files();
1312        assert_eq!(files.len(), 1);
1313        assert_eq!(files[0], image_path);
1314    }
1315
1316    #[test]
1317    fn test_related_files_with_step_images() {
1318        let temp_dir = TempDir::new().unwrap();
1319        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1320        let recipe_path = create_test_recipe(&temp_dir_path, "pasta", "Make pasta");
1321        create_test_image(&temp_dir_path, "pasta.1", "jpg");
1322        create_test_image(&temp_dir_path, "pasta.2", "jpg");
1323
1324        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1325        let files = recipe.related_files();
1326        assert_eq!(files.len(), 2);
1327    }
1328
1329    #[test]
1330    fn test_related_files_content_based_returns_empty() {
1331        let recipe = RecipeEntry::from_content("Just content".to_string(), None).unwrap();
1332        let files = recipe.related_files();
1333        assert!(files.is_empty());
1334    }
1335
1336    #[test]
1337    fn test_related_files_with_referenced_recipe() {
1338        let temp_dir = TempDir::new().unwrap();
1339        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1340
1341        // Create a subdirectory for the referenced recipe
1342        let sauces_dir = temp_dir_path.join("sauces");
1343        std::fs::create_dir_all(&sauces_dir).unwrap();
1344
1345        // Create the referenced recipe with its own image
1346        create_test_recipe(&sauces_dir, "Hollandaise", "Melt @butter{100%g}");
1347        create_test_image(&sauces_dir, "Hollandaise", "jpg");
1348
1349        // Create the main recipe that references it
1350        let recipe_path = create_test_recipe(
1351            &temp_dir_path,
1352            "Eggs Benedict",
1353            "Pour @./sauces/Hollandaise{150%g} over eggs.",
1354        );
1355
1356        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1357        let files = recipe.related_files();
1358
1359        // Should include: sauces/Hollandaise.cook + sauces/Hollandaise.jpg
1360        assert_eq!(files.len(), 2);
1361        assert!(files
1362            .iter()
1363            .any(|f| f.as_str().ends_with("Hollandaise.cook")));
1364        assert!(files
1365            .iter()
1366            .any(|f| f.as_str().ends_with("Hollandaise.jpg")));
1367    }
1368
1369    #[test]
1370    fn test_related_files_recursive() {
1371        let temp_dir = TempDir::new().unwrap();
1372        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1373
1374        let base_dir = temp_dir_path.join("base");
1375        std::fs::create_dir_all(&base_dir).unwrap();
1376
1377        let sauces_dir = temp_dir_path.join("sauces");
1378        std::fs::create_dir_all(&sauces_dir).unwrap();
1379
1380        // base/Stock.cook (leaf - no references)
1381        create_test_recipe(&base_dir, "Stock", "Simmer @bones{500%g}");
1382        create_test_image(&base_dir, "Stock", "png");
1383
1384        // sauces/Gravy.cook -> references base/Stock
1385        create_test_recipe(
1386            &sauces_dir,
1387            "Gravy",
1388            "Add @../base/Stock{200%ml} and thicken.",
1389        );
1390
1391        // Main recipe -> references sauces/Gravy
1392        let recipe_path = create_test_recipe(
1393            &temp_dir_path,
1394            "Roast Dinner",
1395            "Serve with @./sauces/Gravy{100%ml}.",
1396        );
1397
1398        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1399        let files = recipe.related_files();
1400
1401        // Should include:
1402        // - sauces/Gravy.cook (direct reference)
1403        // - base/Stock.cook (transitive reference from Gravy)
1404        // - base/Stock.png (image of Stock)
1405        assert_eq!(files.len(), 3);
1406        assert!(files.iter().any(|f| f.as_str().ends_with("Gravy.cook")));
1407        assert!(files.iter().any(|f| f.as_str().ends_with("Stock.cook")));
1408        assert!(files.iter().any(|f| f.as_str().ends_with("Stock.png")));
1409    }
1410
1411    #[test]
1412    fn test_related_files_circular_reference() {
1413        let temp_dir = TempDir::new().unwrap();
1414        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1415
1416        // Recipe A references Recipe B, Recipe B references Recipe A
1417        create_test_recipe(&temp_dir_path, "RecipeA", "Use @./RecipeB{100%g} as base.");
1418        create_test_recipe(
1419            &temp_dir_path,
1420            "RecipeB",
1421            "Use @./RecipeA{50%g} as topping.",
1422        );
1423
1424        let recipe_path = temp_dir_path.join("RecipeA.cook");
1425        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1426        let files = recipe.related_files();
1427
1428        // Should include RecipeB.cook but not loop infinitely
1429        assert_eq!(files.len(), 1);
1430        assert!(files.iter().any(|f| f.as_str().ends_with("RecipeB.cook")));
1431    }
1432
1433    #[test]
1434    fn test_related_files_missing_reference() {
1435        let temp_dir = TempDir::new().unwrap();
1436        let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
1437
1438        let recipe_path = create_test_recipe(
1439            &temp_dir_path,
1440            "incomplete",
1441            "Use @./nonexistent/Recipe{100%g}.",
1442        );
1443
1444        let recipe = RecipeEntry::from_path(recipe_path).unwrap();
1445        let files = recipe.related_files();
1446
1447        // Missing references are silently skipped
1448        assert!(files.is_empty());
1449    }
1450
1451    #[test]
1452    fn test_extract_recipe_references_parent_dir() {
1453        let content = "Add @../base/Stock{200%ml} and thicken.";
1454        let refs = extract_recipe_references(content);
1455        assert_eq!(refs, vec!["../base/Stock"]);
1456    }
1457
1458    #[test]
1459    fn test_extract_recipe_references_trailing_punctuation() {
1460        let content = "Serve @./sauces/Hollandaise.";
1461        let refs = extract_recipe_references(content);
1462        assert_eq!(refs, vec!["./sauces/Hollandaise"]);
1463    }
1464}