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, Span, Style,
};
use camino::{Utf8Path, Utf8PathBuf};
use cooklang_find::RecipeEntry;
use std::collections::{BTreeMap, BTreeSet};
use yansi::Paint;
#[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 mut diagnostics = collect_diagnostics(parsed.report(), Some(&path));
let timer_warnings = if parsed.output().is_some() {
textual_timer_warnings(&content, &path)
} else {
Vec::new()
};
let rendered = render_findings(
parsed.report(),
&timer_warnings,
path.as_str(),
&content,
style,
);
diagnostics.extend(timer_warnings);
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()
}
fn textual_timer_warnings(source: &str, path: &Utf8Path) -> Vec<Diagnostic> {
use cooklang::{
parser::{Event, PullParser},
Extensions,
};
let mut warnings = Vec::new();
let extensions = PARSER.extensions() | Extensions::RANGE_VALUES;
for event in PullParser::new(source, extensions) {
let Event::Timer(timer) = event else {
continue;
};
let Some(quantity) = timer.quantity.as_ref() else {
continue;
};
let cooklang::Value::Text(text) = quantity.value.value.value() else {
continue;
};
let span = quantity.value.span();
let end = span.start() + source[span.range()].trim_end().len();
let mut diagnostic =
Diagnostic::warning(format!("Timer value is text: {text}")).at_file(path);
if let Some(location) = diagnostic.location.as_mut() {
location.span = Some(Span {
start: span.start(),
end,
});
}
warnings.push(diagnostic);
}
warnings
}
fn render_findings(
report: &cooklang::error::SourceReport,
timer_warnings: &[Diagnostic],
display_path: &str,
content: &str,
style: Style,
) -> String {
if report.is_empty() && timer_warnings.is_empty() {
return String::new();
}
let mut rendered = if report.is_empty() {
String::new()
} else {
render_report(report, display_path, content, style.is_ansi())
};
if timer_warnings.is_empty() {
return rendered;
}
if !rendered.is_empty() && !rendered.ends_with('\n') {
rendered.push('\n');
}
for warning in timer_warnings {
let label = if style.is_ansi() {
"Warning:".yellow().to_string()
} else {
"Warning:".to_string()
};
let location = match warning.location.as_ref().and_then(|l| l.span) {
Some(span) => {
let line = content[..span.start].matches('\n').count() + 1;
format!("{display_path}:{line}")
}
None => display_path.to_string(),
};
rendered.push_str(&format!("{label} {} ({location})\n", warning.message));
}
rendered
}
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 underlined<'a>(diagnostic: &Diagnostic, source: &'a str) -> &'a str {
let span = diagnostic
.location
.as_ref()
.and_then(|location| location.span)
.unwrap_or_else(|| panic!("expected a span: {diagnostic:?}"));
&source[span.start..span.end]
}
#[test]
fn a_textual_timer_quantity_is_a_warning_in_either_shape() {
let anon = "a few";
let named = "a couple";
let source = format!("Cook for ~{{{anon}%minutes}}.\nBake the ~loaf{{{named}%minutes}}.\n");
let dir = tempfile::TempDir::new().unwrap();
write(&base(&dir).join("timer.cook"), &source);
let report = run(&base(&dir));
let timer = recipe(&report, "timer.cook");
assert_eq!(timer.diagnostics.len(), 2, "{:?}", timer.diagnostics);
for (diagnostic, quantity) in timer.diagnostics.iter().zip([anon, named]) {
assert_eq!(diagnostic.severity, Severity::Warning, "{diagnostic:?}");
assert!(
diagnostic.message.contains("Timer value is text"),
"{:?}",
diagnostic.message
);
assert!(
diagnostic.message.contains(quantity),
"{:?} should name {quantity}",
diagnostic.message
);
let location = diagnostic.location.as_ref().expect("location set");
assert_eq!(location.file.as_deref(), Some(Utf8Path::new("timer.cook")));
assert_eq!(underlined(diagnostic, &source), quantity);
assert!(
timer.rendered.contains(quantity),
"the report should identify {quantity}: {}",
timer.rendered
);
}
assert_eq!(report.total_recipes(), 1);
assert_eq!(report.total_errors(), 0);
assert_eq!(report.recipes_with_errors(), 0);
assert_eq!(report.total_warnings(), 2);
assert_eq!(report.recipes_with_warnings(), 1);
}
#[test]
fn numeric_timers_stay_valid_beside_a_textual_ingredient() {
let dir = tempfile::TempDir::new().unwrap();
write(
&base(&dir).join("dish.cook"),
"Bake for ~{40%minutes}, then rest ~{1.5%hours}.\n\
Add @salt{to taste}.\n",
);
let report = run(&base(&dir));
let dish = recipe(&report, "dish.cook");
assert!(
dish.diagnostics.is_empty(),
"a numeric timer must not be reported: {:?}",
dish.diagnostics
);
assert!(dish.rendered.is_empty());
assert_eq!(report.total_errors(), 0);
assert_eq!(report.total_warnings(), 0);
}
#[test]
fn timer_edges_follow_the_shared_parser() {
let named = "Rest the ~dough.\n";
let bare = "Cook for ~{30}.\n";
let unit = "Cook for ~{5%fortnights}, ~{1%hr}, ~{10%Minutes} and ~{5%минут}.\n";
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
write(&base.join("named.cook"), named);
write(&base.join("bare.cook"), bare);
write(&base.join("units.cook"), unit);
let report = run(&base);
let named = recipe(&report, "named.cook");
assert!(
named.diagnostics.is_empty(),
"a named timer with no quantity stays valid: {:?}",
named.diagnostics
);
let bare = recipe(&report, "bare.cook");
assert_eq!(bare.diagnostics.len(), 1, "{:?}", bare.diagnostics);
assert_eq!(bare.diagnostics[0].severity, Severity::Warning);
assert!(
bare.diagnostics[0]
.message
.contains("Invalid timer quantity: missing unit"),
"{:?}",
bare.diagnostics[0]
);
assert_eq!(bare.count(Severity::Error), 0);
let units = recipe(&report, "units.cook");
assert!(
units.diagnostics.is_empty(),
"a numeric timer is valid in any unit: {:?}",
units.diagnostics
);
assert_eq!(report.recipes_with_errors(), 0);
assert_eq!(report.recipes_with_warnings(), 1);
assert_eq!(report.total_errors(), 0);
assert_eq!(report.total_warnings(), 1);
}
#[test]
fn a_numeric_range_is_not_a_warning() {
let source = "Knead for ~{10-20%minutes}, then ~{1.5 - 2%hours} or ~{1 1/2-2%hours}.\n\
Prove ~{a few%minutes}, leave it ~{overnight}, rest ~{½%hour}.\n\
Wait ~{1 2%minutes}, ~{1.2.3%minutes} or ~{1/2/3%minutes}.\n";
let dir = tempfile::TempDir::new().unwrap();
write(&base(&dir).join("timer.cook"), source);
let report = run(&base(&dir));
let timer = recipe(&report, "timer.cook");
let text: Vec<_> = timer
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.message.starts_with("Timer value is text"))
.map(|diagnostic| underlined(diagnostic, source))
.collect();
assert_eq!(
text,
["a few", "overnight", "½", "1 2", "1.2.3", "1/2/3"],
"{:?}",
timer.diagnostics
);
assert_eq!(timer.count(Severity::Error), 0);
}
#[test]
fn the_span_covers_the_timer_value() {
let cases = [
(
"Add @salt{a few}, then cook ~{a few%minutes}.\n",
"a few",
"~{",
),
("Rest ~{a few%minutes}, add @x{a few}.\n", "a few", "~{"),
("---\nnote: \"{x\"\n---\nSimmer ~{x%minutes}.\n", "x", "~{"),
];
for (source, value, marker) in cases {
let dir = tempfile::TempDir::new().unwrap();
write(&base(&dir).join("timer.cook"), source);
let report = run(&base(&dir));
let timer = recipe(&report, "timer.cook");
assert_eq!(
timer.diagnostics.len(),
1,
"{source}: {:?}",
timer.diagnostics
);
let span = timer.diagnostics[0]
.location
.as_ref()
.and_then(|location| location.span)
.expect("a span");
assert_eq!(
span.start,
source.find(marker).unwrap() + marker.len(),
"{source}"
);
assert_eq!(underlined(&timer.diagnostics[0], source), value, "{source}");
}
}
#[test]
fn the_rendered_warning_names_its_file_and_line() {
let dir = tempfile::TempDir::new().unwrap();
write(
&base(&dir).join("timer.cook"),
"---\ntitle: Bread\n---\nMix.\nProve ~{overnight}.\n",
);
let report = run(&base(&dir));
let timer = recipe(&report, "timer.cook");
assert!(
timer
.rendered
.contains("Warning: Timer value is text: overnight (timer.cook:5)"),
"{}",
timer.rendered
);
}
#[test]
fn ingredient_quantities_follow_the_shared_parser() {
let dir = tempfile::TempDir::new().unwrap();
write(
&base(&dir).join("dish.cook"),
"Add @flour{1/0 cups} and @beans{1-1/0%cans}.\n",
);
let report = run(&base(&dir));
let dish = recipe(&report, "dish.cook");
assert!(
dish.diagnostics.is_empty(),
"the shared parser accepts these as text: {:?}",
dish.diagnostics
);
}
#[test]
fn a_mixed_collection_still_reports_every_recipe() {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
write(
&base.join("timer.cook"),
"Cook for ~{a few%minutes}. Make @./sauce{} and @./missing{}.\n",
);
write(&base.join("clean.cook"), "Bake for ~{40%minutes}.\n");
write(&base.join("broken.cook"), BROKEN);
write(&base.join("deprecated.cook"), DEPRECATED);
write(&base.join("sauce.cook"), CLEAN);
write(
&base.join("with_ref.cook"),
"Make @./sauce{} and @./missing{}.\n",
);
let outcome = validate(&Context::new(base), ValidateRequest::default())
.expect("errors in recipes are data, not a failed command");
let report = &outcome.value;
assert_eq!(
paths(report),
[
"broken.cook",
"clean.cook",
"deprecated.cook",
"sauce.cook",
"timer.cook",
"with_ref.cook",
]
);
assert_eq!(report.total_recipes(), 6);
let timer = recipe(report, "timer.cook");
assert_eq!(timer.count(Severity::Warning), 1, "{:?}", timer.diagnostics);
assert_eq!(timer.count(Severity::Error), 0, "{:?}", timer.diagnostics);
assert!(timer.rendered.contains("a few"), "{}", timer.rendered);
assert_eq!(
timer.references,
["./sauce", "./missing"],
"a warning must not hide the recipe's references: {:?}",
timer.references
);
let broken_recipe = recipe(report, "broken.cook");
assert_eq!(
broken_recipe.diagnostics.len(),
2,
"{:?}",
broken_recipe.diagnostics
);
assert!(
broken_recipe
.rendered
.contains("Add @{1%tsp} and @{2%tsp}."),
"{}",
broken_recipe.rendered
);
assert!(recipe(report, "clean.cook").diagnostics.is_empty());
assert!(recipe(report, "sauce.cook").diagnostics.is_empty());
let deprecated = recipe(report, "deprecated.cook");
assert_eq!(
deprecated.diagnostics.len(),
1,
"{:?}",
deprecated.diagnostics
);
assert_eq!(deprecated.diagnostics[0].severity, Severity::Warning);
assert_eq!(
recipe(report, "with_ref.cook").references,
["./sauce", "./missing"]
);
assert_eq!(
broken(report),
[
("timer.cook".to_string(), vec!["./missing".to_string()]),
("with_ref.cook".to_string(), vec!["./missing".to_string()])
]
);
assert_eq!(report.total_errors(), 2);
assert_eq!(report.recipes_with_errors(), 1);
assert_eq!(report.total_warnings(), 2);
assert_eq!(report.recipes_with_warnings(), 2);
assert!(outcome.has_errors());
assert_eq!(
outcome.diagnostics.len(),
report.total_errors() + report.total_warnings()
);
}
#[test]
fn the_shared_parser_still_accepts_a_textual_timer() {
let parsed = crate::PARSER.parse("Cook for ~{a few%minutes}.\n");
assert!(
!parsed.report().has_errors(),
"the shared parser must not grow this check"
);
}
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());
}
}