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 {
Aisle(AisleArgs),
Pantry(PantryArgs),
Validate(ValidateArgs),
}
#[derive(Debug, Args)]
struct AisleArgs {
#[arg(short, long, value_hint = clap::ValueHint::DirPath)]
base_path: Option<Utf8PathBuf>,
#[arg(long)]
show_recipes: bool,
}
#[derive(Debug, Args)]
struct PantryArgs {
#[arg(short, long, value_hint = clap::ValueHint::DirPath)]
base_path: Option<Utf8PathBuf>,
#[arg(long)]
show_recipes: bool,
}
#[derive(Debug, Args)]
struct ValidateArgs {
#[arg(short, long, value_hint = clap::ValueHint::DirPath)]
base_path: Option<Utf8PathBuf>,
#[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 => {
println!("Running all doctor checks...\n");
#[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 ===");
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}");
}
}
}
fn report_check(result: Result<()>) {
if let Err(e) = result {
println!("⚠️ This check could not run: {e:#}");
}
}
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),
}
}
}
fn usage(ingredient: &CheckedIngredient) -> String {
match ingredient.recipes.len() {
1 => "(1 recipe)".to_string(),
n => format!("({n} recipes)"),
}
}
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<()> {
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,
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() {
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();
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();
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(())
}