1use crate::{
14 diagnostic::{parse_failure, Severity},
15 find::{build_tree, listed_ingredients, parse_or_skip, walk},
16 parser::{collect_diagnostics, render_report, PARSER},
17 ConfigSource, Context, CoreError, Diagnostic, Outcome, Style,
18};
19use camino::{Utf8Path, Utf8PathBuf};
20use cooklang_find::RecipeEntry;
21use std::collections::{BTreeMap, BTreeSet};
22
23#[derive(Debug, Clone, Default)]
28pub struct ValidateRequest {
29 pub base_dir: Option<Utf8PathBuf>,
34 pub style: Style,
41}
42
43#[non_exhaustive]
48#[derive(Debug, Clone)]
49pub struct RecipeValidation {
50 pub path: Utf8PathBuf,
60 pub diagnostics: Vec<Diagnostic>,
66 pub rendered: String,
78 pub references: Vec<String>,
87}
88
89impl RecipeValidation {
90 fn count(&self, severity: Severity) -> usize {
92 self.diagnostics
93 .iter()
94 .filter(|d| d.severity == severity)
95 .count()
96 }
97}
98
99#[non_exhaustive]
109#[derive(Debug, Clone)]
110pub struct ValidationReport {
111 pub base_dir: Utf8PathBuf,
119 pub recipes: Vec<RecipeValidation>,
126}
127
128impl ValidationReport {
129 pub fn total_recipes(&self) -> usize {
131 self.recipes.len()
132 }
133
134 pub fn recipes_with_errors(&self) -> usize {
136 self.recipes
137 .iter()
138 .filter(|r| r.count(Severity::Error) > 0)
139 .count()
140 }
141
142 pub fn recipes_with_warnings(&self) -> usize {
144 self.recipes
145 .iter()
146 .filter(|r| r.count(Severity::Warning) > 0)
147 .count()
148 }
149
150 pub fn total_errors(&self) -> usize {
152 self.recipes.iter().map(|r| r.count(Severity::Error)).sum()
153 }
154
155 pub fn total_warnings(&self) -> usize {
157 self.recipes
158 .iter()
159 .map(|r| r.count(Severity::Warning))
160 .sum()
161 }
162
163 pub fn references(&self) -> BTreeMap<&Utf8Path, &[String]> {
177 self.recipes
178 .iter()
179 .filter(|r| !r.references.is_empty())
180 .map(|r| (r.path.as_path(), r.references.as_slice()))
181 .collect()
182 }
183}
184
185pub fn validate(
232 ctx: &Context,
233 req: ValidateRequest,
234) -> Result<Outcome<ValidationReport>, CoreError> {
235 let base_dir = req
236 .base_dir
237 .unwrap_or_else(|| ctx.base_path().to_path_buf());
238
239 tracing::trace!("validating recipes under {base_dir}");
240
241 let tree = build_tree(&base_dir)?;
242
243 let mut recipes: Vec<RecipeValidation> = walk(&tree)
244 .into_iter()
245 .map(|entry| validate_entry(entry, &base_dir, req.style))
246 .collect();
247 recipes.sort_by(|a, b| a.path.cmp(&b.path));
252
253 let diagnostics = recipes
254 .iter()
255 .flat_map(|r| r.diagnostics.iter().cloned())
256 .collect();
257
258 Ok(Outcome::with_diagnostics(
259 ValidationReport { base_dir, recipes },
260 diagnostics,
261 ))
262}
263
264fn validate_entry(entry: &RecipeEntry, base_dir: &Utf8Path, style: Style) -> RecipeValidation {
267 let name = entry
272 .name()
273 .clone()
274 .unwrap_or_else(|| "unknown".to_string());
275 let full_path = entry.path().cloned().unwrap_or_else(|| base_dir.join(name));
276 let path = relative_to(base_dir, &full_path);
277
278 let content = match std::fs::read_to_string(&full_path) {
279 Ok(content) => content,
280 Err(e) => {
281 return RecipeValidation {
282 diagnostics: vec![
286 Diagnostic::error(format!("Failed to read file: {e}")).at_file(path.clone())
287 ],
288 path,
289 rendered: String::new(),
290 references: Vec::new(),
291 };
292 }
293 };
294
295 let parsed = PARSER.parse(&content);
296 let diagnostics = collect_diagnostics(parsed.report(), Some(&path));
297
298 let rendered = if diagnostics.is_empty() {
302 String::new()
303 } else {
304 render_report(parsed.report(), path.as_str(), &content, style.is_ansi())
305 };
306
307 let references = parsed
310 .output()
311 .map(|recipe| {
312 recipe
313 .ingredients
314 .iter()
315 .filter_map(|ingredient| ingredient.reference.as_ref())
316 .map(|reference| {
317 if reference.components.is_empty() {
318 reference.name.clone()
319 } else {
320 reference.path("/")
321 }
322 })
323 .collect()
324 })
325 .unwrap_or_default();
326
327 RecipeValidation {
328 path,
329 diagnostics,
330 rendered,
331 references,
332 }
333}
334
335fn relative_to(base_dir: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
347 path.strip_prefix(base_dir).unwrap_or(path).to_owned()
348}
349
350pub fn broken_references(report: &ValidationReport) -> BTreeMap<&Utf8Path, Vec<String>> {
377 report
378 .references()
379 .into_iter()
380 .filter_map(|(recipe, references)| {
381 let broken: Vec<String> = references
382 .iter()
383 .filter(|reference| crate::find::get_recipe(&report.base_dir, reference).is_err())
384 .cloned()
385 .collect();
386 (!broken.is_empty()).then_some((recipe, broken))
387 })
388 .collect()
389}
390
391#[derive(Debug, Clone, Default)]
400pub struct CoverageRequest {
401 pub base_dir: Option<Utf8PathBuf>,
403}
404
405#[non_exhaustive]
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct CheckedIngredient {
412 pub name: String,
416 pub known: bool,
418}
419
420#[non_exhaustive]
431#[derive(Debug, Clone, Default, PartialEq, Eq)]
432pub struct IngredientCoverage {
433 pub total_recipes: usize,
437 pub ingredients: Vec<CheckedIngredient>,
448}
449
450impl IngredientCoverage {
451 pub fn total_ingredients(&self) -> usize {
453 self.ingredients.len()
454 }
455
456 pub fn known(&self) -> impl Iterator<Item = &str> {
459 self.filtered(true)
460 }
461
462 pub fn unknown(&self) -> impl Iterator<Item = &str> {
469 self.filtered(false)
470 }
471
472 fn filtered(&self, known: bool) -> impl Iterator<Item = &str> {
473 self.ingredients
474 .iter()
475 .filter(move |ingredient| ingredient.known == known)
476 .map(|ingredient| ingredient.name.as_str())
477 }
478}
479
480pub fn aisle_coverage(
509 ctx: &Context,
510 req: CoverageRequest,
511) -> Result<Outcome<IngredientCoverage>, CoreError> {
512 let source = ctx.aisle();
513 let mut diagnostics = Vec::new();
514 let mut known = BTreeSet::new();
515
516 if let Some(text) = source.read()? {
517 let parsed = cooklang::aisle::parse_lenient(&text);
518 diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
519 let conf = parsed
520 .output()
521 .ok_or_else(|| config_error(source, "aisle", &diagnostics))?;
522 known.extend(conf.ingredients_info().into_keys());
524 }
525
526 coverage(ctx, req, &known, diagnostics)
527}
528
529pub fn pantry_coverage(
548 ctx: &Context,
549 req: CoverageRequest,
550) -> Result<Outcome<IngredientCoverage>, CoreError> {
551 let source = ctx.pantry();
552 let mut diagnostics = Vec::new();
553 let mut known = BTreeSet::new();
554
555 if let Some(text) = source.read()? {
556 let parsed = cooklang::pantry::parse_lenient(&text);
557 diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
558 let conf = parsed
559 .output()
560 .ok_or_else(|| config_error(source, "pantry", &diagnostics))?;
561 known.extend(conf.all_items().map(|item| item.name().to_lowercase()));
562 }
563
564 coverage(ctx, req, &known, diagnostics)
565}
566
567fn config_error(source: &ConfigSource, kind: &str, diagnostics: &[Diagnostic]) -> CoreError {
573 CoreError::Config {
574 path: source.path().map(ToOwned::to_owned),
575 message: parse_failure(diagnostics, kind),
576 }
577}
578
579fn coverage(
582 ctx: &Context,
583 req: CoverageRequest,
584 known: &BTreeSet<String>,
585 mut diagnostics: Vec<Diagnostic>,
586) -> Result<Outcome<IngredientCoverage>, CoreError> {
587 let base_dir = req
588 .base_dir
589 .unwrap_or_else(|| ctx.base_path().to_path_buf());
590
591 let tree = build_tree(&base_dir)?;
592 let entries = walk(&tree);
593 let total_recipes = entries.len();
594
595 let mut names: BTreeSet<String> = BTreeSet::new();
599 for entry in entries {
600 let Some(recipe) = parse_or_skip(entry, &mut diagnostics) else {
601 continue;
602 };
603 names.extend(listed_ingredients(&recipe));
604 }
605
606 let ingredients = names
607 .into_iter()
608 .map(|name| CheckedIngredient {
609 known: known.contains(&name.to_lowercase()),
610 name,
611 })
612 .collect();
613
614 Ok(Outcome::with_diagnostics(
615 IngredientCoverage {
616 total_recipes,
617 ingredients,
618 },
619 diagnostics,
620 ))
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use crate::find::tree_error;
627 use cooklang_find::tree::TreeError;
628
629 const CLEAN: &str = "---\ntitle: Basic Sauce\n---\n\nHeat @oil{2%tbsp} in a #pan.\n";
630 const DEPRECATED: &str = ">> title: Old Style\n\nBoil @water{1%l}.\n";
632 const BROKEN: &str = "---\ntitle: Broken\n---\n\nAdd @{1%tsp} and @{2%tsp}.\n";
634
635 fn fixture() -> tempfile::TempDir {
638 let dir = tempfile::TempDir::new().unwrap();
639 let base = base(&dir);
640 std::fs::create_dir(base.join("Breakfast")).unwrap();
641 write(
642 &base.join("Breakfast").join("pancakes.cook"),
643 "---\ntitle: Pancakes\n---\n\nMix @flour{2%cups}.\n",
644 );
645 write(&base.join("sauce.cook"), CLEAN);
646 write(
647 &base.join("with_ref.cook"),
648 "---\ntitle: With Reference\n---\n\nMake @./sauce{} and @./nonexistent{}.\n",
649 );
650 write(&base.join("deprecated.cook"), DEPRECATED);
651 write(&base.join("broken.cook"), BROKEN);
652 dir
653 }
654
655 fn write(path: &Utf8Path, text: &str) {
656 std::fs::write(path, text).unwrap();
657 }
658
659 fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
660 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
661 }
662
663 fn run(base_dir: &Utf8Path) -> ValidationReport {
665 run_styled(base_dir, Style::Plain)
666 }
667
668 fn run_styled(base_dir: &Utf8Path, style: Style) -> ValidationReport {
669 validate(
670 &Context::new(base_dir.to_owned()),
671 ValidateRequest {
672 base_dir: None,
673 style,
674 },
675 )
676 .expect("validation succeeds")
677 .into_value()
678 }
679
680 fn paths(report: &ValidationReport) -> Vec<Utf8PathBuf> {
689 report.recipes.iter().map(|r| r.path.clone()).collect()
690 }
691
692 fn recipe<'a>(report: &'a ValidationReport, path: &str) -> &'a RecipeValidation {
693 report
694 .recipes
695 .iter()
696 .find(|r| r.path == path)
697 .unwrap_or_else(|| panic!("{path} missing from {:?}", paths(report)))
698 }
699
700 #[test]
703 fn every_recipe_under_the_root_is_reported_including_nested_ones() {
704 let dir = fixture();
705 let report = run(&base(&dir));
706 assert_eq!(
707 paths(&report),
708 [
709 "Breakfast/pancakes.cook",
710 "broken.cook",
711 "deprecated.cook",
712 "sauce.cook",
713 "with_ref.cook",
714 ]
715 );
716 assert_eq!(report.total_recipes(), 5);
717 }
718
719 #[test]
722 fn totals_count_diagnostics_and_the_recipes_carrying_them() {
723 let dir = fixture();
724 let report = run(&base(&dir));
725
726 assert_eq!(report.total_recipes(), 5);
727 assert_eq!(report.total_errors(), 2, "both errors in broken.cook");
728 assert_eq!(report.recipes_with_errors(), 1);
729 assert_eq!(report.total_warnings(), 1, "deprecated.cook warns once");
730 assert_eq!(report.recipes_with_warnings(), 1);
731 }
732
733 #[test]
734 fn a_failing_recipe_carries_both_diagnostics_and_a_rendered_report() {
735 let dir = fixture();
736 let report = run(&base(&dir));
737 let broken = recipe(&report, "broken.cook");
738
739 assert_eq!(broken.diagnostics.len(), 2);
740 for d in &broken.diagnostics {
741 assert_eq!(d.severity, Severity::Error, "expected errors: {d:?}");
742 }
743 let location = broken.diagnostics[0]
745 .location
746 .as_ref()
747 .expect("location set");
748 assert_eq!(location.file.as_deref(), Some(Utf8Path::new("broken.cook")));
749 assert!(location.span.is_some(), "an error must carry its span");
750
751 assert!(
754 broken.rendered.contains("broken.cook"),
755 "report should name the file: {}",
756 broken.rendered
757 );
758 assert!(
759 broken.rendered.contains("Add @{1%tsp} and @{2%tsp}."),
760 "report should quote the source line: {}",
761 broken.rendered
762 );
763 }
764
765 #[test]
766 fn a_warning_is_reported_as_a_warning_not_an_error() {
767 let dir = fixture();
768 let report = run(&base(&dir));
769 let deprecated = recipe(&report, "deprecated.cook");
770
771 assert_eq!(deprecated.diagnostics.len(), 1);
772 assert_eq!(deprecated.diagnostics[0].severity, Severity::Warning);
773 assert!(
774 !deprecated.rendered.is_empty(),
775 "a warning is worth showing"
776 );
777 }
778
779 #[test]
780 fn a_clean_recipe_carries_neither_diagnostics_nor_a_rendered_report() {
781 let dir = fixture();
782 let report = run(&base(&dir));
783 let clean = recipe(&report, "sauce.cook");
784
785 assert!(clean.diagnostics.is_empty(), "{:?}", clean.diagnostics);
786 assert!(clean.rendered.is_empty(), "{:?}", clean.rendered);
787 }
788
789 #[test]
790 fn references_are_collected_as_they_are_written() {
791 let dir = fixture();
792 let report = run(&base(&dir));
793
794 assert_eq!(
795 recipe(&report, "with_ref.cook").references,
796 ["./sauce", "./nonexistent"],
797 "in source order, unresolved"
798 );
799 assert!(recipe(&report, "sauce.cook").references.is_empty());
800 }
801
802 #[test]
806 fn a_reference_made_twice_is_listed_twice() {
807 let dir = tempfile::TempDir::new().unwrap();
808 let base = base(&dir);
809 write(&base.join("sauce.cook"), CLEAN);
810 write(
811 &base.join("dup.cook"),
812 "---\ntitle: Dup\n---\n\nMake @./sauce{}, then more @./sauce{}.\n",
813 );
814
815 let report = run(&base);
816 assert_eq!(
817 recipe(&report, "dup.cook").references,
818 ["./sauce", "./sauce"]
819 );
820 }
821
822 #[test]
827 fn a_recipe_with_errors_contributes_no_references() {
828 let dir = tempfile::TempDir::new().unwrap();
829 let base = base(&dir);
830 write(
831 &base.join("broken_ref.cook"),
832 "---\ntitle: Broken\n---\n\nMake @./sauce{} and add @{1%tsp}.\n",
833 );
834
835 let report = run(&base);
836 let broken = recipe(&report, "broken_ref.cook");
837 assert_eq!(broken.count(Severity::Error), 1, "{:?}", broken.diagnostics);
838 assert!(
839 broken.references.is_empty(),
840 "expected no references, got {:?}",
841 broken.references
842 );
843 assert!(report.references().is_empty());
844 }
845
846 #[test]
849 fn the_reference_map_holds_only_recipes_that_reference_something() {
850 let dir = fixture();
851 let report = run(&base(&dir));
852 let references = report.references();
853
854 assert_eq!(
855 references.keys().copied().collect::<Vec<_>>(),
856 [Utf8Path::new("with_ref.cook")]
857 );
858 assert_eq!(references[Utf8Path::new("with_ref.cook")].len(), 2);
859 }
860
861 #[test]
867 fn a_file_that_cannot_be_read_is_one_error_and_does_not_end_the_walk() {
868 let dir = fixture();
869 let base = base(&dir);
870 std::fs::write(
871 base.join("bad_bytes.cook"),
872 b"---\ntitle: Bad Bytes\n---\n\nBoil @water{1%l} \xFF.\n",
873 )
874 .unwrap();
875
876 let report = run(&base);
877 let unreadable = recipe(&report, "bad_bytes.cook");
878
879 assert_eq!(unreadable.diagnostics.len(), 1);
880 assert_eq!(unreadable.diagnostics[0].severity, Severity::Error);
881 assert!(
882 unreadable.diagnostics[0]
883 .message
884 .starts_with("Failed to read file"),
885 "{:?}",
886 unreadable.diagnostics[0]
887 );
888 assert!(
889 unreadable.rendered.is_empty(),
890 "there is no source to quote: {:?}",
891 unreadable.rendered
892 );
893 assert_eq!(unreadable.references, Vec::<String>::new());
894
895 assert_eq!(report.total_recipes(), 6);
897 assert_eq!(report.total_errors(), 3);
898 assert_eq!(report.recipes_with_errors(), 2);
899 assert_eq!(recipe(&report, "broken.cook").diagnostics.len(), 2);
901 assert!(recipe(&report, "sauce.cook").diagnostics.is_empty());
902 }
903
904 #[test]
906 fn a_collection_full_of_errors_still_validates_successfully() {
907 let dir = tempfile::TempDir::new().unwrap();
908 write(&base(&dir).join("broken.cook"), BROKEN);
909
910 let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default())
911 .expect("errors in recipes are data, not a failed command");
912
913 assert_eq!(outcome.value.total_errors(), 2);
914 assert!(
915 outcome.has_errors(),
916 "the outcome must carry the errors it found: {:?}",
917 outcome.diagnostics
918 );
919 assert_eq!(
920 outcome.diagnostics.len(),
921 2,
922 "every diagnostic in the report, flat"
923 );
924 }
925
926 #[test]
928 fn a_clean_collection_has_no_diagnostics_at_all() {
929 let dir = tempfile::TempDir::new().unwrap();
930 write(&base(&dir).join("sauce.cook"), CLEAN);
931
932 let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default()).unwrap();
933 assert!(!outcome.has_errors());
934 assert!(outcome.diagnostics.is_empty());
935 assert_eq!(outcome.value.total_errors(), 0);
936 assert_eq!(outcome.value.total_warnings(), 0);
937 assert_eq!(outcome.value.total_recipes(), 1);
938 }
939
940 #[test]
941 fn style_decides_whether_the_rendered_report_is_coloured() {
942 let dir = fixture();
943 let base = base(&dir);
944
945 let plain = run_styled(&base, Style::Plain);
946 let plain = &recipe(&plain, "broken.cook").rendered;
947 assert!(
948 !plain.contains('\u{1b}'),
949 "Style::Plain must emit no escape codes: {plain:?}"
950 );
951
952 let coloured = run_styled(&base, Style::Ansi);
953 let coloured = &recipe(&coloured, "broken.cook").rendered;
954 assert!(
955 coloured.contains('\u{1b}'),
956 "Style::Ansi must emit escape codes: {coloured:?}"
957 );
958 assert_eq!(
959 *plain,
960 anstream::adapter::strip_str(coloured).to_string(),
961 "the two must differ only in the escape codes"
962 );
963 }
964
965 #[test]
968 fn the_default_style_is_plain() {
969 assert_eq!(ValidateRequest::default().style, Style::Plain);
970 }
971
972 #[test]
973 fn base_dir_overrides_the_context_base_path() {
974 let validated = fixture();
975 let ignored = tempfile::TempDir::new().unwrap();
976 write(&base(&ignored).join("decoy.cook"), BROKEN);
977
978 let report = validate(
979 &Context::new(base(&ignored)),
980 ValidateRequest {
981 base_dir: Some(base(&validated)),
982 style: Style::Plain,
983 },
984 )
985 .expect("validation succeeds")
986 .into_value();
987
988 assert_eq!(report.total_recipes(), 5);
989 assert!(
990 !paths(&report).contains(&Utf8PathBuf::from("decoy.cook")),
991 "{:?}",
992 paths(&report)
993 );
994 }
995
996 #[test]
997 fn without_a_base_dir_the_context_base_path_is_validated() {
998 let dir = fixture();
999 assert_eq!(run(&base(&dir)).total_recipes(), 5);
1000 }
1001
1002 #[test]
1007 fn recipes_come_back_in_path_order_every_time() {
1008 let dir = fixture();
1009 let base = base(&dir);
1010 let expected = paths(&run(&base));
1011 let mut sorted = expected.clone();
1012 sorted.sort();
1013 assert_eq!(expected, sorted);
1014
1015 for _ in 0..8 {
1016 assert_eq!(paths(&run(&base)), expected, "order must not vary");
1017 }
1018 }
1019
1020 #[test]
1021 fn an_empty_directory_validates_to_an_empty_report() {
1022 let dir = tempfile::TempDir::new().unwrap();
1023 let report = run(&base(&dir));
1024 assert_eq!(report.total_recipes(), 0);
1025 assert!(report.references().is_empty());
1026 }
1027
1028 #[test]
1031 fn a_root_that_does_not_exist_is_reported() {
1032 let dir = tempfile::TempDir::new().unwrap();
1033 let missing = base(&dir).join("nope");
1034
1035 match validate(&Context::new(missing.clone()), ValidateRequest::default()) {
1036 Err(CoreError::Search { base_dir, message }) => {
1037 assert_eq!(base_dir, missing);
1038 assert_eq!(message, "no such directory");
1039 }
1040 other => panic!(
1041 "expected CoreError::Search, got {:?}",
1042 other.map(|o| o.value)
1043 ),
1044 }
1045 }
1046
1047 #[test]
1048 fn a_root_that_is_a_file_is_reported() {
1049 let dir = tempfile::TempDir::new().unwrap();
1050 let file = base(&dir).join("sauce.cook");
1051 write(&file, CLEAN);
1052
1053 match validate(&Context::new(file.clone()), ValidateRequest::default()) {
1054 Err(CoreError::Search { base_dir, message }) => {
1055 assert_eq!(base_dir, file);
1056 assert_eq!(message, "not a directory");
1057 }
1058 other => panic!(
1059 "expected CoreError::Search, got {:?}",
1060 other.map(|o| o.value)
1061 ),
1062 }
1063 }
1064
1065 #[test]
1068 fn a_root_that_is_not_a_valid_glob_pattern_is_reported() {
1069 let dir = tempfile::TempDir::new().unwrap();
1070 let root = base(&dir).join("re[ci");
1071 std::fs::create_dir(&root).unwrap();
1072 write(&root.join("sauce.cook"), CLEAN);
1073
1074 match validate(&Context::new(root.clone()), ValidateRequest::default()) {
1075 Err(CoreError::Search { base_dir, message }) => {
1076 assert_eq!(base_dir, root);
1077 assert!(
1078 message.contains("attern"),
1079 "the cause must survive: {message}"
1080 );
1081 }
1082 other => panic!(
1083 "expected CoreError::Search, got {:?}",
1084 other.map(|o| o.value)
1085 ),
1086 }
1087 }
1088
1089 #[test]
1092 fn a_listing_failure_names_the_file_rather_than_the_root() {
1093 let root = Utf8Path::new("/recipes");
1094
1095 let unusable = tree_error(
1096 TreeError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
1097 "bad front matter".to_string(),
1098 )),
1099 root,
1100 );
1101 match unusable {
1102 CoreError::Io { path, source } => {
1103 assert_eq!(path, root);
1104 assert!(
1105 source.to_string().contains("bad front matter"),
1106 "the cause must survive: {source}"
1107 );
1108 }
1109 other => panic!("expected CoreError::Io, got {other:?}"),
1110 }
1111
1112 let unstrippable = tree_error(
1113 TreeError::StripPrefixError("recipes/soup.cook".to_string()),
1114 root,
1115 );
1116 match unstrippable {
1117 CoreError::Search { base_dir, message } => {
1118 assert_eq!(base_dir, root);
1119 assert!(message.contains("recipes/soup.cook"), "{message}");
1120 }
1121 other => panic!("expected CoreError::Search, got {other:?}"),
1122 }
1123 }
1124
1125 #[test]
1126 fn a_path_under_the_root_is_stripped_and_one_outside_is_left_alone() {
1127 assert_eq!(
1128 relative_to(
1129 Utf8Path::new("/recipes"),
1130 Utf8Path::new("/recipes/Breakfast/pancakes.cook")
1131 ),
1132 "Breakfast/pancakes.cook"
1133 );
1134 assert_eq!(
1135 relative_to(
1136 Utf8Path::new("./recipes"),
1137 Utf8Path::new("recipes/soup.cook")
1138 ),
1139 "recipes/soup.cook"
1140 );
1141 }
1142
1143 fn broken(report: &ValidationReport) -> Vec<(String, Vec<String>)> {
1150 broken_references(report)
1151 .into_iter()
1152 .map(|(recipe, missing)| (recipe.to_string(), missing))
1153 .collect()
1154 }
1155
1156 #[test]
1157 fn the_report_records_the_root_it_was_validated_against() {
1158 let dir = fixture();
1159 assert_eq!(run(&base(&dir)).base_dir, base(&dir));
1160
1161 let elsewhere = tempfile::TempDir::new().unwrap();
1162 let report = validate(
1163 &Context::new(base(&elsewhere)),
1164 ValidateRequest {
1165 base_dir: Some(base(&dir)),
1166 style: Style::Plain,
1167 },
1168 )
1169 .expect("validation succeeds")
1170 .into_value();
1171 assert_eq!(
1172 report.base_dir,
1173 base(&dir),
1174 "the root that was walked, not the context's"
1175 );
1176 }
1177
1178 #[test]
1181 fn only_references_that_resolve_to_nothing_are_reported() {
1182 let dir = fixture();
1183 assert_eq!(
1184 broken(&run(&base(&dir))),
1185 [(
1186 "with_ref.cook".to_string(),
1187 vec!["./nonexistent".to_string()]
1188 )]
1189 );
1190 }
1191
1192 #[test]
1193 fn a_collection_whose_references_all_resolve_reports_none() {
1194 let dir = tempfile::TempDir::new().unwrap();
1195 let base = base(&dir);
1196 write(&base.join("sauce.cook"), CLEAN);
1197 write(
1198 &base.join("dish.cook"),
1199 "---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
1200 );
1201
1202 assert!(
1203 broken_references(&run(&base)).is_empty(),
1204 "a resolvable reference must not be reported"
1205 );
1206 }
1207
1208 #[test]
1211 fn a_reference_repeated_is_reported_once_per_mention() {
1212 let dir = tempfile::TempDir::new().unwrap();
1213 let base = base(&dir);
1214 write(
1215 &base.join("dish.cook"),
1216 "---\ntitle: Dish\n---\n\nMake @./absent{}, then more @./absent{}.\n",
1217 );
1218
1219 assert_eq!(
1220 broken(&run(&base)),
1221 [(
1222 "dish.cook".to_string(),
1223 vec!["./absent".to_string(), "./absent".to_string()]
1224 )]
1225 );
1226 }
1227
1228 #[test]
1233 fn references_resolve_against_the_validated_root_from_anywhere_in_it() {
1234 let dir = tempfile::TempDir::new().unwrap();
1235 let base = base(&dir);
1236 write(&base.join("sauce.cook"), CLEAN);
1237 std::fs::create_dir(base.join("Dinner")).unwrap();
1238 write(
1239 &base.join("Dinner").join("dish.cook"),
1240 "---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
1241 );
1242
1243 assert!(
1244 broken_references(&run(&base)).is_empty(),
1245 "a nested recipe must be able to reference the root's"
1246 );
1247 }
1248
1249 #[test]
1252 fn a_collection_with_no_references_has_none_broken() {
1253 let dir = tempfile::TempDir::new().unwrap();
1254 write(&base(&dir).join("sauce.cook"), CLEAN);
1255 assert!(broken_references(&run(&base(&dir))).is_empty());
1256 }
1257
1258 fn one_recipe(text: &str) -> tempfile::TempDir {
1264 let dir = tempfile::TempDir::new().unwrap();
1265 write(&base(&dir).join("dish.cook"), text);
1266 dir
1267 }
1268
1269 fn aisle_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
1270 Context::new(base(dir)).with_aisle(ConfigSource::Inline(conf.to_string()))
1271 }
1272
1273 fn pantry_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
1274 Context::new(base(dir)).with_pantry(ConfigSource::Inline(conf.to_string()))
1275 }
1276
1277 fn checked(ctx: &Context, aisle: bool) -> Outcome<IngredientCoverage> {
1278 let request = CoverageRequest::default();
1279 if aisle {
1280 aisle_coverage(ctx, request)
1281 } else {
1282 pantry_coverage(ctx, request)
1283 }
1284 .expect("the check succeeds")
1285 }
1286
1287 fn known(coverage: &IngredientCoverage) -> Vec<&str> {
1288 coverage.known().collect()
1289 }
1290
1291 fn unknown(coverage: &IngredientCoverage) -> Vec<&str> {
1292 coverage.unknown().collect()
1293 }
1294
1295 fn all(coverage: &IngredientCoverage) -> Vec<&str> {
1296 coverage
1297 .ingredients
1298 .iter()
1299 .map(|ingredient| ingredient.name.as_str())
1300 .collect()
1301 }
1302
1303 #[test]
1304 fn an_aisle_splits_the_collection_into_categorised_and_not() {
1305 let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
1306 let coverage = checked(
1307 &aisle_ctx(&dir, "[produce]\nleek\n\n[pantry]\nsalt\n"),
1308 true,
1309 )
1310 .value;
1311
1312 assert_eq!(known(&coverage), ["leek", "salt"]);
1313 assert_eq!(unknown(&coverage), ["water"]);
1314 assert_eq!(coverage.total_ingredients(), 3);
1315 assert_eq!(coverage.total_recipes, 1);
1316 }
1317
1318 #[test]
1320 fn an_aisle_synonym_counts_as_knowing_the_ingredient() {
1321 let dir = one_recipe("Add @aubergine{1}.\n");
1322 let coverage = checked(&aisle_ctx(&dir, "[produce]\neggplant|aubergine\n"), true).value;
1323
1324 assert_eq!(known(&coverage), ["aubergine"]);
1325 assert!(unknown(&coverage).is_empty());
1326 }
1327
1328 #[test]
1329 fn a_pantry_knows_its_items_from_every_section() {
1330 let dir = one_recipe("Add @salt{1%tsp}, @milk{1%l} and @water{1%l}.\n");
1331 let conf = "[pantry]\nsalt = \"1%kg\"\n\n[dairy]\nmilk = \"1%l\"\n";
1332 let coverage = checked(&pantry_ctx(&dir, conf), false).value;
1333
1334 assert_eq!(known(&coverage), ["milk", "salt"]);
1335 assert_eq!(unknown(&coverage), ["water"]);
1336 }
1337
1338 #[test]
1341 fn a_pantry_item_that_has_run_out_still_counts_as_known() {
1342 let dir = one_recipe("Add @honey{1%tbsp}.\n");
1343 let conf = "[pantry]\nhoney = { quantity = \"0\", low = \"100%g\" }\n";
1344 assert_eq!(
1345 known(&checked(&pantry_ctx(&dir, conf), false).value),
1346 ["honey"]
1347 );
1348 }
1349
1350 #[test]
1353 fn names_are_matched_ignoring_case_and_reported_as_the_recipe_writes_them() {
1354 let dir = one_recipe("Add @Salt{1%tsp} and @PEPPER{}.\n");
1355
1356 let aisle = checked(&aisle_ctx(&dir, "[pantry]\nsalt\npepper\n"), true).value;
1357 assert_eq!(known(&aisle), ["PEPPER", "Salt"]);
1358 assert!(unknown(&aisle).is_empty());
1359
1360 let conf = "[pantry]\nsalt = \"1%kg\"\npepper = \"50%g\"\n";
1361 let pantry = checked(&pantry_ctx(&dir, conf), false).value;
1362 assert_eq!(known(&pantry), ["PEPPER", "Salt"]);
1363 assert!(unknown(&pantry).is_empty());
1364 }
1365
1366 #[test]
1371 fn a_non_ascii_name_is_matched_ignoring_case_too() {
1372 let dir = one_recipe("Add @Öl{1%tbsp} and @Ärter{100%g}.\n");
1373
1374 let aisle = checked(&aisle_ctx(&dir, "[pantry]\növerste|öl\närter\n"), true).value;
1375 assert_eq!(known(&aisle), ["Ärter", "Öl"]);
1376 assert!(unknown(&aisle).is_empty(), "{:?}", unknown(&aisle));
1377
1378 let conf = "[pantry]\n\"öl\" = \"1%l\"\n\"ärter\" = \"1%kg\"\n";
1380 assert_eq!(
1381 known(&checked(&pantry_ctx(&dir, conf), false).value),
1382 ["Ärter", "Öl"]
1383 );
1384 }
1385
1386 #[test]
1389 fn two_spellings_of_one_ingredient_are_both_listed() {
1390 let dir = tempfile::TempDir::new().unwrap();
1391 write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
1392 write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
1393
1394 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1395 assert_eq!(known(&coverage), ["Salt", "salt"]);
1396 assert_eq!(coverage.total_ingredients(), 2);
1397 }
1398
1399 #[test]
1403 fn without_a_configuration_nothing_is_known() {
1404 let dir = one_recipe("Add @salt{1%tsp}.\n");
1405 let ctx = Context::new(base(&dir));
1406
1407 for aisle in [true, false] {
1408 let coverage = checked(&ctx, aisle).value;
1409 assert_eq!(coverage.total_recipes, 1);
1410 assert!(known(&coverage).is_empty(), "aisle: {aisle}");
1411 assert_eq!(unknown(&coverage), ["salt"], "aisle: {aisle}");
1412 }
1413 }
1414
1415 #[test]
1418 fn references_to_other_recipes_are_not_ingredients() {
1419 let dir = one_recipe("Make @./sauce{} and add @water{1%l}.\n");
1420 write(&base(&dir).join("sauce.cook"), CLEAN);
1421
1422 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsauce\nwater\noil\n"), true).value;
1423 assert_eq!(
1424 all(&coverage),
1425 ["oil", "water"],
1426 "the referenced recipe's own ingredients count; the reference does not"
1427 );
1428 }
1429
1430 #[test]
1431 fn every_recipe_under_the_root_is_scanned_including_nested_ones() {
1432 let dir = one_recipe("Boil @water{1%l}.\n");
1433 std::fs::create_dir(base(&dir).join("Breakfast")).unwrap();
1434 write(
1435 &base(&dir).join("Breakfast").join("porridge.cook"),
1436 "Simmer @oats{50%g}.\n",
1437 );
1438
1439 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nwater\n"), true).value;
1440 assert_eq!(coverage.total_recipes, 2);
1441 assert_eq!(
1442 unknown(&coverage),
1443 ["oats"],
1444 "a subdirectory must be walked"
1445 );
1446 }
1447
1448 #[test]
1451 fn a_recipe_that_cannot_be_parsed_is_counted_but_contributes_nothing() {
1452 let dir = one_recipe("Add @salt{1%tsp}.\n");
1453 write(&base(&dir).join("broken.cook"), BROKEN);
1454
1455 let outcome = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true);
1456 assert_eq!(outcome.value.total_recipes, 2);
1457 assert_eq!(outcome.value.total_ingredients(), 1);
1458 assert_eq!(known(&outcome.value), ["salt"]);
1459
1460 let skipped = outcome
1461 .diagnostics
1462 .iter()
1463 .find(|d| d.message.contains("broken.cook"))
1464 .unwrap_or_else(|| {
1465 panic!(
1466 "the skipped recipe must be named: {:?}",
1467 outcome.diagnostics
1468 )
1469 });
1470 assert_eq!(skipped.severity, Severity::Warning);
1473 assert!(!outcome.has_errors());
1474 }
1475
1476 #[test]
1485 fn ingredients_come_back_in_code_point_order_every_time() {
1486 let dir = tempfile::TempDir::new().unwrap();
1487 for (file, ingredient) in [
1488 ("a", "yeast"),
1489 ("b", "flour"),
1490 ("c", "sugar"),
1491 ("d", "Zucchini"),
1492 ("e", "apple"),
1493 ("f", "Beetroot"),
1494 ] {
1495 write(
1496 &base(&dir).join(format!("{file}.cook")),
1497 &format!("Add @{ingredient}{{1}}.\n"),
1498 );
1499 }
1500 let ctx = aisle_ctx(&dir, "[pantry]\nflour\n");
1501
1502 for _ in 0..8 {
1503 let coverage = checked(&ctx, true).value;
1504 assert_eq!(
1505 all(&coverage),
1506 ["Beetroot", "Zucchini", "apple", "flour", "sugar", "yeast"],
1507 "capitalised names sort first, as CookCLI has always printed them"
1508 );
1509 assert_eq!(
1510 unknown(&coverage),
1511 ["Beetroot", "Zucchini", "apple", "sugar", "yeast"]
1512 );
1513 }
1514 }
1515
1516 #[test]
1519 fn the_two_views_partition_the_ingredients() {
1520 let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
1521 let coverage = checked(&aisle_ctx(&dir, "[produce]\nleek\n"), true).value;
1522
1523 let mut both: Vec<&str> = known(&coverage)
1524 .into_iter()
1525 .chain(unknown(&coverage))
1526 .collect();
1527 both.sort_unstable();
1528 assert_eq!(both, all(&coverage));
1529 assert_eq!(
1530 known(&coverage).len() + unknown(&coverage).len(),
1531 coverage.total_ingredients()
1532 );
1533 }
1534
1535 #[test]
1536 fn a_coverage_base_dir_overrides_the_context_base_path() {
1537 let scanned = one_recipe("Add @salt{1%tsp}.\n");
1538 let ignored = one_recipe("Add @decoy{1}.\n");
1539
1540 let coverage = aisle_coverage(
1541 &aisle_ctx(&ignored, "[pantry]\nsalt\n"),
1542 CoverageRequest {
1543 base_dir: Some(base(&scanned)),
1544 },
1545 )
1546 .expect("the check succeeds")
1547 .into_value();
1548
1549 assert_eq!(known(&coverage), ["salt"]);
1550 assert!(!all(&coverage).contains(&"decoy"), "{:?}", all(&coverage));
1551 }
1552
1553 #[test]
1556 fn a_warning_in_the_aisle_configuration_comes_back_as_a_diagnostic() {
1557 let dir = one_recipe("Add @leek{1}.\n");
1558 let outcome = checked(&aisle_ctx(&dir, "[produce]\nleek\n\n[dairy]\nleek\n"), true);
1559
1560 assert!(
1561 outcome
1562 .diagnostics
1563 .iter()
1564 .any(|d| d.message.contains("Duplicate ingredient")),
1565 "{:?}",
1566 outcome.diagnostics
1567 );
1568 assert_eq!(known(&outcome.value), ["leek"]);
1570 }
1571
1572 #[test]
1576 fn a_warning_in_the_pantry_configuration_comes_back_as_a_diagnostic() {
1577 let dir = one_recipe("Add @ice{1}.\n");
1578 let conf = "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n";
1579 let outcome = checked(&pantry_ctx(&dir, conf), false);
1580
1581 assert!(
1582 !outcome.diagnostics.is_empty(),
1583 "the unknown attribute must be reported"
1584 );
1585 for diagnostic in &outcome.diagnostics {
1586 assert_eq!(diagnostic.severity, Severity::Warning, "{diagnostic:?}");
1587 }
1588 assert!(!outcome.has_errors());
1589 assert_eq!(known(&outcome.value), ["ice"]);
1591 }
1592
1593 #[test]
1596 fn a_configuration_warning_is_located_in_the_file_it_came_from() {
1597 let dir = one_recipe("Add @ice{1}.\n");
1598 let path = base(&dir).join("pantry.conf");
1599 write(
1600 &path,
1601 "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n",
1602 );
1603
1604 let ctx = Context::new(base(&dir)).with_pantry(ConfigSource::Path(path.clone()));
1605 let outcome =
1606 pantry_coverage(&ctx, CoverageRequest::default()).expect("the check succeeds");
1607
1608 let located = outcome
1609 .diagnostics
1610 .iter()
1611 .find(|d| d.location.is_some())
1612 .unwrap_or_else(|| panic!("expected a located warning: {:?}", outcome.diagnostics));
1613 assert_eq!(
1614 located.location.as_ref().and_then(|l| l.file.as_deref()),
1615 Some(path.as_path())
1616 );
1617 }
1618
1619 #[test]
1623 fn a_configuration_that_cannot_be_read_is_reported() {
1624 let dir = one_recipe("Add @salt{1%tsp}.\n");
1625 let missing = base(&dir).join("config").join("aisle.conf");
1626
1627 match aisle_coverage(
1628 &Context::new(base(&dir)).with_aisle(ConfigSource::Path(missing.clone())),
1629 CoverageRequest::default(),
1630 ) {
1631 Err(CoreError::Io { path, source }) => {
1632 assert_eq!(path, missing);
1633 assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
1634 }
1635 other => panic!("expected CoreError::Io, got {:?}", other.map(|o| o.value)),
1636 }
1637 }
1638
1639 #[test]
1644 fn a_pantry_that_cannot_be_parsed_at_all_is_reported_as_pantry_load_reports_it() {
1645 let dir = one_recipe("Add @salt{1%tsp}.\n");
1646 let ctx = pantry_ctx(&dir, "this is not toml [");
1647
1648 let from_load = match crate::pantry::load(&ctx) {
1649 Err(CoreError::Config { message, .. }) => message,
1650 other => panic!("expected CoreError::Config from load, got {other:?}"),
1651 };
1652
1653 match pantry_coverage(&ctx, CoverageRequest::default()) {
1654 Err(CoreError::Config { path, message }) => {
1655 assert_eq!(path, None, "an inline configuration has no path");
1656 assert!(
1659 message.contains("TOML parse error"),
1660 "the cause must survive: {message}"
1661 );
1662 assert_eq!(message, from_load, "the two commands must agree");
1663 }
1664 other => panic!(
1665 "expected CoreError::Config, got {:?}",
1666 other.map(|o| o.value)
1667 ),
1668 }
1669 }
1670
1671 #[test]
1673 fn a_root_that_does_not_exist_is_reported_by_a_coverage_check() {
1674 let dir = tempfile::TempDir::new().unwrap();
1675 let missing = base(&dir).join("nope");
1676
1677 match pantry_coverage(
1678 &Context::new(missing.clone()).with_pantry(ConfigSource::Inline(String::new())),
1679 CoverageRequest::default(),
1680 ) {
1681 Err(CoreError::Search { base_dir, message }) => {
1682 assert_eq!(base_dir, missing);
1683 assert_eq!(message, "no such directory");
1684 }
1685 other => panic!(
1686 "expected CoreError::Search, got {:?}",
1687 other.map(|o| o.value)
1688 ),
1689 }
1690 }
1691
1692 #[test]
1693 fn an_empty_collection_covers_nothing() {
1694 let dir = tempfile::TempDir::new().unwrap();
1695 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1696 assert_eq!(coverage.total_recipes, 0);
1697 assert_eq!(coverage.total_ingredients(), 0);
1698 assert!(known(&coverage).is_empty());
1699 assert!(unknown(&coverage).is_empty());
1700 }
1701}