use super::metadata::{extract_and_parse_metadata, Metadata};
use camino::{Utf8Path, Utf8PathBuf};
use glob::glob;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::sync::OnceLock;
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Default)]
pub struct StepImageCollection {
pub images: HashMap<usize, HashMap<usize, String>>,
}
impl StepImageCollection {
pub fn is_empty(&self) -> bool {
self.images.is_empty()
}
pub fn count(&self) -> usize {
self.images.values().map(|steps| steps.len()).sum()
}
pub fn get(&self, section: usize, step: usize) -> Option<&String> {
if step == 0 {
return None; }
let section_idx = if section == 0 { 0 } else { section - 1 };
self.images.get(§ion_idx)?.get(&(step - 1))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "source_type")]
pub enum RecipeSource {
Path {
path: Utf8PathBuf,
},
Content {
content: String,
name: Option<String>,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RecipeEntry {
source: RecipeSource,
metadata: Metadata,
#[serde(skip)]
name: OnceLock<Option<String>>,
#[serde(skip)]
title_image: OnceLock<Option<String>>,
#[serde(skip)]
step_images: OnceLock<StepImageCollection>,
#[serde(skip)]
is_menu: OnceLock<bool>,
}
impl Clone for RecipeEntry {
fn clone(&self) -> Self {
RecipeEntry {
source: self.source.clone(),
metadata: self.metadata.clone(),
name: OnceLock::new(),
title_image: OnceLock::new(),
step_images: OnceLock::new(),
is_menu: OnceLock::new(),
}
}
}
impl RecipeEntry {
pub fn from_path(path: Utf8PathBuf) -> Result<Self, RecipeEntryError> {
let file = File::open(&path).map_err(RecipeEntryError::IoError)?;
let reader = BufReader::new(file);
let metadata = extract_and_parse_metadata(
reader.lines().map(|r| r.map_err(RecipeEntryError::IoError)),
)?;
Ok(RecipeEntry {
source: RecipeSource::Path { path },
metadata,
name: OnceLock::new(),
title_image: OnceLock::new(),
step_images: OnceLock::new(),
is_menu: OnceLock::new(),
})
}
pub fn from_content(content: String, name: Option<String>) -> Result<Self, RecipeEntryError> {
let metadata = extract_and_parse_metadata(
content
.lines()
.map(|line| Ok::<_, RecipeEntryError>(line.to_string())),
)?;
Ok(RecipeEntry {
source: RecipeSource::Content { content, name },
metadata,
name: OnceLock::new(),
title_image: OnceLock::new(),
step_images: OnceLock::new(),
is_menu: OnceLock::new(),
})
}
pub fn name(&self) -> &Option<String> {
self.name.get_or_init(|| {
if let Some(title) = self.metadata.title() {
Some(title.to_string())
} else {
match &self.source {
RecipeSource::Path { path } => Some(path.file_stem()?.to_string()),
RecipeSource::Content { name, .. } => name.clone(),
}
}
})
}
pub fn title_image(&self) -> &Option<String> {
self.title_image.get_or_init(|| {
if let Some(url) = self.metadata.image_url() {
return Some(url);
}
match &self.source {
RecipeSource::Path { path } => find_title_image(path).map(|p| p.to_string()),
RecipeSource::Content { .. } => None,
}
})
}
pub fn content(&self) -> Result<String, RecipeEntryError> {
match &self.source {
RecipeSource::Path { path } => {
std::fs::read_to_string(path).map_err(RecipeEntryError::IoError)
}
RecipeSource::Content { content, .. } => Ok(content.clone()),
}
}
pub fn metadata(&self) -> &Metadata {
&self.metadata
}
pub fn path(&self) -> Option<&Utf8PathBuf> {
match &self.source {
RecipeSource::Path { path } => Some(path),
RecipeSource::Content { .. } => None,
}
}
pub fn file_name(&self) -> Option<String> {
match &self.source {
RecipeSource::Path { path } => Some(path.file_name()?.to_string()),
RecipeSource::Content { .. } => None,
}
}
pub fn tags(&self) -> Vec<String> {
self.metadata.tags()
}
pub fn is_menu(&self) -> bool {
*self.is_menu.get_or_init(|| match &self.source {
RecipeSource::Path { path } => path.extension() == Some("menu"),
RecipeSource::Content { .. } => false,
})
}
pub fn step_images(&self) -> &StepImageCollection {
self.step_images.get_or_init(|| match &self.source {
RecipeSource::Path { path } => find_step_images(path),
RecipeSource::Content { .. } => StepImageCollection::default(),
})
}
pub fn related_files(&self) -> Vec<Utf8PathBuf> {
let path = match &self.source {
RecipeSource::Path { path } => path,
RecipeSource::Content { .. } => return Vec::new(),
};
let mut visited = HashSet::new();
let mut result = Vec::new();
collect_related_files(path, &mut visited, &mut result);
result
}
}
#[derive(Error, Debug)]
pub enum RecipeEntryError {
#[error("Failed to read recipe file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to get file stem from path: {0}")]
InvalidPath(Utf8PathBuf),
#[error("Failed to parse recipe: {0}")]
ParseError(String),
#[error("Failed to parse recipe metadata: {0}")]
MetadataError(String),
}
fn find_title_image(path: &Utf8Path) -> Option<Utf8PathBuf> {
let possible_image_extensions = ["jpg", "jpeg", "png", "webp"];
possible_image_extensions.iter().find_map(|ext| {
let image_path = path.with_extension(ext);
if image_path.exists() {
Some(image_path)
} else {
None
}
})
}
fn find_step_images(path: &Utf8Path) -> StepImageCollection {
let mut collection = StepImageCollection::default();
let stem = match path.file_stem() {
Some(s) => s,
None => return collection,
};
let dir = path.parent().unwrap_or(path);
let extensions = ["jpg", "jpeg", "png", "webp"];
for ext in &extensions {
let pattern = dir.join(format!("{}.*.{}", stem, ext));
let pattern_str = pattern.as_str();
if let Ok(entries) = glob(pattern_str) {
for entry in entries.flatten() {
if let Some(numbers) = parse_image_numbers(&entry, stem, ext) {
let entry_str = entry.to_string_lossy().to_string();
match numbers.len() {
1 => {
let step_num = numbers[0]; collection
.images
.entry(0)
.or_insert_with(HashMap::new)
.entry(step_num - 1) .or_insert(entry_str);
}
2 => {
let (section_num, step_num) = (numbers[0], numbers[1]); collection
.images
.entry(section_num - 1) .or_insert_with(HashMap::new)
.entry(step_num - 1) .or_insert(entry_str);
}
_ => {} }
}
}
}
}
collection
}
fn parse_image_numbers(path: &Path, stem: &str, ext: &str) -> Option<Vec<usize>> {
let filename = path.file_name()?.to_str()?;
let without_stem = filename.strip_prefix(stem)?;
let without_ext = without_stem.strip_suffix(&format!(".{}", ext))?;
let numbers: Vec<usize> = without_ext
.split('.')
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<usize>().ok())
.collect();
if !numbers.is_empty() && numbers.len() <= 2 && numbers.iter().all(|&n| n >= 1) {
Some(numbers)
} else {
None
}
}
fn extract_recipe_references(content: &str) -> Vec<String> {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"@(\.\.?/[^\s\{},.)]+)").unwrap());
let mut seen = HashSet::new();
let mut refs = Vec::new();
for cap in re.captures_iter(content) {
let path = cap[1].to_string();
if seen.insert(path.clone()) {
refs.push(path);
}
}
refs
}
fn collect_related_files(
recipe_path: &Utf8Path,
visited: &mut HashSet<Utf8PathBuf>,
result: &mut Vec<Utf8PathBuf>,
) {
let canonical = match std::fs::canonicalize(recipe_path) {
Ok(p) => Utf8PathBuf::from_path_buf(p)
.unwrap_or_else(|p| Utf8PathBuf::from(p.to_string_lossy().into_owned())),
Err(_) => recipe_path.to_path_buf(),
};
if !visited.insert(canonical) {
return;
}
if let Some(image_path) = find_title_image(recipe_path) {
result.push(image_path);
}
let step_images = find_step_images(recipe_path);
for steps in step_images.images.values() {
for image_path in steps.values() {
result.push(Utf8PathBuf::from(image_path));
}
}
let content = match std::fs::read_to_string(recipe_path) {
Ok(c) => c,
Err(_) => return,
};
let dir = recipe_path.parent().unwrap_or(recipe_path);
for ref_path_str in extract_recipe_references(&content) {
let ref_path = dir.join(&ref_path_str);
let candidates = if ref_path.extension().is_some() {
vec![ref_path]
} else {
vec![ref_path.with_extension("cook")]
};
for candidate in candidates {
let canonical_candidate = match std::fs::canonicalize(&candidate) {
Ok(p) => Utf8PathBuf::from_path_buf(p)
.unwrap_or_else(|p| Utf8PathBuf::from(p.to_string_lossy().into_owned())),
Err(_) => candidate.clone(),
};
if candidate.exists() && !visited.contains(&canonical_candidate) {
result.push(candidate.clone());
collect_related_files(&candidate, visited, result);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
fn create_test_recipe(dir: &Utf8Path, name: &str, content: &str) -> Utf8PathBuf {
let recipe_path = dir.join(format!("{name}.cook"));
let mut file = File::create(&recipe_path).unwrap();
write!(file, "{content}").unwrap();
recipe_path
}
fn create_test_image(dir: &Utf8Path, name: &str, ext: &str) -> Utf8PathBuf {
let image_path = dir.join(format!("{name}.{ext}"));
File::create(&image_path).unwrap();
image_path
}
#[test]
fn test_recipe_creation() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(
&temp_dir_path,
"test_recipe",
indoc! {r#"
---
servings: 4
---
Test recipe content"#},
);
let recipe = RecipeEntry::from_path(recipe_path.clone()).unwrap();
assert_eq!(recipe.name().as_ref().unwrap(), "test_recipe");
assert_eq!(recipe.path(), Some(&recipe_path));
assert_eq!(recipe.file_name().as_ref().unwrap(), "test_recipe.cook");
assert!(recipe.title_image().is_none());
}
#[test]
fn test_recipe_name_from_title() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(
&temp_dir_path,
"test_recipe",
indoc! {r#"
---
title: My Special Recipe
servings: 4
---
Test recipe content"#},
);
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
assert_eq!(recipe.name().as_ref().unwrap(), "My Special Recipe");
}
#[test]
fn test_recipe_with_title_image() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(
&temp_dir_path,
"test_recipe",
indoc! {r#"
---
servings: 4
---
Test recipe content"#},
);
let image_path = create_test_image(&temp_dir_path, "test_recipe", "jpg");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
assert_eq!(
recipe.title_image().as_ref().unwrap(),
&image_path.to_string()
);
}
#[test]
fn test_recipe_content() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let content = indoc! {r#"
---
servings: 4
---
Test recipe content"#};
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", content);
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
assert_eq!(recipe.content().unwrap(), content);
}
#[test]
fn test_recipe_metadata() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let content = indoc! {r#"
---
servings: 4
time: 30 min
cuisine: Italian
---
Test recipe content"#};
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", content);
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let metadata = &recipe.metadata;
assert_eq!(metadata.get("servings").unwrap().as_i64().unwrap(), 4);
assert_eq!(metadata.get("time").unwrap().as_str().unwrap(), "30 min");
assert_eq!(
metadata.get("cuisine").unwrap().as_str().unwrap(),
"Italian"
);
}
#[test]
fn test_recipe_content_access() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let content = indoc! {r#"
---
servings: 4
---
Add @salt{1%tsp} and @pepper{1%tsp}"#};
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", content);
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
assert_eq!(recipe.content().unwrap(), content);
assert_eq!(recipe.metadata().servings().unwrap(), 4);
}
#[test]
fn test_recipe_equality() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let path1 = create_test_recipe(
&temp_dir_path,
"recipe1",
indoc! {r#"
---
servings: 4
---
Test recipe content"#},
);
let path2 = create_test_recipe(
&temp_dir_path,
"recipe2",
indoc! {r#"
---
servings: 4
---
Test recipe content"#},
);
let recipe1 = RecipeEntry::from_path(path1.clone()).unwrap();
let recipe2 = RecipeEntry::from_path(path1).unwrap();
let recipe3 = RecipeEntry::from_path(path2).unwrap();
assert_eq!(recipe1.path(), recipe2.path());
assert_ne!(recipe1.path(), recipe3.path());
}
#[test]
fn test_invalid_recipe_path() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let invalid_path = temp_dir_path.join("nonexistent.cook");
let result = RecipeEntry::from_path(invalid_path);
assert!(result.is_err());
}
#[test]
fn test_find_title_image_no_image() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
assert!(find_title_image(&recipe_path).is_none());
}
#[test]
fn test_find_title_image_all_extensions() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
for ext in ["jpg", "jpeg", "png", "webp"] {
for old_ext in ["jpg", "jpeg", "png", "webp"] {
let _ = std::fs::remove_file(recipe_path.with_extension(old_ext));
}
let image_path = create_test_image(&temp_dir_path, "test_recipe", ext);
let found = find_title_image(&recipe_path);
assert!(found.is_some(), "Failed to find image with extension {ext}");
assert_eq!(found.unwrap(), image_path);
}
}
#[test]
fn test_find_title_image_multiple_images() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
let jpg_path = create_test_image(&temp_dir_path, "test_recipe", "jpg");
let _png_path = create_test_image(&temp_dir_path, "test_recipe", "png");
let _webp_path = create_test_image(&temp_dir_path, "test_recipe", "webp");
let found_image = find_title_image(&recipe_path);
assert!(found_image.is_some());
assert_eq!(found_image.unwrap(), jpg_path);
}
#[test]
fn test_recipe_from_content() {
let content = indoc! {r#"
---
title: Test Recipe
servings: 4
---
Test recipe content from string"#};
let recipe =
RecipeEntry::from_content(content.to_string(), Some("my_recipe".to_string())).unwrap();
assert_eq!(recipe.name().as_ref().unwrap(), "Test Recipe"); assert!(recipe.path().is_none());
assert!(recipe.title_image().is_none());
assert_eq!(recipe.content().unwrap(), content);
assert_eq!(recipe.metadata().servings().unwrap(), 4);
}
#[test]
fn test_recipe_with_metadata_image() {
let content = indoc! {r#"
---
title: Test Recipe
image: https://example.com/recipe.jpg
---
Test recipe content"#};
let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
assert_eq!(
recipe.title_image().as_ref().unwrap(),
"https://example.com/recipe.jpg"
);
}
#[test]
fn test_recipe_with_metadata_images_array() {
let content = indoc! {r#"
---
title: Test Recipe
images:
- https://example.com/recipe1.jpg
- https://example.com/recipe2.jpg
---
Test recipe content"#};
let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
assert_eq!(
recipe.title_image().as_ref().unwrap(),
"https://example.com/recipe1.jpg"
);
}
#[test]
fn test_recipe_with_metadata_picture() {
let content = indoc! {r#"
---
title: Test Recipe
picture: https://example.com/pic.png
---
Test recipe content"#};
let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
assert_eq!(
recipe.title_image().as_ref().unwrap(),
"https://example.com/pic.png"
);
}
#[test]
fn test_recipe_with_metadata_pictures_array() {
let content = indoc! {r#"
---
title: Test Recipe
pictures:
- https://example.com/pic1.png
- https://example.com/pic2.png
---
Test recipe content"#};
let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
assert_eq!(
recipe.title_image().as_ref().unwrap(),
"https://example.com/pic1.png"
);
}
#[test]
fn test_recipe_from_content_no_title() {
let content = indoc! {r#"
---
servings: 2
---
Test recipe content"#};
let recipe =
RecipeEntry::from_content(content.to_string(), Some("content_recipe".to_string()))
.unwrap();
assert_eq!(recipe.name().as_ref().unwrap(), "content_recipe");
assert!(recipe.path().is_none());
}
#[test]
fn test_recipe_from_content_no_name() {
let content = "Just recipe content";
let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
assert!(recipe.name().is_none());
assert!(recipe.path().is_none());
assert!(recipe.file_name().is_none());
}
#[test]
#[ignore]
fn test_find_title_image_case_sensitivity() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
let image_path = temp_dir_path.join("test_recipe.JPG");
File::create(&image_path).unwrap();
let found_image = find_title_image(&recipe_path);
assert!(found_image.is_some());
}
#[test]
fn test_step_image_collection_empty() {
let collection = StepImageCollection::default();
assert!(collection.is_empty());
assert_eq!(collection.count(), 0);
assert_eq!(collection.get(0, 1), None);
}
#[test]
fn test_step_image_collection_get_zero_step() {
let mut collection = StepImageCollection::default();
collection
.images
.entry(0)
.or_insert_with(HashMap::new)
.insert(0, "test.jpg".to_string());
assert_eq!(collection.get(0, 0), None);
}
#[test]
fn test_recipe_with_linear_step_images() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
create_test_image(&temp_dir_path, "test_recipe.1", "jpg");
create_test_image(&temp_dir_path, "test_recipe.3", "jpg");
create_test_image(&temp_dir_path, "test_recipe.5", "jpg");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let images = recipe.step_images();
assert!(!images.is_empty());
assert_eq!(images.count(), 3);
assert!(images.get(0, 1).is_some()); assert!(images.get(0, 2).is_none()); assert!(images.get(0, 3).is_some()); assert!(images.get(0, 5).is_some());
let img1 = images.get(0, 1).unwrap();
assert!(img1.contains("test_recipe.1.jpg"));
}
#[test]
fn test_recipe_with_section_step_images() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
create_test_image(&temp_dir_path, "test_recipe.2.4", "jpg");
create_test_image(&temp_dir_path, "test_recipe.1.1", "jpg");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let images = recipe.step_images();
assert!(!images.is_empty());
assert_eq!(images.count(), 2);
assert!(images.get(2, 4).is_some());
let img = images.get(2, 4).unwrap();
assert!(img.contains("test_recipe.2.4.jpg"));
assert!(images.get(1, 1).is_some());
let img = images.get(1, 1).unwrap();
assert!(img.contains("test_recipe.1.1.jpg"));
}
#[test]
fn test_recipe_with_mixed_image_types() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
create_test_image(&temp_dir_path, "test_recipe", "jpg"); create_test_image(&temp_dir_path, "test_recipe.2", "jpg"); create_test_image(&temp_dir_path, "test_recipe.2.4", "jpg");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
assert!(recipe.title_image().is_some());
let images = recipe.step_images();
assert_eq!(images.count(), 2);
assert!(images.get(0, 2).is_some());
assert!(images.get(2, 4).is_some());
}
#[test]
fn test_recipe_step_images_all_extensions() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
create_test_image(&temp_dir_path, "test_recipe.1", "jpg");
create_test_image(&temp_dir_path, "test_recipe.2", "jpeg");
create_test_image(&temp_dir_path, "test_recipe.3", "png");
create_test_image(&temp_dir_path, "test_recipe.4", "webp");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let images = recipe.step_images();
assert_eq!(images.count(), 4);
assert!(images.get(0, 1).unwrap().ends_with(".jpg"));
assert!(images.get(0, 2).unwrap().ends_with(".jpeg"));
assert!(images.get(0, 3).unwrap().ends_with(".png"));
assert!(images.get(0, 4).unwrap().ends_with(".webp"));
}
#[test]
fn test_recipe_step_image_extension_priority() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
create_test_image(&temp_dir_path, "test_recipe.1", "jpg");
create_test_image(&temp_dir_path, "test_recipe.1", "png");
create_test_image(&temp_dir_path, "test_recipe.1", "webp");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let images = recipe.step_images();
assert_eq!(images.count(), 1);
assert!(images.get(0, 1).unwrap().ends_with(".jpg"));
}
#[test]
fn test_recipe_from_content_no_step_images() {
let content = indoc! {r#"
---
servings: 4
---
Test recipe content"#};
let recipe = RecipeEntry::from_content(content.to_string(), None).unwrap();
let images = recipe.step_images();
assert!(images.is_empty());
assert_eq!(images.count(), 0);
}
#[test]
fn test_recipe_no_step_images() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
create_test_image(&temp_dir_path, "test_recipe", "jpg");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let images = recipe.step_images();
assert!(images.is_empty());
assert_eq!(images.count(), 0);
}
#[test]
fn test_recipe_step_images_with_gaps() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
create_test_image(&temp_dir_path, "test_recipe.1", "jpg");
create_test_image(&temp_dir_path, "test_recipe.7", "jpg");
create_test_image(&temp_dir_path, "test_recipe.15", "jpg");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let images = recipe.step_images();
assert_eq!(images.count(), 3);
assert!(images.get(0, 1).is_some());
assert!(images.get(0, 2).is_none());
assert!(images.get(0, 7).is_some());
assert!(images.get(0, 15).is_some());
}
#[test]
fn test_direct_hashmap_iteration() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "test_recipe", "Test content");
create_test_image(&temp_dir_path, "test_recipe.1", "jpg");
create_test_image(&temp_dir_path, "test_recipe.2", "jpg");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let images = recipe.step_images();
if let Some(section_steps) = images.images.get(&0) {
assert_eq!(section_steps.len(), 2);
assert!(section_steps.contains_key(&0)); assert!(section_steps.contains_key(&1)); } else {
panic!("Section 0 should exist");
}
}
#[test]
fn test_parse_image_numbers_valid() {
use std::path::PathBuf;
let path = PathBuf::from("Recipe.3.jpg");
let result = parse_image_numbers(&path, "Recipe", "jpg");
assert_eq!(result, Some(vec![3]));
let path = PathBuf::from("Recipe.2.4.jpg");
let result = parse_image_numbers(&path, "Recipe", "jpg");
assert_eq!(result, Some(vec![2, 4]));
}
#[test]
fn test_parse_image_numbers_invalid() {
use std::path::PathBuf;
let path = PathBuf::from("Recipe.0.jpg");
let result = parse_image_numbers(&path, "Recipe", "jpg");
assert_eq!(result, None);
let path = PathBuf::from("Recipe.invalid.jpg");
let result = parse_image_numbers(&path, "Recipe", "jpg");
assert_eq!(result, None);
let path = PathBuf::from("Recipe.1.2.3.jpg");
let result = parse_image_numbers(&path, "Recipe", "jpg");
assert_eq!(result, None);
}
#[test]
fn test_extract_recipe_references_simple() {
let content = "Pour @./sauces/Hollandaise{150%g} over the eggs.";
let refs = extract_recipe_references(content);
assert_eq!(refs, vec!["./sauces/Hollandaise"]);
}
#[test]
fn test_extract_recipe_references_multiple() {
let content = "Serve @./sauces/Hollandaise{150%g} with @./sides/Asparagus{200%g}.";
let refs = extract_recipe_references(content);
assert_eq!(refs.len(), 2);
assert!(refs.contains(&"./sauces/Hollandaise".to_string()));
assert!(refs.contains(&"./sides/Asparagus".to_string()));
}
#[test]
fn test_extract_recipe_references_no_refs() {
let content = "Add @salt{1%tsp} and @pepper{1%tsp}.";
let refs = extract_recipe_references(content);
assert!(refs.is_empty());
}
#[test]
fn test_extract_recipe_references_no_quantity() {
let content = "Serve with @./sauces/Hollandaise over eggs.";
let refs = extract_recipe_references(content);
assert_eq!(refs, vec!["./sauces/Hollandaise"]);
}
#[test]
fn test_extract_recipe_references_deduplicates() {
let content = "Use @./base/Stock{100%ml} twice and @./base/Stock{200%ml} again.";
let refs = extract_recipe_references(content);
assert_eq!(refs, vec!["./base/Stock"]);
}
#[test]
fn test_related_files_empty() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "simple", "Just a recipe");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let files = recipe.related_files();
assert!(files.is_empty());
}
#[test]
fn test_related_files_with_title_image() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "pasta", "Make pasta");
let image_path = create_test_image(&temp_dir_path, "pasta", "jpg");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let files = recipe.related_files();
assert_eq!(files.len(), 1);
assert_eq!(files[0], image_path);
}
#[test]
fn test_related_files_with_step_images() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(&temp_dir_path, "pasta", "Make pasta");
create_test_image(&temp_dir_path, "pasta.1", "jpg");
create_test_image(&temp_dir_path, "pasta.2", "jpg");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let files = recipe.related_files();
assert_eq!(files.len(), 2);
}
#[test]
fn test_related_files_content_based_returns_empty() {
let recipe = RecipeEntry::from_content("Just content".to_string(), None).unwrap();
let files = recipe.related_files();
assert!(files.is_empty());
}
#[test]
fn test_related_files_with_referenced_recipe() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let sauces_dir = temp_dir_path.join("sauces");
std::fs::create_dir_all(&sauces_dir).unwrap();
create_test_recipe(&sauces_dir, "Hollandaise", "Melt @butter{100%g}");
create_test_image(&sauces_dir, "Hollandaise", "jpg");
let recipe_path = create_test_recipe(
&temp_dir_path,
"Eggs Benedict",
"Pour @./sauces/Hollandaise{150%g} over eggs.",
);
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let files = recipe.related_files();
assert_eq!(files.len(), 2);
assert!(files
.iter()
.any(|f| f.as_str().ends_with("Hollandaise.cook")));
assert!(files
.iter()
.any(|f| f.as_str().ends_with("Hollandaise.jpg")));
}
#[test]
fn test_related_files_recursive() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let base_dir = temp_dir_path.join("base");
std::fs::create_dir_all(&base_dir).unwrap();
let sauces_dir = temp_dir_path.join("sauces");
std::fs::create_dir_all(&sauces_dir).unwrap();
create_test_recipe(&base_dir, "Stock", "Simmer @bones{500%g}");
create_test_image(&base_dir, "Stock", "png");
create_test_recipe(
&sauces_dir,
"Gravy",
"Add @../base/Stock{200%ml} and thicken.",
);
let recipe_path = create_test_recipe(
&temp_dir_path,
"Roast Dinner",
"Serve with @./sauces/Gravy{100%ml}.",
);
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let files = recipe.related_files();
assert_eq!(files.len(), 3);
assert!(files.iter().any(|f| f.as_str().ends_with("Gravy.cook")));
assert!(files.iter().any(|f| f.as_str().ends_with("Stock.cook")));
assert!(files.iter().any(|f| f.as_str().ends_with("Stock.png")));
}
#[test]
fn test_related_files_circular_reference() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
create_test_recipe(&temp_dir_path, "RecipeA", "Use @./RecipeB{100%g} as base.");
create_test_recipe(
&temp_dir_path,
"RecipeB",
"Use @./RecipeA{50%g} as topping.",
);
let recipe_path = temp_dir_path.join("RecipeA.cook");
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let files = recipe.related_files();
assert_eq!(files.len(), 1);
assert!(files.iter().any(|f| f.as_str().ends_with("RecipeB.cook")));
}
#[test]
fn test_related_files_missing_reference() {
let temp_dir = TempDir::new().unwrap();
let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
let recipe_path = create_test_recipe(
&temp_dir_path,
"incomplete",
"Use @./nonexistent/Recipe{100%g}.",
);
let recipe = RecipeEntry::from_path(recipe_path).unwrap();
let files = recipe.related_files();
assert!(files.is_empty());
}
#[test]
fn test_extract_recipe_references_parent_dir() {
let content = "Add @../base/Stock{200%ml} and thicken.";
let refs = extract_recipe_references(content);
assert_eq!(refs, vec!["../base/Stock"]);
}
#[test]
fn test_extract_recipe_references_trailing_punctuation() {
let content = "Serve @./sauces/Hollandaise.";
let refs = extract_recipe_references(content);
assert_eq!(refs, vec!["./sauces/Hollandaise"]);
}
}