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>> {
379 report
380 .references()
381 .into_iter()
382 .filter_map(|(recipe, references)| {
383 let from = recipe.parent().unwrap_or(Utf8Path::new("")).to_owned();
387 let broken: Vec<String> = references
388 .iter()
389 .filter(|reference| {
390 crate::find::resolve_reference(&from, reference).is_none_or(|path| {
391 crate::find::get_recipe(&report.base_dir, path.as_str()).is_err()
392 })
393 })
394 .cloned()
395 .collect();
396 (!broken.is_empty()).then_some((recipe, broken))
397 })
398 .collect()
399}
400
401#[derive(Debug, Clone, Default)]
410pub struct CoverageRequest {
411 pub base_dir: Option<Utf8PathBuf>,
413}
414
415#[non_exhaustive]
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct CheckedIngredient {
422 pub name: String,
426 pub known: bool,
428 pub recipes: Vec<Utf8PathBuf>,
438}
439
440#[non_exhaustive]
451#[derive(Debug, Clone, Default, PartialEq, Eq)]
452pub struct IngredientCoverage {
453 pub total_recipes: usize,
457 pub ingredients: Vec<CheckedIngredient>,
468}
469
470impl IngredientCoverage {
471 pub fn total_ingredients(&self) -> usize {
473 self.ingredients.len()
474 }
475
476 pub fn known(&self) -> impl Iterator<Item = &str> {
479 self.known_entries()
480 .map(|ingredient| ingredient.name.as_str())
481 }
482
483 pub fn unknown(&self) -> impl Iterator<Item = &str> {
490 self.unknown_entries()
491 .map(|ingredient| ingredient.name.as_str())
492 }
493
494 pub fn known_entries(&self) -> impl Iterator<Item = &CheckedIngredient> {
497 self.filtered(true)
498 }
499
500 pub fn unknown_entries(&self) -> impl Iterator<Item = &CheckedIngredient> {
507 self.filtered(false)
508 }
509
510 fn filtered(&self, known: bool) -> impl Iterator<Item = &CheckedIngredient> {
511 self.ingredients
512 .iter()
513 .filter(move |ingredient| ingredient.known == known)
514 }
515}
516
517pub fn aisle_coverage(
546 ctx: &Context,
547 req: CoverageRequest,
548) -> Result<Outcome<IngredientCoverage>, CoreError> {
549 let source = ctx.aisle();
550 let mut diagnostics = Vec::new();
551 let mut known = BTreeSet::new();
552
553 if let Some(text) = source.read()? {
554 let parsed = cooklang::aisle::parse_lenient(&text);
555 diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
556 let conf = parsed
557 .output()
558 .ok_or_else(|| config_error(source, "aisle", &diagnostics))?;
559 known.extend(conf.ingredients_info().into_keys());
561 }
562
563 coverage(ctx, req, &known, diagnostics)
564}
565
566pub fn pantry_coverage(
585 ctx: &Context,
586 req: CoverageRequest,
587) -> Result<Outcome<IngredientCoverage>, CoreError> {
588 let source = ctx.pantry();
589 let mut diagnostics = Vec::new();
590 let mut known = BTreeSet::new();
591
592 if let Some(text) = source.read()? {
593 let parsed = cooklang::pantry::parse_lenient(&text);
594 diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
595 let conf = parsed
596 .output()
597 .ok_or_else(|| config_error(source, "pantry", &diagnostics))?;
598 known.extend(conf.all_items().map(|item| item.name().to_lowercase()));
599 }
600
601 coverage(ctx, req, &known, diagnostics)
602}
603
604fn config_error(source: &ConfigSource, kind: &str, diagnostics: &[Diagnostic]) -> CoreError {
610 CoreError::Config {
611 path: source.path().map(ToOwned::to_owned),
612 message: parse_failure(diagnostics, kind),
613 }
614}
615
616fn coverage(
619 ctx: &Context,
620 req: CoverageRequest,
621 known: &BTreeSet<String>,
622 mut diagnostics: Vec<Diagnostic>,
623) -> Result<Outcome<IngredientCoverage>, CoreError> {
624 let base_dir = req
625 .base_dir
626 .unwrap_or_else(|| ctx.base_path().to_path_buf());
627
628 let tree = build_tree(&base_dir)?;
629 let entries = walk(&tree);
630 let total_recipes = entries.len();
631
632 let mut names: BTreeMap<String, BTreeSet<Utf8PathBuf>> = BTreeMap::new();
637 for entry in entries {
638 let Some(recipe) = parse_or_skip(entry, &mut diagnostics) else {
639 continue;
640 };
641 let path = entry
646 .path()
647 .map(|path| relative_to(&base_dir, path))
648 .or_else(|| entry.name().clone().map(Utf8PathBuf::from))
649 .unwrap_or_else(|| Utf8PathBuf::from("unknown"));
650 for name in listed_ingredients(&recipe) {
651 names.entry(name).or_default().insert(path.clone());
652 }
653 }
654
655 let ingredients = names
656 .into_iter()
657 .map(|(name, recipes)| CheckedIngredient {
658 known: known.contains(&name.to_lowercase()),
659 name,
660 recipes: recipes.into_iter().collect(),
661 })
662 .collect();
663
664 Ok(Outcome::with_diagnostics(
665 IngredientCoverage {
666 total_recipes,
667 ingredients,
668 },
669 diagnostics,
670 ))
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use crate::find::tree_error;
677 use cooklang_find::tree::TreeError;
678
679 const CLEAN: &str = "---\ntitle: Basic Sauce\n---\n\nHeat @oil{2%tbsp} in a #pan.\n";
680 const DEPRECATED: &str = ">> title: Old Style\n\nBoil @water{1%l}.\n";
682 const BROKEN: &str = "---\ntitle: Broken\n---\n\nAdd @{1%tsp} and @{2%tsp}.\n";
684
685 fn fixture() -> tempfile::TempDir {
688 let dir = tempfile::TempDir::new().unwrap();
689 let base = base(&dir);
690 std::fs::create_dir(base.join("Breakfast")).unwrap();
691 write(
692 &base.join("Breakfast").join("pancakes.cook"),
693 "---\ntitle: Pancakes\n---\n\nMix @flour{2%cups}.\n",
694 );
695 write(&base.join("sauce.cook"), CLEAN);
696 write(
697 &base.join("with_ref.cook"),
698 "---\ntitle: With Reference\n---\n\nMake @./sauce{} and @./nonexistent{}.\n",
699 );
700 write(&base.join("deprecated.cook"), DEPRECATED);
701 write(&base.join("broken.cook"), BROKEN);
702 dir
703 }
704
705 fn write(path: &Utf8Path, text: &str) {
706 std::fs::write(path, text).unwrap();
707 }
708
709 fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
710 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
711 }
712
713 fn run(base_dir: &Utf8Path) -> ValidationReport {
715 run_styled(base_dir, Style::Plain)
716 }
717
718 fn run_styled(base_dir: &Utf8Path, style: Style) -> ValidationReport {
719 validate(
720 &Context::new(base_dir.to_owned()),
721 ValidateRequest {
722 base_dir: None,
723 style,
724 },
725 )
726 .expect("validation succeeds")
727 .into_value()
728 }
729
730 fn paths(report: &ValidationReport) -> Vec<Utf8PathBuf> {
739 report.recipes.iter().map(|r| r.path.clone()).collect()
740 }
741
742 fn recipe<'a>(report: &'a ValidationReport, path: &str) -> &'a RecipeValidation {
743 report
744 .recipes
745 .iter()
746 .find(|r| r.path == path)
747 .unwrap_or_else(|| panic!("{path} missing from {:?}", paths(report)))
748 }
749
750 #[test]
753 fn every_recipe_under_the_root_is_reported_including_nested_ones() {
754 let dir = fixture();
755 let report = run(&base(&dir));
756 assert_eq!(
757 paths(&report),
758 [
759 "Breakfast/pancakes.cook",
760 "broken.cook",
761 "deprecated.cook",
762 "sauce.cook",
763 "with_ref.cook",
764 ]
765 );
766 assert_eq!(report.total_recipes(), 5);
767 }
768
769 #[test]
772 fn totals_count_diagnostics_and_the_recipes_carrying_them() {
773 let dir = fixture();
774 let report = run(&base(&dir));
775
776 assert_eq!(report.total_recipes(), 5);
777 assert_eq!(report.total_errors(), 2, "both errors in broken.cook");
778 assert_eq!(report.recipes_with_errors(), 1);
779 assert_eq!(report.total_warnings(), 1, "deprecated.cook warns once");
780 assert_eq!(report.recipes_with_warnings(), 1);
781 }
782
783 #[test]
784 fn a_failing_recipe_carries_both_diagnostics_and_a_rendered_report() {
785 let dir = fixture();
786 let report = run(&base(&dir));
787 let broken = recipe(&report, "broken.cook");
788
789 assert_eq!(broken.diagnostics.len(), 2);
790 for d in &broken.diagnostics {
791 assert_eq!(d.severity, Severity::Error, "expected errors: {d:?}");
792 }
793 let location = broken.diagnostics[0]
795 .location
796 .as_ref()
797 .expect("location set");
798 assert_eq!(location.file.as_deref(), Some(Utf8Path::new("broken.cook")));
799 assert!(location.span.is_some(), "an error must carry its span");
800
801 assert!(
804 broken.rendered.contains("broken.cook"),
805 "report should name the file: {}",
806 broken.rendered
807 );
808 assert!(
809 broken.rendered.contains("Add @{1%tsp} and @{2%tsp}."),
810 "report should quote the source line: {}",
811 broken.rendered
812 );
813 }
814
815 #[test]
816 fn a_warning_is_reported_as_a_warning_not_an_error() {
817 let dir = fixture();
818 let report = run(&base(&dir));
819 let deprecated = recipe(&report, "deprecated.cook");
820
821 assert_eq!(deprecated.diagnostics.len(), 1);
822 assert_eq!(deprecated.diagnostics[0].severity, Severity::Warning);
823 assert!(
824 !deprecated.rendered.is_empty(),
825 "a warning is worth showing"
826 );
827 }
828
829 #[test]
830 fn a_clean_recipe_carries_neither_diagnostics_nor_a_rendered_report() {
831 let dir = fixture();
832 let report = run(&base(&dir));
833 let clean = recipe(&report, "sauce.cook");
834
835 assert!(clean.diagnostics.is_empty(), "{:?}", clean.diagnostics);
836 assert!(clean.rendered.is_empty(), "{:?}", clean.rendered);
837 }
838
839 #[test]
840 fn references_are_collected_as_they_are_written() {
841 let dir = fixture();
842 let report = run(&base(&dir));
843
844 assert_eq!(
845 recipe(&report, "with_ref.cook").references,
846 ["./sauce", "./nonexistent"],
847 "in source order, unresolved"
848 );
849 assert!(recipe(&report, "sauce.cook").references.is_empty());
850 }
851
852 #[test]
856 fn a_reference_made_twice_is_listed_twice() {
857 let dir = tempfile::TempDir::new().unwrap();
858 let base = base(&dir);
859 write(&base.join("sauce.cook"), CLEAN);
860 write(
861 &base.join("dup.cook"),
862 "---\ntitle: Dup\n---\n\nMake @./sauce{}, then more @./sauce{}.\n",
863 );
864
865 let report = run(&base);
866 assert_eq!(
867 recipe(&report, "dup.cook").references,
868 ["./sauce", "./sauce"]
869 );
870 }
871
872 #[test]
877 fn a_recipe_with_errors_contributes_no_references() {
878 let dir = tempfile::TempDir::new().unwrap();
879 let base = base(&dir);
880 write(
881 &base.join("broken_ref.cook"),
882 "---\ntitle: Broken\n---\n\nMake @./sauce{} and add @{1%tsp}.\n",
883 );
884
885 let report = run(&base);
886 let broken = recipe(&report, "broken_ref.cook");
887 assert_eq!(broken.count(Severity::Error), 1, "{:?}", broken.diagnostics);
888 assert!(
889 broken.references.is_empty(),
890 "expected no references, got {:?}",
891 broken.references
892 );
893 assert!(report.references().is_empty());
894 }
895
896 #[test]
899 fn the_reference_map_holds_only_recipes_that_reference_something() {
900 let dir = fixture();
901 let report = run(&base(&dir));
902 let references = report.references();
903
904 assert_eq!(
905 references.keys().copied().collect::<Vec<_>>(),
906 [Utf8Path::new("with_ref.cook")]
907 );
908 assert_eq!(references[Utf8Path::new("with_ref.cook")].len(), 2);
909 }
910
911 #[test]
917 fn a_file_that_cannot_be_read_is_one_error_and_does_not_end_the_walk() {
918 let dir = fixture();
919 let base = base(&dir);
920 std::fs::write(
921 base.join("bad_bytes.cook"),
922 b"---\ntitle: Bad Bytes\n---\n\nBoil @water{1%l} \xFF.\n",
923 )
924 .unwrap();
925
926 let report = run(&base);
927 let unreadable = recipe(&report, "bad_bytes.cook");
928
929 assert_eq!(unreadable.diagnostics.len(), 1);
930 assert_eq!(unreadable.diagnostics[0].severity, Severity::Error);
931 assert!(
932 unreadable.diagnostics[0]
933 .message
934 .starts_with("Failed to read file"),
935 "{:?}",
936 unreadable.diagnostics[0]
937 );
938 assert!(
939 unreadable.rendered.is_empty(),
940 "there is no source to quote: {:?}",
941 unreadable.rendered
942 );
943 assert_eq!(unreadable.references, Vec::<String>::new());
944
945 assert_eq!(report.total_recipes(), 6);
947 assert_eq!(report.total_errors(), 3);
948 assert_eq!(report.recipes_with_errors(), 2);
949 assert_eq!(recipe(&report, "broken.cook").diagnostics.len(), 2);
951 assert!(recipe(&report, "sauce.cook").diagnostics.is_empty());
952 }
953
954 #[test]
956 fn a_collection_full_of_errors_still_validates_successfully() {
957 let dir = tempfile::TempDir::new().unwrap();
958 write(&base(&dir).join("broken.cook"), BROKEN);
959
960 let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default())
961 .expect("errors in recipes are data, not a failed command");
962
963 assert_eq!(outcome.value.total_errors(), 2);
964 assert!(
965 outcome.has_errors(),
966 "the outcome must carry the errors it found: {:?}",
967 outcome.diagnostics
968 );
969 assert_eq!(
970 outcome.diagnostics.len(),
971 2,
972 "every diagnostic in the report, flat"
973 );
974 }
975
976 #[test]
978 fn a_clean_collection_has_no_diagnostics_at_all() {
979 let dir = tempfile::TempDir::new().unwrap();
980 write(&base(&dir).join("sauce.cook"), CLEAN);
981
982 let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default()).unwrap();
983 assert!(!outcome.has_errors());
984 assert!(outcome.diagnostics.is_empty());
985 assert_eq!(outcome.value.total_errors(), 0);
986 assert_eq!(outcome.value.total_warnings(), 0);
987 assert_eq!(outcome.value.total_recipes(), 1);
988 }
989
990 #[test]
991 fn style_decides_whether_the_rendered_report_is_coloured() {
992 let dir = fixture();
993 let base = base(&dir);
994
995 let plain = run_styled(&base, Style::Plain);
996 let plain = &recipe(&plain, "broken.cook").rendered;
997 assert!(
998 !plain.contains('\u{1b}'),
999 "Style::Plain must emit no escape codes: {plain:?}"
1000 );
1001
1002 let coloured = run_styled(&base, Style::Ansi);
1003 let coloured = &recipe(&coloured, "broken.cook").rendered;
1004 assert!(
1005 coloured.contains('\u{1b}'),
1006 "Style::Ansi must emit escape codes: {coloured:?}"
1007 );
1008 assert_eq!(
1009 *plain,
1010 anstream::adapter::strip_str(coloured).to_string(),
1011 "the two must differ only in the escape codes"
1012 );
1013 }
1014
1015 #[test]
1018 fn the_default_style_is_plain() {
1019 assert_eq!(ValidateRequest::default().style, Style::Plain);
1020 }
1021
1022 #[test]
1023 fn base_dir_overrides_the_context_base_path() {
1024 let validated = fixture();
1025 let ignored = tempfile::TempDir::new().unwrap();
1026 write(&base(&ignored).join("decoy.cook"), BROKEN);
1027
1028 let report = validate(
1029 &Context::new(base(&ignored)),
1030 ValidateRequest {
1031 base_dir: Some(base(&validated)),
1032 style: Style::Plain,
1033 },
1034 )
1035 .expect("validation succeeds")
1036 .into_value();
1037
1038 assert_eq!(report.total_recipes(), 5);
1039 assert!(
1040 !paths(&report).contains(&Utf8PathBuf::from("decoy.cook")),
1041 "{:?}",
1042 paths(&report)
1043 );
1044 }
1045
1046 #[test]
1047 fn without_a_base_dir_the_context_base_path_is_validated() {
1048 let dir = fixture();
1049 assert_eq!(run(&base(&dir)).total_recipes(), 5);
1050 }
1051
1052 #[test]
1057 fn recipes_come_back_in_path_order_every_time() {
1058 let dir = fixture();
1059 let base = base(&dir);
1060 let expected = paths(&run(&base));
1061 let mut sorted = expected.clone();
1062 sorted.sort();
1063 assert_eq!(expected, sorted);
1064
1065 for _ in 0..8 {
1066 assert_eq!(paths(&run(&base)), expected, "order must not vary");
1067 }
1068 }
1069
1070 #[test]
1071 fn an_empty_directory_validates_to_an_empty_report() {
1072 let dir = tempfile::TempDir::new().unwrap();
1073 let report = run(&base(&dir));
1074 assert_eq!(report.total_recipes(), 0);
1075 assert!(report.references().is_empty());
1076 }
1077
1078 #[test]
1081 fn a_root_that_does_not_exist_is_reported() {
1082 let dir = tempfile::TempDir::new().unwrap();
1083 let missing = base(&dir).join("nope");
1084
1085 match validate(&Context::new(missing.clone()), ValidateRequest::default()) {
1086 Err(CoreError::Search { base_dir, message }) => {
1087 assert_eq!(base_dir, missing);
1088 assert_eq!(message, "no such directory");
1089 }
1090 other => panic!(
1091 "expected CoreError::Search, got {:?}",
1092 other.map(|o| o.value)
1093 ),
1094 }
1095 }
1096
1097 #[test]
1098 fn a_root_that_is_a_file_is_reported() {
1099 let dir = tempfile::TempDir::new().unwrap();
1100 let file = base(&dir).join("sauce.cook");
1101 write(&file, CLEAN);
1102
1103 match validate(&Context::new(file.clone()), ValidateRequest::default()) {
1104 Err(CoreError::Search { base_dir, message }) => {
1105 assert_eq!(base_dir, file);
1106 assert_eq!(message, "not a directory");
1107 }
1108 other => panic!(
1109 "expected CoreError::Search, got {:?}",
1110 other.map(|o| o.value)
1111 ),
1112 }
1113 }
1114
1115 #[test]
1118 fn a_root_that_is_not_a_valid_glob_pattern_is_reported() {
1119 let dir = tempfile::TempDir::new().unwrap();
1120 let root = base(&dir).join("re[ci");
1121 std::fs::create_dir(&root).unwrap();
1122 write(&root.join("sauce.cook"), CLEAN);
1123
1124 match validate(&Context::new(root.clone()), ValidateRequest::default()) {
1125 Err(CoreError::Search { base_dir, message }) => {
1126 assert_eq!(base_dir, root);
1127 assert!(
1128 message.contains("attern"),
1129 "the cause must survive: {message}"
1130 );
1131 }
1132 other => panic!(
1133 "expected CoreError::Search, got {:?}",
1134 other.map(|o| o.value)
1135 ),
1136 }
1137 }
1138
1139 #[test]
1142 fn a_listing_failure_names_the_file_rather_than_the_root() {
1143 let root = Utf8Path::new("/recipes");
1144
1145 let unusable = tree_error(
1146 TreeError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
1147 "bad front matter".to_string(),
1148 )),
1149 root,
1150 );
1151 match unusable {
1152 CoreError::Io { path, source } => {
1153 assert_eq!(path, root);
1154 assert!(
1155 source.to_string().contains("bad front matter"),
1156 "the cause must survive: {source}"
1157 );
1158 }
1159 other => panic!("expected CoreError::Io, got {other:?}"),
1160 }
1161
1162 let unstrippable = tree_error(
1163 TreeError::StripPrefixError("recipes/soup.cook".to_string()),
1164 root,
1165 );
1166 match unstrippable {
1167 CoreError::Search { base_dir, message } => {
1168 assert_eq!(base_dir, root);
1169 assert!(message.contains("recipes/soup.cook"), "{message}");
1170 }
1171 other => panic!("expected CoreError::Search, got {other:?}"),
1172 }
1173 }
1174
1175 #[test]
1176 fn a_path_under_the_root_is_stripped_and_one_outside_is_left_alone() {
1177 assert_eq!(
1178 relative_to(
1179 Utf8Path::new("/recipes"),
1180 Utf8Path::new("/recipes/Breakfast/pancakes.cook")
1181 ),
1182 "Breakfast/pancakes.cook"
1183 );
1184 assert_eq!(
1185 relative_to(
1186 Utf8Path::new("./recipes"),
1187 Utf8Path::new("recipes/soup.cook")
1188 ),
1189 "recipes/soup.cook"
1190 );
1191 }
1192
1193 fn broken(report: &ValidationReport) -> Vec<(String, Vec<String>)> {
1200 broken_references(report)
1201 .into_iter()
1202 .map(|(recipe, missing)| (recipe.to_string(), missing))
1203 .collect()
1204 }
1205
1206 #[test]
1207 fn the_report_records_the_root_it_was_validated_against() {
1208 let dir = fixture();
1209 assert_eq!(run(&base(&dir)).base_dir, base(&dir));
1210
1211 let elsewhere = tempfile::TempDir::new().unwrap();
1212 let report = validate(
1213 &Context::new(base(&elsewhere)),
1214 ValidateRequest {
1215 base_dir: Some(base(&dir)),
1216 style: Style::Plain,
1217 },
1218 )
1219 .expect("validation succeeds")
1220 .into_value();
1221 assert_eq!(
1222 report.base_dir,
1223 base(&dir),
1224 "the root that was walked, not the context's"
1225 );
1226 }
1227
1228 #[test]
1231 fn only_references_that_resolve_to_nothing_are_reported() {
1232 let dir = fixture();
1233 assert_eq!(
1234 broken(&run(&base(&dir))),
1235 [(
1236 "with_ref.cook".to_string(),
1237 vec!["./nonexistent".to_string()]
1238 )]
1239 );
1240 }
1241
1242 #[test]
1243 fn a_collection_whose_references_all_resolve_reports_none() {
1244 let dir = tempfile::TempDir::new().unwrap();
1245 let base = base(&dir);
1246 write(&base.join("sauce.cook"), CLEAN);
1247 write(
1248 &base.join("dish.cook"),
1249 "---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
1250 );
1251
1252 assert!(
1253 broken_references(&run(&base)).is_empty(),
1254 "a resolvable reference must not be reported"
1255 );
1256 }
1257
1258 #[test]
1261 fn a_reference_repeated_is_reported_once_per_mention() {
1262 let dir = tempfile::TempDir::new().unwrap();
1263 let base = base(&dir);
1264 write(
1265 &base.join("dish.cook"),
1266 "---\ntitle: Dish\n---\n\nMake @./absent{}, then more @./absent{}.\n",
1267 );
1268
1269 assert_eq!(
1270 broken(&run(&base)),
1271 [(
1272 "dish.cook".to_string(),
1273 vec!["./absent".to_string(), "./absent".to_string()]
1274 )]
1275 );
1276 }
1277
1278 #[test]
1283 fn references_resolve_against_the_validated_root_from_anywhere_in_it() {
1284 let dir = tempfile::TempDir::new().unwrap();
1285 let base = base(&dir);
1286 write(&base.join("sauce.cook"), CLEAN);
1287 std::fs::create_dir(base.join("Dinner")).unwrap();
1288 write(
1289 &base.join("Dinner").join("dish.cook"),
1290 "---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
1291 );
1292
1293 assert!(
1294 broken_references(&run(&base)).is_empty(),
1295 "a nested recipe must be able to reference the root's"
1296 );
1297 }
1298
1299 #[test]
1302 fn a_collection_with_no_references_has_none_broken() {
1303 let dir = tempfile::TempDir::new().unwrap();
1304 write(&base(&dir).join("sauce.cook"), CLEAN);
1305 assert!(broken_references(&run(&base(&dir))).is_empty());
1306 }
1307
1308 fn one_recipe(text: &str) -> tempfile::TempDir {
1314 let dir = tempfile::TempDir::new().unwrap();
1315 write(&base(&dir).join("dish.cook"), text);
1316 dir
1317 }
1318
1319 fn aisle_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
1320 Context::new(base(dir)).with_aisle(ConfigSource::Inline(conf.to_string()))
1321 }
1322
1323 fn pantry_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
1324 Context::new(base(dir)).with_pantry(ConfigSource::Inline(conf.to_string()))
1325 }
1326
1327 fn checked(ctx: &Context, aisle: bool) -> Outcome<IngredientCoverage> {
1328 let request = CoverageRequest::default();
1329 if aisle {
1330 aisle_coverage(ctx, request)
1331 } else {
1332 pantry_coverage(ctx, request)
1333 }
1334 .expect("the check succeeds")
1335 }
1336
1337 fn known(coverage: &IngredientCoverage) -> Vec<&str> {
1338 coverage.known().collect()
1339 }
1340
1341 fn unknown(coverage: &IngredientCoverage) -> Vec<&str> {
1342 coverage.unknown().collect()
1343 }
1344
1345 fn all(coverage: &IngredientCoverage) -> Vec<&str> {
1346 coverage
1347 .ingredients
1348 .iter()
1349 .map(|ingredient| ingredient.name.as_str())
1350 .collect()
1351 }
1352
1353 fn recipes_for<'a>(coverage: &'a IngredientCoverage, name: &str) -> Vec<&'a Utf8Path> {
1360 coverage
1361 .ingredients
1362 .iter()
1363 .find(|ingredient| ingredient.name == name)
1364 .unwrap_or_else(|| panic!("{name} is in the coverage"))
1365 .recipes
1366 .iter()
1367 .map(Utf8PathBuf::as_path)
1368 .collect()
1369 }
1370
1371 #[test]
1372 fn an_aisle_splits_the_collection_into_categorised_and_not() {
1373 let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
1374 let coverage = checked(
1375 &aisle_ctx(&dir, "[produce]\nleek\n\n[pantry]\nsalt\n"),
1376 true,
1377 )
1378 .value;
1379
1380 assert_eq!(known(&coverage), ["leek", "salt"]);
1381 assert_eq!(unknown(&coverage), ["water"]);
1382 assert_eq!(coverage.total_ingredients(), 3);
1383 assert_eq!(coverage.total_recipes, 1);
1384 }
1385
1386 #[test]
1388 fn an_aisle_synonym_counts_as_knowing_the_ingredient() {
1389 let dir = one_recipe("Add @aubergine{1}.\n");
1390 let coverage = checked(&aisle_ctx(&dir, "[produce]\neggplant|aubergine\n"), true).value;
1391
1392 assert_eq!(known(&coverage), ["aubergine"]);
1393 assert!(unknown(&coverage).is_empty());
1394 }
1395
1396 #[test]
1397 fn a_pantry_knows_its_items_from_every_section() {
1398 let dir = one_recipe("Add @salt{1%tsp}, @milk{1%l} and @water{1%l}.\n");
1399 let conf = "[pantry]\nsalt = \"1%kg\"\n\n[dairy]\nmilk = \"1%l\"\n";
1400 let coverage = checked(&pantry_ctx(&dir, conf), false).value;
1401
1402 assert_eq!(known(&coverage), ["milk", "salt"]);
1403 assert_eq!(unknown(&coverage), ["water"]);
1404 }
1405
1406 #[test]
1409 fn a_pantry_item_that_has_run_out_still_counts_as_known() {
1410 let dir = one_recipe("Add @honey{1%tbsp}.\n");
1411 let conf = "[pantry]\nhoney = { quantity = \"0\", low = \"100%g\" }\n";
1412 assert_eq!(
1413 known(&checked(&pantry_ctx(&dir, conf), false).value),
1414 ["honey"]
1415 );
1416 }
1417
1418 #[test]
1421 fn names_are_matched_ignoring_case_and_reported_as_the_recipe_writes_them() {
1422 let dir = one_recipe("Add @Salt{1%tsp} and @PEPPER{}.\n");
1423
1424 let aisle = checked(&aisle_ctx(&dir, "[pantry]\nsalt\npepper\n"), true).value;
1425 assert_eq!(known(&aisle), ["PEPPER", "Salt"]);
1426 assert!(unknown(&aisle).is_empty());
1427
1428 let conf = "[pantry]\nsalt = \"1%kg\"\npepper = \"50%g\"\n";
1429 let pantry = checked(&pantry_ctx(&dir, conf), false).value;
1430 assert_eq!(known(&pantry), ["PEPPER", "Salt"]);
1431 assert!(unknown(&pantry).is_empty());
1432 }
1433
1434 #[test]
1439 fn a_non_ascii_name_is_matched_ignoring_case_too() {
1440 let dir = one_recipe("Add @Öl{1%tbsp} and @Ärter{100%g}.\n");
1441
1442 let aisle = checked(&aisle_ctx(&dir, "[pantry]\növerste|öl\närter\n"), true).value;
1443 assert_eq!(known(&aisle), ["Ärter", "Öl"]);
1444 assert!(unknown(&aisle).is_empty(), "{:?}", unknown(&aisle));
1445
1446 let conf = "[pantry]\n\"öl\" = \"1%l\"\n\"ärter\" = \"1%kg\"\n";
1448 assert_eq!(
1449 known(&checked(&pantry_ctx(&dir, conf), false).value),
1450 ["Ärter", "Öl"]
1451 );
1452 }
1453
1454 #[test]
1457 fn two_spellings_of_one_ingredient_are_both_listed() {
1458 let dir = tempfile::TempDir::new().unwrap();
1459 write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
1460 write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
1461
1462 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1463 assert_eq!(known(&coverage), ["Salt", "salt"]);
1464 assert_eq!(coverage.total_ingredients(), 2);
1465 }
1466
1467 #[test]
1471 fn without_a_configuration_nothing_is_known() {
1472 let dir = one_recipe("Add @salt{1%tsp}.\n");
1473 let ctx = Context::new(base(&dir));
1474
1475 for aisle in [true, false] {
1476 let coverage = checked(&ctx, aisle).value;
1477 assert_eq!(coverage.total_recipes, 1);
1478 assert!(known(&coverage).is_empty(), "aisle: {aisle}");
1479 assert_eq!(unknown(&coverage), ["salt"], "aisle: {aisle}");
1480 }
1481 }
1482
1483 #[test]
1486 fn references_to_other_recipes_are_not_ingredients() {
1487 let dir = one_recipe("Make @./sauce{} and add @water{1%l}.\n");
1488 write(&base(&dir).join("sauce.cook"), CLEAN);
1489
1490 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsauce\nwater\noil\n"), true).value;
1491 assert_eq!(
1492 all(&coverage),
1493 ["oil", "water"],
1494 "the referenced recipe's own ingredients count; the reference does not"
1495 );
1496 }
1497
1498 #[test]
1499 fn every_recipe_under_the_root_is_scanned_including_nested_ones() {
1500 let dir = one_recipe("Boil @water{1%l}.\n");
1501 std::fs::create_dir(base(&dir).join("Breakfast")).unwrap();
1502 write(
1503 &base(&dir).join("Breakfast").join("porridge.cook"),
1504 "Simmer @oats{50%g}.\n",
1505 );
1506
1507 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nwater\n"), true).value;
1508 assert_eq!(coverage.total_recipes, 2);
1509 assert_eq!(
1510 unknown(&coverage),
1511 ["oats"],
1512 "a subdirectory must be walked"
1513 );
1514 assert_eq!(
1515 recipes_for(&coverage, "oats"),
1516 [Utf8Path::new("Breakfast/porridge.cook")],
1517 "a recipe is named relative to the directory that was scanned"
1518 );
1519 }
1520
1521 #[test]
1524 fn an_ingredient_carries_every_recipe_that_writes_it() {
1525 let dir = tempfile::TempDir::new().unwrap();
1526 write(
1527 &base(&dir).join("curry.cook"),
1528 "Add @ground cumin{1%tsp}.\n",
1529 );
1530 write(&base(&dir).join("dal.cook"), "Add @ground cumin{2%tsp}.\n");
1531 write(&base(&dir).join("stew.cook"), "Add @cumin powder{1%tsp}.\n");
1532
1533 let coverage = checked(&aisle_ctx(&dir, "[spices]\nground cumin\n"), true).value;
1534
1535 assert_eq!(
1536 recipes_for(&coverage, "ground cumin"),
1537 [Utf8Path::new("curry.cook"), Utf8Path::new("dal.cook")],
1538 "shared by two recipes, listed once each, in path order"
1539 );
1540 assert_eq!(
1541 recipes_for(&coverage, "cumin powder"),
1542 [Utf8Path::new("stew.cook")]
1543 );
1544 }
1545
1546 #[test]
1549 fn a_recipe_using_an_ingredient_twice_is_listed_once() {
1550 let dir = one_recipe("Add @salt{1%tsp}, then more @salt{1%tsp}.\n");
1551 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1552 assert_eq!(recipes_for(&coverage, "salt"), [Utf8Path::new("dish.cook")]);
1553 }
1554
1555 #[test]
1558 fn each_spelling_keeps_its_own_recipes() {
1559 let dir = tempfile::TempDir::new().unwrap();
1560 write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
1561 write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
1562
1563 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1564 assert_eq!(recipes_for(&coverage, "Salt"), [Utf8Path::new("a.cook")]);
1565 assert_eq!(recipes_for(&coverage, "salt"), [Utf8Path::new("b.cook")]);
1566 }
1567
1568 #[test]
1571 fn the_pantry_check_lists_recipes_as_well() {
1572 let dir = tempfile::TempDir::new().unwrap();
1573 write(&base(&dir).join("a.cook"), "Add @rice{100%g}.\n");
1574 write(&base(&dir).join("b.cook"), "Add @rice{200%g}.\n");
1575
1576 let coverage = checked(&pantry_ctx(&dir, "[pantry]\nrice = \"5%kg\"\n"), false).value;
1577 assert_eq!(known(&coverage), ["rice"]);
1578 assert_eq!(
1579 recipes_for(&coverage, "rice"),
1580 [Utf8Path::new("a.cook"), Utf8Path::new("b.cook")]
1581 );
1582 }
1583
1584 #[test]
1587 fn a_recipe_that_cannot_be_parsed_is_counted_but_contributes_nothing() {
1588 let dir = one_recipe("Add @salt{1%tsp}.\n");
1589 write(&base(&dir).join("broken.cook"), BROKEN);
1590
1591 let outcome = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true);
1592 assert_eq!(outcome.value.total_recipes, 2);
1593 assert_eq!(outcome.value.total_ingredients(), 1);
1594 assert_eq!(known(&outcome.value), ["salt"]);
1595
1596 let skipped = outcome
1597 .diagnostics
1598 .iter()
1599 .find(|d| d.message.contains("broken.cook"))
1600 .unwrap_or_else(|| {
1601 panic!(
1602 "the skipped recipe must be named: {:?}",
1603 outcome.diagnostics
1604 )
1605 });
1606 assert_eq!(skipped.severity, Severity::Warning);
1609 assert!(!outcome.has_errors());
1610 }
1611
1612 #[test]
1621 fn ingredients_come_back_in_code_point_order_every_time() {
1622 let dir = tempfile::TempDir::new().unwrap();
1623 for (file, ingredient) in [
1624 ("a", "yeast"),
1625 ("b", "flour"),
1626 ("c", "sugar"),
1627 ("d", "Zucchini"),
1628 ("e", "apple"),
1629 ("f", "Beetroot"),
1630 ] {
1631 write(
1632 &base(&dir).join(format!("{file}.cook")),
1633 &format!("Add @{ingredient}{{1}}.\n"),
1634 );
1635 }
1636 let ctx = aisle_ctx(&dir, "[pantry]\nflour\n");
1637
1638 for _ in 0..8 {
1639 let coverage = checked(&ctx, true).value;
1640 assert_eq!(
1641 all(&coverage),
1642 ["Beetroot", "Zucchini", "apple", "flour", "sugar", "yeast"],
1643 "capitalised names sort first, as CookCLI has always printed them"
1644 );
1645 assert_eq!(
1646 unknown(&coverage),
1647 ["Beetroot", "Zucchini", "apple", "sugar", "yeast"]
1648 );
1649 }
1650 }
1651
1652 #[test]
1655 fn the_two_views_partition_the_ingredients() {
1656 let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
1657 let coverage = checked(&aisle_ctx(&dir, "[produce]\nleek\n"), true).value;
1658
1659 let mut both: Vec<&str> = known(&coverage)
1660 .into_iter()
1661 .chain(unknown(&coverage))
1662 .collect();
1663 both.sort_unstable();
1664 assert_eq!(both, all(&coverage));
1665 assert_eq!(
1666 known(&coverage).len() + unknown(&coverage).len(),
1667 coverage.total_ingredients()
1668 );
1669 }
1670
1671 #[test]
1672 fn a_coverage_base_dir_overrides_the_context_base_path() {
1673 let scanned = one_recipe("Add @salt{1%tsp}.\n");
1674 let ignored = one_recipe("Add @decoy{1}.\n");
1675
1676 let coverage = aisle_coverage(
1677 &aisle_ctx(&ignored, "[pantry]\nsalt\n"),
1678 CoverageRequest {
1679 base_dir: Some(base(&scanned)),
1680 },
1681 )
1682 .expect("the check succeeds")
1683 .into_value();
1684
1685 assert_eq!(known(&coverage), ["salt"]);
1686 assert!(!all(&coverage).contains(&"decoy"), "{:?}", all(&coverage));
1687 }
1688
1689 #[test]
1692 fn a_warning_in_the_aisle_configuration_comes_back_as_a_diagnostic() {
1693 let dir = one_recipe("Add @leek{1}.\n");
1694 let outcome = checked(&aisle_ctx(&dir, "[produce]\nleek\n\n[dairy]\nleek\n"), true);
1695
1696 assert!(
1697 outcome
1698 .diagnostics
1699 .iter()
1700 .any(|d| d.message.contains("Duplicate ingredient")),
1701 "{:?}",
1702 outcome.diagnostics
1703 );
1704 assert_eq!(known(&outcome.value), ["leek"]);
1706 }
1707
1708 #[test]
1712 fn a_warning_in_the_pantry_configuration_comes_back_as_a_diagnostic() {
1713 let dir = one_recipe("Add @ice{1}.\n");
1714 let conf = "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n";
1715 let outcome = checked(&pantry_ctx(&dir, conf), false);
1716
1717 assert!(
1718 !outcome.diagnostics.is_empty(),
1719 "the unknown attribute must be reported"
1720 );
1721 for diagnostic in &outcome.diagnostics {
1722 assert_eq!(diagnostic.severity, Severity::Warning, "{diagnostic:?}");
1723 }
1724 assert!(!outcome.has_errors());
1725 assert_eq!(known(&outcome.value), ["ice"]);
1727 }
1728
1729 #[test]
1732 fn a_configuration_warning_is_located_in_the_file_it_came_from() {
1733 let dir = one_recipe("Add @ice{1}.\n");
1734 let path = base(&dir).join("pantry.conf");
1735 write(
1736 &path,
1737 "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n",
1738 );
1739
1740 let ctx = Context::new(base(&dir)).with_pantry(ConfigSource::Path(path.clone()));
1741 let outcome =
1742 pantry_coverage(&ctx, CoverageRequest::default()).expect("the check succeeds");
1743
1744 let located = outcome
1745 .diagnostics
1746 .iter()
1747 .find(|d| d.location.is_some())
1748 .unwrap_or_else(|| panic!("expected a located warning: {:?}", outcome.diagnostics));
1749 assert_eq!(
1750 located.location.as_ref().and_then(|l| l.file.as_deref()),
1751 Some(path.as_path())
1752 );
1753 }
1754
1755 #[test]
1759 fn a_configuration_that_cannot_be_read_is_reported() {
1760 let dir = one_recipe("Add @salt{1%tsp}.\n");
1761 let missing = base(&dir).join("config").join("aisle.conf");
1762
1763 match aisle_coverage(
1764 &Context::new(base(&dir)).with_aisle(ConfigSource::Path(missing.clone())),
1765 CoverageRequest::default(),
1766 ) {
1767 Err(CoreError::Io { path, source }) => {
1768 assert_eq!(path, missing);
1769 assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
1770 }
1771 other => panic!("expected CoreError::Io, got {:?}", other.map(|o| o.value)),
1772 }
1773 }
1774
1775 #[test]
1780 fn a_pantry_that_cannot_be_parsed_at_all_is_reported_as_pantry_load_reports_it() {
1781 let dir = one_recipe("Add @salt{1%tsp}.\n");
1782 let ctx = pantry_ctx(&dir, "this is not toml [");
1783
1784 let from_load = match crate::pantry::load(&ctx) {
1785 Err(CoreError::Config { message, .. }) => message,
1786 other => panic!("expected CoreError::Config from load, got {other:?}"),
1787 };
1788
1789 match pantry_coverage(&ctx, CoverageRequest::default()) {
1790 Err(CoreError::Config { path, message }) => {
1791 assert_eq!(path, None, "an inline configuration has no path");
1792 assert!(
1795 message.contains("TOML parse error"),
1796 "the cause must survive: {message}"
1797 );
1798 assert_eq!(message, from_load, "the two commands must agree");
1799 }
1800 other => panic!(
1801 "expected CoreError::Config, got {:?}",
1802 other.map(|o| o.value)
1803 ),
1804 }
1805 }
1806
1807 #[test]
1809 fn a_root_that_does_not_exist_is_reported_by_a_coverage_check() {
1810 let dir = tempfile::TempDir::new().unwrap();
1811 let missing = base(&dir).join("nope");
1812
1813 match pantry_coverage(
1814 &Context::new(missing.clone()).with_pantry(ConfigSource::Inline(String::new())),
1815 CoverageRequest::default(),
1816 ) {
1817 Err(CoreError::Search { base_dir, message }) => {
1818 assert_eq!(base_dir, missing);
1819 assert_eq!(message, "no such directory");
1820 }
1821 other => panic!(
1822 "expected CoreError::Search, got {:?}",
1823 other.map(|o| o.value)
1824 ),
1825 }
1826 }
1827
1828 #[test]
1829 fn an_empty_collection_covers_nothing() {
1830 let dir = tempfile::TempDir::new().unwrap();
1831 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1832 assert_eq!(coverage.total_recipes, 0);
1833 assert_eq!(coverage.total_ingredients(), 0);
1834 assert!(known(&coverage).is_empty());
1835 assert!(unknown(&coverage).is_empty());
1836 }
1837}