diff --git a/Cargo.lock b/Cargo.lock index bbc9ff4..cc5a930 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,6 +375,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -437,6 +446,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "cc" version = "1.2.59" @@ -601,7 +633,7 @@ dependencies = [ "chrono", "clap", "cooklang", - "cooklang-find", + "cooklang-find 0.6.0", "cooklang-import", "cooklang-language-server", "cooklang-reports", @@ -688,6 +720,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cooklang-find" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67908231b509d167b701f38dc81ffb1cf90e57040b07571524f5bf9b9dca6355" +dependencies = [ + "camino", + "glob", + "regex", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "uniffi", +] + [[package]] name = "cooklang-import" version = "0.9.9" @@ -739,7 +787,7 @@ checksum = "456c2955407200f45ab9680fa94c603a19c5d37e564f9fe172946ae36b9e3d62" dependencies = [ "anyhow", "cooklang", - "cooklang-find", + "cooklang-find 0.5.0", "minijinja", "serde", "serde_yaml", @@ -1404,6 +1452,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1598,6 +1655,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2496,6 +2564,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "path-slash" version = "0.2.1" @@ -3208,6 +3282,26 @@ dependencies = [ "tendril", ] +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "selectors" version = "0.25.0" @@ -3282,6 +3376,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3546,6 +3644,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "str_indices" version = "0.4.4" @@ -3920,6 +4024,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.23" @@ -4305,6 +4418,134 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "uniffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" +dependencies = [ + "anyhow", + "cargo_metadata", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cade167af943e189a55020eda2c314681e223f1e42aca7c4e52614c2b627698f" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "once_cell", + "paste", + "serde", + "textwrap", + "toml 0.5.11", + "uniffi_meta", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7cf32576e08104b7dc2a6a5d815f37616e66c6866c2a639fe16e6d2286b75b" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_checksum_derive" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "uniffi_core" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" +dependencies = [ + "anyhow", + "bytes", + "log", + "once_cell", + "paste", + "static_assertions", +] + +[[package]] +name = "uniffi_macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" +dependencies = [ + "bincode", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml 0.5.11", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" +dependencies = [ + "anyhow", + "bytes", + "siphasher 0.3.11", + "uniffi_checksum_derive", +] + +[[package]] +name = "uniffi_testing" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6f984f0781f892cc864a62c3a5c60361b1ccbd68e538e6c9fbced5d82268ac" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "fs-err", + "once_cell", +] + +[[package]] +name = "uniffi_udl" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037820a4cfc4422db1eaa82f291a3863c92c7d1789dc513489c36223f9b4cdfc" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "uniffi_testing", + "weedle2", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4602,6 +4843,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "whatlang" version = "0.16.4" diff --git a/Cargo.toml b/Cargo.toml index c19301f..e40437e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ chrono = "0.4" clap = { version = "4.5", features = ["derive"] } base64 = { version = "0.22", optional = true } cooklang = { version = "0.18.5", default-features = false, features = ["aisle", "pantry", "shopping_list"] } -cooklang-find = { version = "0.5.0" } +cooklang-find = { version = "0.6" } cooklang-import = "0.9.3" cooklang-sync-client = { version = "0.4.11", optional = true } libsqlite3-sys = { version = "0.35", features = ["bundled"], optional = true } diff --git a/docs/superpowers/plans/2026-06-24-menus-for-date-integration.md b/docs/superpowers/plans/2026-06-24-menus-for-date-integration.md new file mode 100644 index 0000000..e90a5ad --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-menus-for-date-integration.md @@ -0,0 +1,192 @@ +# Menus-for-date Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace cookcli's custom "today's menu" scan with `cooklang_find::list_menus_for_date` from cooklang-find 0.6.0. + +**Architecture:** Bump the `cooklang-find` dependency to 0.6, then rewrite `find_todays_menu` in `src/server/handlers/menus.rs` to delegate the directory scan to the library, keeping chrono in cookcli for computing/displaying "today". Drop the now-unused `tree` parameter and update its single caller. + +**Tech Stack:** Rust, cooklang-find 0.6, chrono, axum, tempfile (dev). + +--- + +## Spec + +See `docs/superpowers/specs/2026-06-24-menus-for-date-integration-design.md`. + +## File Structure + +- **Modify:** `Cargo.toml` — bump `cooklang-find` to `0.6`. +- **Modify:** `src/server/handlers/menus.rs` — rewrite `find_todays_menu`; add a `#[cfg(test)]` module (none exists today). +- **Modify:** `src/server/builders.rs:142` — update the call site to drop the `tree` argument. + +`collect_menus`, `extract_date`, `extract_time`, `extract_meal_type`, `is_meal_header`, and the `get_menu`/`list_menus` handlers are unchanged. The `RecipeTree` import in `menus.rs` stays (used by `collect_menus`). + +--- + +## Task 1: Bump cooklang-find to 0.6 + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1: Update the dependency version** + +In `Cargo.toml`, change the `cooklang-find` line (currently `cooklang-find = { version = "0.5.0" }`) to: + +```toml +cooklang-find = { version = "0.6" } +``` + +- [ ] **Step 2: Update the lockfile and verify the build still compiles** + +Run: `cargo build` +Expected: Compiles successfully. `Cargo.lock` now resolves `cooklang-find` to `0.6.0`. (The 0.6 release is backward compatible with the native API cookcli already uses: `search`, `build_tree`, `get_recipe`, `RecipeEntry`, `RecipeTree`.) + +- [ ] **Step 3: Confirm the resolved version** + +Run: `grep -A2 'name = "cooklang-find"' Cargo.lock` +Expected: shows `version = "0.6.0"`. + +- [ ] **Step 4: Commit** + +```bash +git add Cargo.toml Cargo.lock +git commit -m "chore: bump cooklang-find to 0.6" +``` + +--- + +## Task 2: Rewrite `find_todays_menu` to use `list_menus_for_date` + +**Files:** +- Modify: `src/server/handlers/menus.rs` (rewrite `find_todays_menu` at lines 411-453; add a test module at end of file) +- Modify: `src/server/builders.rs:142` (drop the `tree` argument) +- Test: `#[cfg(test)] mod tests` in `src/server/handlers/menus.rs` + +- [ ] **Step 1: Write the failing tests** + +Append this test module at the very end of `src/server/handlers/menus.rs` (after the `LineItem` enum). It calls `find_todays_menu` with the new single-argument signature, so it will not compile until Step 3 changes the signature. + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn find_todays_menu_matches_section_with_today() { + let temp = TempDir::new().unwrap(); + let dir = camino::Utf8Path::from_path(temp.path()).unwrap(); + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + let content = format!("= Day 1 ({today})\n\nBreakfast:\n- @eggs{{}}\n"); + fs::write(dir.join("week.menu"), content).unwrap(); + + let result = find_todays_menu(dir); + + assert!(result.is_some()); + assert_eq!(result.unwrap().menu_path, "week"); + } + + #[test] + fn find_todays_menu_returns_none_when_no_section_matches_today() { + let temp = TempDir::new().unwrap(); + let dir = camino::Utf8Path::from_path(temp.path()).unwrap(); + let content = "= Day 1 (1999-01-01)\n\nBreakfast:\n- @eggs{}\n"; + fs::write(dir.join("week.menu"), content).unwrap(); + + let result = find_todays_menu(dir); + + assert!(result.is_none()); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test find_todays_menu` +Expected: FAIL to compile — `find_todays_menu` takes 2 arguments but 1 was supplied (the signature still requires `tree`). + +- [ ] **Step 3: Rewrite `find_todays_menu`** + +Replace the entire existing `find_todays_menu` function (lines 411-453, the doc comment plus the body) with: + +```rust +/// Find the menu whose section matches today's date, using cooklang-find's +/// `list_menus_for_date`. Returns the first match with menu name, path, and a +/// human-friendly date for display. +pub fn find_todays_menu( + base_path: &camino::Utf8Path, +) -> Option { + let now = chrono::Local::now(); + let today = now.format("%Y-%m-%d").to_string(); + let today_display = now.format("%A, %B %-d").to_string(); + + let menus = cooklang_find::list_menus_for_date(&[base_path], &today).unwrap_or_default(); + let entry = menus.first()?; + + let full_path = entry.path()?; + let relative = full_path + .strip_prefix(base_path) + .unwrap_or(full_path.as_ref()); + let menu_name = entry.name().clone().unwrap_or_else(|| relative.to_string()); + let menu_path = relative + .as_str() + .trim_end_matches(".cook") + .trim_end_matches(".menu") + .to_string(); + + Some(crate::server::templates::TodaysMenu { + menu_name, + menu_path, + date_display: today_display, + }) +} +``` + +- [ ] **Step 4: Update the caller in `src/server/builders.rs`** + +At `src/server/builders.rs:142`, change: + +```rust + crate::server::handlers::find_todays_menu(base_path, &tree) +``` + +to: + +```rust + crate::server::handlers::find_todays_menu(base_path) +``` + +(`tree` remains used earlier in the function to build the recipe list, so it does not become unused.) + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cargo test find_todays_menu` +Expected: PASS (2 tests). + +- [ ] **Step 6: Verify the whole crate builds cleanly with no warnings** + +Run: `cargo build` +Expected: builds with no errors and no warnings. (In particular, confirm `find_todays_menu`'s old per-menu parsing locals are gone and no `unused` warnings appear.) + +- [ ] **Step 7: Run the full test suite** + +Run: `cargo test` +Expected: PASS (existing tests plus the 2 new ones). + +- [ ] **Step 8: Commit** + +```bash +git add src/server/handlers/menus.rs src/server/builders.rs +git commit -m "feat: use list_menus_for_date for the web UI today's menu" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** dependency bump (Task 1); `find_todays_menu` rewrite keeping chrono for `today`/`today_display` (Task 2 Step 3); `tree` param removal + caller update (Task 2 Steps 3-4); helpers/handlers left unchanged (not touched); tests for match + no-match (Task 2 Step 1). All covered. +- **Type consistency:** `find_todays_menu(base_path: &camino::Utf8Path) -> Option` is used identically in the test, the implementation, and the caller. `entry.path()` returns `Option<&Utf8PathBuf>` (handled via `?` then `.as_ref()`); `entry.name()` returns `&Option` (handled via `.clone()`). `list_menus_for_date(&[base_path], &today)` matches the library signature `>(base_dirs: &[P], date: &str)` since `&Utf8Path: AsRef`. +- **No placeholders:** every step contains the exact code/command. +- **Spec note correction:** the spec mentioned "existing tests for extract_date remain unchanged"; in fact `menus.rs` currently has no test module, so Task 2 Step 1 creates one. No behavior impact. diff --git a/docs/superpowers/specs/2026-06-24-menus-for-date-integration-design.md b/docs/superpowers/specs/2026-06-24-menus-for-date-integration-design.md new file mode 100644 index 0000000..9f21025 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-menus-for-date-integration-design.md @@ -0,0 +1,94 @@ +# Design: Use `cooklang_find::list_menus_for_date` for the web UI's today's-menu + +**Date:** 2026-06-24 +**Status:** Approved + +## Overview + +The cookcli web server shows "today's menu" on its home page. The detection +currently lives in `find_todays_menu` (`src/server/handlers/menus.rs`), which +builds the full menu list and then, for each menu, loads and fully parses the +file, walks its sections, and regex-extracts a `(YYYY-MM-DD)` date to find one +matching today. + +`cooklang-find` 0.6.0 adds `list_menus_for_date(base_dirs, date)`, which scans +`.menu` files for a section header containing a date string and returns the +matching entries. This change replaces the custom scan in `find_todays_menu` +with that library call. + +Scope is limited to the *implementation* of `find_todays_menu`. Behavior is +preserved: the web UI still surfaces today's menu. No template/UI changes, no +new endpoint, today only (not tomorrow). + +## Changes + +### 1. Dependency bump + +`Cargo.toml`: `cooklang-find = { version = "0.6" }` (currently `"0.5.0"`). +Version 0.6.0 is published on crates.io. + +### 2. `find_todays_menu` + +Keep using `chrono` to compute: +- `today` — `chrono::Local::now().format("%Y-%m-%d")` (the match key). +- `today_display` — `format("%A, %B %-d")` (for `TodaysMenu.date_display`). + +Replace the `collect_menus` + per-menu `get_recipe` / `parse_recipe_from_entry` +/ section-loop / `extract_date` block with: + +```rust +let menus = cooklang_find::list_menus_for_date(&[base_path], &today).unwrap_or_default(); +let entry = menus.first()?; +``` + +Build `TodaysMenu` from the first matching entry: +- `menu_name` — `entry.name()` if present, else the relative path string. +- `menu_path` — `entry.path()` stripped of the `base_path` prefix, with a + trailing `.menu`/`.cook` removed (matching current behavior). +- `date_display` — `today_display`. + +Signature stays `fn find_todays_menu(base_path: &Utf8Path, tree: &RecipeTree) -> Option`. +The `tree` parameter is no longer needed by the body; remove it and update the +single caller (`src/server/builders.rs:111`) — OR keep it to minimize the call +site change. Decision: **remove the now-unused `tree` parameter** and update the +caller, since `list_menus_for_date` does its own directory scan and leaving an +unused parameter is misleading. The caller still has `base_path` available. + +### What stays unchanged + +- `collect_menus` — still used by the `list_menus` handler. +- `extract_date`, `extract_time`, `extract_meal_type`, `is_meal_header` — still + used by `get_menu` for per-section date/meal display. +- `chrono` dependency — still needed here. + +## Semantic difference (accepted) + +The old code required the date inside parentheses (`(2026-06-24)`); the library +matches the date as a substring anywhere in a section header. For menus written +as "Day 1 (2026-06-24)" the two are equivalent. The library is slightly more +lenient (it would also match a bare `= 2026-06-24` header). This is acceptable +and arguably an improvement. + +If multiple menus match today, the old code returned the first found during a +recursive tree walk; the new code returns `menus.first()`. Ordering may differ +slightly (glob order vs tree-walk order). Only one today's-menu is shown, and no +ordering guarantee was documented, so this is acceptable. + +## Testing + +Add unit tests in `src/server/handlers/menus.rs`: + +- **Match:** create a temp dir with a `.menu` file whose section header contains + today's date (computed via `chrono::Local::now()`), assert `find_todays_menu` + returns `Some` with the expected `menu_path`/`menu_name`. +- **No match:** a `.menu` file with a clearly non-today date → `None`. + +Existing tests for `extract_date` / `extract_meal_type` / `is_meal_header` +remain unchanged. + +## Out of scope + +- Tomorrow's menu (today only). +- Any template/HTML or frontend changes. +- A new `/api/menus?date=...` endpoint. +- Changing the menu detail view (`get_menu`) date handling. diff --git a/src/server/builders.rs b/src/server/builders.rs index aaed901..3d32cab 100644 --- a/src/server/builders.rs +++ b/src/server/builders.rs @@ -139,7 +139,7 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result Option { let now = chrono::Local::now(); let today = now.format("%Y-%m-%d").to_string(); let today_display = now.format("%A, %B %-d").to_string(); - let mut menus = Vec::new(); - collect_menus(tree, base_path, &mut menus); - - for menu_item in &menus { - let recipe_path = camino::Utf8PathBuf::from(&menu_item.path); - let entry = match cooklang_find::get_recipe(vec![base_path], &recipe_path) { - Ok(e) => e, - Err(_) => continue, - }; - - let recipe = match crate::util::parse_recipe_from_entry(&entry, 1.0) { - Ok(r) => r, - Err(_) => continue, - }; - - for section in &recipe.sections { - let date = section.name.as_deref().and_then(extract_date); - if date.as_deref() == Some(today.as_str()) { - return Some(crate::server::templates::TodaysMenu { - menu_name: menu_item.name.clone(), - menu_path: menu_item - .path - .trim_end_matches(".cook") - .trim_end_matches(".menu") - .to_string(), - date_display: today_display, - }); - } - } - } - - None + let menus = cooklang_find::list_menus_for_date(&[base_path], &today).unwrap_or_default(); + let entry = menus.first()?; + + let full_path = entry.path()?; + let relative = full_path + .strip_prefix(base_path) + .unwrap_or(full_path.as_ref()); + let menu_name = entry.name().clone().unwrap_or_else(|| relative.to_string()); + let menu_path = relative + .as_str() + .trim_end_matches(".cook") + .trim_end_matches(".menu") + .to_string(); + + Some(crate::server::templates::TodaysMenu { + menu_name, + menu_path, + date_display: today_display, + }) } /// Internal representation of a line item while parsing. @@ -467,3 +455,52 @@ enum LineItem { unit: Option, }, } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn find_todays_menu_matches_section_with_today() { + let temp = TempDir::new().unwrap(); + let dir = camino::Utf8Path::from_path(temp.path()).unwrap(); + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + let content = format!("= Day 1 ({today})\n\nBreakfast:\n- @eggs{{}}\n"); + fs::write(dir.join("week.menu"), content).unwrap(); + + let result = find_todays_menu(dir); + + assert!(result.is_some()); + assert_eq!(result.unwrap().menu_path, "week"); + } + + #[test] + fn find_todays_menu_matches_bare_date_header() { + // The library matches the date as a substring, so a header without + // parentheses (e.g. "= 2026-06-24 Dinner") also counts as today. + let temp = TempDir::new().unwrap(); + let dir = camino::Utf8Path::from_path(temp.path()).unwrap(); + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + let content = format!("= {today} Dinner\n\nBreakfast:\n- @eggs{{}}\n"); + fs::write(dir.join("week.menu"), content).unwrap(); + + let result = find_todays_menu(dir); + + assert!(result.is_some()); + assert_eq!(result.unwrap().menu_path, "week"); + } + + #[test] + fn find_todays_menu_returns_none_when_no_section_matches_today() { + let temp = TempDir::new().unwrap(); + let dir = camino::Utf8Path::from_path(temp.path()).unwrap(); + let content = "= Day 1 (1999-01-01)\n\nBreakfast:\n- @eggs{}\n"; + fs::write(dir.join("week.menu"), content).unwrap(); + + let result = find_todays_menu(dir); + + assert!(result.is_none()); + } +}