1use crate::{
18 diagnostic::{parse_failure, Severity},
19 find::{build_tree, listed_ingredients, parse_or_skip, walk},
20 parser::{collect_diagnostics, render_report, PARSER},
21 ConfigSource, Context, CoreError, Diagnostic, Outcome, Span, Style,
22};
23use camino::{Utf8Path, Utf8PathBuf};
24use cooklang_find::RecipeEntry;
25use std::collections::{BTreeMap, BTreeSet};
26use yansi::Paint;
27
28#[derive(Debug, Clone, Default)]
33pub struct ValidateRequest {
34 pub base_dir: Option<Utf8PathBuf>,
39 pub style: Style,
46}
47
48#[non_exhaustive]
53#[derive(Debug, Clone)]
54pub struct RecipeValidation {
55 pub path: Utf8PathBuf,
65 pub diagnostics: Vec<Diagnostic>,
71 pub rendered: String,
83 pub references: Vec<String>,
94}
95
96impl RecipeValidation {
97 fn count(&self, severity: Severity) -> usize {
99 self.diagnostics
100 .iter()
101 .filter(|d| d.severity == severity)
102 .count()
103 }
104}
105
106#[non_exhaustive]
116#[derive(Debug, Clone)]
117pub struct ValidationReport {
118 pub base_dir: Utf8PathBuf,
126 pub recipes: Vec<RecipeValidation>,
133}
134
135impl ValidationReport {
136 pub fn total_recipes(&self) -> usize {
138 self.recipes.len()
139 }
140
141 pub fn recipes_with_errors(&self) -> usize {
143 self.recipes
144 .iter()
145 .filter(|r| r.count(Severity::Error) > 0)
146 .count()
147 }
148
149 pub fn recipes_with_warnings(&self) -> usize {
151 self.recipes
152 .iter()
153 .filter(|r| r.count(Severity::Warning) > 0)
154 .count()
155 }
156
157 pub fn total_errors(&self) -> usize {
159 self.recipes.iter().map(|r| r.count(Severity::Error)).sum()
160 }
161
162 pub fn total_warnings(&self) -> usize {
164 self.recipes
165 .iter()
166 .map(|r| r.count(Severity::Warning))
167 .sum()
168 }
169
170 pub fn references(&self) -> BTreeMap<&Utf8Path, &[String]> {
184 self.recipes
185 .iter()
186 .filter(|r| !r.references.is_empty())
187 .map(|r| (r.path.as_path(), r.references.as_slice()))
188 .collect()
189 }
190}
191
192pub fn validate(
251 ctx: &Context,
252 req: ValidateRequest,
253) -> Result<Outcome<ValidationReport>, CoreError> {
254 let base_dir = req
255 .base_dir
256 .unwrap_or_else(|| ctx.base_path().to_path_buf());
257
258 tracing::trace!("validating recipes under {base_dir}");
259
260 let tree = build_tree(&base_dir)?;
261
262 let mut recipes: Vec<RecipeValidation> = walk(&tree)
263 .into_iter()
264 .map(|entry| validate_entry(entry, &base_dir, req.style))
265 .collect();
266 recipes.sort_by(|a, b| a.path.cmp(&b.path));
271
272 let diagnostics = recipes
273 .iter()
274 .flat_map(|r| r.diagnostics.iter().cloned())
275 .collect();
276
277 Ok(Outcome::with_diagnostics(
278 ValidationReport { base_dir, recipes },
279 diagnostics,
280 ))
281}
282
283fn validate_entry(entry: &RecipeEntry, base_dir: &Utf8Path, style: Style) -> RecipeValidation {
286 let name = entry
291 .name()
292 .clone()
293 .unwrap_or_else(|| "unknown".to_string());
294 let full_path = entry.path().cloned().unwrap_or_else(|| base_dir.join(name));
295 let path = relative_to(base_dir, &full_path);
296
297 let content = match std::fs::read_to_string(&full_path) {
298 Ok(content) => content,
299 Err(e) => {
300 return RecipeValidation {
301 diagnostics: vec![
305 Diagnostic::error(format!("Failed to read file: {e}")).at_file(path.clone())
306 ],
307 path,
308 rendered: String::new(),
309 references: Vec::new(),
310 };
311 }
312 };
313
314 let parsed = PARSER.parse(&content);
315 let mut diagnostics = collect_diagnostics(parsed.report(), Some(&path));
316
317 let timer_warnings = if parsed.output().is_some() {
321 textual_timer_warnings(&content, &path)
322 } else {
323 Vec::new()
324 };
325
326 let rendered = render_findings(
331 parsed.report(),
332 &timer_warnings,
333 path.as_str(),
334 &content,
335 style,
336 );
337 diagnostics.extend(timer_warnings);
338
339 let references = parsed
342 .output()
343 .map(|recipe| {
344 recipe
345 .ingredients
346 .iter()
347 .filter_map(|ingredient| ingredient.reference.as_ref())
348 .map(|reference| {
349 if reference.components.is_empty() {
350 reference.name.clone()
351 } else {
352 reference.path("/")
353 }
354 })
355 .collect()
356 })
357 .unwrap_or_default();
358
359 RecipeValidation {
360 path,
361 diagnostics,
362 rendered,
363 references,
364 }
365}
366
367fn relative_to(base_dir: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
379 path.strip_prefix(base_dir).unwrap_or(path).to_owned()
380}
381
382fn textual_timer_warnings(source: &str, path: &Utf8Path) -> Vec<Diagnostic> {
392 use cooklang::{
393 parser::{Event, PullParser},
394 Extensions,
395 };
396
397 let mut warnings = Vec::new();
398 let extensions = PARSER.extensions() | Extensions::RANGE_VALUES;
399 for event in PullParser::new(source, extensions) {
400 let Event::Timer(timer) = event else {
401 continue;
402 };
403 let Some(quantity) = timer.quantity.as_ref() else {
404 continue;
405 };
406 let cooklang::Value::Text(text) = quantity.value.value.value() else {
407 continue;
408 };
409 let span = quantity.value.span();
410 let end = span.start() + source[span.range()].trim_end().len();
412 let mut diagnostic =
413 Diagnostic::warning(format!("Timer value is text: {text}")).at_file(path);
414 if let Some(location) = diagnostic.location.as_mut() {
415 location.span = Some(Span {
416 start: span.start(),
417 end,
418 });
419 }
420 warnings.push(diagnostic);
421 }
422 warnings
423}
424
425fn render_findings(
429 report: &cooklang::error::SourceReport,
430 timer_warnings: &[Diagnostic],
431 display_path: &str,
432 content: &str,
433 style: Style,
434) -> String {
435 if report.is_empty() && timer_warnings.is_empty() {
436 return String::new();
437 }
438 let mut rendered = if report.is_empty() {
439 String::new()
440 } else {
441 render_report(report, display_path, content, style.is_ansi())
442 };
443 if timer_warnings.is_empty() {
444 return rendered;
445 }
446 if !rendered.is_empty() && !rendered.ends_with('\n') {
447 rendered.push('\n');
448 }
449 for warning in timer_warnings {
450 let label = if style.is_ansi() {
451 "Warning:".yellow().to_string()
452 } else {
453 "Warning:".to_string()
454 };
455 let location = match warning.location.as_ref().and_then(|l| l.span) {
456 Some(span) => {
457 let line = content[..span.start].matches('\n').count() + 1;
458 format!("{display_path}:{line}")
459 }
460 None => display_path.to_string(),
461 };
462 rendered.push_str(&format!("{label} {} ({location})\n", warning.message));
463 }
464 rendered
465}
466
467pub fn broken_references(report: &ValidationReport) -> BTreeMap<&Utf8Path, Vec<String>> {
496 report
497 .references()
498 .into_iter()
499 .filter_map(|(recipe, references)| {
500 let from = recipe.parent().unwrap_or(Utf8Path::new("")).to_owned();
504 let broken: Vec<String> = references
505 .iter()
506 .filter(|reference| {
507 crate::find::resolve_reference(&from, reference).is_none_or(|path| {
508 crate::find::get_recipe(&report.base_dir, path.as_str()).is_err()
509 })
510 })
511 .cloned()
512 .collect();
513 (!broken.is_empty()).then_some((recipe, broken))
514 })
515 .collect()
516}
517
518#[derive(Debug, Clone, Default)]
527pub struct CoverageRequest {
528 pub base_dir: Option<Utf8PathBuf>,
530}
531
532#[non_exhaustive]
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct CheckedIngredient {
539 pub name: String,
543 pub known: bool,
545 pub recipes: Vec<Utf8PathBuf>,
555}
556
557#[non_exhaustive]
568#[derive(Debug, Clone, Default, PartialEq, Eq)]
569pub struct IngredientCoverage {
570 pub total_recipes: usize,
574 pub ingredients: Vec<CheckedIngredient>,
585}
586
587impl IngredientCoverage {
588 pub fn total_ingredients(&self) -> usize {
590 self.ingredients.len()
591 }
592
593 pub fn known(&self) -> impl Iterator<Item = &str> {
596 self.known_entries()
597 .map(|ingredient| ingredient.name.as_str())
598 }
599
600 pub fn unknown(&self) -> impl Iterator<Item = &str> {
607 self.unknown_entries()
608 .map(|ingredient| ingredient.name.as_str())
609 }
610
611 pub fn known_entries(&self) -> impl Iterator<Item = &CheckedIngredient> {
614 self.filtered(true)
615 }
616
617 pub fn unknown_entries(&self) -> impl Iterator<Item = &CheckedIngredient> {
624 self.filtered(false)
625 }
626
627 fn filtered(&self, known: bool) -> impl Iterator<Item = &CheckedIngredient> {
628 self.ingredients
629 .iter()
630 .filter(move |ingredient| ingredient.known == known)
631 }
632}
633
634pub fn aisle_coverage(
663 ctx: &Context,
664 req: CoverageRequest,
665) -> Result<Outcome<IngredientCoverage>, CoreError> {
666 let source = ctx.aisle();
667 let mut diagnostics = Vec::new();
668 let mut known = BTreeSet::new();
669
670 if let Some(text) = source.read()? {
671 let parsed = cooklang::aisle::parse_lenient(&text);
672 diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
673 let conf = parsed
674 .output()
675 .ok_or_else(|| config_error(source, "aisle", &diagnostics))?;
676 known.extend(conf.ingredients_info().into_keys());
678 }
679
680 coverage(ctx, req, &known, diagnostics)
681}
682
683pub fn pantry_coverage(
702 ctx: &Context,
703 req: CoverageRequest,
704) -> Result<Outcome<IngredientCoverage>, CoreError> {
705 let source = ctx.pantry();
706 let mut diagnostics = Vec::new();
707 let mut known = BTreeSet::new();
708
709 if let Some(text) = source.read()? {
710 let parsed = cooklang::pantry::parse_lenient(&text);
711 diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
712 let conf = parsed
713 .output()
714 .ok_or_else(|| config_error(source, "pantry", &diagnostics))?;
715 known.extend(conf.all_items().map(|item| item.name().to_lowercase()));
716 }
717
718 coverage(ctx, req, &known, diagnostics)
719}
720
721fn config_error(source: &ConfigSource, kind: &str, diagnostics: &[Diagnostic]) -> CoreError {
727 CoreError::Config {
728 path: source.path().map(ToOwned::to_owned),
729 message: parse_failure(diagnostics, kind),
730 }
731}
732
733fn coverage(
736 ctx: &Context,
737 req: CoverageRequest,
738 known: &BTreeSet<String>,
739 mut diagnostics: Vec<Diagnostic>,
740) -> Result<Outcome<IngredientCoverage>, CoreError> {
741 let base_dir = req
742 .base_dir
743 .unwrap_or_else(|| ctx.base_path().to_path_buf());
744
745 let tree = build_tree(&base_dir)?;
746 let entries = walk(&tree);
747 let total_recipes = entries.len();
748
749 let mut names: BTreeMap<String, BTreeSet<Utf8PathBuf>> = BTreeMap::new();
754 for entry in entries {
755 let Some(recipe) = parse_or_skip(entry, &mut diagnostics) else {
756 continue;
757 };
758 let path = entry
763 .path()
764 .map(|path| relative_to(&base_dir, path))
765 .or_else(|| entry.name().clone().map(Utf8PathBuf::from))
766 .unwrap_or_else(|| Utf8PathBuf::from("unknown"));
767 for name in listed_ingredients(&recipe) {
768 names.entry(name).or_default().insert(path.clone());
769 }
770 }
771
772 let ingredients = names
773 .into_iter()
774 .map(|(name, recipes)| CheckedIngredient {
775 known: known.contains(&name.to_lowercase()),
776 name,
777 recipes: recipes.into_iter().collect(),
778 })
779 .collect();
780
781 Ok(Outcome::with_diagnostics(
782 IngredientCoverage {
783 total_recipes,
784 ingredients,
785 },
786 diagnostics,
787 ))
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793 use crate::find::tree_error;
794 use cooklang_find::tree::TreeError;
795
796 const CLEAN: &str = "---\ntitle: Basic Sauce\n---\n\nHeat @oil{2%tbsp} in a #pan.\n";
797 const DEPRECATED: &str = ">> title: Old Style\n\nBoil @water{1%l}.\n";
799 const BROKEN: &str = "---\ntitle: Broken\n---\n\nAdd @{1%tsp} and @{2%tsp}.\n";
801
802 fn fixture() -> tempfile::TempDir {
805 let dir = tempfile::TempDir::new().unwrap();
806 let base = base(&dir);
807 std::fs::create_dir(base.join("Breakfast")).unwrap();
808 write(
809 &base.join("Breakfast").join("pancakes.cook"),
810 "---\ntitle: Pancakes\n---\n\nMix @flour{2%cups}.\n",
811 );
812 write(&base.join("sauce.cook"), CLEAN);
813 write(
814 &base.join("with_ref.cook"),
815 "---\ntitle: With Reference\n---\n\nMake @./sauce{} and @./nonexistent{}.\n",
816 );
817 write(&base.join("deprecated.cook"), DEPRECATED);
818 write(&base.join("broken.cook"), BROKEN);
819 dir
820 }
821
822 fn write(path: &Utf8Path, text: &str) {
823 std::fs::write(path, text).unwrap();
824 }
825
826 fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
827 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
828 }
829
830 fn run(base_dir: &Utf8Path) -> ValidationReport {
832 run_styled(base_dir, Style::Plain)
833 }
834
835 fn run_styled(base_dir: &Utf8Path, style: Style) -> ValidationReport {
836 validate(
837 &Context::new(base_dir.to_owned()),
838 ValidateRequest {
839 base_dir: None,
840 style,
841 },
842 )
843 .expect("validation succeeds")
844 .into_value()
845 }
846
847 fn paths(report: &ValidationReport) -> Vec<Utf8PathBuf> {
856 report.recipes.iter().map(|r| r.path.clone()).collect()
857 }
858
859 fn recipe<'a>(report: &'a ValidationReport, path: &str) -> &'a RecipeValidation {
860 report
861 .recipes
862 .iter()
863 .find(|r| r.path == path)
864 .unwrap_or_else(|| panic!("{path} missing from {:?}", paths(report)))
865 }
866
867 #[test]
870 fn every_recipe_under_the_root_is_reported_including_nested_ones() {
871 let dir = fixture();
872 let report = run(&base(&dir));
873 assert_eq!(
874 paths(&report),
875 [
876 "Breakfast/pancakes.cook",
877 "broken.cook",
878 "deprecated.cook",
879 "sauce.cook",
880 "with_ref.cook",
881 ]
882 );
883 assert_eq!(report.total_recipes(), 5);
884 }
885
886 #[test]
889 fn totals_count_diagnostics_and_the_recipes_carrying_them() {
890 let dir = fixture();
891 let report = run(&base(&dir));
892
893 assert_eq!(report.total_recipes(), 5);
894 assert_eq!(report.total_errors(), 2, "both errors in broken.cook");
895 assert_eq!(report.recipes_with_errors(), 1);
896 assert_eq!(report.total_warnings(), 1, "deprecated.cook warns once");
897 assert_eq!(report.recipes_with_warnings(), 1);
898 }
899
900 #[test]
901 fn a_failing_recipe_carries_both_diagnostics_and_a_rendered_report() {
902 let dir = fixture();
903 let report = run(&base(&dir));
904 let broken = recipe(&report, "broken.cook");
905
906 assert_eq!(broken.diagnostics.len(), 2);
907 for d in &broken.diagnostics {
908 assert_eq!(d.severity, Severity::Error, "expected errors: {d:?}");
909 }
910 let location = broken.diagnostics[0]
912 .location
913 .as_ref()
914 .expect("location set");
915 assert_eq!(location.file.as_deref(), Some(Utf8Path::new("broken.cook")));
916 assert!(location.span.is_some(), "an error must carry its span");
917
918 assert!(
921 broken.rendered.contains("broken.cook"),
922 "report should name the file: {}",
923 broken.rendered
924 );
925 assert!(
926 broken.rendered.contains("Add @{1%tsp} and @{2%tsp}."),
927 "report should quote the source line: {}",
928 broken.rendered
929 );
930 }
931
932 #[test]
933 fn a_warning_is_reported_as_a_warning_not_an_error() {
934 let dir = fixture();
935 let report = run(&base(&dir));
936 let deprecated = recipe(&report, "deprecated.cook");
937
938 assert_eq!(deprecated.diagnostics.len(), 1);
939 assert_eq!(deprecated.diagnostics[0].severity, Severity::Warning);
940 assert!(
941 !deprecated.rendered.is_empty(),
942 "a warning is worth showing"
943 );
944 }
945
946 #[test]
947 fn a_clean_recipe_carries_neither_diagnostics_nor_a_rendered_report() {
948 let dir = fixture();
949 let report = run(&base(&dir));
950 let clean = recipe(&report, "sauce.cook");
951
952 assert!(clean.diagnostics.is_empty(), "{:?}", clean.diagnostics);
953 assert!(clean.rendered.is_empty(), "{:?}", clean.rendered);
954 }
955
956 #[test]
957 fn references_are_collected_as_they_are_written() {
958 let dir = fixture();
959 let report = run(&base(&dir));
960
961 assert_eq!(
962 recipe(&report, "with_ref.cook").references,
963 ["./sauce", "./nonexistent"],
964 "in source order, unresolved"
965 );
966 assert!(recipe(&report, "sauce.cook").references.is_empty());
967 }
968
969 #[test]
973 fn a_reference_made_twice_is_listed_twice() {
974 let dir = tempfile::TempDir::new().unwrap();
975 let base = base(&dir);
976 write(&base.join("sauce.cook"), CLEAN);
977 write(
978 &base.join("dup.cook"),
979 "---\ntitle: Dup\n---\n\nMake @./sauce{}, then more @./sauce{}.\n",
980 );
981
982 let report = run(&base);
983 assert_eq!(
984 recipe(&report, "dup.cook").references,
985 ["./sauce", "./sauce"]
986 );
987 }
988
989 #[test]
994 fn a_recipe_with_errors_contributes_no_references() {
995 let dir = tempfile::TempDir::new().unwrap();
996 let base = base(&dir);
997 write(
998 &base.join("broken_ref.cook"),
999 "---\ntitle: Broken\n---\n\nMake @./sauce{} and add @{1%tsp}.\n",
1000 );
1001
1002 let report = run(&base);
1003 let broken = recipe(&report, "broken_ref.cook");
1004 assert_eq!(broken.count(Severity::Error), 1, "{:?}", broken.diagnostics);
1005 assert!(
1006 broken.references.is_empty(),
1007 "expected no references, got {:?}",
1008 broken.references
1009 );
1010 assert!(report.references().is_empty());
1011 }
1012
1013 #[test]
1016 fn the_reference_map_holds_only_recipes_that_reference_something() {
1017 let dir = fixture();
1018 let report = run(&base(&dir));
1019 let references = report.references();
1020
1021 assert_eq!(
1022 references.keys().copied().collect::<Vec<_>>(),
1023 [Utf8Path::new("with_ref.cook")]
1024 );
1025 assert_eq!(references[Utf8Path::new("with_ref.cook")].len(), 2);
1026 }
1027
1028 #[test]
1034 fn a_file_that_cannot_be_read_is_one_error_and_does_not_end_the_walk() {
1035 let dir = fixture();
1036 let base = base(&dir);
1037 std::fs::write(
1038 base.join("bad_bytes.cook"),
1039 b"---\ntitle: Bad Bytes\n---\n\nBoil @water{1%l} \xFF.\n",
1040 )
1041 .unwrap();
1042
1043 let report = run(&base);
1044 let unreadable = recipe(&report, "bad_bytes.cook");
1045
1046 assert_eq!(unreadable.diagnostics.len(), 1);
1047 assert_eq!(unreadable.diagnostics[0].severity, Severity::Error);
1048 assert!(
1049 unreadable.diagnostics[0]
1050 .message
1051 .starts_with("Failed to read file"),
1052 "{:?}",
1053 unreadable.diagnostics[0]
1054 );
1055 assert!(
1056 unreadable.rendered.is_empty(),
1057 "there is no source to quote: {:?}",
1058 unreadable.rendered
1059 );
1060 assert_eq!(unreadable.references, Vec::<String>::new());
1061
1062 assert_eq!(report.total_recipes(), 6);
1064 assert_eq!(report.total_errors(), 3);
1065 assert_eq!(report.recipes_with_errors(), 2);
1066 assert_eq!(recipe(&report, "broken.cook").diagnostics.len(), 2);
1068 assert!(recipe(&report, "sauce.cook").diagnostics.is_empty());
1069 }
1070
1071 #[test]
1073 fn a_collection_full_of_errors_still_validates_successfully() {
1074 let dir = tempfile::TempDir::new().unwrap();
1075 write(&base(&dir).join("broken.cook"), BROKEN);
1076
1077 let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default())
1078 .expect("errors in recipes are data, not a failed command");
1079
1080 assert_eq!(outcome.value.total_errors(), 2);
1081 assert!(
1082 outcome.has_errors(),
1083 "the outcome must carry the errors it found: {:?}",
1084 outcome.diagnostics
1085 );
1086 assert_eq!(
1087 outcome.diagnostics.len(),
1088 2,
1089 "every diagnostic in the report, flat"
1090 );
1091 }
1092
1093 #[test]
1095 fn a_clean_collection_has_no_diagnostics_at_all() {
1096 let dir = tempfile::TempDir::new().unwrap();
1097 write(&base(&dir).join("sauce.cook"), CLEAN);
1098
1099 let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default()).unwrap();
1100 assert!(!outcome.has_errors());
1101 assert!(outcome.diagnostics.is_empty());
1102 assert_eq!(outcome.value.total_errors(), 0);
1103 assert_eq!(outcome.value.total_warnings(), 0);
1104 assert_eq!(outcome.value.total_recipes(), 1);
1105 }
1106
1107 #[test]
1108 fn style_decides_whether_the_rendered_report_is_coloured() {
1109 let dir = fixture();
1110 let base = base(&dir);
1111
1112 let plain = run_styled(&base, Style::Plain);
1113 let plain = &recipe(&plain, "broken.cook").rendered;
1114 assert!(
1115 !plain.contains('\u{1b}'),
1116 "Style::Plain must emit no escape codes: {plain:?}"
1117 );
1118
1119 let coloured = run_styled(&base, Style::Ansi);
1120 let coloured = &recipe(&coloured, "broken.cook").rendered;
1121 assert!(
1122 coloured.contains('\u{1b}'),
1123 "Style::Ansi must emit escape codes: {coloured:?}"
1124 );
1125 assert_eq!(
1126 *plain,
1127 anstream::adapter::strip_str(coloured).to_string(),
1128 "the two must differ only in the escape codes"
1129 );
1130 }
1131
1132 #[test]
1135 fn the_default_style_is_plain() {
1136 assert_eq!(ValidateRequest::default().style, Style::Plain);
1137 }
1138
1139 #[test]
1140 fn base_dir_overrides_the_context_base_path() {
1141 let validated = fixture();
1142 let ignored = tempfile::TempDir::new().unwrap();
1143 write(&base(&ignored).join("decoy.cook"), BROKEN);
1144
1145 let report = validate(
1146 &Context::new(base(&ignored)),
1147 ValidateRequest {
1148 base_dir: Some(base(&validated)),
1149 style: Style::Plain,
1150 },
1151 )
1152 .expect("validation succeeds")
1153 .into_value();
1154
1155 assert_eq!(report.total_recipes(), 5);
1156 assert!(
1157 !paths(&report).contains(&Utf8PathBuf::from("decoy.cook")),
1158 "{:?}",
1159 paths(&report)
1160 );
1161 }
1162
1163 #[test]
1164 fn without_a_base_dir_the_context_base_path_is_validated() {
1165 let dir = fixture();
1166 assert_eq!(run(&base(&dir)).total_recipes(), 5);
1167 }
1168
1169 #[test]
1174 fn recipes_come_back_in_path_order_every_time() {
1175 let dir = fixture();
1176 let base = base(&dir);
1177 let expected = paths(&run(&base));
1178 let mut sorted = expected.clone();
1179 sorted.sort();
1180 assert_eq!(expected, sorted);
1181
1182 for _ in 0..8 {
1183 assert_eq!(paths(&run(&base)), expected, "order must not vary");
1184 }
1185 }
1186
1187 #[test]
1188 fn an_empty_directory_validates_to_an_empty_report() {
1189 let dir = tempfile::TempDir::new().unwrap();
1190 let report = run(&base(&dir));
1191 assert_eq!(report.total_recipes(), 0);
1192 assert!(report.references().is_empty());
1193 }
1194
1195 #[test]
1198 fn a_root_that_does_not_exist_is_reported() {
1199 let dir = tempfile::TempDir::new().unwrap();
1200 let missing = base(&dir).join("nope");
1201
1202 match validate(&Context::new(missing.clone()), ValidateRequest::default()) {
1203 Err(CoreError::Search { base_dir, message }) => {
1204 assert_eq!(base_dir, missing);
1205 assert_eq!(message, "no such directory");
1206 }
1207 other => panic!(
1208 "expected CoreError::Search, got {:?}",
1209 other.map(|o| o.value)
1210 ),
1211 }
1212 }
1213
1214 #[test]
1215 fn a_root_that_is_a_file_is_reported() {
1216 let dir = tempfile::TempDir::new().unwrap();
1217 let file = base(&dir).join("sauce.cook");
1218 write(&file, CLEAN);
1219
1220 match validate(&Context::new(file.clone()), ValidateRequest::default()) {
1221 Err(CoreError::Search { base_dir, message }) => {
1222 assert_eq!(base_dir, file);
1223 assert_eq!(message, "not a directory");
1224 }
1225 other => panic!(
1226 "expected CoreError::Search, got {:?}",
1227 other.map(|o| o.value)
1228 ),
1229 }
1230 }
1231
1232 #[test]
1235 fn a_root_that_is_not_a_valid_glob_pattern_is_reported() {
1236 let dir = tempfile::TempDir::new().unwrap();
1237 let root = base(&dir).join("re[ci");
1238 std::fs::create_dir(&root).unwrap();
1239 write(&root.join("sauce.cook"), CLEAN);
1240
1241 match validate(&Context::new(root.clone()), ValidateRequest::default()) {
1242 Err(CoreError::Search { base_dir, message }) => {
1243 assert_eq!(base_dir, root);
1244 assert!(
1245 message.contains("attern"),
1246 "the cause must survive: {message}"
1247 );
1248 }
1249 other => panic!(
1250 "expected CoreError::Search, got {:?}",
1251 other.map(|o| o.value)
1252 ),
1253 }
1254 }
1255
1256 #[test]
1259 fn a_listing_failure_names_the_file_rather_than_the_root() {
1260 let root = Utf8Path::new("/recipes");
1261
1262 let unusable = tree_error(
1263 TreeError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
1264 "bad front matter".to_string(),
1265 )),
1266 root,
1267 );
1268 match unusable {
1269 CoreError::Io { path, source } => {
1270 assert_eq!(path, root);
1271 assert!(
1272 source.to_string().contains("bad front matter"),
1273 "the cause must survive: {source}"
1274 );
1275 }
1276 other => panic!("expected CoreError::Io, got {other:?}"),
1277 }
1278
1279 let unstrippable = tree_error(
1280 TreeError::StripPrefixError("recipes/soup.cook".to_string()),
1281 root,
1282 );
1283 match unstrippable {
1284 CoreError::Search { base_dir, message } => {
1285 assert_eq!(base_dir, root);
1286 assert!(message.contains("recipes/soup.cook"), "{message}");
1287 }
1288 other => panic!("expected CoreError::Search, got {other:?}"),
1289 }
1290 }
1291
1292 #[test]
1293 fn a_path_under_the_root_is_stripped_and_one_outside_is_left_alone() {
1294 assert_eq!(
1295 relative_to(
1296 Utf8Path::new("/recipes"),
1297 Utf8Path::new("/recipes/Breakfast/pancakes.cook")
1298 ),
1299 "Breakfast/pancakes.cook"
1300 );
1301 assert_eq!(
1302 relative_to(
1303 Utf8Path::new("./recipes"),
1304 Utf8Path::new("recipes/soup.cook")
1305 ),
1306 "recipes/soup.cook"
1307 );
1308 }
1309
1310 fn underlined<'a>(diagnostic: &Diagnostic, source: &'a str) -> &'a str {
1316 let span = diagnostic
1317 .location
1318 .as_ref()
1319 .and_then(|location| location.span)
1320 .unwrap_or_else(|| panic!("expected a span: {diagnostic:?}"));
1321 &source[span.start..span.end]
1322 }
1323
1324 #[test]
1329 fn a_textual_timer_quantity_is_a_warning_in_either_shape() {
1330 let anon = "a few";
1331 let named = "a couple";
1332 let source = format!("Cook for ~{{{anon}%minutes}}.\nBake the ~loaf{{{named}%minutes}}.\n");
1333
1334 let dir = tempfile::TempDir::new().unwrap();
1335 write(&base(&dir).join("timer.cook"), &source);
1336 let report = run(&base(&dir));
1337 let timer = recipe(&report, "timer.cook");
1338
1339 assert_eq!(timer.diagnostics.len(), 2, "{:?}", timer.diagnostics);
1340 for (diagnostic, quantity) in timer.diagnostics.iter().zip([anon, named]) {
1341 assert_eq!(diagnostic.severity, Severity::Warning, "{diagnostic:?}");
1342 assert!(
1343 diagnostic.message.contains("Timer value is text"),
1344 "{:?}",
1345 diagnostic.message
1346 );
1347 assert!(
1348 diagnostic.message.contains(quantity),
1349 "{:?} should name {quantity}",
1350 diagnostic.message
1351 );
1352 let location = diagnostic.location.as_ref().expect("location set");
1353 assert_eq!(location.file.as_deref(), Some(Utf8Path::new("timer.cook")));
1354 assert_eq!(underlined(diagnostic, &source), quantity);
1355 assert!(
1356 timer.rendered.contains(quantity),
1357 "the report should identify {quantity}: {}",
1358 timer.rendered
1359 );
1360 }
1361
1362 assert_eq!(report.total_recipes(), 1);
1363 assert_eq!(report.total_errors(), 0);
1364 assert_eq!(report.recipes_with_errors(), 0);
1365 assert_eq!(report.total_warnings(), 2);
1366 assert_eq!(report.recipes_with_warnings(), 1);
1367 }
1368
1369 #[test]
1373 fn numeric_timers_stay_valid_beside_a_textual_ingredient() {
1374 let dir = tempfile::TempDir::new().unwrap();
1375 write(
1376 &base(&dir).join("dish.cook"),
1377 "Bake for ~{40%minutes}, then rest ~{1.5%hours}.\n\
1378 Add @salt{to taste}.\n",
1379 );
1380
1381 let report = run(&base(&dir));
1382 let dish = recipe(&report, "dish.cook");
1383 assert!(
1384 dish.diagnostics.is_empty(),
1385 "a numeric timer must not be reported: {:?}",
1386 dish.diagnostics
1387 );
1388 assert!(dish.rendered.is_empty());
1389 assert_eq!(report.total_errors(), 0);
1390 assert_eq!(report.total_warnings(), 0);
1391 }
1392
1393 #[test]
1398 fn timer_edges_follow_the_shared_parser() {
1399 let named = "Rest the ~dough.\n";
1400 let bare = "Cook for ~{30}.\n";
1401 let unit = "Cook for ~{5%fortnights}, ~{1%hr}, ~{10%Minutes} and ~{5%минут}.\n";
1402
1403 let dir = tempfile::TempDir::new().unwrap();
1404 let base = base(&dir);
1405 write(&base.join("named.cook"), named);
1406 write(&base.join("bare.cook"), bare);
1407 write(&base.join("units.cook"), unit);
1408
1409 let report = run(&base);
1410
1411 let named = recipe(&report, "named.cook");
1412 assert!(
1413 named.diagnostics.is_empty(),
1414 "a named timer with no quantity stays valid: {:?}",
1415 named.diagnostics
1416 );
1417
1418 let bare = recipe(&report, "bare.cook");
1419 assert_eq!(bare.diagnostics.len(), 1, "{:?}", bare.diagnostics);
1420 assert_eq!(bare.diagnostics[0].severity, Severity::Warning);
1421 assert!(
1422 bare.diagnostics[0]
1423 .message
1424 .contains("Invalid timer quantity: missing unit"),
1425 "{:?}",
1426 bare.diagnostics[0]
1427 );
1428 assert_eq!(bare.count(Severity::Error), 0);
1429
1430 let units = recipe(&report, "units.cook");
1431 assert!(
1432 units.diagnostics.is_empty(),
1433 "a numeric timer is valid in any unit: {:?}",
1434 units.diagnostics
1435 );
1436
1437 assert_eq!(report.recipes_with_errors(), 0);
1438 assert_eq!(report.recipes_with_warnings(), 1);
1439 assert_eq!(report.total_errors(), 0);
1440 assert_eq!(report.total_warnings(), 1);
1441 }
1442
1443 #[test]
1448 fn a_numeric_range_is_not_a_warning() {
1449 let source = "Knead for ~{10-20%minutes}, then ~{1.5 - 2%hours} or ~{1 1/2-2%hours}.\n\
1450 Prove ~{a few%minutes}, leave it ~{overnight}, rest ~{½%hour}.\n\
1451 Wait ~{1 2%minutes}, ~{1.2.3%minutes} or ~{1/2/3%minutes}.\n";
1452 let dir = tempfile::TempDir::new().unwrap();
1453 write(&base(&dir).join("timer.cook"), source);
1454 let report = run(&base(&dir));
1455 let timer = recipe(&report, "timer.cook");
1456
1457 let text: Vec<_> = timer
1459 .diagnostics
1460 .iter()
1461 .filter(|diagnostic| diagnostic.message.starts_with("Timer value is text"))
1462 .map(|diagnostic| underlined(diagnostic, source))
1463 .collect();
1464 assert_eq!(
1465 text,
1466 ["a few", "overnight", "½", "1 2", "1.2.3", "1/2/3"],
1467 "{:?}",
1468 timer.diagnostics
1469 );
1470 assert_eq!(timer.count(Severity::Error), 0);
1471 }
1472
1473 #[test]
1477 fn the_span_covers_the_timer_value() {
1478 let cases = [
1479 (
1480 "Add @salt{a few}, then cook ~{a few%minutes}.\n",
1481 "a few",
1482 "~{",
1483 ),
1484 ("Rest ~{a few%minutes}, add @x{a few}.\n", "a few", "~{"),
1485 ("---\nnote: \"{x\"\n---\nSimmer ~{x%minutes}.\n", "x", "~{"),
1486 ];
1487 for (source, value, marker) in cases {
1488 let dir = tempfile::TempDir::new().unwrap();
1489 write(&base(&dir).join("timer.cook"), source);
1490 let report = run(&base(&dir));
1491 let timer = recipe(&report, "timer.cook");
1492
1493 assert_eq!(
1494 timer.diagnostics.len(),
1495 1,
1496 "{source}: {:?}",
1497 timer.diagnostics
1498 );
1499 let span = timer.diagnostics[0]
1500 .location
1501 .as_ref()
1502 .and_then(|location| location.span)
1503 .expect("a span");
1504 assert_eq!(
1505 span.start,
1506 source.find(marker).unwrap() + marker.len(),
1507 "{source}"
1508 );
1509 assert_eq!(underlined(&timer.diagnostics[0], source), value, "{source}");
1510 }
1511 }
1512
1513 #[test]
1515 fn the_rendered_warning_names_its_file_and_line() {
1516 let dir = tempfile::TempDir::new().unwrap();
1517 write(
1518 &base(&dir).join("timer.cook"),
1519 "---\ntitle: Bread\n---\nMix.\nProve ~{overnight}.\n",
1520 );
1521 let report = run(&base(&dir));
1522 let timer = recipe(&report, "timer.cook");
1523
1524 assert!(
1525 timer
1526 .rendered
1527 .contains("Warning: Timer value is text: overnight (timer.cook:5)"),
1528 "{}",
1529 timer.rendered
1530 );
1531 }
1532
1533 #[test]
1536 fn ingredient_quantities_follow_the_shared_parser() {
1537 let dir = tempfile::TempDir::new().unwrap();
1538 write(
1539 &base(&dir).join("dish.cook"),
1540 "Add @flour{1/0 cups} and @beans{1-1/0%cans}.\n",
1541 );
1542 let report = run(&base(&dir));
1543 let dish = recipe(&report, "dish.cook");
1544 assert!(
1545 dish.diagnostics.is_empty(),
1546 "the shared parser accepts these as text: {:?}",
1547 dish.diagnostics
1548 );
1549 }
1550
1551 #[test]
1555 fn a_mixed_collection_still_reports_every_recipe() {
1556 let dir = tempfile::TempDir::new().unwrap();
1557 let base = base(&dir);
1558 write(
1559 &base.join("timer.cook"),
1560 "Cook for ~{a few%minutes}. Make @./sauce{} and @./missing{}.\n",
1561 );
1562 write(&base.join("clean.cook"), "Bake for ~{40%minutes}.\n");
1563 write(&base.join("broken.cook"), BROKEN);
1564 write(&base.join("deprecated.cook"), DEPRECATED);
1565 write(&base.join("sauce.cook"), CLEAN);
1566 write(
1567 &base.join("with_ref.cook"),
1568 "Make @./sauce{} and @./missing{}.\n",
1569 );
1570
1571 let outcome = validate(&Context::new(base), ValidateRequest::default())
1572 .expect("errors in recipes are data, not a failed command");
1573 let report = &outcome.value;
1574
1575 assert_eq!(
1576 paths(report),
1577 [
1578 "broken.cook",
1579 "clean.cook",
1580 "deprecated.cook",
1581 "sauce.cook",
1582 "timer.cook",
1583 "with_ref.cook",
1584 ]
1585 );
1586 assert_eq!(report.total_recipes(), 6);
1587
1588 let timer = recipe(report, "timer.cook");
1589 assert_eq!(timer.count(Severity::Warning), 1, "{:?}", timer.diagnostics);
1590 assert_eq!(timer.count(Severity::Error), 0, "{:?}", timer.diagnostics);
1591 assert!(timer.rendered.contains("a few"), "{}", timer.rendered);
1592 assert_eq!(
1593 timer.references,
1594 ["./sauce", "./missing"],
1595 "a warning must not hide the recipe's references: {:?}",
1596 timer.references
1597 );
1598
1599 let broken_recipe = recipe(report, "broken.cook");
1600 assert_eq!(
1601 broken_recipe.diagnostics.len(),
1602 2,
1603 "{:?}",
1604 broken_recipe.diagnostics
1605 );
1606 assert!(
1607 broken_recipe
1608 .rendered
1609 .contains("Add @{1%tsp} and @{2%tsp}."),
1610 "{}",
1611 broken_recipe.rendered
1612 );
1613
1614 assert!(recipe(report, "clean.cook").diagnostics.is_empty());
1615 assert!(recipe(report, "sauce.cook").diagnostics.is_empty());
1616
1617 let deprecated = recipe(report, "deprecated.cook");
1618 assert_eq!(
1619 deprecated.diagnostics.len(),
1620 1,
1621 "{:?}",
1622 deprecated.diagnostics
1623 );
1624 assert_eq!(deprecated.diagnostics[0].severity, Severity::Warning);
1625
1626 assert_eq!(
1627 recipe(report, "with_ref.cook").references,
1628 ["./sauce", "./missing"]
1629 );
1630 assert_eq!(
1631 broken(report),
1632 [
1633 ("timer.cook".to_string(), vec!["./missing".to_string()]),
1634 ("with_ref.cook".to_string(), vec!["./missing".to_string()])
1635 ]
1636 );
1637
1638 assert_eq!(report.total_errors(), 2);
1639 assert_eq!(report.recipes_with_errors(), 1);
1640 assert_eq!(report.total_warnings(), 2);
1641 assert_eq!(report.recipes_with_warnings(), 2);
1642 assert!(outcome.has_errors());
1643 assert_eq!(
1644 outcome.diagnostics.len(),
1645 report.total_errors() + report.total_warnings()
1646 );
1647 }
1648
1649 #[test]
1652 fn the_shared_parser_still_accepts_a_textual_timer() {
1653 let parsed = crate::PARSER.parse("Cook for ~{a few%minutes}.\n");
1654 assert!(
1655 !parsed.report().has_errors(),
1656 "the shared parser must not grow this check"
1657 );
1658 }
1659
1660 fn broken(report: &ValidationReport) -> Vec<(String, Vec<String>)> {
1667 broken_references(report)
1668 .into_iter()
1669 .map(|(recipe, missing)| (recipe.to_string(), missing))
1670 .collect()
1671 }
1672
1673 #[test]
1674 fn the_report_records_the_root_it_was_validated_against() {
1675 let dir = fixture();
1676 assert_eq!(run(&base(&dir)).base_dir, base(&dir));
1677
1678 let elsewhere = tempfile::TempDir::new().unwrap();
1679 let report = validate(
1680 &Context::new(base(&elsewhere)),
1681 ValidateRequest {
1682 base_dir: Some(base(&dir)),
1683 style: Style::Plain,
1684 },
1685 )
1686 .expect("validation succeeds")
1687 .into_value();
1688 assert_eq!(
1689 report.base_dir,
1690 base(&dir),
1691 "the root that was walked, not the context's"
1692 );
1693 }
1694
1695 #[test]
1698 fn only_references_that_resolve_to_nothing_are_reported() {
1699 let dir = fixture();
1700 assert_eq!(
1701 broken(&run(&base(&dir))),
1702 [(
1703 "with_ref.cook".to_string(),
1704 vec!["./nonexistent".to_string()]
1705 )]
1706 );
1707 }
1708
1709 #[test]
1710 fn a_collection_whose_references_all_resolve_reports_none() {
1711 let dir = tempfile::TempDir::new().unwrap();
1712 let base = base(&dir);
1713 write(&base.join("sauce.cook"), CLEAN);
1714 write(
1715 &base.join("dish.cook"),
1716 "---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
1717 );
1718
1719 assert!(
1720 broken_references(&run(&base)).is_empty(),
1721 "a resolvable reference must not be reported"
1722 );
1723 }
1724
1725 #[test]
1728 fn a_reference_repeated_is_reported_once_per_mention() {
1729 let dir = tempfile::TempDir::new().unwrap();
1730 let base = base(&dir);
1731 write(
1732 &base.join("dish.cook"),
1733 "---\ntitle: Dish\n---\n\nMake @./absent{}, then more @./absent{}.\n",
1734 );
1735
1736 assert_eq!(
1737 broken(&run(&base)),
1738 [(
1739 "dish.cook".to_string(),
1740 vec!["./absent".to_string(), "./absent".to_string()]
1741 )]
1742 );
1743 }
1744
1745 #[test]
1750 fn references_resolve_against_the_validated_root_from_anywhere_in_it() {
1751 let dir = tempfile::TempDir::new().unwrap();
1752 let base = base(&dir);
1753 write(&base.join("sauce.cook"), CLEAN);
1754 std::fs::create_dir(base.join("Dinner")).unwrap();
1755 write(
1756 &base.join("Dinner").join("dish.cook"),
1757 "---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
1758 );
1759
1760 assert!(
1761 broken_references(&run(&base)).is_empty(),
1762 "a nested recipe must be able to reference the root's"
1763 );
1764 }
1765
1766 #[test]
1769 fn a_collection_with_no_references_has_none_broken() {
1770 let dir = tempfile::TempDir::new().unwrap();
1771 write(&base(&dir).join("sauce.cook"), CLEAN);
1772 assert!(broken_references(&run(&base(&dir))).is_empty());
1773 }
1774
1775 fn one_recipe(text: &str) -> tempfile::TempDir {
1781 let dir = tempfile::TempDir::new().unwrap();
1782 write(&base(&dir).join("dish.cook"), text);
1783 dir
1784 }
1785
1786 fn aisle_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
1787 Context::new(base(dir)).with_aisle(ConfigSource::Inline(conf.to_string()))
1788 }
1789
1790 fn pantry_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
1791 Context::new(base(dir)).with_pantry(ConfigSource::Inline(conf.to_string()))
1792 }
1793
1794 fn checked(ctx: &Context, aisle: bool) -> Outcome<IngredientCoverage> {
1795 let request = CoverageRequest::default();
1796 if aisle {
1797 aisle_coverage(ctx, request)
1798 } else {
1799 pantry_coverage(ctx, request)
1800 }
1801 .expect("the check succeeds")
1802 }
1803
1804 fn known(coverage: &IngredientCoverage) -> Vec<&str> {
1805 coverage.known().collect()
1806 }
1807
1808 fn unknown(coverage: &IngredientCoverage) -> Vec<&str> {
1809 coverage.unknown().collect()
1810 }
1811
1812 fn all(coverage: &IngredientCoverage) -> Vec<&str> {
1813 coverage
1814 .ingredients
1815 .iter()
1816 .map(|ingredient| ingredient.name.as_str())
1817 .collect()
1818 }
1819
1820 fn recipes_for<'a>(coverage: &'a IngredientCoverage, name: &str) -> Vec<&'a Utf8Path> {
1827 coverage
1828 .ingredients
1829 .iter()
1830 .find(|ingredient| ingredient.name == name)
1831 .unwrap_or_else(|| panic!("{name} is in the coverage"))
1832 .recipes
1833 .iter()
1834 .map(Utf8PathBuf::as_path)
1835 .collect()
1836 }
1837
1838 #[test]
1839 fn an_aisle_splits_the_collection_into_categorised_and_not() {
1840 let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
1841 let coverage = checked(
1842 &aisle_ctx(&dir, "[produce]\nleek\n\n[pantry]\nsalt\n"),
1843 true,
1844 )
1845 .value;
1846
1847 assert_eq!(known(&coverage), ["leek", "salt"]);
1848 assert_eq!(unknown(&coverage), ["water"]);
1849 assert_eq!(coverage.total_ingredients(), 3);
1850 assert_eq!(coverage.total_recipes, 1);
1851 }
1852
1853 #[test]
1855 fn an_aisle_synonym_counts_as_knowing_the_ingredient() {
1856 let dir = one_recipe("Add @aubergine{1}.\n");
1857 let coverage = checked(&aisle_ctx(&dir, "[produce]\neggplant|aubergine\n"), true).value;
1858
1859 assert_eq!(known(&coverage), ["aubergine"]);
1860 assert!(unknown(&coverage).is_empty());
1861 }
1862
1863 #[test]
1864 fn a_pantry_knows_its_items_from_every_section() {
1865 let dir = one_recipe("Add @salt{1%tsp}, @milk{1%l} and @water{1%l}.\n");
1866 let conf = "[pantry]\nsalt = \"1%kg\"\n\n[dairy]\nmilk = \"1%l\"\n";
1867 let coverage = checked(&pantry_ctx(&dir, conf), false).value;
1868
1869 assert_eq!(known(&coverage), ["milk", "salt"]);
1870 assert_eq!(unknown(&coverage), ["water"]);
1871 }
1872
1873 #[test]
1876 fn a_pantry_item_that_has_run_out_still_counts_as_known() {
1877 let dir = one_recipe("Add @honey{1%tbsp}.\n");
1878 let conf = "[pantry]\nhoney = { quantity = \"0\", low = \"100%g\" }\n";
1879 assert_eq!(
1880 known(&checked(&pantry_ctx(&dir, conf), false).value),
1881 ["honey"]
1882 );
1883 }
1884
1885 #[test]
1888 fn names_are_matched_ignoring_case_and_reported_as_the_recipe_writes_them() {
1889 let dir = one_recipe("Add @Salt{1%tsp} and @PEPPER{}.\n");
1890
1891 let aisle = checked(&aisle_ctx(&dir, "[pantry]\nsalt\npepper\n"), true).value;
1892 assert_eq!(known(&aisle), ["PEPPER", "Salt"]);
1893 assert!(unknown(&aisle).is_empty());
1894
1895 let conf = "[pantry]\nsalt = \"1%kg\"\npepper = \"50%g\"\n";
1896 let pantry = checked(&pantry_ctx(&dir, conf), false).value;
1897 assert_eq!(known(&pantry), ["PEPPER", "Salt"]);
1898 assert!(unknown(&pantry).is_empty());
1899 }
1900
1901 #[test]
1906 fn a_non_ascii_name_is_matched_ignoring_case_too() {
1907 let dir = one_recipe("Add @Öl{1%tbsp} and @Ärter{100%g}.\n");
1908
1909 let aisle = checked(&aisle_ctx(&dir, "[pantry]\növerste|öl\närter\n"), true).value;
1910 assert_eq!(known(&aisle), ["Ärter", "Öl"]);
1911 assert!(unknown(&aisle).is_empty(), "{:?}", unknown(&aisle));
1912
1913 let conf = "[pantry]\n\"öl\" = \"1%l\"\n\"ärter\" = \"1%kg\"\n";
1915 assert_eq!(
1916 known(&checked(&pantry_ctx(&dir, conf), false).value),
1917 ["Ärter", "Öl"]
1918 );
1919 }
1920
1921 #[test]
1924 fn two_spellings_of_one_ingredient_are_both_listed() {
1925 let dir = tempfile::TempDir::new().unwrap();
1926 write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
1927 write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
1928
1929 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1930 assert_eq!(known(&coverage), ["Salt", "salt"]);
1931 assert_eq!(coverage.total_ingredients(), 2);
1932 }
1933
1934 #[test]
1938 fn without_a_configuration_nothing_is_known() {
1939 let dir = one_recipe("Add @salt{1%tsp}.\n");
1940 let ctx = Context::new(base(&dir));
1941
1942 for aisle in [true, false] {
1943 let coverage = checked(&ctx, aisle).value;
1944 assert_eq!(coverage.total_recipes, 1);
1945 assert!(known(&coverage).is_empty(), "aisle: {aisle}");
1946 assert_eq!(unknown(&coverage), ["salt"], "aisle: {aisle}");
1947 }
1948 }
1949
1950 #[test]
1953 fn references_to_other_recipes_are_not_ingredients() {
1954 let dir = one_recipe("Make @./sauce{} and add @water{1%l}.\n");
1955 write(&base(&dir).join("sauce.cook"), CLEAN);
1956
1957 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsauce\nwater\noil\n"), true).value;
1958 assert_eq!(
1959 all(&coverage),
1960 ["oil", "water"],
1961 "the referenced recipe's own ingredients count; the reference does not"
1962 );
1963 }
1964
1965 #[test]
1966 fn every_recipe_under_the_root_is_scanned_including_nested_ones() {
1967 let dir = one_recipe("Boil @water{1%l}.\n");
1968 std::fs::create_dir(base(&dir).join("Breakfast")).unwrap();
1969 write(
1970 &base(&dir).join("Breakfast").join("porridge.cook"),
1971 "Simmer @oats{50%g}.\n",
1972 );
1973
1974 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nwater\n"), true).value;
1975 assert_eq!(coverage.total_recipes, 2);
1976 assert_eq!(
1977 unknown(&coverage),
1978 ["oats"],
1979 "a subdirectory must be walked"
1980 );
1981 assert_eq!(
1982 recipes_for(&coverage, "oats"),
1983 [Utf8Path::new("Breakfast/porridge.cook")],
1984 "a recipe is named relative to the directory that was scanned"
1985 );
1986 }
1987
1988 #[test]
1991 fn an_ingredient_carries_every_recipe_that_writes_it() {
1992 let dir = tempfile::TempDir::new().unwrap();
1993 write(
1994 &base(&dir).join("curry.cook"),
1995 "Add @ground cumin{1%tsp}.\n",
1996 );
1997 write(&base(&dir).join("dal.cook"), "Add @ground cumin{2%tsp}.\n");
1998 write(&base(&dir).join("stew.cook"), "Add @cumin powder{1%tsp}.\n");
1999
2000 let coverage = checked(&aisle_ctx(&dir, "[spices]\nground cumin\n"), true).value;
2001
2002 assert_eq!(
2003 recipes_for(&coverage, "ground cumin"),
2004 [Utf8Path::new("curry.cook"), Utf8Path::new("dal.cook")],
2005 "shared by two recipes, listed once each, in path order"
2006 );
2007 assert_eq!(
2008 recipes_for(&coverage, "cumin powder"),
2009 [Utf8Path::new("stew.cook")]
2010 );
2011 }
2012
2013 #[test]
2016 fn a_recipe_using_an_ingredient_twice_is_listed_once() {
2017 let dir = one_recipe("Add @salt{1%tsp}, then more @salt{1%tsp}.\n");
2018 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
2019 assert_eq!(recipes_for(&coverage, "salt"), [Utf8Path::new("dish.cook")]);
2020 }
2021
2022 #[test]
2025 fn each_spelling_keeps_its_own_recipes() {
2026 let dir = tempfile::TempDir::new().unwrap();
2027 write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
2028 write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
2029
2030 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
2031 assert_eq!(recipes_for(&coverage, "Salt"), [Utf8Path::new("a.cook")]);
2032 assert_eq!(recipes_for(&coverage, "salt"), [Utf8Path::new("b.cook")]);
2033 }
2034
2035 #[test]
2038 fn the_pantry_check_lists_recipes_as_well() {
2039 let dir = tempfile::TempDir::new().unwrap();
2040 write(&base(&dir).join("a.cook"), "Add @rice{100%g}.\n");
2041 write(&base(&dir).join("b.cook"), "Add @rice{200%g}.\n");
2042
2043 let coverage = checked(&pantry_ctx(&dir, "[pantry]\nrice = \"5%kg\"\n"), false).value;
2044 assert_eq!(known(&coverage), ["rice"]);
2045 assert_eq!(
2046 recipes_for(&coverage, "rice"),
2047 [Utf8Path::new("a.cook"), Utf8Path::new("b.cook")]
2048 );
2049 }
2050
2051 #[test]
2054 fn a_recipe_that_cannot_be_parsed_is_counted_but_contributes_nothing() {
2055 let dir = one_recipe("Add @salt{1%tsp}.\n");
2056 write(&base(&dir).join("broken.cook"), BROKEN);
2057
2058 let outcome = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true);
2059 assert_eq!(outcome.value.total_recipes, 2);
2060 assert_eq!(outcome.value.total_ingredients(), 1);
2061 assert_eq!(known(&outcome.value), ["salt"]);
2062
2063 let skipped = outcome
2064 .diagnostics
2065 .iter()
2066 .find(|d| d.message.contains("broken.cook"))
2067 .unwrap_or_else(|| {
2068 panic!(
2069 "the skipped recipe must be named: {:?}",
2070 outcome.diagnostics
2071 )
2072 });
2073 assert_eq!(skipped.severity, Severity::Warning);
2076 assert!(!outcome.has_errors());
2077 }
2078
2079 #[test]
2088 fn ingredients_come_back_in_code_point_order_every_time() {
2089 let dir = tempfile::TempDir::new().unwrap();
2090 for (file, ingredient) in [
2091 ("a", "yeast"),
2092 ("b", "flour"),
2093 ("c", "sugar"),
2094 ("d", "Zucchini"),
2095 ("e", "apple"),
2096 ("f", "Beetroot"),
2097 ] {
2098 write(
2099 &base(&dir).join(format!("{file}.cook")),
2100 &format!("Add @{ingredient}{{1}}.\n"),
2101 );
2102 }
2103 let ctx = aisle_ctx(&dir, "[pantry]\nflour\n");
2104
2105 for _ in 0..8 {
2106 let coverage = checked(&ctx, true).value;
2107 assert_eq!(
2108 all(&coverage),
2109 ["Beetroot", "Zucchini", "apple", "flour", "sugar", "yeast"],
2110 "capitalised names sort first, as CookCLI has always printed them"
2111 );
2112 assert_eq!(
2113 unknown(&coverage),
2114 ["Beetroot", "Zucchini", "apple", "sugar", "yeast"]
2115 );
2116 }
2117 }
2118
2119 #[test]
2122 fn the_two_views_partition_the_ingredients() {
2123 let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
2124 let coverage = checked(&aisle_ctx(&dir, "[produce]\nleek\n"), true).value;
2125
2126 let mut both: Vec<&str> = known(&coverage)
2127 .into_iter()
2128 .chain(unknown(&coverage))
2129 .collect();
2130 both.sort_unstable();
2131 assert_eq!(both, all(&coverage));
2132 assert_eq!(
2133 known(&coverage).len() + unknown(&coverage).len(),
2134 coverage.total_ingredients()
2135 );
2136 }
2137
2138 #[test]
2139 fn a_coverage_base_dir_overrides_the_context_base_path() {
2140 let scanned = one_recipe("Add @salt{1%tsp}.\n");
2141 let ignored = one_recipe("Add @decoy{1}.\n");
2142
2143 let coverage = aisle_coverage(
2144 &aisle_ctx(&ignored, "[pantry]\nsalt\n"),
2145 CoverageRequest {
2146 base_dir: Some(base(&scanned)),
2147 },
2148 )
2149 .expect("the check succeeds")
2150 .into_value();
2151
2152 assert_eq!(known(&coverage), ["salt"]);
2153 assert!(!all(&coverage).contains(&"decoy"), "{:?}", all(&coverage));
2154 }
2155
2156 #[test]
2159 fn a_warning_in_the_aisle_configuration_comes_back_as_a_diagnostic() {
2160 let dir = one_recipe("Add @leek{1}.\n");
2161 let outcome = checked(&aisle_ctx(&dir, "[produce]\nleek\n\n[dairy]\nleek\n"), true);
2162
2163 assert!(
2164 outcome
2165 .diagnostics
2166 .iter()
2167 .any(|d| d.message.contains("Duplicate ingredient")),
2168 "{:?}",
2169 outcome.diagnostics
2170 );
2171 assert_eq!(known(&outcome.value), ["leek"]);
2173 }
2174
2175 #[test]
2179 fn a_warning_in_the_pantry_configuration_comes_back_as_a_diagnostic() {
2180 let dir = one_recipe("Add @ice{1}.\n");
2181 let conf = "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n";
2182 let outcome = checked(&pantry_ctx(&dir, conf), false);
2183
2184 assert!(
2185 !outcome.diagnostics.is_empty(),
2186 "the unknown attribute must be reported"
2187 );
2188 for diagnostic in &outcome.diagnostics {
2189 assert_eq!(diagnostic.severity, Severity::Warning, "{diagnostic:?}");
2190 }
2191 assert!(!outcome.has_errors());
2192 assert_eq!(known(&outcome.value), ["ice"]);
2194 }
2195
2196 #[test]
2199 fn a_configuration_warning_is_located_in_the_file_it_came_from() {
2200 let dir = one_recipe("Add @ice{1}.\n");
2201 let path = base(&dir).join("pantry.conf");
2202 write(
2203 &path,
2204 "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n",
2205 );
2206
2207 let ctx = Context::new(base(&dir)).with_pantry(ConfigSource::Path(path.clone()));
2208 let outcome =
2209 pantry_coverage(&ctx, CoverageRequest::default()).expect("the check succeeds");
2210
2211 let located = outcome
2212 .diagnostics
2213 .iter()
2214 .find(|d| d.location.is_some())
2215 .unwrap_or_else(|| panic!("expected a located warning: {:?}", outcome.diagnostics));
2216 assert_eq!(
2217 located.location.as_ref().and_then(|l| l.file.as_deref()),
2218 Some(path.as_path())
2219 );
2220 }
2221
2222 #[test]
2226 fn a_configuration_that_cannot_be_read_is_reported() {
2227 let dir = one_recipe("Add @salt{1%tsp}.\n");
2228 let missing = base(&dir).join("config").join("aisle.conf");
2229
2230 match aisle_coverage(
2231 &Context::new(base(&dir)).with_aisle(ConfigSource::Path(missing.clone())),
2232 CoverageRequest::default(),
2233 ) {
2234 Err(CoreError::Io { path, source }) => {
2235 assert_eq!(path, missing);
2236 assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
2237 }
2238 other => panic!("expected CoreError::Io, got {:?}", other.map(|o| o.value)),
2239 }
2240 }
2241
2242 #[test]
2247 fn a_pantry_that_cannot_be_parsed_at_all_is_reported_as_pantry_load_reports_it() {
2248 let dir = one_recipe("Add @salt{1%tsp}.\n");
2249 let ctx = pantry_ctx(&dir, "this is not toml [");
2250
2251 let from_load = match crate::pantry::load(&ctx) {
2252 Err(CoreError::Config { message, .. }) => message,
2253 other => panic!("expected CoreError::Config from load, got {other:?}"),
2254 };
2255
2256 match pantry_coverage(&ctx, CoverageRequest::default()) {
2257 Err(CoreError::Config { path, message }) => {
2258 assert_eq!(path, None, "an inline configuration has no path");
2259 assert!(
2262 message.contains("TOML parse error"),
2263 "the cause must survive: {message}"
2264 );
2265 assert_eq!(message, from_load, "the two commands must agree");
2266 }
2267 other => panic!(
2268 "expected CoreError::Config, got {:?}",
2269 other.map(|o| o.value)
2270 ),
2271 }
2272 }
2273
2274 #[test]
2276 fn a_root_that_does_not_exist_is_reported_by_a_coverage_check() {
2277 let dir = tempfile::TempDir::new().unwrap();
2278 let missing = base(&dir).join("nope");
2279
2280 match pantry_coverage(
2281 &Context::new(missing.clone()).with_pantry(ConfigSource::Inline(String::new())),
2282 CoverageRequest::default(),
2283 ) {
2284 Err(CoreError::Search { base_dir, message }) => {
2285 assert_eq!(base_dir, missing);
2286 assert_eq!(message, "no such directory");
2287 }
2288 other => panic!(
2289 "expected CoreError::Search, got {:?}",
2290 other.map(|o| o.value)
2291 ),
2292 }
2293 }
2294
2295 #[test]
2296 fn an_empty_collection_covers_nothing() {
2297 let dir = tempfile::TempDir::new().unwrap();
2298 let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
2299 assert_eq!(coverage.total_recipes, 0);
2300 assert_eq!(coverage.total_ingredients(), 0);
2301 assert!(known(&coverage).is_empty());
2302 assert!(unknown(&coverage).is_empty());
2303 }
2304}