use crate::{
diagnostic::{parse_failure, Severity},
find::{build_tree, listed_ingredients, parse_or_skip, walk},
parser::{collect_diagnostics, render_report, PARSER},
ConfigSource, Context, CoreError, Diagnostic, Outcome, Style,
};
use camino::{Utf8Path, Utf8PathBuf};
use cooklang_find::RecipeEntry;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Default)]
pub struct ValidateRequest {
pub base_dir: Option<Utf8PathBuf>,
pub style: Style,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct RecipeValidation {
pub path: Utf8PathBuf,
pub diagnostics: Vec<Diagnostic>,
pub rendered: String,
pub references: Vec<String>,
}
impl RecipeValidation {
fn count(&self, severity: Severity) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == severity)
.count()
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct ValidationReport {
pub base_dir: Utf8PathBuf,
pub recipes: Vec<RecipeValidation>,
}
impl ValidationReport {
pub fn total_recipes(&self) -> usize {
self.recipes.len()
}
pub fn recipes_with_errors(&self) -> usize {
self.recipes
.iter()
.filter(|r| r.count(Severity::Error) > 0)
.count()
}
pub fn recipes_with_warnings(&self) -> usize {
self.recipes
.iter()
.filter(|r| r.count(Severity::Warning) > 0)
.count()
}
pub fn total_errors(&self) -> usize {
self.recipes.iter().map(|r| r.count(Severity::Error)).sum()
}
pub fn total_warnings(&self) -> usize {
self.recipes
.iter()
.map(|r| r.count(Severity::Warning))
.sum()
}
pub fn references(&self) -> BTreeMap<&Utf8Path, &[String]> {
self.recipes
.iter()
.filter(|r| !r.references.is_empty())
.map(|r| (r.path.as_path(), r.references.as_slice()))
.collect()
}
}
pub fn validate(
ctx: &Context,
req: ValidateRequest,
) -> Result<Outcome<ValidationReport>, CoreError> {
let base_dir = req
.base_dir
.unwrap_or_else(|| ctx.base_path().to_path_buf());
tracing::trace!("validating recipes under {base_dir}");
let tree = build_tree(&base_dir)?;
let mut recipes: Vec<RecipeValidation> = walk(&tree)
.into_iter()
.map(|entry| validate_entry(entry, &base_dir, req.style))
.collect();
recipes.sort_by(|a, b| a.path.cmp(&b.path));
let diagnostics = recipes
.iter()
.flat_map(|r| r.diagnostics.iter().cloned())
.collect();
Ok(Outcome::with_diagnostics(
ValidationReport { base_dir, recipes },
diagnostics,
))
}
fn validate_entry(entry: &RecipeEntry, base_dir: &Utf8Path, style: Style) -> RecipeValidation {
let name = entry
.name()
.clone()
.unwrap_or_else(|| "unknown".to_string());
let full_path = entry.path().cloned().unwrap_or_else(|| base_dir.join(name));
let path = relative_to(base_dir, &full_path);
let content = match std::fs::read_to_string(&full_path) {
Ok(content) => content,
Err(e) => {
return RecipeValidation {
diagnostics: vec![
Diagnostic::error(format!("Failed to read file: {e}")).at_file(path.clone())
],
path,
rendered: String::new(),
references: Vec::new(),
};
}
};
let parsed = PARSER.parse(&content);
let diagnostics = collect_diagnostics(parsed.report(), Some(&path));
let rendered = if diagnostics.is_empty() {
String::new()
} else {
render_report(parsed.report(), path.as_str(), &content, style.is_ansi())
};
let references = parsed
.output()
.map(|recipe| {
recipe
.ingredients
.iter()
.filter_map(|ingredient| ingredient.reference.as_ref())
.map(|reference| {
if reference.components.is_empty() {
reference.name.clone()
} else {
reference.path("/")
}
})
.collect()
})
.unwrap_or_default();
RecipeValidation {
path,
diagnostics,
rendered,
references,
}
}
fn relative_to(base_dir: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
path.strip_prefix(base_dir).unwrap_or(path).to_owned()
}
pub fn broken_references(report: &ValidationReport) -> BTreeMap<&Utf8Path, Vec<String>> {
report
.references()
.into_iter()
.filter_map(|(recipe, references)| {
let from = recipe.parent().unwrap_or(Utf8Path::new("")).to_owned();
let broken: Vec<String> = references
.iter()
.filter(|reference| {
crate::find::resolve_reference(&from, reference).is_none_or(|path| {
crate::find::get_recipe(&report.base_dir, path.as_str()).is_err()
})
})
.cloned()
.collect();
(!broken.is_empty()).then_some((recipe, broken))
})
.collect()
}
#[derive(Debug, Clone, Default)]
pub struct CoverageRequest {
pub base_dir: Option<Utf8PathBuf>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckedIngredient {
pub name: String,
pub known: bool,
pub recipes: Vec<Utf8PathBuf>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IngredientCoverage {
pub total_recipes: usize,
pub ingredients: Vec<CheckedIngredient>,
}
impl IngredientCoverage {
pub fn total_ingredients(&self) -> usize {
self.ingredients.len()
}
pub fn known(&self) -> impl Iterator<Item = &str> {
self.known_entries()
.map(|ingredient| ingredient.name.as_str())
}
pub fn unknown(&self) -> impl Iterator<Item = &str> {
self.unknown_entries()
.map(|ingredient| ingredient.name.as_str())
}
pub fn known_entries(&self) -> impl Iterator<Item = &CheckedIngredient> {
self.filtered(true)
}
pub fn unknown_entries(&self) -> impl Iterator<Item = &CheckedIngredient> {
self.filtered(false)
}
fn filtered(&self, known: bool) -> impl Iterator<Item = &CheckedIngredient> {
self.ingredients
.iter()
.filter(move |ingredient| ingredient.known == known)
}
}
pub fn aisle_coverage(
ctx: &Context,
req: CoverageRequest,
) -> Result<Outcome<IngredientCoverage>, CoreError> {
let source = ctx.aisle();
let mut diagnostics = Vec::new();
let mut known = BTreeSet::new();
if let Some(text) = source.read()? {
let parsed = cooklang::aisle::parse_lenient(&text);
diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
let conf = parsed
.output()
.ok_or_else(|| config_error(source, "aisle", &diagnostics))?;
known.extend(conf.ingredients_info().into_keys());
}
coverage(ctx, req, &known, diagnostics)
}
pub fn pantry_coverage(
ctx: &Context,
req: CoverageRequest,
) -> Result<Outcome<IngredientCoverage>, CoreError> {
let source = ctx.pantry();
let mut diagnostics = Vec::new();
let mut known = BTreeSet::new();
if let Some(text) = source.read()? {
let parsed = cooklang::pantry::parse_lenient(&text);
diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
let conf = parsed
.output()
.ok_or_else(|| config_error(source, "pantry", &diagnostics))?;
known.extend(conf.all_items().map(|item| item.name().to_lowercase()));
}
coverage(ctx, req, &known, diagnostics)
}
fn config_error(source: &ConfigSource, kind: &str, diagnostics: &[Diagnostic]) -> CoreError {
CoreError::Config {
path: source.path().map(ToOwned::to_owned),
message: parse_failure(diagnostics, kind),
}
}
fn coverage(
ctx: &Context,
req: CoverageRequest,
known: &BTreeSet<String>,
mut diagnostics: Vec<Diagnostic>,
) -> Result<Outcome<IngredientCoverage>, CoreError> {
let base_dir = req
.base_dir
.unwrap_or_else(|| ctx.base_path().to_path_buf());
let tree = build_tree(&base_dir)?;
let entries = walk(&tree);
let total_recipes = entries.len();
let mut names: BTreeMap<String, BTreeSet<Utf8PathBuf>> = BTreeMap::new();
for entry in entries {
let Some(recipe) = parse_or_skip(entry, &mut diagnostics) else {
continue;
};
let path = entry
.path()
.map(|path| relative_to(&base_dir, path))
.or_else(|| entry.name().clone().map(Utf8PathBuf::from))
.unwrap_or_else(|| Utf8PathBuf::from("unknown"));
for name in listed_ingredients(&recipe) {
names.entry(name).or_default().insert(path.clone());
}
}
let ingredients = names
.into_iter()
.map(|(name, recipes)| CheckedIngredient {
known: known.contains(&name.to_lowercase()),
name,
recipes: recipes.into_iter().collect(),
})
.collect();
Ok(Outcome::with_diagnostics(
IngredientCoverage {
total_recipes,
ingredients,
},
diagnostics,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::find::tree_error;
use cooklang_find::tree::TreeError;
const CLEAN: &str = "---\ntitle: Basic Sauce\n---\n\nHeat @oil{2%tbsp} in a #pan.\n";
const DEPRECATED: &str = ">> title: Old Style\n\nBoil @water{1%l}.\n";
const BROKEN: &str = "---\ntitle: Broken\n---\n\nAdd @{1%tsp} and @{2%tsp}.\n";
fn fixture() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
std::fs::create_dir(base.join("Breakfast")).unwrap();
write(
&base.join("Breakfast").join("pancakes.cook"),
"---\ntitle: Pancakes\n---\n\nMix @flour{2%cups}.\n",
);
write(&base.join("sauce.cook"), CLEAN);
write(
&base.join("with_ref.cook"),
"---\ntitle: With Reference\n---\n\nMake @./sauce{} and @./nonexistent{}.\n",
);
write(&base.join("deprecated.cook"), DEPRECATED);
write(&base.join("broken.cook"), BROKEN);
dir
}
fn write(path: &Utf8Path, text: &str) {
std::fs::write(path, text).unwrap();
}
fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
}
fn run(base_dir: &Utf8Path) -> ValidationReport {
run_styled(base_dir, Style::Plain)
}
fn run_styled(base_dir: &Utf8Path, style: Style) -> ValidationReport {
validate(
&Context::new(base_dir.to_owned()),
ValidateRequest {
base_dir: None,
style,
},
)
.expect("validation succeeds")
.into_value()
}
fn paths(report: &ValidationReport) -> Vec<Utf8PathBuf> {
report.recipes.iter().map(|r| r.path.clone()).collect()
}
fn recipe<'a>(report: &'a ValidationReport, path: &str) -> &'a RecipeValidation {
report
.recipes
.iter()
.find(|r| r.path == path)
.unwrap_or_else(|| panic!("{path} missing from {:?}", paths(report)))
}
#[test]
fn every_recipe_under_the_root_is_reported_including_nested_ones() {
let dir = fixture();
let report = run(&base(&dir));
assert_eq!(
paths(&report),
[
"Breakfast/pancakes.cook",
"broken.cook",
"deprecated.cook",
"sauce.cook",
"with_ref.cook",
]
);
assert_eq!(report.total_recipes(), 5);
}
#[test]
fn totals_count_diagnostics_and_the_recipes_carrying_them() {
let dir = fixture();
let report = run(&base(&dir));
assert_eq!(report.total_recipes(), 5);
assert_eq!(report.total_errors(), 2, "both errors in broken.cook");
assert_eq!(report.recipes_with_errors(), 1);
assert_eq!(report.total_warnings(), 1, "deprecated.cook warns once");
assert_eq!(report.recipes_with_warnings(), 1);
}
#[test]
fn a_failing_recipe_carries_both_diagnostics_and_a_rendered_report() {
let dir = fixture();
let report = run(&base(&dir));
let broken = recipe(&report, "broken.cook");
assert_eq!(broken.diagnostics.len(), 2);
for d in &broken.diagnostics {
assert_eq!(d.severity, Severity::Error, "expected errors: {d:?}");
}
let location = broken.diagnostics[0]
.location
.as_ref()
.expect("location set");
assert_eq!(location.file.as_deref(), Some(Utf8Path::new("broken.cook")));
assert!(location.span.is_some(), "an error must carry its span");
assert!(
broken.rendered.contains("broken.cook"),
"report should name the file: {}",
broken.rendered
);
assert!(
broken.rendered.contains("Add @{1%tsp} and @{2%tsp}."),
"report should quote the source line: {}",
broken.rendered
);
}
#[test]
fn a_warning_is_reported_as_a_warning_not_an_error() {
let dir = fixture();
let report = run(&base(&dir));
let deprecated = recipe(&report, "deprecated.cook");
assert_eq!(deprecated.diagnostics.len(), 1);
assert_eq!(deprecated.diagnostics[0].severity, Severity::Warning);
assert!(
!deprecated.rendered.is_empty(),
"a warning is worth showing"
);
}
#[test]
fn a_clean_recipe_carries_neither_diagnostics_nor_a_rendered_report() {
let dir = fixture();
let report = run(&base(&dir));
let clean = recipe(&report, "sauce.cook");
assert!(clean.diagnostics.is_empty(), "{:?}", clean.diagnostics);
assert!(clean.rendered.is_empty(), "{:?}", clean.rendered);
}
#[test]
fn references_are_collected_as_they_are_written() {
let dir = fixture();
let report = run(&base(&dir));
assert_eq!(
recipe(&report, "with_ref.cook").references,
["./sauce", "./nonexistent"],
"in source order, unresolved"
);
assert!(recipe(&report, "sauce.cook").references.is_empty());
}
#[test]
fn a_reference_made_twice_is_listed_twice() {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
write(&base.join("sauce.cook"), CLEAN);
write(
&base.join("dup.cook"),
"---\ntitle: Dup\n---\n\nMake @./sauce{}, then more @./sauce{}.\n",
);
let report = run(&base);
assert_eq!(
recipe(&report, "dup.cook").references,
["./sauce", "./sauce"]
);
}
#[test]
fn a_recipe_with_errors_contributes_no_references() {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
write(
&base.join("broken_ref.cook"),
"---\ntitle: Broken\n---\n\nMake @./sauce{} and add @{1%tsp}.\n",
);
let report = run(&base);
let broken = recipe(&report, "broken_ref.cook");
assert_eq!(broken.count(Severity::Error), 1, "{:?}", broken.diagnostics);
assert!(
broken.references.is_empty(),
"expected no references, got {:?}",
broken.references
);
assert!(report.references().is_empty());
}
#[test]
fn the_reference_map_holds_only_recipes_that_reference_something() {
let dir = fixture();
let report = run(&base(&dir));
let references = report.references();
assert_eq!(
references.keys().copied().collect::<Vec<_>>(),
[Utf8Path::new("with_ref.cook")]
);
assert_eq!(references[Utf8Path::new("with_ref.cook")].len(), 2);
}
#[test]
fn a_file_that_cannot_be_read_is_one_error_and_does_not_end_the_walk() {
let dir = fixture();
let base = base(&dir);
std::fs::write(
base.join("bad_bytes.cook"),
b"---\ntitle: Bad Bytes\n---\n\nBoil @water{1%l} \xFF.\n",
)
.unwrap();
let report = run(&base);
let unreadable = recipe(&report, "bad_bytes.cook");
assert_eq!(unreadable.diagnostics.len(), 1);
assert_eq!(unreadable.diagnostics[0].severity, Severity::Error);
assert!(
unreadable.diagnostics[0]
.message
.starts_with("Failed to read file"),
"{:?}",
unreadable.diagnostics[0]
);
assert!(
unreadable.rendered.is_empty(),
"there is no source to quote: {:?}",
unreadable.rendered
);
assert_eq!(unreadable.references, Vec::<String>::new());
assert_eq!(report.total_recipes(), 6);
assert_eq!(report.total_errors(), 3);
assert_eq!(report.recipes_with_errors(), 2);
assert_eq!(recipe(&report, "broken.cook").diagnostics.len(), 2);
assert!(recipe(&report, "sauce.cook").diagnostics.is_empty());
}
#[test]
fn a_collection_full_of_errors_still_validates_successfully() {
let dir = tempfile::TempDir::new().unwrap();
write(&base(&dir).join("broken.cook"), BROKEN);
let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default())
.expect("errors in recipes are data, not a failed command");
assert_eq!(outcome.value.total_errors(), 2);
assert!(
outcome.has_errors(),
"the outcome must carry the errors it found: {:?}",
outcome.diagnostics
);
assert_eq!(
outcome.diagnostics.len(),
2,
"every diagnostic in the report, flat"
);
}
#[test]
fn a_clean_collection_has_no_diagnostics_at_all() {
let dir = tempfile::TempDir::new().unwrap();
write(&base(&dir).join("sauce.cook"), CLEAN);
let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default()).unwrap();
assert!(!outcome.has_errors());
assert!(outcome.diagnostics.is_empty());
assert_eq!(outcome.value.total_errors(), 0);
assert_eq!(outcome.value.total_warnings(), 0);
assert_eq!(outcome.value.total_recipes(), 1);
}
#[test]
fn style_decides_whether_the_rendered_report_is_coloured() {
let dir = fixture();
let base = base(&dir);
let plain = run_styled(&base, Style::Plain);
let plain = &recipe(&plain, "broken.cook").rendered;
assert!(
!plain.contains('\u{1b}'),
"Style::Plain must emit no escape codes: {plain:?}"
);
let coloured = run_styled(&base, Style::Ansi);
let coloured = &recipe(&coloured, "broken.cook").rendered;
assert!(
coloured.contains('\u{1b}'),
"Style::Ansi must emit escape codes: {coloured:?}"
);
assert_eq!(
*plain,
anstream::adapter::strip_str(coloured).to_string(),
"the two must differ only in the escape codes"
);
}
#[test]
fn the_default_style_is_plain() {
assert_eq!(ValidateRequest::default().style, Style::Plain);
}
#[test]
fn base_dir_overrides_the_context_base_path() {
let validated = fixture();
let ignored = tempfile::TempDir::new().unwrap();
write(&base(&ignored).join("decoy.cook"), BROKEN);
let report = validate(
&Context::new(base(&ignored)),
ValidateRequest {
base_dir: Some(base(&validated)),
style: Style::Plain,
},
)
.expect("validation succeeds")
.into_value();
assert_eq!(report.total_recipes(), 5);
assert!(
!paths(&report).contains(&Utf8PathBuf::from("decoy.cook")),
"{:?}",
paths(&report)
);
}
#[test]
fn without_a_base_dir_the_context_base_path_is_validated() {
let dir = fixture();
assert_eq!(run(&base(&dir)).total_recipes(), 5);
}
#[test]
fn recipes_come_back_in_path_order_every_time() {
let dir = fixture();
let base = base(&dir);
let expected = paths(&run(&base));
let mut sorted = expected.clone();
sorted.sort();
assert_eq!(expected, sorted);
for _ in 0..8 {
assert_eq!(paths(&run(&base)), expected, "order must not vary");
}
}
#[test]
fn an_empty_directory_validates_to_an_empty_report() {
let dir = tempfile::TempDir::new().unwrap();
let report = run(&base(&dir));
assert_eq!(report.total_recipes(), 0);
assert!(report.references().is_empty());
}
#[test]
fn a_root_that_does_not_exist_is_reported() {
let dir = tempfile::TempDir::new().unwrap();
let missing = base(&dir).join("nope");
match validate(&Context::new(missing.clone()), ValidateRequest::default()) {
Err(CoreError::Search { base_dir, message }) => {
assert_eq!(base_dir, missing);
assert_eq!(message, "no such directory");
}
other => panic!(
"expected CoreError::Search, got {:?}",
other.map(|o| o.value)
),
}
}
#[test]
fn a_root_that_is_a_file_is_reported() {
let dir = tempfile::TempDir::new().unwrap();
let file = base(&dir).join("sauce.cook");
write(&file, CLEAN);
match validate(&Context::new(file.clone()), ValidateRequest::default()) {
Err(CoreError::Search { base_dir, message }) => {
assert_eq!(base_dir, file);
assert_eq!(message, "not a directory");
}
other => panic!(
"expected CoreError::Search, got {:?}",
other.map(|o| o.value)
),
}
}
#[test]
fn a_root_that_is_not_a_valid_glob_pattern_is_reported() {
let dir = tempfile::TempDir::new().unwrap();
let root = base(&dir).join("re[ci");
std::fs::create_dir(&root).unwrap();
write(&root.join("sauce.cook"), CLEAN);
match validate(&Context::new(root.clone()), ValidateRequest::default()) {
Err(CoreError::Search { base_dir, message }) => {
assert_eq!(base_dir, root);
assert!(
message.contains("attern"),
"the cause must survive: {message}"
);
}
other => panic!(
"expected CoreError::Search, got {:?}",
other.map(|o| o.value)
),
}
}
#[test]
fn a_listing_failure_names_the_file_rather_than_the_root() {
let root = Utf8Path::new("/recipes");
let unusable = tree_error(
TreeError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
"bad front matter".to_string(),
)),
root,
);
match unusable {
CoreError::Io { path, source } => {
assert_eq!(path, root);
assert!(
source.to_string().contains("bad front matter"),
"the cause must survive: {source}"
);
}
other => panic!("expected CoreError::Io, got {other:?}"),
}
let unstrippable = tree_error(
TreeError::StripPrefixError("recipes/soup.cook".to_string()),
root,
);
match unstrippable {
CoreError::Search { base_dir, message } => {
assert_eq!(base_dir, root);
assert!(message.contains("recipes/soup.cook"), "{message}");
}
other => panic!("expected CoreError::Search, got {other:?}"),
}
}
#[test]
fn a_path_under_the_root_is_stripped_and_one_outside_is_left_alone() {
assert_eq!(
relative_to(
Utf8Path::new("/recipes"),
Utf8Path::new("/recipes/Breakfast/pancakes.cook")
),
"Breakfast/pancakes.cook"
);
assert_eq!(
relative_to(
Utf8Path::new("./recipes"),
Utf8Path::new("recipes/soup.cook")
),
"recipes/soup.cook"
);
}
fn broken(report: &ValidationReport) -> Vec<(String, Vec<String>)> {
broken_references(report)
.into_iter()
.map(|(recipe, missing)| (recipe.to_string(), missing))
.collect()
}
#[test]
fn the_report_records_the_root_it_was_validated_against() {
let dir = fixture();
assert_eq!(run(&base(&dir)).base_dir, base(&dir));
let elsewhere = tempfile::TempDir::new().unwrap();
let report = validate(
&Context::new(base(&elsewhere)),
ValidateRequest {
base_dir: Some(base(&dir)),
style: Style::Plain,
},
)
.expect("validation succeeds")
.into_value();
assert_eq!(
report.base_dir,
base(&dir),
"the root that was walked, not the context's"
);
}
#[test]
fn only_references_that_resolve_to_nothing_are_reported() {
let dir = fixture();
assert_eq!(
broken(&run(&base(&dir))),
[(
"with_ref.cook".to_string(),
vec!["./nonexistent".to_string()]
)]
);
}
#[test]
fn a_collection_whose_references_all_resolve_reports_none() {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
write(&base.join("sauce.cook"), CLEAN);
write(
&base.join("dish.cook"),
"---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
);
assert!(
broken_references(&run(&base)).is_empty(),
"a resolvable reference must not be reported"
);
}
#[test]
fn a_reference_repeated_is_reported_once_per_mention() {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
write(
&base.join("dish.cook"),
"---\ntitle: Dish\n---\n\nMake @./absent{}, then more @./absent{}.\n",
);
assert_eq!(
broken(&run(&base)),
[(
"dish.cook".to_string(),
vec!["./absent".to_string(), "./absent".to_string()]
)]
);
}
#[test]
fn references_resolve_against_the_validated_root_from_anywhere_in_it() {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
write(&base.join("sauce.cook"), CLEAN);
std::fs::create_dir(base.join("Dinner")).unwrap();
write(
&base.join("Dinner").join("dish.cook"),
"---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
);
assert!(
broken_references(&run(&base)).is_empty(),
"a nested recipe must be able to reference the root's"
);
}
#[test]
fn a_collection_with_no_references_has_none_broken() {
let dir = tempfile::TempDir::new().unwrap();
write(&base(&dir).join("sauce.cook"), CLEAN);
assert!(broken_references(&run(&base(&dir))).is_empty());
}
fn one_recipe(text: &str) -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
write(&base(&dir).join("dish.cook"), text);
dir
}
fn aisle_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
Context::new(base(dir)).with_aisle(ConfigSource::Inline(conf.to_string()))
}
fn pantry_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
Context::new(base(dir)).with_pantry(ConfigSource::Inline(conf.to_string()))
}
fn checked(ctx: &Context, aisle: bool) -> Outcome<IngredientCoverage> {
let request = CoverageRequest::default();
if aisle {
aisle_coverage(ctx, request)
} else {
pantry_coverage(ctx, request)
}
.expect("the check succeeds")
}
fn known(coverage: &IngredientCoverage) -> Vec<&str> {
coverage.known().collect()
}
fn unknown(coverage: &IngredientCoverage) -> Vec<&str> {
coverage.unknown().collect()
}
fn all(coverage: &IngredientCoverage) -> Vec<&str> {
coverage
.ingredients
.iter()
.map(|ingredient| ingredient.name.as_str())
.collect()
}
fn recipes_for<'a>(coverage: &'a IngredientCoverage, name: &str) -> Vec<&'a Utf8Path> {
coverage
.ingredients
.iter()
.find(|ingredient| ingredient.name == name)
.unwrap_or_else(|| panic!("{name} is in the coverage"))
.recipes
.iter()
.map(Utf8PathBuf::as_path)
.collect()
}
#[test]
fn an_aisle_splits_the_collection_into_categorised_and_not() {
let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
let coverage = checked(
&aisle_ctx(&dir, "[produce]\nleek\n\n[pantry]\nsalt\n"),
true,
)
.value;
assert_eq!(known(&coverage), ["leek", "salt"]);
assert_eq!(unknown(&coverage), ["water"]);
assert_eq!(coverage.total_ingredients(), 3);
assert_eq!(coverage.total_recipes, 1);
}
#[test]
fn an_aisle_synonym_counts_as_knowing_the_ingredient() {
let dir = one_recipe("Add @aubergine{1}.\n");
let coverage = checked(&aisle_ctx(&dir, "[produce]\neggplant|aubergine\n"), true).value;
assert_eq!(known(&coverage), ["aubergine"]);
assert!(unknown(&coverage).is_empty());
}
#[test]
fn a_pantry_knows_its_items_from_every_section() {
let dir = one_recipe("Add @salt{1%tsp}, @milk{1%l} and @water{1%l}.\n");
let conf = "[pantry]\nsalt = \"1%kg\"\n\n[dairy]\nmilk = \"1%l\"\n";
let coverage = checked(&pantry_ctx(&dir, conf), false).value;
assert_eq!(known(&coverage), ["milk", "salt"]);
assert_eq!(unknown(&coverage), ["water"]);
}
#[test]
fn a_pantry_item_that_has_run_out_still_counts_as_known() {
let dir = one_recipe("Add @honey{1%tbsp}.\n");
let conf = "[pantry]\nhoney = { quantity = \"0\", low = \"100%g\" }\n";
assert_eq!(
known(&checked(&pantry_ctx(&dir, conf), false).value),
["honey"]
);
}
#[test]
fn names_are_matched_ignoring_case_and_reported_as_the_recipe_writes_them() {
let dir = one_recipe("Add @Salt{1%tsp} and @PEPPER{}.\n");
let aisle = checked(&aisle_ctx(&dir, "[pantry]\nsalt\npepper\n"), true).value;
assert_eq!(known(&aisle), ["PEPPER", "Salt"]);
assert!(unknown(&aisle).is_empty());
let conf = "[pantry]\nsalt = \"1%kg\"\npepper = \"50%g\"\n";
let pantry = checked(&pantry_ctx(&dir, conf), false).value;
assert_eq!(known(&pantry), ["PEPPER", "Salt"]);
assert!(unknown(&pantry).is_empty());
}
#[test]
fn a_non_ascii_name_is_matched_ignoring_case_too() {
let dir = one_recipe("Add @Öl{1%tbsp} and @Ärter{100%g}.\n");
let aisle = checked(&aisle_ctx(&dir, "[pantry]\növerste|öl\närter\n"), true).value;
assert_eq!(known(&aisle), ["Ärter", "Öl"]);
assert!(unknown(&aisle).is_empty(), "{:?}", unknown(&aisle));
let conf = "[pantry]\n\"öl\" = \"1%l\"\n\"ärter\" = \"1%kg\"\n";
assert_eq!(
known(&checked(&pantry_ctx(&dir, conf), false).value),
["Ärter", "Öl"]
);
}
#[test]
fn two_spellings_of_one_ingredient_are_both_listed() {
let dir = tempfile::TempDir::new().unwrap();
write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
assert_eq!(known(&coverage), ["Salt", "salt"]);
assert_eq!(coverage.total_ingredients(), 2);
}
#[test]
fn without_a_configuration_nothing_is_known() {
let dir = one_recipe("Add @salt{1%tsp}.\n");
let ctx = Context::new(base(&dir));
for aisle in [true, false] {
let coverage = checked(&ctx, aisle).value;
assert_eq!(coverage.total_recipes, 1);
assert!(known(&coverage).is_empty(), "aisle: {aisle}");
assert_eq!(unknown(&coverage), ["salt"], "aisle: {aisle}");
}
}
#[test]
fn references_to_other_recipes_are_not_ingredients() {
let dir = one_recipe("Make @./sauce{} and add @water{1%l}.\n");
write(&base(&dir).join("sauce.cook"), CLEAN);
let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsauce\nwater\noil\n"), true).value;
assert_eq!(
all(&coverage),
["oil", "water"],
"the referenced recipe's own ingredients count; the reference does not"
);
}
#[test]
fn every_recipe_under_the_root_is_scanned_including_nested_ones() {
let dir = one_recipe("Boil @water{1%l}.\n");
std::fs::create_dir(base(&dir).join("Breakfast")).unwrap();
write(
&base(&dir).join("Breakfast").join("porridge.cook"),
"Simmer @oats{50%g}.\n",
);
let coverage = checked(&aisle_ctx(&dir, "[pantry]\nwater\n"), true).value;
assert_eq!(coverage.total_recipes, 2);
assert_eq!(
unknown(&coverage),
["oats"],
"a subdirectory must be walked"
);
assert_eq!(
recipes_for(&coverage, "oats"),
[Utf8Path::new("Breakfast/porridge.cook")],
"a recipe is named relative to the directory that was scanned"
);
}
#[test]
fn an_ingredient_carries_every_recipe_that_writes_it() {
let dir = tempfile::TempDir::new().unwrap();
write(
&base(&dir).join("curry.cook"),
"Add @ground cumin{1%tsp}.\n",
);
write(&base(&dir).join("dal.cook"), "Add @ground cumin{2%tsp}.\n");
write(&base(&dir).join("stew.cook"), "Add @cumin powder{1%tsp}.\n");
let coverage = checked(&aisle_ctx(&dir, "[spices]\nground cumin\n"), true).value;
assert_eq!(
recipes_for(&coverage, "ground cumin"),
[Utf8Path::new("curry.cook"), Utf8Path::new("dal.cook")],
"shared by two recipes, listed once each, in path order"
);
assert_eq!(
recipes_for(&coverage, "cumin powder"),
[Utf8Path::new("stew.cook")]
);
}
#[test]
fn a_recipe_using_an_ingredient_twice_is_listed_once() {
let dir = one_recipe("Add @salt{1%tsp}, then more @salt{1%tsp}.\n");
let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
assert_eq!(recipes_for(&coverage, "salt"), [Utf8Path::new("dish.cook")]);
}
#[test]
fn each_spelling_keeps_its_own_recipes() {
let dir = tempfile::TempDir::new().unwrap();
write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
assert_eq!(recipes_for(&coverage, "Salt"), [Utf8Path::new("a.cook")]);
assert_eq!(recipes_for(&coverage, "salt"), [Utf8Path::new("b.cook")]);
}
#[test]
fn the_pantry_check_lists_recipes_as_well() {
let dir = tempfile::TempDir::new().unwrap();
write(&base(&dir).join("a.cook"), "Add @rice{100%g}.\n");
write(&base(&dir).join("b.cook"), "Add @rice{200%g}.\n");
let coverage = checked(&pantry_ctx(&dir, "[pantry]\nrice = \"5%kg\"\n"), false).value;
assert_eq!(known(&coverage), ["rice"]);
assert_eq!(
recipes_for(&coverage, "rice"),
[Utf8Path::new("a.cook"), Utf8Path::new("b.cook")]
);
}
#[test]
fn a_recipe_that_cannot_be_parsed_is_counted_but_contributes_nothing() {
let dir = one_recipe("Add @salt{1%tsp}.\n");
write(&base(&dir).join("broken.cook"), BROKEN);
let outcome = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true);
assert_eq!(outcome.value.total_recipes, 2);
assert_eq!(outcome.value.total_ingredients(), 1);
assert_eq!(known(&outcome.value), ["salt"]);
let skipped = outcome
.diagnostics
.iter()
.find(|d| d.message.contains("broken.cook"))
.unwrap_or_else(|| {
panic!(
"the skipped recipe must be named: {:?}",
outcome.diagnostics
)
});
assert_eq!(skipped.severity, Severity::Warning);
assert!(!outcome.has_errors());
}
#[test]
fn ingredients_come_back_in_code_point_order_every_time() {
let dir = tempfile::TempDir::new().unwrap();
for (file, ingredient) in [
("a", "yeast"),
("b", "flour"),
("c", "sugar"),
("d", "Zucchini"),
("e", "apple"),
("f", "Beetroot"),
] {
write(
&base(&dir).join(format!("{file}.cook")),
&format!("Add @{ingredient}{{1}}.\n"),
);
}
let ctx = aisle_ctx(&dir, "[pantry]\nflour\n");
for _ in 0..8 {
let coverage = checked(&ctx, true).value;
assert_eq!(
all(&coverage),
["Beetroot", "Zucchini", "apple", "flour", "sugar", "yeast"],
"capitalised names sort first, as CookCLI has always printed them"
);
assert_eq!(
unknown(&coverage),
["Beetroot", "Zucchini", "apple", "sugar", "yeast"]
);
}
}
#[test]
fn the_two_views_partition_the_ingredients() {
let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
let coverage = checked(&aisle_ctx(&dir, "[produce]\nleek\n"), true).value;
let mut both: Vec<&str> = known(&coverage)
.into_iter()
.chain(unknown(&coverage))
.collect();
both.sort_unstable();
assert_eq!(both, all(&coverage));
assert_eq!(
known(&coverage).len() + unknown(&coverage).len(),
coverage.total_ingredients()
);
}
#[test]
fn a_coverage_base_dir_overrides_the_context_base_path() {
let scanned = one_recipe("Add @salt{1%tsp}.\n");
let ignored = one_recipe("Add @decoy{1}.\n");
let coverage = aisle_coverage(
&aisle_ctx(&ignored, "[pantry]\nsalt\n"),
CoverageRequest {
base_dir: Some(base(&scanned)),
},
)
.expect("the check succeeds")
.into_value();
assert_eq!(known(&coverage), ["salt"]);
assert!(!all(&coverage).contains(&"decoy"), "{:?}", all(&coverage));
}
#[test]
fn a_warning_in_the_aisle_configuration_comes_back_as_a_diagnostic() {
let dir = one_recipe("Add @leek{1}.\n");
let outcome = checked(&aisle_ctx(&dir, "[produce]\nleek\n\n[dairy]\nleek\n"), true);
assert!(
outcome
.diagnostics
.iter()
.any(|d| d.message.contains("Duplicate ingredient")),
"{:?}",
outcome.diagnostics
);
assert_eq!(known(&outcome.value), ["leek"]);
}
#[test]
fn a_warning_in_the_pantry_configuration_comes_back_as_a_diagnostic() {
let dir = one_recipe("Add @ice{1}.\n");
let conf = "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n";
let outcome = checked(&pantry_ctx(&dir, conf), false);
assert!(
!outcome.diagnostics.is_empty(),
"the unknown attribute must be reported"
);
for diagnostic in &outcome.diagnostics {
assert_eq!(diagnostic.severity, Severity::Warning, "{diagnostic:?}");
}
assert!(!outcome.has_errors());
assert_eq!(known(&outcome.value), ["ice"]);
}
#[test]
fn a_configuration_warning_is_located_in_the_file_it_came_from() {
let dir = one_recipe("Add @ice{1}.\n");
let path = base(&dir).join("pantry.conf");
write(
&path,
"[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n",
);
let ctx = Context::new(base(&dir)).with_pantry(ConfigSource::Path(path.clone()));
let outcome =
pantry_coverage(&ctx, CoverageRequest::default()).expect("the check succeeds");
let located = outcome
.diagnostics
.iter()
.find(|d| d.location.is_some())
.unwrap_or_else(|| panic!("expected a located warning: {:?}", outcome.diagnostics));
assert_eq!(
located.location.as_ref().and_then(|l| l.file.as_deref()),
Some(path.as_path())
);
}
#[test]
fn a_configuration_that_cannot_be_read_is_reported() {
let dir = one_recipe("Add @salt{1%tsp}.\n");
let missing = base(&dir).join("config").join("aisle.conf");
match aisle_coverage(
&Context::new(base(&dir)).with_aisle(ConfigSource::Path(missing.clone())),
CoverageRequest::default(),
) {
Err(CoreError::Io { path, source }) => {
assert_eq!(path, missing);
assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
}
other => panic!("expected CoreError::Io, got {:?}", other.map(|o| o.value)),
}
}
#[test]
fn a_pantry_that_cannot_be_parsed_at_all_is_reported_as_pantry_load_reports_it() {
let dir = one_recipe("Add @salt{1%tsp}.\n");
let ctx = pantry_ctx(&dir, "this is not toml [");
let from_load = match crate::pantry::load(&ctx) {
Err(CoreError::Config { message, .. }) => message,
other => panic!("expected CoreError::Config from load, got {other:?}"),
};
match pantry_coverage(&ctx, CoverageRequest::default()) {
Err(CoreError::Config { path, message }) => {
assert_eq!(path, None, "an inline configuration has no path");
assert!(
message.contains("TOML parse error"),
"the cause must survive: {message}"
);
assert_eq!(message, from_load, "the two commands must agree");
}
other => panic!(
"expected CoreError::Config, got {:?}",
other.map(|o| o.value)
),
}
}
#[test]
fn a_root_that_does_not_exist_is_reported_by_a_coverage_check() {
let dir = tempfile::TempDir::new().unwrap();
let missing = base(&dir).join("nope");
match pantry_coverage(
&Context::new(missing.clone()).with_pantry(ConfigSource::Inline(String::new())),
CoverageRequest::default(),
) {
Err(CoreError::Search { base_dir, message }) => {
assert_eq!(base_dir, missing);
assert_eq!(message, "no such directory");
}
other => panic!(
"expected CoreError::Search, got {:?}",
other.map(|o| o.value)
),
}
}
#[test]
fn an_empty_collection_covers_nothing() {
let dir = tempfile::TempDir::new().unwrap();
let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
assert_eq!(coverage.total_recipes, 0);
assert_eq!(coverage.total_ingredients(), 0);
assert!(known(&coverage).is_empty());
assert!(unknown(&coverage).is_empty());
}
}