cookcli 0.38.1

A command-line interface for managing and working with Cooklang recipes
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
use anyhow::Result;
use camino::{Utf8Path, Utf8PathBuf};
use clap::{Args, Subcommand};
use cookcli_core::{
    doctor::{
        aisle_coverage, broken_references, pantry_coverage, CheckedIngredient, CoverageRequest,
    },
    Diagnostic, Severity,
};
use std::collections::BTreeSet;
use tracing::warn;

use crate::{util::cli_error, Context};

#[derive(Debug, Args)]
pub struct DoctorArgs {
    #[command(subcommand)]
    command: Option<DoctorCommand>,
}

#[derive(Debug, Subcommand)]
enum DoctorCommand {
    /// Check for ingredients missing from aisle configuration
    ///
    /// Scans all recipes in your collection and identifies ingredients
    /// that are not categorized in your aisle.conf file. This helps
    /// maintain a complete shopping list categorization.
    ///
    /// The aisle.conf file groups ingredients by store section (produce,
    /// dairy, etc.) for better organized shopping lists.
    ///
    /// Example:
    ///   cook doctor aisle                # Check current directory
    ///   cook doctor aisle -b ~/recipes   # Check specific directory
    ///   cook doctor aisle --show-recipes # Name the recipes using each one
    Aisle(AisleArgs),

    /// Check which recipe ingredients are in your pantry
    ///
    /// Scans all recipes and shows which ingredients are already in your
    /// pantry inventory. This helps identify what you don't need to buy.
    ///
    /// The pantry.conf file (TOML format) tracks your ingredient inventory
    /// with quantities and can be used to exclude items from shopping lists.
    ///
    /// Example:
    ///   cook doctor pantry                # Check current directory
    ///   cook doctor pantry -b ~/recipes   # Check specific directory
    ///   cook doctor pantry --show-recipes # Name the recipes using each one
    Pantry(PantryArgs),

    /// Validate all recipes for syntax errors and warnings
    ///
    /// Scans all recipes in your collection and reports:
    /// - Syntax errors that prevent parsing
    /// - Warnings about potential issues
    /// - Missing recipe references (when one recipe includes another)
    /// - Invalid units or quantities
    ///
    /// Example:
    ///   cook doctor validate           # Validate current directory
    ///   cook doctor validate -b ~/recipes # Validate specific directory
    ///   cook doctor validate --strict  # Exit with error code if issues found
    Validate(ValidateArgs),
}

#[derive(Debug, Args)]
struct AisleArgs {
    /// Directory to scan for recipe files
    ///
    /// The command will recursively search this directory for .cook files
    /// and check all ingredients against the aisle configuration.
    /// Defaults to the current directory.
    #[arg(short, long, value_hint = clap::ValueHint::DirPath)]
    base_path: Option<Utf8PathBuf>,

    /// List the recipes each uncategorized ingredient comes from
    ///
    /// An ingredient missing from aisle.conf is often a misspelling of one
    /// that is in it - "cumin powder" where the rest of the collection says
    /// "ground cumin". This names the files to go and fix.
    #[arg(long)]
    show_recipes: bool,
}

#[derive(Debug, Args)]
struct PantryArgs {
    /// Directory to scan for recipe files
    ///
    /// The command will recursively search this directory for .cook files
    /// and check all ingredients against the pantry configuration.
    /// Defaults to the current directory.
    #[arg(short, long, value_hint = clap::ValueHint::DirPath)]
    base_path: Option<Utf8PathBuf>,

    /// List the recipes each pantry ingredient comes from
    ///
    /// Shows which of your recipes a pantry item is actually keeping off the
    /// shopping list.
    #[arg(long)]
    show_recipes: bool,
}

#[derive(Debug, Args)]
struct ValidateArgs {
    /// Directory to scan for recipe files
    ///
    /// The command will recursively search this directory for .cook files
    /// and validate their syntax and references.
    /// Defaults to the current directory.
    #[arg(short, long, value_hint = clap::ValueHint::DirPath)]
    base_path: Option<Utf8PathBuf>,

    /// Exit with error code if any issues are found
    ///
    /// By default, the command reports issues but exits successfully.
    /// Use this flag in CI/CD pipelines to fail on validation errors.
    #[arg(long)]
    strict: bool,
}

pub fn run(ctx: &Context, args: DoctorArgs) -> Result<()> {
    match args.command {
        Some(DoctorCommand::Aisle(aisle_args)) => run_aisle(ctx, aisle_args),
        Some(DoctorCommand::Pantry(pantry_args)) => run_pantry(ctx, pantry_args),
        Some(DoctorCommand::Validate(validate_args)) => run_validate(ctx, validate_args),
        None => {
            // Run all doctor checks
            println!("Running all doctor checks...\n");

            // Check for updates
            #[cfg(feature = "self-update")]
            {
                println!("=== Version Check ===");
                check_for_updates();
            }
            #[cfg(not(feature = "self-update"))]
            {
                println!("=== Version Check ===");
                println!("ℹ️  Self-update is disabled in this build.");
                println!("   Please update through your package manager or build from source.");
            }

            println!("\n=== Recipe Validation ===");
            report_check(run_validate(
                ctx,
                ValidateArgs {
                    base_path: None,
                    strict: false,
                },
            ));

            println!("\n=== Aisle Check ===");
            // `cook doctor` with no subcommand is a summary of three checks;
            // naming every recipe behind every ingredient would bury it. The
            // flag is for the subcommand you run once the summary has pointed
            // you at it.
            report_check(run_aisle(
                ctx,
                AisleArgs {
                    base_path: None,
                    show_recipes: false,
                },
            ));

            println!("\n=== Pantry Check ===");
            report_check(run_pantry(
                ctx,
                PantryArgs {
                    base_path: None,
                    show_recipes: false,
                },
            ));

            Ok(())
        }
    }
}

#[cfg(feature = "self-update")]
fn check_for_updates() {
    match crate::update::check_for_updates() {
        Ok(Some(new_version)) => {
            println!("🆕 A new version ({new_version}) is available!");
            println!("   Run 'cook update' to install the latest version.");
        }
        Ok(None) => {
            println!("✅ You are running the latest version.");
        }
        Err(e) => {
            println!("⚠️  Unable to check for updates: {e}");
        }
    }
}

/// Print a check's failure and carry on to the next one.
///
/// Only the aggregate `cook doctor` goes through here. Each subcommand run on
/// its own still exits non-zero on a failure, because a script asking for one
/// answer wants to know it did not get it — but `cook doctor` is asked for
/// several, and a command whose whole job is reporting problems must not stop
/// at the first one and leave the remaining checks unrun. An unreadable
/// `aisle.conf` should not be able to hide what the pantry check would have
/// said.
fn report_check(result: Result<()>) {
    if let Err(e) = result {
        // `{e:#}` so the chain — core's line and the underlying cause — comes
        // out on the one line, matching the version check just above.
        println!("⚠️  This check could not run: {e:#}");
    }
}

/// Core returns its warnings instead of logging them, so that a library
/// consumer can show them its own way. Logging them is this boundary's job.
fn log_diagnostics(diagnostics: &[Diagnostic]) {
    for diagnostic in diagnostics {
        match diagnostic.location.as_ref().and_then(|l| l.file.as_ref()) {
            Some(file) => warn!("{file}: {}", diagnostic.message),
            None => warn!("{}", diagnostic.message),
        }
    }
}

/// How many recipes an ingredient is used by, as it is printed beside the name.
///
/// The count is on every line whether or not `--show-recipes` was passed: it is
/// what makes an odd spelling stand out — one recipe saying `cumin powder`
/// against twelve saying `ground cumin` — and it costs one line either way.
fn usage(ingredient: &CheckedIngredient) -> String {
    match ingredient.recipes.len() {
        1 => "(1 recipe)".to_string(),
        n => format!("({n} recipes)"),
    }
}

/// The recipes behind one ingredient, indented under it, when asked for.
///
/// Every one of them, uncapped: the whole point is to go and open them, and a
/// list truncated at three hides exactly the stray recipe being hunted for.
fn print_recipes(ingredient: &CheckedIngredient, show: bool) {
    if !show {
        return;
    }
    for recipe in &ingredient.recipes {
        println!("      {recipe}");
    }
}

fn run_pantry(ctx: &Context, args: PantryArgs) -> Result<()> {
    if ctx.pantry().is_unset() {
        println!("No pantry configuration found.");
        println!("To track your ingredient inventory, create a pantry.conf file in:");
        println!("  - ./config/pantry.conf (project-specific)");
        println!("  - ~/.config/cook/pantry.conf (global)");
        println!("\nExample pantry.conf (TOML format):");
        println!("[pantry]");
        println!("rice = \"5%kg\"");
        println!("pasta = \"1%kg\"");
        println!("\n[freezer]");
        println!("ice_cream = \"1%L\"");
        return Ok(());
    }

    let outcome = pantry_coverage(
        ctx,
        CoverageRequest {
            base_dir: args.base_path,
        },
    )
    .map_err(cli_error)?;
    log_diagnostics(&outcome.diagnostics);
    let coverage = outcome.value;

    println!(
        "Scanned {} recipes, found {} unique ingredients",
        coverage.total_recipes,
        coverage.total_ingredients()
    );

    let in_pantry: Vec<&CheckedIngredient> = coverage.known_entries().collect();
    if in_pantry.is_empty() {
        println!("\n✓ No recipe ingredients are currently in your pantry");
    } else {
        println!(
            "\n{} ingredients from recipes are in your pantry:",
            in_pantry.len()
        );
        for ingredient in in_pantry {
            println!("  ✓ {} {}", ingredient.name, usage(ingredient));
            print_recipes(ingredient, args.show_recipes);
        }
        println!("\nThese ingredients will be excluded from shopping lists.");
    }

    Ok(())
}

fn run_aisle(ctx: &Context, args: AisleArgs) -> Result<()> {
    // Unlike the pantry check, this one scans first and reports a missing
    // configuration afterwards, so the count is printed either way.
    let configured = !ctx.aisle().is_unset();
    if !configured {
        warn!("No aisle configuration found");
    }

    let outcome = aisle_coverage(
        ctx,
        CoverageRequest {
            base_dir: args.base_path,
        },
    )
    .map_err(cli_error)?;
    log_diagnostics(&outcome.diagnostics);
    let coverage = outcome.value;

    println!(
        "Scanned {} recipes, found {} unique ingredients",
        coverage.total_recipes,
        coverage.total_ingredients()
    );

    if !configured {
        println!("\nNo aisle configuration found.");
        println!("To organize ingredients by store section, create an aisle.conf file in:");
        println!("  - ./config/aisle.conf (project-specific)");
        println!("  - ~/.config/cook/aisle.conf (global)");
        return Ok(());
    }

    let missing: Vec<&CheckedIngredient> = coverage.unknown_entries().collect();
    if missing.is_empty() {
        println!("✓ All ingredients are present in aisle configuration");
    } else {
        println!(
            "\n{} ingredients not found in aisle configuration:",
            missing.len()
        );
        for ingredient in missing {
            println!("  - {} {}", ingredient.name, usage(ingredient));
            print_recipes(ingredient, args.show_recipes);
        }
        if !args.show_recipes {
            println!("\nRun `cook doctor aisle --show-recipes` to see which recipes use them.");
        }
        println!("\nConsider adding these ingredients to your aisle.conf file.");
    }

    Ok(())
}

fn run_validate(ctx: &Context, args: ValidateArgs) -> Result<()> {
    let report = cookcli_core::doctor::validate(
        ctx,
        cookcli_core::doctor::ValidateRequest {
            base_dir: args.base_path,
            // This report is going straight to a terminal.
            style: cookcli_core::Style::Ansi,
        },
    )
    .map_err(cli_error)?
    .into_value();

    for recipe in &report.recipes {
        if recipe.diagnostics.is_empty() {
            continue;
        }
        println!("\n📄 {}", recipe.path);

        if recipe.rendered.is_empty() {
            // A recipe core could not read has no source to quote, so there is
            // nothing rendered for it. Print what it did say instead.
            for diagnostic in &recipe.diagnostics {
                println!("  ❌ Error: {}", diagnostic.message);
            }
        } else {
            print!("{}", recipe.rendered);
        }
    }

    let total_recipes = report.total_recipes();
    let recipes_with_warnings = report.recipes_with_warnings();
    let mut total_errors = report.total_errors();
    let total_warnings = report.total_warnings();

    // Recipe references are resolved separately from validation, so a broken
    // one reaches neither the report's error count nor its recipe count. Both
    // have to be topped up here, and the recipe count as a *set* — a recipe
    // that both fails to parse and references something missing is still one
    // recipe, and one that carries two broken references is too.
    let mut failing_recipes: BTreeSet<&Utf8Path> = report
        .recipes
        .iter()
        .filter(|r| r.diagnostics.iter().any(|d| d.severity == Severity::Error))
        .map(|r| r.path.as_path())
        .collect();

    if !report.references().is_empty() {
        println!("\n=== Recipe References ===");
        let broken = broken_references(&report);

        if broken.is_empty() {
            println!("✓ All recipe references are valid");
        } else {
            for (recipe_path, missing) in broken {
                println!("\n📄 {recipe_path}");
                failing_recipes.insert(recipe_path);
                for missing_ref in missing {
                    println!("  ❌ Missing reference: {missing_ref}");
                    total_errors += 1;
                }
            }
        }
    }

    let recipes_with_errors = failing_recipes.len();

    // Print summary
    println!("\n=== Validation Summary ===");
    println!("Total recipes scanned: {total_recipes}");

    if total_errors == 0 && total_warnings == 0 {
        println!("✅ All recipes are valid!");
    } else {
        if total_errors > 0 {
            println!("❌ {total_errors} error(s) found in {recipes_with_errors} recipe(s)");
        }
        if total_warnings > 0 {
            println!("⚠️  {total_warnings} warning(s) found in {recipes_with_warnings} recipe(s)");
        }

        if args.strict {
            anyhow::bail!(
                "Recipe validation failed with {} errors and {} warnings",
                total_errors,
                total_warnings
            );
        }
    }

    Ok(())
}