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
use crate::util::{cli_error, split_recipe_name_and_scaling_factor};
use anyhow::{Context, Result};
use camino::Utf8PathBuf;
use clap::Parser;
use cookcli_core::{report::RenderRequest, ConfigSource, CoreError, RecipeSource};
use std::fs;
use tracing::warn;
#[derive(Parser, Debug)]
pub struct ReportArgs {
/// Path to the Jinja2 template file
///
/// The template receives the recipe as a set of top-level variables:
/// - metadata (title, author, tags, etc.)
/// - ingredients (with quantities and units)
/// - sections (the parsed steps and text)
/// - cookware
/// - scale (the scaling factor in effect)
///
/// Example template:
/// {{ metadata.title }}
/// {% for ingredient in ingredients %}
/// {{ ingredient.name }}: {{ ingredient.quantity }}
/// {% endfor %}
#[arg(short, long, value_hint = clap::ValueHint::FilePath)]
template: Utf8PathBuf,
/// Recipe to process
///
/// Either a path to a .cook file or a bare recipe name, resolved
/// against the base path like every other recipe argument.
///
/// Can include an optional scaling factor using the :N syntax
/// (e.g., "recipe.cook:2" to double the recipe). The scaling
/// will be applied to all ingredient quantities in the template.
#[arg(value_name = "RECIPE")]
recipe: String,
/// Path to the datastore directory with additional recipe data
///
/// The datastore can contain nutritional information, costs,
/// and other data that can be accessed in the template.
#[arg(short, long, value_hint = clap::ValueHint::DirPath)]
datastore: Option<Utf8PathBuf>,
/// Path to the aisle configuration file
///
/// Used for categorizing ingredients by store section.
/// The template can access ingredient aisle information.
#[arg(short, long, value_hint = clap::ValueHint::FilePath)]
aisle: Option<Utf8PathBuf>,
/// Path to the pantry configuration file
///
/// Used for filtering out pantry items from shopping lists.
/// Ingredients marked as pantry items can be accessed in templates.
#[arg(short = 'p', long, value_hint = clap::ValueHint::FilePath)]
pantry: Option<Utf8PathBuf>,
/// Base path for resolving the recipe and its references
///
/// Defaults to the current working directory. Both the RECIPE
/// argument and any recipes it references are looked up under it.
#[arg(short = 'b', long, value_hint = clap::ValueHint::DirPath)]
base_path: Option<Utf8PathBuf>,
}
pub fn run(ctx: &crate::Context, args: ReportArgs) -> Result<()> {
// Print warning about prototype feature
warn!("⚠️ The report command is a prototype feature and will change in future versions.");
// Split recipe name and scaling factor
let (recipe_name, scaling_factor) =
split_recipe_name_and_scaling_factor(&args.recipe).unwrap_or((&args.recipe, 1.0));
// Read the template file
let template = fs::read_to_string(&args.template)
.with_context(|| format!("Failed to read template file: {}", args.template))?;
// Aisle and pantry fall back to the context, which discovers them relative
// to the working directory — not to `--base-path`, which only steers recipe
// references inside the template.
let mut core_ctx = ctx.clone();
if let Some(aisle) = args.aisle {
core_ctx = core_ctx.with_aisle(ConfigSource::Path(to_absolute(aisle)));
}
if let Some(pantry) = args.pantry {
core_ctx = core_ctx.with_pantry(ConfigSource::Path(to_absolute(pantry)));
}
let outcome = cookcli_core::report::render(
&core_ctx,
RenderRequest {
// A path *or* a bare recipe name, resolved through `cooklang-find`
// under `base_path` — the same lookup `recipe`, `shopping-list`
// and `doctor` use. `report` used to read the argument with a
// plain `fs::read_to_string`, so `cook report -t t.jinja pancakes`
// failed where `cook recipe pancakes` worked, and `--base-path`
// had no bearing on the argument it most looked like it should
// (#430).
source: RecipeSource::Path(recipe_name.into()),
template,
scale: scaling_factor,
datastore: args.datastore,
base_path: args.base_path.map(to_absolute),
},
);
let report = match outcome {
Ok(outcome) => outcome.value,
// The template engine's own report, with source location and hints,
// reads better than anything wrapped around it — so print it as-is and
// stop, rather than letting anyhow prefix it.
Err(CoreError::Render { rendered, .. }) => {
eprintln!("{rendered}");
std::process::exit(1);
}
Err(other) => return Err(cli_error(other)),
};
// Print the report
println!("{report}");
Ok(())
}
/// Resolve a path against the working directory without touching the disk.
///
/// Not `util::resolve_to_absolute_path`, which canonicalises and therefore
/// fails on a path that does not exist yet; this command has always joined and
/// left it at that.
fn to_absolute(path: Utf8PathBuf) -> Utf8PathBuf {
if path.is_absolute() {
return path;
}
let cwd = std::env::current_dir()
.ok()
.and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
.unwrap_or_else(|| Utf8PathBuf::from("."));
cwd.join(path)
}