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 pub recipes: Vec<Utf8PathBuf>,
428}
429
430#[non_exhaustive]
441#[derive(Debug, Clone, Default, PartialEq, Eq)]
442pub struct IngredientCoverage {
443 pub total_recipes: usize,
447 pub ingredients: Vec<CheckedIngredient>,
458}
459
460impl IngredientCoverage {
461 pub fn total_ingredients(&self) -> usize {
463 self.ingredients.len()
464 }
465
466 pub fn known(&self) -> impl Iterator<Item = &str> {
469 self.known_entries()
470 .map(|ingredient| ingredient.name.as_str())
471 }
472
473 pub fn unknown(&self) -> impl Iterator<Item = &str> {
480 self.unknown_entries()
481 .map(|ingredient| ingredient.name.as_str())
482 }
483
484 pub fn known_entries(&self) -> impl Iterator<Item = &CheckedIngredient> {
487 self.filtered(true)
488 }
489
490 pub fn unknown_entries(&self) -> impl Iterator<Item = &CheckedIngredient> {
497 self.filtered(false)
498 }
499
500 fn filtered(&self, known: bool) -> impl Iterator<Item = &CheckedIngredient> {
501 self.ingredients
502 .iter()
503 .filter(move |ingredient| ingredient.known == known)
504 }
505}
506
507pub fn aisle_coverage(
536 ctx: &Context,
537 req: CoverageRequest,
538) -> Result<Outcome<IngredientCoverage>, CoreError> {
539 let source = ctx.aisle();
540 let mut diagnostics = Vec::new();
541 let mut known = BTreeSet::new();
542
543 if let Some(text) = source.read()? {
544 let parsed = cooklang::aisle::parse_lenient(&text);
545 diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
546 let conf = parsed
547 .output()
548 .ok_or_else(|| config_error(source, "aisle", &diagnostics))?;
549 known.extend(conf.ingredients_info().into_keys());
551 }
552
553 coverage(ctx, req, &known, diagnostics)
554}
555
556pub fn pantry_coverage(
575 ctx: &Context,
576 req: CoverageRequest,
577) -> Result<Outcome<IngredientCoverage>, CoreError> {
578 let source = ctx.pantry();
579 let mut diagnostics = Vec::new();
580 let mut known = BTreeSet::new();
581
582 if let Some(text) = source.read()? {
583 let parsed = cooklang::pantry::parse_lenient(&text);
584 diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
585 let conf = parsed
586 .output()
587 .ok_or_else(|| config_error(source, "pantry", &diagnostics))?;
588 known.extend(conf.all_items().map(|item| item.name().to_lowercase()));
589 }
590
591 coverage(ctx, req, &known, diagnostics)
592}
593
594fn config_error(source: &ConfigSource, kind: &str, diagnostics: &[Diagnostic]) -> CoreError {
600 CoreError::Config {
601 path: source.path().map(ToOwned::to_owned),
602 message: parse_failure(diagnostics, kind),
603 }
604}
605
606fn coverage(
609 ctx: &Context,
610 req: CoverageRequest,
611 known: &BTreeSet<String>,
612 mut diagnostics: Vec<Diagnostic>,
613) -> Result<Outcome<IngredientCoverage>, CoreError> {
614 let base_dir = req
615 .base_dir
616 .unwrap_or_else(|| ctx.base_path().to_path_buf());
617
618 let tree = build_tree(&base_dir)?;
619 let entries = walk(&tree);
620 let total_recipes = entries.len();
621
622 let mut names: BTreeMap<String, BTreeSet<Utf8PathBuf>> = BTreeMap::new();
627 for entry in entries {
628 let Some(recipe) = parse_or_skip(entry, &mut diagnostics) else {
629 continue;
630 };
631 let path = entry
636 .path()
637 .map(|path| relative_to(&base_dir, path))
638 .or_else(|| entry.name().clone().map(Utf8PathBuf::from))
639 .unwrap_or_else(|| Utf8PathBuf::from("unknown"));
640 for name in listed_ingredients(&recipe) {
641 names.entry(name).or_default().insert(path.clone());
642 }
643 }
644
645 let ingredients = names
646 .into_iter()
647 .map(|(name, recipes)| CheckedIngredient {
648 known: known.contains(&name.to_lowercase()),
649 name,
650 recipes: recipes.into_iter().collect(),
651 })
652 .collect();
653
654 Ok(Outcome::with_diagnostics(
655 IngredientCoverage {
656 total_recipes,
657 ingredients,
658 },
659 diagnostics,
660 ))
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use crate::find::tree_error;
667 use cooklang_find::tree::TreeError;
668
669 const CLEAN: &str = "---\ntitle: Basic Sauce\n---\n\nHeat @oil{2%tbsp} in a #pan.\n";
670 const DEPRECATED: &str = ">> title: Old Style\n\nBoil @water{1%l}.\n";
672 const BROKEN: &str = "---\ntitle: Broken\n---\n\nAdd @{1%tsp} and @{2%tsp}.\n";
674
675 fn fixture() -> tempfile::TempDir {
678 let dir = tempfile::TempDir::new().unwrap();
679 let base = base(&dir);
680 std::fs::create_dir(base.join("Breakfast")).unwrap();
681 write(
682 &base.join("Breakfast").join("pancakes.cook"),
683 "---\ntitle: Pancakes\n---\n\nMix @flour{2%cups}.\n",
684 );
685 write(&base.join("sauce.cook"), CLEAN);
686 write(
687 &base.join("with_ref.cook"),
688 "---\ntitle: With Reference\n---\n\nMake @./sauce{} and @./nonexistent{}.\n",
689 );
690 write(&base.join("deprecated.cook"), DEPRECATED);
691 write(&base.join("broken.cook"), BROKEN);
692 dir
693 }
694
695 fn write(path: &Utf8Path, text: &str) {
696 std::fs::write(path, text).unwrap();
697 }
698
699 fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
700 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
701 }
702
703 fn run(base_dir: &Utf8Path) -> ValidationReport {
705 run_styled(base_dir, Style::Plain)
706 }
707
708 fn run_styled(base_dir: &Utf8Path, style: Style) -> ValidationReport {
709 validate(
710 &Context::new(base_dir.to_owned()),
711 ValidateRequest {
712 base_dir: None,
713 style,
714 },
715 )
716 .expect("validation succeeds")
717 .into_value()
718 }
719
720 fn paths(report: &ValidationReport) -> Vec<Utf8PathBuf> {
729 report.recipes.iter().map(|r| r.path.clone()).collect()
730 }
731
732 fn recipe<'a>(report: &'a ValidationReport, path: &str) -> &'a RecipeValidation {
733 report
734 .recipes
735 .iter()
736 .find(|r| r.path == path)
737 .unwrap_or_else(|| panic!("{path} missing from {:?}", paths(report)))
738 }
739
740 #[test]
743 fn every_recipe_under_the_root_is_reported_including_nested_ones() {
744 let dir = fixture();
745 let report = run(&base(&dir));
746 assert_eq!(
747 paths(&report),
748 [
749 "Breakfast/pancakes.cook",
750 "broken.cook",
751 "deprecated.cook",
752 "sauce.cook",
753 "with_ref.cook",
754 ]
755 );
756 assert_eq!(report.total_recipes(), 5);
757 }
758
759 #[test]
762 fn totals_count_diagnostics_and_the_recipes_carrying_them() {
763 let dir = fixture();
764 let report = run(&base(&dir));
765
766 assert_eq!(report.total_recipes(), 5);
767 assert_eq!(report.total_errors(), 2, "both errors in broken.cook");
768 assert_eq!(report.recipes_with_errors(), 1);
769 assert_eq!(report.total_warnings(), 1, "deprecated.cook warns once");
770 assert_eq!(report.recipes_with_warnings(), 1);
771 }
772
773 #[test]
774 fn a_failing_recipe_carries_both_diagnostics_and_a_rendered_report() {
775 let dir = fixture();
776 let report = run(&base(&dir));
777 let broken = recipe(&report, "broken.cook");
778
779 assert_eq!(broken.diagnostics.len(), 2);
780 for d in &broken.diagnostics {
781 assert_eq!(d.severity, Severity::Error, "expected errors: {d:?}");
782 }
783 let location = broken.diagnostics[0]
785 .location
786 .as_ref()
787 .expect("location set");
788 assert_eq!(location.file.as_deref(), Some(Utf8Path::new("broken.cook")));
789 assert!(location.span.is_some(), "an error must carry its span");
790
791 assert!(
794 broken.rendered.contains("broken.cook"),
795 "report should name the file: {}",
796 broken.rendered
797 );
798 assert!(
799 broken.rendered.contains("Add @{1%tsp} and @{2%tsp}."),
800 "report should quote the source line: {}",
801 broken.rendered
802 );
803 }
804
805 #[test]
806 fn a_warning_is_reported_as_a_warning_not_an_error() {
807 let dir = fixture();
808 let report = run(&base(&dir));
809 let deprecated = recipe(&report, "deprecated.cook");
810
811 assert_eq!(deprecated.diagnostics.len(), 1);
812 assert_eq!(deprecated.diagnostics[0].severity, Severity::Warning);
813 assert!(
814 !deprecated.rendered.is_empty(),
815 "a warning is worth showing"
816 );
817 }
818
819 #[test]
820 fn a_clean_recipe_carries_neither_diagnostics_nor_a_rendered_report() {
821 let dir = fixture();
822 let report = run(&base(&dir));
823 let clean = recipe(&report, "sauce.cook");
824
825 assert!(clean.diagnostics.is_empty(), "{:?}", clean.diagnostics);
826 assert!(clean.rendered.is_empty(), "{:?}", clean.rendered);
827 }
828
829 #[test]
830 fn references_are_collected_as_they_are_written() {
831 let dir = fixture();
832 let report = run(&base(&dir));
833
834 assert_eq!(
835 recipe(&report, "with_ref.cook").references,
836 ["./sauce", "./nonexistent"],
837 "in source order, unresolved"
838 );
839 assert!(recipe(&report, "sauce.cook").references.is_empty());
840 }
841
842 #[test]
846 fn a_reference_made_twice_is_listed_twice() {
847 let dir = tempfile::TempDir::new().unwrap();
848 let base = base(&dir);
849 write(&base.join("sauce.cook"), CLEAN);
850 write(
851 &base.join("dup.cook"),
852 "---\ntitle: Dup\n---\n\nMake @./sauce{}, then more @./sauce{}.\n",
853 );
854
855 let report = run(&base);
856 assert_eq!(
857 recipe(&report, "dup.cook").references,
858 ["./sauce", "./sauce"]
859 );
860 }
861
862 #[test]
867 fn a_recipe_with_errors_contributes_no_references() {
868 let dir = tempfile::TempDir::new().unwrap();
869 let base = base(&dir);
870 write(
871 &base.join("broken_ref.cook"),
872 "---\ntitle: Broken\n---\n\nMake @./sauce{} and add @{1%tsp}.\n",
873 );
874
875 let report = run(&base);
876 let broken = recipe(&report, "broken_ref.cook");
877 assert_eq!(broken.count(Severity::Error), 1, "{:?}", broken.diagnostics);
878 assert!(
879 broken.references.is_empty(),
880 "expected no references, got {:?}",
881 broken.references
882 );
883 assert!(report.references().is_empty());
884 }
885
886 #[test]
889 fn the_reference_map_holds_only_recipes_that_reference_something() {
890 let dir = fixture();
891 let report = run(&base(&dir));
892 let references = report.references();
893
894 assert_eq!(
895 references.keys().copied().collect::<Vec<_>>(),
896 [Utf8Path::new("with_ref.cook")]
897 );
898 assert_eq!(references[Utf8Path::new("with_ref.cook")].len(), 2);
899 }
900
901 #[test]
907 fn a_file_that_cannot_be_read_is_one_error_and_does_not_end_the_walk() {
908 let dir = fixture();
909 let base = base(&dir);
910 std::fs::write(
911 base.join("bad_bytes.cook"),
912 b"---\ntitle: Bad Bytes\n---\n\nBoil @water{1%l} \xFF.\n",
913 )
914 .unwrap();
915
916 let report = run(&base);
917 let unreadable = recipe(&report, "bad_bytes.cook");
918
919 assert_eq!(unreadable.diagnostics.len(), 1);
920 assert_eq!(unreadable.diagnostics[0].severity, Severity::Error);
921 assert!(
922 unreadable.diagnostics[0]
923 .message
924 .starts_with("Failed to read file"),
925 "{:?}",
926 unreadable.diagnostics[0]
927 );
928 assert!(
929 unreadable.rendered.is_empty(),
930 "there is no source to quote: {:?}",
931 unreadable.rendered
932 );
933 assert_eq!(unreadable.references, Vec::<String>::new());
934
935 assert_eq!(report.total_recipes(), 6);
937 assert_eq!(report.total_errors(), 3);
938 assert_eq!(report.recipes_with_errors(), 2);
939 assert_eq!(recipe(&report, "broken.cook").diagnostics.len(), 2);
941 assert!(recipe(&report, "sauce.cook").diagnostics.is_empty());
942 }
943
944 #[test]
946 fn a_collection_full_of_errors_still_validates_successfully() {
947 let dir = tempfile::TempDir::new().unwrap();
948 write(&base(&dir).join("broken.cook"), BROKEN);
949
950 let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default())
951 .expect("errors in recipes are data, not a failed command");
952
953 assert_eq!(outcome.value.total_errors(), 2);
954 assert!(
955 outcome.has_errors(),
956 "the outcome must carry the errors it found: {:?}",
957 outcome.diagnostics
958 );
959 assert_eq!(
960 outcome.diagnostics.len(),
961 2,
962 "every diagnostic in the report, flat"
963 );
964 }
965
966 #[test]
968 fn a_clean_collection_has_no_diagnostics_at_all() {
969 let dir = tempfile::TempDir::new().unwrap();
970 write(&base(&dir).join("sauce.cook"), CLEAN);
971
972 let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default()).unwrap();
973 assert!(!outcome.has_errors());
974 assert!(outcome.diagnostics.is_empty());
975 assert_eq!(outcome.value.total_errors(), 0);
976 assert_eq!(outcome.value.total_warnings(), 0);
977 assert_eq!(outcome.value.total_recipes(), 1);
978 }
979
980 #[test]
981 fn style_decides_whether_the_rendered_report_is_coloured() {
982 let dir = fixture();
983 let base = base(&dir);
984
985 let plain = run_styled(&base, Style::Plain);
986 let plain = &recipe(&plain, "broken.cook").rendered;
987 assert!(
988 !plain.contains('\u{1b}'),
989 "Style::Plain must emit no escape codes: {plain:?}"
990 );
991
992 let coloured = run_styled(&base, Style::Ansi);
993 let coloured = &recipe(&coloured, "broken.cook").rendered;
994 assert!(
995 coloured.contains('\u{1b}'),
996 "Style::Ansi must emit escape codes: {coloured:?}"
997 );
998 assert_eq!(
999 *plain,
1000 anstream::adapter::strip_str(coloured).to_string(),
1001 "the two must differ only in the escape codes"
1002 );
1003 }
1004
1005 #[test]
1008 fn the_default_style_is_plain() {
1009 assert_eq!(ValidateRequest::default().style, Style::Plain);
1010 }
1011
1012 #[test]
1013 fn base_dir_overrides_the_context_base_path() {
1014 let validated = fixture();
1015 let ignored = tempfile::TempDir::new().unwrap();
1016 write(&base(&ignored).join("decoy.cook"), BROKEN);
1017
1018 let report = validate(
1019 &Context::new(base(&ignored)),
1020 ValidateRequest {
1021 base_dir: Some(base(&validated)),
1022 style: Style::Plain,
1023 },
1024 )
1025 .expect("validation succeeds")
1026 .into_value();
1027
1028 assert_eq!(report.total_recipes(), 5);
1029 assert!(
1030 !paths(&report).contains(&Utf8PathBuf::from("decoy.cook")),
1031 "{:?}",
1032 paths(&report)
1033 );
1034 }
1035
1036 #[test]
1037 fn without_a_base_dir_the_context_base_path_is_validated() {
1038 let dir = fixture();
1039 assert_eq!(run(&base(&dir)).total_recipes(), 5);
1040 }
1041
1042 #[test]
1047 fn recipes_come_back_in_path_order_every_time() {
1048 let dir = fixture();
1049 let base = base(&dir);
1050 let expected = paths(&run(&base));
1051 let mut sorted = expected.clone();
1052 sorted.sort();
1053 assert_eq!(expected, sorted);
1054
1055 for _ in 0..8 {
1056 assert_eq!(paths(&run(&base)), expected, "order must not vary");
1057 }
1058 }
1059
1060 #[test]
1061 fn an_empty_directory_validates_to_an_empty_report() {
1062 let dir = tempfile::TempDir::new().unwrap();
1063 let report = run(&base(&dir));
1064 assert_eq!(report.total_recipes(), 0);
1065 assert!(report.references().is_empty());
1066 }
1067
1068 #[test]
1071 fn a_root_that_does_not_exist_is_reported() {
1072 let dir = tempfile::TempDir::new().unwrap();
1073 let missing = base(&dir).join("nope");
1074
1075 match validate(&Context::new(missing.clone()), ValidateRequest::default()) {
1076 Err(CoreError::Search { base_dir, message }) => {
1077 assert_eq!(base_dir, missing);
1078 assert_eq!(message, "no such directory");
1079 }
1080 other => panic!(
1081 "expected CoreError::Search, got {:?}",
1082 other.map(|o| o.value)
1083 ),
1084 }
1085 }
1086
1087 #[test]
1088 fn a_root_that_is_a_file_is_reported() {
1089 let dir = tempfile::TempDir::new().unwrap();
1090 let file = base(&dir).join("sauce.cook");
1091 write(&file, CLEAN);
1092
1093 match validate(&Context::new(file.clone()), ValidateRequest::default()) {
1094 Err(CoreError::Search { base_dir, message }) => {
1095 assert_eq!(base_dir, file);
1096 assert_eq!(message, "not a directory");
1097 }
1098 other => panic!(
1099 "expected CoreError::Search, got {:?}",
1100 other.map(|o| o.value)
1101 ),
1102 }
1103 }
1104
1105 #[test]
1108 fn a_root_that_is_not_a_valid_glob_pattern_is_reported() {
1109 let dir = tempfile::TempDir::new().unwrap();
1110 let root = base(&dir).join("re[ci");
1111 std::fs::create_dir(&root).unwrap();
1112 write(&root.join("sauce.cook"), CLEAN);
1113
1114 match validate(&Context::new(root.clone()), ValidateRequest::default()) {
1115 Err(CoreError::Search { base_dir, message }) => {
1116 assert_eq!(base_dir, root);
1117 assert!(
1118 message.contains("attern"),
1119 "the cause must survive: {message}"
1120 );
1121 }
1122 other => panic!(
1123 "expected CoreError::Search, got {:?}",
1124 other.map(|o| o.value)
1125 ),
1126 }
1127 }
1128
1129 #[test]
1132 fn a_listing_failure_names_the_file_rather_than_the_root() {
1133 let root = Utf8Path::new("/recipes");
1134
1135 let unusable = tree_error(
1136 TreeError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
1137 "bad front matter".to_string(),
1138 )),
1139 root,
1140 );
1141 match unusable {
1142 CoreError::Io { path, source } => {
1143 assert_eq!(path, root);
1144 assert!(
1145 source.to_string().contains("bad front matter"),
1146 "the cause must survive: {source}"
1147 );
1148 }
1149 other => panic!("expected CoreError::Io, got {other:?}"),
1150 }
1151
1152 let unstrippable = tree_error(
1153 TreeError::StripPrefixError("recipes/soup.cook".to_string()),
1154 root,
1155 );
1156 match unstrippable {
1157 CoreError::Search { base_dir, message } => {
1158 assert_eq!(base_dir, root);
1159 assert!(message.contains("recipes/soup.cook"), "{message}");
1160 }
1161 other => panic!("expected CoreError::Search, got {other:?}"),
1162 }
1163 }
1164
1165 #[test]
1166 fn a_path_under_the_root_is_stripped_and_one_outside_is_left_alone() {
1167 assert_eq!(
1168 relative_to(
1169 Utf8Path::new("/recipes"),
1170 Utf8Path::new("/recipes/Breakfast/pancakes.cook")
1171 ),
1172 "Breakfast/pancakes.cook"
1173 );
1174 assert_eq!(
1175 relative_to(
1176 Utf8Path::new("./recipes"),
1177 Utf8Path::new("recipes/soup.cook")
1178 ),
1179 "recipes/soup.cook"
1180 );
1181 }
1182
1183 fn broken(report: &ValidationReport) -> Vec<(String, Vec<String>)> {
1190 broken_references(report)
1191 .into_iter()
1192 .map(|(recipe, missing)| (recipe.to_string(), missing))
1193 .collect()
1194 }
1195
1196 #[test]
1197 fn the_report_records_the_root_it_was_validated_against() {
1198 let dir = fixture();
1199 assert_eq!(run(&base(&dir)).base_dir, base(&dir));
1200
1201 let elsewhere = tempfile::TempDir::new().unwrap();
1202 let report = validate(
1203 &Context::new(base(&elsewhere)),
1204 ValidateRequest {
1205 base_dir: Some(base(&dir)),
1206 style: Style::Plain,
1207 },
1208 )
1209 .expect("validation succeeds")
1210 .into_value();
1211 assert_eq!(
1212 report.base_dir,
1213 base(&dir),
1214 "the root that was walked, not the context's"
1215 );
1216 }
1217
1218 #[test]
1221 fn only_references_that_resolve_to_nothing_are_reported() {
1222 let dir = fixture();
1223 assert_eq!(
1224 broken(&run(&base(&dir))),
1225 [(
1226 "with_ref.cook".to_string(),
1227 vec!["./nonexistent".to_string()]
1228 )]
1229 );
1230 }
1231
1232 #[test]
1233 fn a_collection_whose_references_all_resolve_reports_none() {
1234 let dir = tempfile::TempDir::new().unwrap();
1235 let base = base(&dir);
1236 write(&base.join("sauce.cook"), CLEAN);
1237 write(
1238 &base.join("dish.cook"),
1239 "---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
1240 );
1241
1242 assert!(
1243 broken_references(&run(&base)).is_empty(),
1244 "a resolvable reference must not be reported"
1245 );
1246 }
1247
1248 #[test]
1251 fn a_reference_repeated_is_reported_once_per_mention() {
1252 let dir = tempfile::TempDir::new().unwrap();
1253 let base = base(&dir);
1254 write(
1255 &base.join("dish.cook"),
1256 "---\ntitle: Dish\n---\n\nMake @./absent{}, then more @./absent{}.\n",
1257 );
1258
1259 assert_eq!(
1260 broken(&run(&base)),
1261 [(
1262 "dish.cook".to_string(),
1263 vec!["./absent".to_string(), "./absent".to_string()]
1264 )]
1265 );
1266 }
1267
1268 #[test]
1273 fn references_resolve_against_the_validated_root_from_anywhere_in_it() {
1274 let dir = tempfile::TempDir::new().unwrap();
1275 let base = base(&dir);
1276 write(&base.join("sauce.cook"), CLEAN);
1277 std::fs::create_dir(base.join("Dinner")).unwrap();
1278 write(
1279 &base.join("Dinner").join("dish.cook"),
1280 "---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
1281 );
1282
1283 assert!(
1284 broken_references(&run(&base)).is_empty(),
1285 "a nested recipe must be able to reference the root's"
1286 );
1287 }
1288
1289 #[test]
1292 fn a_collection_with_no_references_has_none_broken() {
1293 let dir = tempfile::TempDir::new().unwrap();
1294 write(&base(&dir).join("sauce.cook"), CLEAN);
1295 assert!(broken_references(&run(&base(&dir))).is_empty());
1296 }
1297
1298 fn one_recipe(text: &str) -> tempfile::TempDir {
1304 let dir = tempfile::TempDir::new().unwrap();
1305 write(&base(&dir).join("dish.cook"), text);
1306 dir
1307 }
1308
1309 fn aisle_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
1310 Context::new(base(dir)).with_aisle(ConfigSource::Inline(conf.to_string()))
1311 }
1312
1313 fn pantry_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
1314 Context::new(base(dir)).with_pantry(ConfigSource::Inline(conf.to_string()))
1315 }
1316
1317 fn checked(ctx: &Context, aisle: bool) -> Outcome<IngredientCoverage> {
1318 let request = CoverageRequest::default();
1319 if aisle {
1320 aisle_coverage(ctx, request)
1321 } else {
1322 pantry_coverage(ctx, request)
1323 }
1324 .expect("the check succeeds")
1325 }
1326
1327 fn known(coverage: &IngredientCoverage) -> Vec<&str> {
1328 coverage.known().collect()
1329 }
1330
1331 fn unknown(coverage: &IngredientCoverage) -> Vec<&str> {
1332 coverage.unknown().collect()
1333 }
1334
1335 fn all(coverage: &IngredientCoverage) -> Vec<&str> {
1336 coverage
1337 .ingredients
1338 .iter()
1339 .map(|ingredient| ingredient.name.as_str())
1340 .collect()
1341 }
1342
1343 fn recipes_for<'a>(coverage: &'a IngredientCoverage, name: &str) -> Vec<&'a Utf8Path> {
1350 coverage
1351 .ingredients
1352 .iter()
1353 .find(|ingredient| ingredient.name == name)
1354 .unwrap_or_else(|| panic!("{name} is in the coverage"))
1355 .recipes
1356 .iter()
1357 .map(Utf8PathBuf::as_path)
1358 .collect()
1359 }
1360
1361 #[test]
1362 fn an_aisle_splits_the_collection_into_categorised_and_not() {
1363 let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
1364 let coverage = checked(
1365 &aisle_ctx(&dir, "[produce]\nleek\n\n[pantry]\nsalt\n"),
1366 true,
1367 )
1368 .value;
1369
1370 assert_eq!(known(&coverage), ["leek", "salt"]);
1371 assert_eq!(unknown(&coverage), ["water"]);
1372 assert_eq!(coverage.total_ingredients(), 3);
1373 assert_eq!(coverage.total_recipes, 1);
1374 }
1375
1376 #[test]
1378 fn an_aisle_synonym_counts_as_knowing_the_ingredient() {
1379 let dir = one_recipe("Add @aubergine{1}.\n");
1380 let coverage = checked(&aisle_ctx(&dir, "[produce]\neggplant|aubergine\n"), true).value;
1381
1382 assert_eq!(known(&coverage), ["aubergine"]);
1383 assert!(unknown(&coverage).is_empty());
1384 }
1385
1386 #[test]
1387 fn a_pantry_knows_its_items_from_every_section() {
1388 let dir = one_recipe("Add @salt{1%tsp}, @milk{1%l} and @water{1%l}.\n");
1389 let conf = "[pantry]\nsalt = \"1%kg\"\n\n[dairy]\nmilk = \"1%l\"\n";
1390 let coverage = checked(&pantry_ctx(&dir, conf), false).value;
1391
1392 assert_eq!(known(&coverage), ["milk", "salt"]);
1393 assert_eq!(unknown(&coverage), ["water"]);
1394 }
1395
1396 #[test]
1399 fn a_pantry_item_that_has_run_out_still_counts_as_known() {
1400 let dir = one_recipe("Add @honey{1%tbsp}.\n");
1401 let conf = "[pantry]\nhoney = { quantity = \"0\", low = \"100%g\" }\n";
1402 assert_eq!(
1403 known(&checked(&pantry_ctx(&dir, conf), false).value),
1404 ["honey"]
1405 );
1406 }
1407
1408 #[test]
1411 fn names_are_matched_ignoring_case_and_reported_as_the_recipe_writes_them() {
1412 let dir = one_recipe("Add @Salt{1%tsp} and @PEPPER{}.\n");
1413
1414 let aisle = checked(&aisle_ctx(&dir, "[pantry]\nsalt\npepper\n"), true).value;
1415 assert_eq!(known(&aisle), ["PEPPER", "Salt"]);
1416 assert!(unknown(&aisle).is_empty());
1417
1418 let conf = "[pantry]\nsalt = \"1%kg\"\npepper = \"50%g\"\n";
1419 let pantry = checked(&pantry_ctx(&dir, conf), false).value;
1420 assert_eq!(known(&pantry), ["PEPPER", "Salt"]);
1421 assert!(unknown(&pantry).is_empty());
1422 }
1423
1424 #[test]
1429 fn a_non_ascii_name_is_matched_ignoring_case_too() {
1430 let dir = one_recipe("Add @Öl{1%tbsp} and @Ärter{100%g}.\n");
1431
1432 let aisle = checked(&aisle_ctx(&dir, "[pantry]\növerste|öl\närter\n"), true).value;
1433 assert_eq!(known(&aisle), ["Ärter", "Öl"]);
1434 assert!(unknown(&aisle).is_empty(), "{:?}", unknown(&aisle));
1435
1436 let conf = "[pantry]\n\"öl\" = \"1%l\"\n\"ärter\" = \"1%kg\"\n";
1438 assert_eq!(
1439 known(&checked(&pantry_ctx(&dir, conf), false).value),
1440 ["Ärter", "Öl"]
1441 );
1442 }
1443
1444 #[test]
1447 fn two_spellings_of_one_ingredient_are_both_listed() {
1448 let dir = tempfile::TempDir::new().unwrap();
1449 write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
1450 write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
1451
1452 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1453 assert_eq!(known(&coverage), ["Salt", "salt"]);
1454 assert_eq!(coverage.total_ingredients(), 2);
1455 }
1456
1457 #[test]
1461 fn without_a_configuration_nothing_is_known() {
1462 let dir = one_recipe("Add @salt{1%tsp}.\n");
1463 let ctx = Context::new(base(&dir));
1464
1465 for aisle in [true, false] {
1466 let coverage = checked(&ctx, aisle).value;
1467 assert_eq!(coverage.total_recipes, 1);
1468 assert!(known(&coverage).is_empty(), "aisle: {aisle}");
1469 assert_eq!(unknown(&coverage), ["salt"], "aisle: {aisle}");
1470 }
1471 }
1472
1473 #[test]
1476 fn references_to_other_recipes_are_not_ingredients() {
1477 let dir = one_recipe("Make @./sauce{} and add @water{1%l}.\n");
1478 write(&base(&dir).join("sauce.cook"), CLEAN);
1479
1480 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsauce\nwater\noil\n"), true).value;
1481 assert_eq!(
1482 all(&coverage),
1483 ["oil", "water"],
1484 "the referenced recipe's own ingredients count; the reference does not"
1485 );
1486 }
1487
1488 #[test]
1489 fn every_recipe_under_the_root_is_scanned_including_nested_ones() {
1490 let dir = one_recipe("Boil @water{1%l}.\n");
1491 std::fs::create_dir(base(&dir).join("Breakfast")).unwrap();
1492 write(
1493 &base(&dir).join("Breakfast").join("porridge.cook"),
1494 "Simmer @oats{50%g}.\n",
1495 );
1496
1497 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nwater\n"), true).value;
1498 assert_eq!(coverage.total_recipes, 2);
1499 assert_eq!(
1500 unknown(&coverage),
1501 ["oats"],
1502 "a subdirectory must be walked"
1503 );
1504 assert_eq!(
1505 recipes_for(&coverage, "oats"),
1506 [Utf8Path::new("Breakfast/porridge.cook")],
1507 "a recipe is named relative to the directory that was scanned"
1508 );
1509 }
1510
1511 #[test]
1514 fn an_ingredient_carries_every_recipe_that_writes_it() {
1515 let dir = tempfile::TempDir::new().unwrap();
1516 write(
1517 &base(&dir).join("curry.cook"),
1518 "Add @ground cumin{1%tsp}.\n",
1519 );
1520 write(&base(&dir).join("dal.cook"), "Add @ground cumin{2%tsp}.\n");
1521 write(&base(&dir).join("stew.cook"), "Add @cumin powder{1%tsp}.\n");
1522
1523 let coverage = checked(&aisle_ctx(&dir, "[spices]\nground cumin\n"), true).value;
1524
1525 assert_eq!(
1526 recipes_for(&coverage, "ground cumin"),
1527 [Utf8Path::new("curry.cook"), Utf8Path::new("dal.cook")],
1528 "shared by two recipes, listed once each, in path order"
1529 );
1530 assert_eq!(
1531 recipes_for(&coverage, "cumin powder"),
1532 [Utf8Path::new("stew.cook")]
1533 );
1534 }
1535
1536 #[test]
1539 fn a_recipe_using_an_ingredient_twice_is_listed_once() {
1540 let dir = one_recipe("Add @salt{1%tsp}, then more @salt{1%tsp}.\n");
1541 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1542 assert_eq!(recipes_for(&coverage, "salt"), [Utf8Path::new("dish.cook")]);
1543 }
1544
1545 #[test]
1548 fn each_spelling_keeps_its_own_recipes() {
1549 let dir = tempfile::TempDir::new().unwrap();
1550 write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
1551 write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
1552
1553 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1554 assert_eq!(recipes_for(&coverage, "Salt"), [Utf8Path::new("a.cook")]);
1555 assert_eq!(recipes_for(&coverage, "salt"), [Utf8Path::new("b.cook")]);
1556 }
1557
1558 #[test]
1561 fn the_pantry_check_lists_recipes_as_well() {
1562 let dir = tempfile::TempDir::new().unwrap();
1563 write(&base(&dir).join("a.cook"), "Add @rice{100%g}.\n");
1564 write(&base(&dir).join("b.cook"), "Add @rice{200%g}.\n");
1565
1566 let coverage = checked(&pantry_ctx(&dir, "[pantry]\nrice = \"5%kg\"\n"), false).value;
1567 assert_eq!(known(&coverage), ["rice"]);
1568 assert_eq!(
1569 recipes_for(&coverage, "rice"),
1570 [Utf8Path::new("a.cook"), Utf8Path::new("b.cook")]
1571 );
1572 }
1573
1574 #[test]
1577 fn a_recipe_that_cannot_be_parsed_is_counted_but_contributes_nothing() {
1578 let dir = one_recipe("Add @salt{1%tsp}.\n");
1579 write(&base(&dir).join("broken.cook"), BROKEN);
1580
1581 let outcome = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true);
1582 assert_eq!(outcome.value.total_recipes, 2);
1583 assert_eq!(outcome.value.total_ingredients(), 1);
1584 assert_eq!(known(&outcome.value), ["salt"]);
1585
1586 let skipped = outcome
1587 .diagnostics
1588 .iter()
1589 .find(|d| d.message.contains("broken.cook"))
1590 .unwrap_or_else(|| {
1591 panic!(
1592 "the skipped recipe must be named: {:?}",
1593 outcome.diagnostics
1594 )
1595 });
1596 assert_eq!(skipped.severity, Severity::Warning);
1599 assert!(!outcome.has_errors());
1600 }
1601
1602 #[test]
1611 fn ingredients_come_back_in_code_point_order_every_time() {
1612 let dir = tempfile::TempDir::new().unwrap();
1613 for (file, ingredient) in [
1614 ("a", "yeast"),
1615 ("b", "flour"),
1616 ("c", "sugar"),
1617 ("d", "Zucchini"),
1618 ("e", "apple"),
1619 ("f", "Beetroot"),
1620 ] {
1621 write(
1622 &base(&dir).join(format!("{file}.cook")),
1623 &format!("Add @{ingredient}{{1}}.\n"),
1624 );
1625 }
1626 let ctx = aisle_ctx(&dir, "[pantry]\nflour\n");
1627
1628 for _ in 0..8 {
1629 let coverage = checked(&ctx, true).value;
1630 assert_eq!(
1631 all(&coverage),
1632 ["Beetroot", "Zucchini", "apple", "flour", "sugar", "yeast"],
1633 "capitalised names sort first, as CookCLI has always printed them"
1634 );
1635 assert_eq!(
1636 unknown(&coverage),
1637 ["Beetroot", "Zucchini", "apple", "sugar", "yeast"]
1638 );
1639 }
1640 }
1641
1642 #[test]
1645 fn the_two_views_partition_the_ingredients() {
1646 let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
1647 let coverage = checked(&aisle_ctx(&dir, "[produce]\nleek\n"), true).value;
1648
1649 let mut both: Vec<&str> = known(&coverage)
1650 .into_iter()
1651 .chain(unknown(&coverage))
1652 .collect();
1653 both.sort_unstable();
1654 assert_eq!(both, all(&coverage));
1655 assert_eq!(
1656 known(&coverage).len() + unknown(&coverage).len(),
1657 coverage.total_ingredients()
1658 );
1659 }
1660
1661 #[test]
1662 fn a_coverage_base_dir_overrides_the_context_base_path() {
1663 let scanned = one_recipe("Add @salt{1%tsp}.\n");
1664 let ignored = one_recipe("Add @decoy{1}.\n");
1665
1666 let coverage = aisle_coverage(
1667 &aisle_ctx(&ignored, "[pantry]\nsalt\n"),
1668 CoverageRequest {
1669 base_dir: Some(base(&scanned)),
1670 },
1671 )
1672 .expect("the check succeeds")
1673 .into_value();
1674
1675 assert_eq!(known(&coverage), ["salt"]);
1676 assert!(!all(&coverage).contains(&"decoy"), "{:?}", all(&coverage));
1677 }
1678
1679 #[test]
1682 fn a_warning_in_the_aisle_configuration_comes_back_as_a_diagnostic() {
1683 let dir = one_recipe("Add @leek{1}.\n");
1684 let outcome = checked(&aisle_ctx(&dir, "[produce]\nleek\n\n[dairy]\nleek\n"), true);
1685
1686 assert!(
1687 outcome
1688 .diagnostics
1689 .iter()
1690 .any(|d| d.message.contains("Duplicate ingredient")),
1691 "{:?}",
1692 outcome.diagnostics
1693 );
1694 assert_eq!(known(&outcome.value), ["leek"]);
1696 }
1697
1698 #[test]
1702 fn a_warning_in_the_pantry_configuration_comes_back_as_a_diagnostic() {
1703 let dir = one_recipe("Add @ice{1}.\n");
1704 let conf = "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n";
1705 let outcome = checked(&pantry_ctx(&dir, conf), false);
1706
1707 assert!(
1708 !outcome.diagnostics.is_empty(),
1709 "the unknown attribute must be reported"
1710 );
1711 for diagnostic in &outcome.diagnostics {
1712 assert_eq!(diagnostic.severity, Severity::Warning, "{diagnostic:?}");
1713 }
1714 assert!(!outcome.has_errors());
1715 assert_eq!(known(&outcome.value), ["ice"]);
1717 }
1718
1719 #[test]
1722 fn a_configuration_warning_is_located_in_the_file_it_came_from() {
1723 let dir = one_recipe("Add @ice{1}.\n");
1724 let path = base(&dir).join("pantry.conf");
1725 write(
1726 &path,
1727 "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n",
1728 );
1729
1730 let ctx = Context::new(base(&dir)).with_pantry(ConfigSource::Path(path.clone()));
1731 let outcome =
1732 pantry_coverage(&ctx, CoverageRequest::default()).expect("the check succeeds");
1733
1734 let located = outcome
1735 .diagnostics
1736 .iter()
1737 .find(|d| d.location.is_some())
1738 .unwrap_or_else(|| panic!("expected a located warning: {:?}", outcome.diagnostics));
1739 assert_eq!(
1740 located.location.as_ref().and_then(|l| l.file.as_deref()),
1741 Some(path.as_path())
1742 );
1743 }
1744
1745 #[test]
1749 fn a_configuration_that_cannot_be_read_is_reported() {
1750 let dir = one_recipe("Add @salt{1%tsp}.\n");
1751 let missing = base(&dir).join("config").join("aisle.conf");
1752
1753 match aisle_coverage(
1754 &Context::new(base(&dir)).with_aisle(ConfigSource::Path(missing.clone())),
1755 CoverageRequest::default(),
1756 ) {
1757 Err(CoreError::Io { path, source }) => {
1758 assert_eq!(path, missing);
1759 assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
1760 }
1761 other => panic!("expected CoreError::Io, got {:?}", other.map(|o| o.value)),
1762 }
1763 }
1764
1765 #[test]
1770 fn a_pantry_that_cannot_be_parsed_at_all_is_reported_as_pantry_load_reports_it() {
1771 let dir = one_recipe("Add @salt{1%tsp}.\n");
1772 let ctx = pantry_ctx(&dir, "this is not toml [");
1773
1774 let from_load = match crate::pantry::load(&ctx) {
1775 Err(CoreError::Config { message, .. }) => message,
1776 other => panic!("expected CoreError::Config from load, got {other:?}"),
1777 };
1778
1779 match pantry_coverage(&ctx, CoverageRequest::default()) {
1780 Err(CoreError::Config { path, message }) => {
1781 assert_eq!(path, None, "an inline configuration has no path");
1782 assert!(
1785 message.contains("TOML parse error"),
1786 "the cause must survive: {message}"
1787 );
1788 assert_eq!(message, from_load, "the two commands must agree");
1789 }
1790 other => panic!(
1791 "expected CoreError::Config, got {:?}",
1792 other.map(|o| o.value)
1793 ),
1794 }
1795 }
1796
1797 #[test]
1799 fn a_root_that_does_not_exist_is_reported_by_a_coverage_check() {
1800 let dir = tempfile::TempDir::new().unwrap();
1801 let missing = base(&dir).join("nope");
1802
1803 match pantry_coverage(
1804 &Context::new(missing.clone()).with_pantry(ConfigSource::Inline(String::new())),
1805 CoverageRequest::default(),
1806 ) {
1807 Err(CoreError::Search { base_dir, message }) => {
1808 assert_eq!(base_dir, missing);
1809 assert_eq!(message, "no such directory");
1810 }
1811 other => panic!(
1812 "expected CoreError::Search, got {:?}",
1813 other.map(|o| o.value)
1814 ),
1815 }
1816 }
1817
1818 #[test]
1819 fn an_empty_collection_covers_nothing() {
1820 let dir = tempfile::TempDir::new().unwrap();
1821 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1822 assert_eq!(coverage.total_recipes, 0);
1823 assert_eq!(coverage.total_ingredients(), 0);
1824 assert!(known(&coverage).is_empty());
1825 assert!(unknown(&coverage).is_empty());
1826 }
1827}