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#[derive(Debug, Clone, Serialize, Default)]
26pub struct StepImageCollection {
27 pub images: HashMap<usize, HashMap<usize, String>>,
33}
34
35impl StepImageCollection {
36 pub fn is_empty(&self) -> bool {
38 self.images.is_empty()
39 }
40
41 pub fn count(&self) -> usize {
43 self.images.values().map(|steps| steps.len()).sum()
44 }
45
46 pub fn get(&self, section: usize, step: usize) -> Option<&String> {
66 if step == 0 {
67 return None; }
69 let section_idx = if section == 0 { 0 } else { section - 1 };
72 self.images.get(§ion_idx)?.get(&(step - 1))
73 }
74}
75
76#[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#[derive(Debug, Serialize, Deserialize)]
116pub struct RecipeEntry {
117 source: RecipeSource,
119 metadata: Metadata,
121
122 #[serde(skip)]
124 name: OnceLock<Option<String>>,
125 #[serde(skip)]
127 title_image: OnceLock<Option<String>>,
128 #[serde(skip)]
130 step_images: OnceLock<StepImageCollection>,
131 #[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 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 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 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 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 pub fn title_image(&self) -> &Option<String> {
244 self.title_image.get_or_init(|| {
245 if let Some(url) = self.metadata.image_url() {
247 return Some(url);
248 }
249
250 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 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 pub fn metadata(&self) -> &Metadata {
282 &self.metadata
283 }
284
285 pub fn path(&self) -> Option<&Utf8PathBuf> {
289 match &self.source {
290 RecipeSource::Path { path } => Some(path),
291 RecipeSource::Content { .. } => None,
292 }
293 }
294
295 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 pub fn tags(&self) -> Vec<String> {
313 self.metadata.tags()
314 }
315
316 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 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 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#[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 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
430fn 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 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 1 => {
468 let step_num = numbers[0]; collection
472 .images
473 .entry(0)
474 .or_insert_with(HashMap::new)
475 .entry(step_num - 1) .or_insert(entry_str);
477 }
478 2 => {
480 let (section_num, step_num) = (numbers[0], numbers[1]); collection
483 .images
484 .entry(section_num - 1) .or_insert_with(HashMap::new)
486 .entry(step_num - 1) .or_insert(entry_str);
488 }
489 _ => {} }
491 }
492 }
493 }
494 }
495
496 collection
497}
498
499fn parse_image_numbers(path: &Path, stem: &str, ext: &str) -> Option<Vec<usize>> {
516 let filename = path.file_name()?.to_str()?;
517
518 let without_stem = filename.strip_prefix(stem)?;
521 let without_ext = without_stem.strip_suffix(&format!(".{}", ext))?;
522
523 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 if !numbers.is_empty() && numbers.len() <= 2 && numbers.iter().all(|&n| n >= 1) {
533 Some(numbers)
534 } else {
535 None
536 }
537}
538
539fn 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
560fn collect_related_files(
565 recipe_path: &Utf8Path,
566 visited: &mut HashSet<Utf8PathBuf>,
567 result: &mut Vec<Utf8PathBuf>,
568) {
569 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 if let Some(image_path) = find_title_image(recipe_path) {
581 result.push(image_path);
582 }
583
584 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 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 let ref_path = dir.join(&ref_path_str);
602
603 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 assert_eq!(recipe.content().unwrap(), content);
767
768 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 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 for ext in ["jpg", "jpeg", "png", "webp"] {
832 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 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 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"); 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 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 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 assert!(found_image.is_some());
994 }
995
996 #[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 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_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 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();
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_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 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 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_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();
1086
1087 assert!(recipe.title_image().is_some());
1089
1090 let images = recipe.step_images();
1092 assert_eq!(images.count(), 2);
1093
1094 assert!(images.get(0, 2).is_some());
1096
1097 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_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_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 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_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 if let Some(section_steps) = images.images.get(&0) {
1208 assert_eq!(section_steps.len(), 2);
1209 assert!(section_steps.contains_key(&0)); assert!(section_steps.contains_key(&1)); } 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 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 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 let path = PathBuf::from("Recipe.0.jpg");
1237 let result = parse_image_numbers(&path, "Recipe", "jpg");
1238 assert_eq!(result, None);
1239
1240 let path = PathBuf::from("Recipe.invalid.jpg");
1242 let result = parse_image_numbers(&path, "Recipe", "jpg");
1243 assert_eq!(result, None);
1244
1245 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 #[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 #[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 let sauces_dir = temp_dir_path.join("sauces");
1343 std::fs::create_dir_all(&sauces_dir).unwrap();
1344
1345 create_test_recipe(&sauces_dir, "Hollandaise", "Melt @butter{100%g}");
1347 create_test_image(&sauces_dir, "Hollandaise", "jpg");
1348
1349 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 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 create_test_recipe(&base_dir, "Stock", "Simmer @bones{500%g}");
1382 create_test_image(&base_dir, "Stock", "png");
1383
1384 create_test_recipe(
1386 &sauces_dir,
1387 "Gravy",
1388 "Add @../base/Stock{200%ml} and thicken.",
1389 );
1390
1391 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 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 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 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 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}