diff --git a/.github/Formula/pizza-cli.rb.in b/.github/Formula/pizza-cli.rb.in deleted file mode 100644 index c59688a..0000000 --- a/.github/Formula/pizza-cli.rb.in +++ /dev/null @@ -1,24 +0,0 @@ -class PizzaCli < Formula - desc "Command-line tool to calculate pizza dough ingredients and fermentation timelines" - homepage "https://github.com/marcocot/pizza-cli" - version "0.0.0" # replaced below - - on_macos do - on_intel do - url "https://github.com/marcocot/pizza-cli/releases/download/TAG/pizza-cli-TAG-x86_64-apple-darwin.tar.gz" - sha256 "X64_SHA" - end - on_arm do - url "https://github.com/marcocot/pizza-cli/releases/download/TAG/pizza-cli-TAG-aarch64-apple-darwin.tar.gz" - sha256 "ARM_SHA" - end - end - - def install - bin.install "pizza-cli" - end - - test do - system "#{bin}/pizza-cli", "--help" - end -end diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9007c3e..81c0633 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,61 +125,3 @@ jobs: - name: Install github_changelog_generator uses: janheinrichmerker/action-github-changelog-generator@v2.4 - - homebrew_tap: - name: Update Private Homebrew Tap - if: startsWith(github.ref, 'refs/tags/') - needs: [prepare_release, build_and_upload] - runs-on: ubuntu-latest - env: - TAG: ${{ needs.prepare_release.outputs.tag }} - steps: - - uses: actions/checkout@v6 - - - name: Checkout tap repo - uses: actions/checkout@v6 - with: - repository: marcocot/homebrew-tap - token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - path: tap - fetch-depth: 0 - - - name: Download macOS assets from release - working-directory: tap - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release download "${TAG}" \ - --pattern "${TAG}-aarch64-apple-darwin.tar.gz" \ - --pattern "${TAG}-x86_64-apple-darwin.tar.gz" \ - --repo "${GITHUB_REPOSITORY}" - - - name: Compute SHAs - id: shas - working-directory: tap - run: | - echo "arm=$(sha256sum ${TAG}-aarch64-apple-darwin.tar.gz | awk '{print $1}')" >> $GITHUB_OUTPUT - echo "x64=$(sha256sum ${TAG}-x86_64-apple-darwin.tar.gz | awk '{print $1}')" >> $GITHUB_OUTPUT - - - name: Prepare formula from template - run: | - set -euo pipefail - CLEAN_VER="${TAG#v}" - mkdir -p tap/Formula - # Il template deve stare in questo repo principale - cp .github/Formula/pizza-cli.rb.in tap/Formula/pizza-cli.rb - sed -i "s|VERSION_HERE|${CLEAN_VER}|g" tap/Formula/pizza-cli.rb - sed -i "s|TAG_HERE|${TAG}|g" tap/Formula/pizza-cli.rb - sed -i "s|X64_SHA_HERE|${{ steps.shas.outputs.x64 }}|g" tap/Formula/pizza-cli.rb - sed -i "s|ARM_SHA_HERE|${{ steps.shas.outputs.arm }}|g" tap/Formula/pizza-cli.rb - - - name: Commit & push to tap - working-directory: tap - env: - GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/pizza-cli.rb - git diff --cached --quiet || git commit -m "pizza-cli: ${TAG}" - git push diff --git a/README.md b/README.md index f408de1..e715f73 100644 --- a/README.md +++ b/README.md @@ -1,175 +1,97 @@ -# Pizza CLI & Core Library +# pizza-cli -A small Rust workspace for calculating pizza dough recipes and fermentation timelines. -This project started as a playful experiment and gradually became a reusable library (`pizza-core`) plus a command-line tool (`pizza-cli`). +A little command-line tool that works out how much flour, water, salt and yeast +go into a Neapolitan-style dough, and roughly when to start each step so the +balls are ready when you want to bake. -The goal is to provide bakers and hobbyists with a way to compute ingredient weights and timelines given flour strength, hydration, temperature, yeast type, and fermentation strategy (with or without fridge). +It started as a way to stop me doing the same arithmetic on a napkin every +Saturday. The numbers are heuristics, not gospel — treat them as a starting +point and trust your dough over the table. ---- +The repo is a small Cargo workspace: `pizza-core` holds the math (and its +tests), `pizza-cli` is the thing you actually run. -## Table of Contents +## Building -- [Overview](#overview) -- [How the calculations work](#how-the-calculations-work) - - [Ingredients math](#ingredients-math) - - [Yeast models](#yeast-models) - - [Effective fermentation hours](#effective-fermentation-hours) - - [Timelines](#timelines) -- [Compiling and running](#compiling-and-running) - - [Requirements](#requirements) - - [Build](#build) - - [Run](#run) - - [Examples](#examples) -- [Project structure](#project-structure) -- [Contributing](#contributing) -- [License](#license) - ---- - -## Overview - -The workspace has two crates: - -- **`pizza-core`**: a pure library with all the formulas, unit-tested. -- **`pizza-cli`**: a command-line application that uses `pizza-core`, handles JSON profiles, pretty tables, and time-of-day calculations. - -The project is open source, built for fun, and intended for learning and experimentation. -Do not treat the output as professional baking advice: the numbers are heuristics and approximations. - ---- - -## How the calculations work - -### Ingredients math - -Given: -- total dough weight = number of balls × weight per ball, -- hydration as a fraction (e.g. 0.75 = 75%), -- salt expressed in g/kg of flour, -- yeast type and estimated percentage. - -The formulas are: - -- **Baker’s yeast (dry or fresh)** - ``` - flour = total_dough / (1 + hydration + salt% + yeast%) - water = flour × hydration - salt = flour × salt% - yeast = flour × yeast% - ``` - -### Yeast models - -- **Dry yeast baseline**: 0.35% of flour at 25 °C, W=260, 12h. - Adjustments: - - Temperature: Q10 ≈ 2 per 10 °C difference. - - Flour strength (W): mild effect (exponent 0.2). - - Time: inversely proportional. - -- **Fresh yeast**: treated as ~3× dry yeast. - -### Effective fermentation hours - -Fridge fermentation is slower. We model this with a **fridge factor** (default 0.25): - -``` -effective_hours = (total_hours - fridge_hours) + fridge_hours × fridge_factor -``` - -So 4h in fridge counts like 1h at room temperature. - -### Timelines - -Two modes: - -- **No fridge**: total time split ~55% bulk, ~45% final proof, adjusted by temperature. -- **With fridge**: - ``` - total = bulk + fridge + warmup + proof - ``` - Remaining time (after fridge+warmup) is split ~35% bulk / ~65% proof, adjusted by temperature. - ---- - -## Compiling and running - -### Requirements - -- Rust toolchain (1.70+ recommended). - Install via [rustup](https://rustup.rs). - -### Build - -Clone and build the workspace: +You need a recent Rust toolchain — the crates are on the 2024 edition, so +**1.85 or newer**. Get it from [rustup](https://rustup.rs) if you don't have it. ```bash git clone https://github.com/marcocot/pizza-cli.git -cd pizza-workspace +cd pizza-cli cargo build --release ``` -### Run +The binary lands in `target/release/pizza-cli`. -Run the CLI directly: +## Running + +You have to give it the flour strength (`--w`); everything else has a sensible +default. A plain same-day dough: ```bash -cargo run -p pizza-cli -- --w 270 --temp 25 --yeast dry --hydration 0.75 --ball-weight 280 --balls 2 --salt-per-kg 20 --total-hours 11 --start 09:00 +cargo run -p pizza-cli -- --w 270 --hydration 0.75 --balls 2 --total-hours 11 --start 09:00 ``` -### Examples +Cold-fermented in the fridge, fresh yeast: -- **Dry yeast, no fridge**: ```bash -cargo run -p pizza-cli -- --w 270 --temp 25 --yeast dry --hydration 0.75 --ball-weight 280 --balls 2 --salt-per-kg 20 --total-hours 11 --start 09:00 +cargo run -p pizza-cli -- --w 270 --yeast fresh --hydration 0.70 \ + --balls 4 --ball-weight 260 --salt-per-kg 22 \ + --total-hours 24 --fridge-hours 16 --warmup-hours 3 --start 18:00 ``` -- **Fresh yeast with fridge**: -```bash -cargo run -p pizza-cli -- --w 270 --temp 24 --yeast fresh --hydration 0.70 --ball-weight 260 --balls 4 --salt-per-kg 22 --total-hours 24 --fridge-hours 16 --warmup-hours 3 --fridge-factor 0.25 --start 18:00 -``` +Timeline steps that spill past midnight are marked with `(+1d)`, so a 24-hour +fridge plan doesn't quietly look like it finishes the same evening. -- **Save a profile**: -```bash -cargo run -p pizza-cli -- --w 270 --temp 25 --yeast dry --hydration 0.75 --ball-weight 280 --balls 2 --salt-per-kg 20 --total-hours 12 --fridge-hours 4 --warmup-hours 3 --start 09:00 --save-profile ./torino-caputo.json -``` +`--help` lists every flag with its default. The defaults are dry yeast, 75% +hydration, 20 g/kg salt, 280 g balls, 2 balls, 11 total hours, no fridge. + +### Profiles + +If you keep making the same dough, save the parameters and reload them later: -- **Load a profile**: ```bash -cargo run -p pizza-cli -- --profile ./torino-caputo.json --temp 24 --start 08:30 +# save +cargo run -p pizza-cli -- --w 270 --hydration 0.75 --total-hours 12 \ + --fridge-hours 4 --start 09:00 --save-profile torino.json + +# reload — the profile even carries --w, so you don't repeat it +cargo run -p pizza-cli -- --profile torino.json --temp 24 --start 08:30 ``` ---- +Anything you pass on the command line overrides the profile; the profile +overrides the built-in defaults. -## Project structure +## How the numbers are worked out -``` -pizza-workspace/ -├─ Cargo.toml # workspace definition -├─ pizza-core/ # library crate -│ ├─ src/lib.rs # all calculations and tests -│ └─ Cargo.toml -└─ pizza-cli/ # command-line interface - ├─ src/main.rs - └─ Cargo.toml -``` +Ingredients are baker's math: total dough weight is `balls × ball-weight`, and +flour is solved so `flour + water + salt + yeast` adds back up to that total. +Water is `flour × hydration`, salt is the g/kg you asked for, yeast is an +estimated percentage of the flour. ---- +The yeast estimate is the interesting bit. It starts from ~0.35% dry yeast at +25 °C, W=260, over 12 hours, then scales it: -## Contributing +- temperature: Q10 ≈ 2, so every 10 °C roughly halves or doubles the amount; +- flour strength: a mild pull (W to the power 0.2) — stronger flour, a touch + more yeast; +- time: inversely, longer ferment needs less yeast. -This is an open source project born almost as a toy. Contributions are welcome, whether to improve math models, extend yeast types, add new output formats, or clean up the code. +The result is clamped to a sane 0.05%–1.5%. Fresh yeast is just treated as ~3× +dry. Fridge time counts as slower fermentation via a `--fridge-factor` (default +0.25, i.e. four hours in the fridge ≈ one at room temperature), which feeds the +"effective hours" the yeast math actually sees. -### Guidelines -- Fork the repository and create a branch for your feature or fix. -- Add or update unit tests in `pizza-core` where applicable. -- Ensure the project builds with `cargo build --release`. -- Run tests with `cargo test`. -- Submit a pull request with a clear description of the change. +The timeline splits the total into bulk and proof (and, with a fridge, the cold +stretch plus a bench warm-up in between), nudging the bulk/proof balance a +little with temperature. -Even small improvements (docs, examples, formatting) are appreciated. +## Contributing ---- +Patches welcome — better models, more yeast types, nicer output, whatever. Keep +`cargo test` green and run `cargo clippy` before opening a PR. ## License -MIT License. See [LICENSE](LICENSE) file for details. +MIT — see [LICENSE](LICENSE). diff --git a/pizza-cli/src/main.rs b/pizza-cli/src/main.rs index e08b645..71c9d77 100644 --- a/pizza-cli/src/main.rs +++ b/pizza-cli/src/main.rs @@ -1,15 +1,31 @@ -use clap::{ArgGroup, Parser, ValueEnum}; -use chrono::{Local, NaiveTime, Timelike}; +use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; +use clap::{Parser, ValueEnum}; use comfy_table::{presets::UTF8_FULL, Attribute, Cell, ContentArrangement, Table}; use pizza_core::{ - compute_ingredients, effective_hours, timeline_no_fridge, timeline_with_fridge, IngredientsInput, - Timeline, YeastKind, + compute_ingredients, effective_hours, timeline_no_fridge, timeline_with_fridge, + IngredientsInput, Timeline, YeastKind, }; use serde::{Deserialize, Serialize}; -use std::{fs, path::PathBuf}; - -/// Yeast CLI enum mirrors pizza-core (derive for Clap). -#[derive(Copy, Clone, Debug, ValueEnum, Serialize, Deserialize)] +use std::{fs, path::PathBuf, process::exit}; + +// Defaults live here (not in clap's `default_value_t`) so we can tell an +// explicit CLI value apart from an unset one: unset stays `None`, and the +// resolution order becomes CLI > profile > default. +const DEFAULT_TEMP: f64 = 25.0; +const DEFAULT_YEAST: YeastFlag = YeastFlag::Dry; +const DEFAULT_HYDRATION: f64 = 0.75; +const DEFAULT_SALT_PER_KG: f64 = 20.0; +const DEFAULT_BALL_WEIGHT: f64 = 280.0; +const DEFAULT_BALLS: u32 = 2; +const DEFAULT_TOTAL_HOURS: f64 = 11.0; +const DEFAULT_FRIDGE_HOURS: f64 = 0.0; +const DEFAULT_WARMUP_HOURS: f64 = 3.0; +const DEFAULT_FRIDGE_FACTOR: f64 = 0.25; + +const W_MIN: u16 = 200; +const W_MAX: u16 = 450; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] enum YeastFlag { Dry, @@ -25,75 +41,75 @@ impl From for YeastKind { } } -#[derive(Parser, Debug, Serialize, Deserialize)] +/// Every parameter is optional so we can distinguish "given on the CLI" from +/// "left to the profile or the default". `w` has no default: it must come from +/// either the CLI or a profile. +#[derive(Parser, Debug)] #[command( - name="pizza-cli", - about="Calculate ingredients & timeline for Neapolitan pizza (direct dough).", + name = "pizza-cli", + about = "Calculate ingredients & timeline for Neapolitan pizza (direct dough).", version )] -#[command(group( - ArgGroup::new("time_group") - .args(["total_hours"]) - .required(false) -))] struct Args { - /// Flour strength W (e.g., 260–300) - #[arg(long, value_parser = clap::value_parser!(u16).range(200..=450))] - w: u16, + /// Flour strength W, 200–450 [required unless supplied by a profile] + #[arg(long, value_parser = clap::value_parser!(u16).range(W_MIN as i64..=W_MAX as i64))] + w: Option, - /// Ambient temperature in °C - #[arg(long, default_value_t = 25.0)] - temp: f64, + /// Ambient temperature in °C [default: 25] + #[arg(long)] + temp: Option, - /// Yeast type - #[arg(long, value_enum, default_value_t = YeastFlag::Dry)] - yeast: YeastFlag, + /// Yeast type [default: dry] + #[arg(long, value_enum)] + yeast: Option, - /// Target hydration (0.55..0.85) - #[arg(long, default_value_t = 0.75)] - hydration: f64, + /// Target hydration, 0.55–0.85 [default: 0.75] + #[arg(long)] + hydration: Option, - /// Salt in g/kg flour - #[arg(long, default_value_t = 20.0)] - salt_per_kg: f64, + /// Salt in g/kg flour [default: 20] + #[arg(long)] + salt_per_kg: Option, - /// Dough ball weight in grams - #[arg(long, default_value_t = 280.0)] - ball_weight: f64, + /// Dough ball weight in grams [default: 280] + #[arg(long)] + ball_weight: Option, - /// Number of balls - #[arg(long, default_value_t = 2)] - balls: u32, + /// Number of balls [default: 2] + #[arg(long)] + balls: Option, - /// Total process hours (mix → bake) - #[arg(long, default_value_t = 11.0)] - total_hours: f64, + /// Total process hours, mix → bake [default: 11] + #[arg(long)] + total_hours: Option, - /// Fridge time in hours (0 = no fridge mode) - #[arg(long, default_value_t = 0.0)] - fridge_hours: f64, + /// Fridge time in hours, 0 = no fridge [default: 0] + #[arg(long)] + fridge_hours: Option, - /// Warmup time after fridge (bench rest) in hours - #[arg(long, default_value_t = 3.0)] - warmup_hours: f64, + /// Bench rest after fridge, in hours [default: 3] + #[arg(long)] + warmup_hours: Option, - /// Fridge factor (activity speed vs room), default 0.25 - #[arg(long, default_value_t = 0.25)] - fridge_factor: f64, + /// Fridge activity vs room, 0.05–0.5 [default: 0.25] + #[arg(long)] + fridge_factor: Option, - /// Start time HH:MM (optional); defaults to now + /// Start time HH:MM; defaults to now #[arg(long)] start: Option, - /// Load a profile JSON before applying CLI overrides + /// Load a profile JSON, then apply CLI overrides on top #[arg(long)] profile: Option, - /// Save the current effective parameters to a profile JSON + /// Save the effective parameters to a profile JSON #[arg(long)] save_profile: Option, } +/// A profile holds fully-resolved values: it is what we serialize on save and +/// what we read back on load. #[derive(Debug, Serialize, Deserialize)] struct Profile { w: u16, @@ -110,25 +126,6 @@ struct Profile { start: Option, } -impl From<&Args> for Profile { - fn from(a: &Args) -> Self { - Profile { - w: a.w, - temp: a.temp, - yeast: a.yeast, - hydration: a.hydration, - salt_per_kg: a.salt_per_kg, - ball_weight: a.ball_weight, - balls: a.balls, - total_hours: a.total_hours, - fridge_hours: a.fridge_hours, - warmup_hours: a.warmup_hours, - fridge_factor: a.fridge_factor, - start: a.start.clone(), - } - } -} - fn fmt_g(x: f64) -> String { let v = (x * 10.0).round() / 10.0; if (v - v.round()).abs() < 1e-9 { @@ -138,145 +135,170 @@ fn fmt_g(x: f64) -> String { } } -fn main() { - let mut args = Args::parse(); - - // Load profile if present, then apply CLI overrides (CLI wins). - if let Some(path) = &args.profile { - let Ok(txt) = fs::read_to_string(path) else { - eprintln!("Failed to read profile: {}", path.display()); - std::process::exit(1); - }; - let Ok(p): Result = serde_json::from_str(&txt) else { - eprintln!("Invalid profile JSON: {}", path.display()); - std::process::exit(1); - }; - - // Defaults snapshot to detect "unset" fields - let def = Args::parse_from(["pizza-cli"]); - - macro_rules! take { - ($field:ident) => { - if args.$field == def.$field { p.$field } else { args.$field } - }; - } +fn die(msg: impl std::fmt::Display) -> ! { + eprintln!("{msg}"); + exit(1); +} - args.w = take!(w); - args.temp = take!(temp); - args.yeast = if matches!(args.yeast, YeastFlag::Dry) && !matches!(p.yeast, YeastFlag::Dry) { - p.yeast - } else { - args.yeast - }; - args.hydration = take!(hydration); - args.salt_per_kg = take!(salt_per_kg); - args.ball_weight = take!(ball_weight); - args.balls = take!(balls); - args.total_hours = take!(total_hours); - args.fridge_hours = take!(fridge_hours); - args.warmup_hours = take!(warmup_hours); - args.fridge_factor = take!(fridge_factor); - if args.start.is_none() { - args.start = p.start; - } - } +fn load_profile(path: &PathBuf) -> Profile { + let txt = fs::read_to_string(path) + .unwrap_or_else(|e| die(format!("Failed to read profile {}: {e}", path.display()))); + serde_json::from_str(&txt) + .unwrap_or_else(|e| die(format!("Invalid profile JSON {}: {e}", path.display()))) +} - // Save profile if requested (using the effective arguments). - if let Some(path) = &args.save_profile { - let prof = Profile::from(&args); - if let Err(e) = fs::write(path, serde_json::to_string_pretty(&prof).unwrap()) { - eprintln!("Failed to save profile: {e}"); - std::process::exit(1); - } else { - println!("Profile saved to {}", path.display()); - } +fn main() { + let args = Args::parse(); + + let profile = args.profile.as_ref().map(load_profile); + let p = profile.as_ref(); + + // Resolution order for every field: explicit CLI value, then profile, then + // the built-in default. `w` is the exception — it has no default. + let w = args + .w + .or(p.map(|p| p.w)) + .unwrap_or_else(|| die("--w is required (pass it on the CLI or via a profile)")); + let temp = args.temp.or(p.map(|p| p.temp)).unwrap_or(DEFAULT_TEMP); + let yeast = args.yeast.or(p.map(|p| p.yeast)).unwrap_or(DEFAULT_YEAST); + let hydration = args + .hydration + .or(p.map(|p| p.hydration)) + .unwrap_or(DEFAULT_HYDRATION); + let salt_per_kg = args + .salt_per_kg + .or(p.map(|p| p.salt_per_kg)) + .unwrap_or(DEFAULT_SALT_PER_KG); + let ball_weight = args + .ball_weight + .or(p.map(|p| p.ball_weight)) + .unwrap_or(DEFAULT_BALL_WEIGHT); + let balls = args.balls.or(p.map(|p| p.balls)).unwrap_or(DEFAULT_BALLS); + let total_hours = args + .total_hours + .or(p.map(|p| p.total_hours)) + .unwrap_or(DEFAULT_TOTAL_HOURS); + let fridge_hours = args + .fridge_hours + .or(p.map(|p| p.fridge_hours)) + .unwrap_or(DEFAULT_FRIDGE_HOURS); + let warmup_hours = args + .warmup_hours + .or(p.map(|p| p.warmup_hours)) + .unwrap_or(DEFAULT_WARMUP_HOURS); + let fridge_factor = args + .fridge_factor + .or(p.map(|p| p.fridge_factor)) + .unwrap_or(DEFAULT_FRIDGE_FACTOR); + let start = args.start.clone().or_else(|| p.and_then(|p| p.start.clone())); + + // Validation. A profile can carry values that bypass clap's parsers, so we + // re-check everything here rather than trusting the source. + if !(W_MIN..=W_MAX).contains(&w) { + die(format!("W must be between {W_MIN} and {W_MAX}")); } - - // Validations - if !(0.55..=0.85).contains(&args.hydration) { - eprintln!("Hydration must be between 0.55 and 0.85"); - std::process::exit(1); + if !(0.55..=0.85).contains(&hydration) { + die("Hydration must be between 0.55 and 0.85"); } - if args.total_hours <= 0.0 { - eprintln!("total-hours must be > 0"); - std::process::exit(1); + if total_hours <= 0.0 { + die("total-hours must be > 0"); } - if args.fridge_hours < 0.0 || args.warmup_hours < 0.0 { - eprintln!("fridge-hours and warmup-hours must be >= 0"); - std::process::exit(1); + if fridge_hours < 0.0 || warmup_hours < 0.0 { + die("fridge-hours and warmup-hours must be >= 0"); } - if args.fridge_hours > 0.0 && args.fridge_hours + args.warmup_hours >= args.total_hours { - eprintln!("Sum of fridge-hours and warmup-hours must be < total-hours"); - std::process::exit(1); + if fridge_hours > 0.0 && fridge_hours + warmup_hours >= total_hours { + die("Sum of fridge-hours and warmup-hours must be < total-hours"); } - // Totals - let balls = args.balls as f64; - let total_dough = balls * args.ball_weight; + // Save once, only after everything is valid. + if let Some(path) = &args.save_profile { + let prof = Profile { + w, + temp, + yeast, + hydration, + salt_per_kg, + ball_weight, + balls, + total_hours, + fridge_hours, + warmup_hours, + fridge_factor, + start: start.clone(), + }; + let json = serde_json::to_string_pretty(&prof).expect("profile serializes"); + if let Err(e) = fs::write(path, json) { + die(format!("Failed to save profile: {e}")); + } + println!("Profile saved to {}", path.display()); + } - // Effective hours for yeast model - let eff_hours = effective_hours(args.total_hours, args.fridge_hours, args.fridge_factor); + let total_dough = balls as f64 * ball_weight; + let eff_hours = effective_hours(total_hours, fridge_hours, fridge_factor); - // Ingredients let ing = compute_ingredients(IngredientsInput { total_dough_g: total_dough, - hydration: args.hydration, - salt_per_kg: args.salt_per_kg, - yeast: args.yeast.into(), - temp_c: args.temp, - w: args.w, + hydration, + salt_per_kg, + yeast: yeast.into(), + temp_c: temp, + w, effective_hours: eff_hours, }); - // Timeline (with/without fridge) - let tl: Timeline = if args.fridge_hours > 0.0 { - timeline_with_fridge(args.total_hours, args.temp, args.fridge_hours, args.warmup_hours) + let tl: Timeline = if fridge_hours > 0.0 { + timeline_with_fridge(total_hours, temp, fridge_hours, warmup_hours) } else { - timeline_no_fridge(args.total_hours, args.temp) + timeline_no_fridge(total_hours, temp) }; - // Start time and phase ends - let start_time = if let Some(hhmm) = args.start.as_ref() { - NaiveTime::parse_from_str(hhmm, "%H:%M").ok() - } else { - Some(Local::now().naive_local().time()) + // Walk the phases forward from the start time, keeping full datetimes so we + // can flag phases that spill into the following day(s). + let start_time = match start.as_deref() { + Some(hhmm) => match NaiveTime::parse_from_str(hhmm, "%H:%M") { + Ok(t) => Some(t), + Err(_) => die(format!("Invalid --start time '{hhmm}', expected HH:MM")), + }, + None => Some(Local::now().naive_local().time()), }; - let (t_bulk_end, t_fridge_end, t_warmup_end, t_proof_end) = if let Some(st) = start_time { - let to_min = |h: f64| (h * 60.0).round() as i64; - let mut dt = Local::now().date_naive().and_time(st); + let (start_date, bulk_end, fridge_end, warmup_end, proof_end) = match start_time { + Some(st) => { + let to_min = |h: f64| (h * 60.0).round() as i64; + let start_dt = Local::now().date_naive().and_time(st); + let mut dt = start_dt; - let bulk_end = dt + chrono::Duration::minutes(to_min(tl.bulk_h)); - dt = bulk_end; + dt += chrono::Duration::minutes(to_min(tl.bulk_h)); + let bulk_end = Some(dt); - let fridge_end = if tl.fridge_h > 0.0 { - let e = dt + chrono::Duration::minutes(to_min(tl.fridge_h)); - dt = e; - Some(e) - } else { - None - }; + let fridge_end = if tl.fridge_h > 0.0 { + dt += chrono::Duration::minutes(to_min(tl.fridge_h)); + Some(dt) + } else { + None + }; - let warmup_end = if tl.warmup_h > 0.0 { - let e = dt + chrono::Duration::minutes(to_min(tl.warmup_h)); - dt = e; - Some(e) - } else { - None - }; + let warmup_end = if tl.warmup_h > 0.0 { + dt += chrono::Duration::minutes(to_min(tl.warmup_h)); + Some(dt) + } else { + None + }; - let proof_end = dt + chrono::Duration::minutes(to_min(tl.proof_h)); - ( - Some(bulk_end.time()), - fridge_end.map(|x| x.time()), - warmup_end.map(|x| x.time()), - Some(proof_end.time()), - ) - } else { - (None, None, None, None) + dt += chrono::Duration::minutes(to_min(tl.proof_h)); + let proof_end = Some(dt); + + ( + Some(start_dt.date()), + bulk_end, + fridge_end, + warmup_end, + proof_end, + ) + } + None => (None, None, None, None, None), }; - // Ingredients table let mut table = Table::new(); table .load_preset(UTF8_FULL) @@ -289,22 +311,26 @@ fn main() { table.add_row(vec![ Cell::new("Balls"), - Cell::new(format!("{} × {:.0} g", args.balls, args.ball_weight)), + Cell::new(format!("{} × {:.0} g", balls, ball_weight)), Cell::new(""), ]); table.add_row(vec![ Cell::new("Flour"), Cell::new(fmt_g(ing.flour_g)), - Cell::new(format!("W={} | H={:.0}%", args.w, args.hydration * 100.0)), + Cell::new(format!("W={} | H={:.0}%", w, hydration * 100.0)), + ]); + table.add_row(vec![ + Cell::new("Water"), + Cell::new(fmt_g(ing.water_g)), + Cell::new(""), ]); - table.add_row(vec![Cell::new("Water"), Cell::new(fmt_g(ing.water_g)), Cell::new("")]); table.add_row(vec![ Cell::new("Salt"), Cell::new(fmt_g(ing.salt_g)), - Cell::new(format!("{:.1} g/kg", args.salt_per_kg)), + Cell::new(format!("{:.1} g/kg", salt_per_kg)), ]); - match args.yeast { + match yeast { YeastFlag::Dry => table.add_row(vec![ Cell::new("Dry yeast"), Cell::new(fmt_g(ing.yeast_g)), @@ -318,45 +344,32 @@ fn main() { }; println!("\n=== Ingredients summary ==="); - println!("{}", table); + println!("{table}"); - // Timeline println!("\n=== Timeline ==="); println!( "- Bulk rise (whole dough): {:.1} h{}", tl.bulk_h, - match t_bulk_end { - Some(t) => format!(" → ~end at {:02}:{:02}", t.hour(), t.minute()), - None => "".to_string(), - } + fmt_end(start_date, bulk_end) ); if tl.fridge_h > 0.0 { println!( "- Fridge (covered): {:.1} h{}", tl.fridge_h, - match t_fridge_end { - Some(t) => format!(" → ~end at {:02}:{:02}", t.hour(), t.minute()), - None => "".to_string(), - } + fmt_end(start_date, fridge_end) ); println!( "- Warmup (bench rest): {:.1} h{}", tl.warmup_h, - match t_warmup_end { - Some(t) => format!(" → ~end at {:02}:{:02}", t.hour(), t.minute()), - None => "".to_string(), - } + fmt_end(start_date, warmup_end) ); } println!( "- Final proof (balls): {:.1} h{}", tl.proof_h, - match t_proof_end { - Some(t) => format!(" → ~end at {:02}:{:02}", t.hour(), t.minute()), - None => "".to_string(), - } + fmt_end(start_date, proof_end) ); println!( @@ -367,10 +380,20 @@ fn main() { println!("\nNotes:"); println!("• Yeast amounts are heuristic (Q10≈2/10°C; mild W effect). Fridge counted at configurable factor."); println!("• If dough rises too fast in warm conditions (>27°C), shorten bulk or reduce yeast slightly."); +} - // Save profile at the end if requested (again, to reflect any defaults resolved) - if let Some(path) = &args.save_profile { - let prof = Profile::from(&args); - let _ = fs::write(path, serde_json::to_string_pretty(&prof).unwrap()); +/// Format a phase end time, appending `(+Nd)` when it lands on a later day than +/// the start so long fridge timelines aren't silently misread. +fn fmt_end(start_date: Option, end: Option) -> String { + match (start_date, end) { + (Some(start), Some(dt)) => { + let days = (dt.date() - start).num_days(); + if days > 0 { + format!(" → ~end at {:02}:{:02} (+{}d)", dt.hour(), dt.minute(), days) + } else { + format!(" → ~end at {:02}:{:02}", dt.hour(), dt.minute()) + } + } + _ => String::new(), } } diff --git a/pizza-core/src/lib.rs b/pizza-core/src/lib.rs index 9ac116d..2a2b79b 100644 --- a/pizza-core/src/lib.rs +++ b/pizza-core/src/lib.rs @@ -33,10 +33,7 @@ pub struct Ingredients { pub flour_g: f64, pub water_g: f64, pub salt_g: f64, - /// For baker’s yeast (dry/fresh). pub yeast_g: f64, - /// For sourdough only: total starter (flour+water) at 100% hydration. - pub starter_total_g: f64, } #[inline] @@ -70,33 +67,25 @@ pub fn effective_hours(total_hours: f64, fridge_hours: f64, fridge_factor: f64) } /// Compute ingredients for given input. -/// - Dry/Fresh: dough = flour + water + salt + yeast -/// - Sourdough: dough = flour + water + salt, where part of flour+water comes from starter (100%) +/// Dough = flour + water + salt + yeast, solving for flour so the parts add +/// up to the requested total dough weight. pub fn compute_ingredients(input: IngredientsInput) -> Ingredients { let salt_pct = input.salt_per_kg / 1000.0; let h = input.hydration; - match input.yeast { - YeastKind::Dry | YeastKind::Fresh => { - let dry_pct = estimate_yeast_percent_dry(input.temp_c, input.w, input.effective_hours); - let yeast_pct = match input.yeast { - YeastKind::Dry => dry_pct, - YeastKind::Fresh => dry_pct * 3.0, - }; - - let flour = input.total_dough_g / (1.0 + h + salt_pct + yeast_pct); - let water = flour * h; - let salt = flour * salt_pct; - let yeast = flour * yeast_pct; - - Ingredients { - flour_g: flour, - water_g: water, - salt_g: salt, - yeast_g: yeast, - starter_total_g: 0.0, - } - } + let dry_pct = estimate_yeast_percent_dry(input.temp_c, input.w, input.effective_hours); + let yeast_pct = match input.yeast { + YeastKind::Dry => dry_pct, + YeastKind::Fresh => dry_pct * 3.0, + }; + + let flour = input.total_dough_g / (1.0 + h + salt_pct + yeast_pct); + + Ingredients { + flour_g: flour, + water_g: flour * h, + salt_g: flour * salt_pct, + yeast_g: flour * yeast_pct, } } @@ -191,10 +180,23 @@ mod tests { #[test] fn test_yeast_percent_bounds() { + // Hot + long time pushes the estimate down; cold + strong flour + short + // time pushes it up. Both must stay inside the clamp window. let p_lo = estimate_yeast_percent_dry(35.0, 260, 24.0); let p_hi = estimate_yeast_percent_dry(10.0, 450, 6.0); - (0.0005..=0.015).contains(&p_lo); - (0.0005..=0.015).contains(&p_hi); + assert!((0.0005..=0.015).contains(&p_lo), "p_lo out of bounds: {p_lo}"); + assert!((0.0005..=0.015).contains(&p_hi), "p_hi out of bounds: {p_hi}"); + assert!(p_lo < p_hi, "hotter/longer should need less yeast than colder/shorter"); + } + + #[test] + fn test_yeast_percent_clamps_extremes() { + // Extreme cold + short time would blow past 1.5% without the clamp. + let capped = estimate_yeast_percent_dry(0.0, 450, 1.0); + assert!((capped - 0.015).abs() < 1e-9, "expected upper clamp at 1.5%, got {capped}"); + // Extreme heat + very long time would fall below 0.05% without the clamp. + let floored = estimate_yeast_percent_dry(45.0, 200, 72.0); + assert!((floored - 0.0005).abs() < 1e-9, "expected lower clamp at 0.05%, got {floored}"); } #[test]