From 031704d9757a08e56326857a7d72d9e2928a54b0 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Mon, 13 Jul 2026 10:46:34 +0100 Subject: [PATCH 1/2] refactor: split rendering layer out of server module src/server/ held both the axum HTTP layer and the rendering layer (askama templates, view-model builders, i18n, embedded static assets). cook build depends on the latter, so the two could not be gated apart. Move rendering to src/web/ and lift the sync client to src/sync/ (login and logout need it without the server). src/server/ now holds only the axum layer, which lets the next commit put it behind a feature. Pure code movement: no behaviour change. --- src/build/mod.rs | 2 +- src/build/renderer.rs | 4 +- src/build/writer.rs | 4 +- src/lib.rs | 3 + src/login.rs | 2 +- src/logout.rs | 2 +- src/main.rs | 3 + src/server/handlers/menus.rs | 81 --------------------------- src/server/handlers/mod.rs | 2 +- src/server/handlers/sync.rs | 2 +- src/server/mod.rs | 34 ++++-------- src/server/ui.rs | 29 +++++----- src/{server => }/sync/device_flow.rs | 0 src/{server => }/sync/endpoints.rs | 0 src/{server => }/sync/mod.rs | 0 src/{server => }/sync/runner.rs | 0 src/{server => }/sync/session.rs | 0 src/{server => web}/builders.rs | 14 ++--- src/{server => web}/i18n.rs | 0 src/{server => web}/language.rs | 0 src/web/menus.rs | 82 ++++++++++++++++++++++++++++ src/web/mod.rs | 41 ++++++++++++++ src/{server => web}/templates.rs | 4 +- templates/menu.html | 12 ++-- templates/recipe.html | 6 +- 25 files changed, 182 insertions(+), 145 deletions(-) rename src/{server => }/sync/device_flow.rs (100%) rename src/{server => }/sync/endpoints.rs (100%) rename src/{server => }/sync/mod.rs (100%) rename src/{server => }/sync/runner.rs (100%) rename src/{server => }/sync/session.rs (100%) rename src/{server => web}/builders.rs (98%) rename src/{server => web}/i18n.rs (100%) rename src/{server => web}/language.rs (100%) create mode 100644 src/web/menus.rs create mode 100644 src/web/mod.rs rename src/{server => web}/templates.rs (99%) diff --git a/src/build/mod.rs b/src/build/mod.rs index 41cda060..b7445f01 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -4,8 +4,8 @@ mod renderer; mod sitemap; mod writer; -use crate::server::language::{parse_supported_language, EN_US}; use crate::util::resolve_to_absolute_path; +use crate::web::language::{parse_supported_language, EN_US}; use crate::Context; use anyhow::{bail, Context as _, Result}; use camino::Utf8PathBuf; diff --git a/src/build/renderer.rs b/src/build/renderer.rs index 297237d9..767d22ff 100644 --- a/src/build/renderer.rs +++ b/src/build/renderer.rs @@ -1,10 +1,10 @@ use crate::build::links::relative_prefix; use crate::build::writer::write_html; -use crate::server::builders::{ +use crate::web::builders::{ build_recipe_template, build_recipes_template, RecipeBuildInput, RecipeBuildOutput, RecipesBuildInput, }; -use crate::server::language::FeatureFlags; +use crate::web::language::FeatureFlags; use anyhow::Result; use askama::Template; use camino::{Utf8Path, Utf8PathBuf}; diff --git a/src/build/writer.rs b/src/build/writer.rs index e0cb5877..bf20a6c7 100644 --- a/src/build/writer.rs +++ b/src/build/writer.rs @@ -27,9 +27,9 @@ pub fn write_bytes(output_root: &Utf8Path, relpath: &Utf8Path, bytes: &[u8]) -> /// Copy every file in the rust-embed `StaticFiles` to `output_root/static/`. pub fn copy_static_assets(output_root: &Utf8Path) -> Result { let mut count = 0; - for path in crate::server::StaticFiles::iter() { + for path in crate::web::StaticFiles::iter() { let rel = Utf8Path::new("static").join(path.as_ref()); - let file = crate::server::StaticFiles::get(path.as_ref()) + let file = crate::web::StaticFiles::get(path.as_ref()) .with_context(|| format!("Embedded file vanished: {path}"))?; write_bytes(output_root, &rel, &file.data)?; count += 1; diff --git a/src/lib.rs b/src/lib.rs index 234f99aa..da07d073 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,8 +18,11 @@ pub mod search; pub mod seed; pub mod server; pub mod shopping_list; +#[cfg(feature = "sync")] +pub mod sync; #[cfg(feature = "self-update")] pub mod update; +pub mod web; // Other modules pub mod args; diff --git a/src/login.rs b/src/login.rs index 36258529..d9f56663 100644 --- a/src/login.rs +++ b/src/login.rs @@ -4,7 +4,7 @@ use anyhow::Result; use clap::Args; use tokio_util::sync::CancellationToken; -use crate::server::sync::{device_flow, SyncSession}; +use crate::sync::{device_flow, SyncSession}; use crate::Context; #[derive(Debug, Args)] diff --git a/src/logout.rs b/src/logout.rs index ee11ffb6..9520e122 100644 --- a/src/logout.rs +++ b/src/logout.rs @@ -1,7 +1,7 @@ use anyhow::Result; use clap::Args; -use crate::server::sync::SyncSession; +use crate::sync::SyncSession; use crate::Context; #[derive(Debug, Args)] diff --git a/src/main.rs b/src/main.rs index 7b837b5d..d3c3cf6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,8 +50,11 @@ mod search; mod seed; mod server; mod shopping_list; +#[cfg(feature = "sync")] +mod sync; #[cfg(feature = "self-update")] mod update; +mod web; // other modules mod args; diff --git a/src/server/handlers/menus.rs b/src/server/handlers/menus.rs index 1d925b85..b81bc227 100644 --- a/src/server/handlers/menus.rs +++ b/src/server/handlers/menus.rs @@ -408,38 +408,6 @@ pub async fn get_menu( })) } -/// Find the first menu with a section matching today's date, using -/// cooklang-find's `list_menus_for_date`. A section header containing the date -/// anywhere (e.g. `= Day 1 (2026-06-24)` or `= 2026-06-24 Dinner`) counts as a -/// match. Returns the 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, - }) -} - /// Internal representation of a line item while parsing. #[derive(Clone)] enum LineItem { @@ -455,52 +423,3 @@ 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()); - } -} diff --git a/src/server/handlers/mod.rs b/src/server/handlers/mod.rs index dc48d713..3018e7f4 100644 --- a/src/server/handlers/mod.rs +++ b/src/server/handlers/mod.rs @@ -8,7 +8,7 @@ pub mod stats; #[cfg(feature = "sync")] pub mod sync; -pub use menus::{find_todays_menu, get_menu, list_menus}; +pub use menus::{get_menu, list_menus}; pub use pantry::{ add_item as add_pantry_item, get_depleted, get_expiring, get_pantry, remove_item as remove_pantry_item, update_item as update_pantry_item, diff --git a/src/server/handlers/sync.rs b/src/server/handlers/sync.rs index eca5b8d1..07407f22 100644 --- a/src/server/handlers/sync.rs +++ b/src/server/handlers/sync.rs @@ -1,5 +1,5 @@ -use crate::server::sync::{self, device_flow, PendingDeviceFlow, SyncSession}; use crate::server::AppState; +use crate::sync::{self, device_flow, PendingDeviceFlow, SyncSession}; use axum::{extract::State, http::StatusCode, Json}; use serde::Serialize; use std::sync::Arc; diff --git a/src/server/mod.rs b/src/server/mod.rs index 46a61fce..da988eea 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -40,31 +40,19 @@ use axum::{ }; use camino::Utf8PathBuf; use clap::Args; -use rust_embed::RustEmbed; #[cfg(feature = "sync")] use std::sync::Mutex; use std::{net::IpAddr, net::SocketAddr, sync::Arc}; use tower_http::{cors::CorsLayer, services::ServeDir}; use tracing::{error, info}; -pub mod builders; mod fs_atomic; mod handlers; -mod i18n; -pub mod language; mod lsp_bridge; mod shopping_list_store; mod shopping_list_watcher; -#[cfg(feature = "sync")] -pub mod sync; -pub mod templates; mod ui; -// Embed static files at compile time -#[derive(RustEmbed)] -#[folder = "static/"] -pub struct StaticFiles; - #[derive(Debug, Args)] pub struct ServerArgs { /// Root directory containing your recipe files @@ -151,9 +139,9 @@ pub async fn run(ctx: Context, args: ServerArgs) -> Result<()> { { let session_guard = state.sync_session.lock().unwrap(); if let Some(ref session) = *session_guard { - match sync::sync_db_path() { + match crate::sync::sync_db_path() { Ok(db_path) => { - match sync::start_sync(session, state.base_path.to_string(), db_path) { + match crate::sync::start_sync(session, state.base_path.to_string(), db_path) { Ok(handle) => { // Safe to use try_lock here: no contention before the server accepts connections if let Ok(mut guard) = state.sync_handle.try_lock() { @@ -169,7 +157,7 @@ pub async fn run(ctx: Context, args: ServerArgs) -> Result<()> { } // Start token refresh task (cancelled on shutdown via shutdown_token) - sync::runner::start_token_refresh( + crate::sync::runner::start_token_refresh( Arc::clone(&state.sync_session), state.session_path.clone(), state.shutdown_token.child_token(), @@ -204,9 +192,11 @@ pub async fn run(ctx: Context, args: ServerArgs) -> Result<()> { .layer(DefaultBodyLimit::max(MAX_BODY_SIZE)) .layer(axum::middleware::from_fn_with_state( url_prefix_for_features, - language::features_middleware, + crate::web::language::features_middleware, + )) + .layer(axum::middleware::from_fn( + crate::web::language::language_middleware, )) - .layer(axum::middleware::from_fn(language::language_middleware)) .layer( CorsLayer::new() .allow_origin("*".parse::().unwrap()) @@ -302,7 +292,7 @@ fn build_state(ctx: Context, args: ServerArgs) -> Result> { let path = crate::global_file_path("session.json") .map(|p: Utf8PathBuf| p.into_std_path_buf()) .unwrap_or_else(|_| std::path::PathBuf::from(".cook-session.json")); - let session = match sync::SyncSession::load(&path) { + let session = match crate::sync::SyncSession::load(&path) { Ok(s) => s, Err(e) => { tracing::warn!("Failed to load sync session: {e}"); @@ -374,11 +364,11 @@ pub struct AppState { /// clients can still connect but will never receive events. pub shopping_list_events: Option, #[cfg(feature = "sync")] - pub sync_session: Arc>>, + pub sync_session: Arc>>, #[cfg(feature = "sync")] - pub sync_handle: Arc>>, + pub sync_handle: Arc>>, #[cfg(feature = "sync")] - pub pending_device_flow: Arc>>, + pub pending_device_flow: Arc>>, #[cfg(feature = "sync")] pub session_path: std::path::PathBuf, #[cfg(feature = "sync")] @@ -477,7 +467,7 @@ fn api(_state: &AppState) -> Result>> { async fn serve_static(Path(path): Path) -> impl axum::response::IntoResponse { let path = path.trim_start_matches('/'); - StaticFiles::get(path) + crate::web::StaticFiles::get(path) .map(|content| { let mime = mime_guess::from_path(path).first_or_octet_stream(); Response::builder() diff --git a/src/server/ui.rs b/src/server/ui.rs index 59bb71a7..e3bddbf7 100644 --- a/src/server/ui.rs +++ b/src/server/ui.rs @@ -1,5 +1,6 @@ -use crate::server::language::FeatureFlags; -use crate::server::{templates::*, AppState}; +use crate::server::AppState; +use crate::web::language::FeatureFlags; +use crate::web::templates::*; use axum::{ extract::{Extension, Host, Path, Query, State}, http::{header, HeaderMap, StatusCode}, @@ -64,7 +65,7 @@ async fn recipes_handler( lang: LanguageIdentifier, features: FeatureFlags, ) -> axum::response::Response { - let input = crate::server::builders::RecipesBuildInput { + let input = crate::web::builders::RecipesBuildInput { base_path: &state.base_path, url_prefix: &state.url_prefix, sub_path: path.as_deref(), @@ -72,7 +73,7 @@ async fn recipes_handler( static_mode: false, features, }; - match crate::server::builders::build_recipes_template(input) { + match crate::web::builders::build_recipes_template(input) { Ok(template) => template.into_response(), Err(e) => { tracing::error!("Failed to build recipes template: {:?}", e); @@ -95,7 +96,7 @@ async fn recipe_page( ) -> axum::response::Response { let scale = query.scale.unwrap_or(1.0); - let input = crate::server::builders::RecipeBuildInput { + let input = crate::web::builders::RecipeBuildInput { base_path: &state.base_path, url_prefix: &state.url_prefix, recipe_path: &path, @@ -106,11 +107,9 @@ async fn recipe_page( features, }; - match crate::server::builders::build_recipe_template(input) { - Ok(crate::server::builders::RecipeBuildOutput::Recipe(template)) => { - template.into_response() - } - Ok(crate::server::builders::RecipeBuildOutput::Menu(template)) => template.into_response(), + match crate::web::builders::build_recipe_template(input) { + Ok(crate::web::builders::RecipeBuildOutput::Recipe(template)) => template.into_response(), + Ok(crate::web::builders::RecipeBuildOutput::Menu(template)) => template.into_response(), Err(e) => { tracing::error!("Failed to build recipe template: {:?}", e); error_page(lang, &state.url_prefix, &e, features) @@ -192,13 +191,13 @@ async fn edit_page( .replace(".cook", "") .replace(".menu", ""); - let template = crate::server::templates::EditTemplate { + let template = crate::web::templates::EditTemplate { active: "recipes".to_string(), recipe_name, recipe_path: path, content, base_path: state.base_path.to_string(), - tr: crate::server::templates::Tr::new(lang), + tr: crate::web::templates::Tr::new(lang), prefix: state.url_prefix.clone(), static_mode: false, features, @@ -219,7 +218,7 @@ async fn new_page( Extension(features): Extension, Query(query): Query, ) -> impl askama_axum::IntoResponse { - crate::server::templates::NewTemplate { + crate::web::templates::NewTemplate { active: "recipes".to_string(), tr: Tr::new(lang), error: query.error, @@ -484,7 +483,7 @@ async fn pantry_page( let mut pantry_items = Vec::new(); for item in items { - pantry_items.push(crate::server::templates::PantryItem { + pantry_items.push(crate::web::templates::PantryItem { name: item.name().to_string(), quantity: item.quantity().map(|q| q.to_string()), bought: item.bought().map(|b| b.to_string()), @@ -493,7 +492,7 @@ async fn pantry_page( }); } - sections.push(crate::server::templates::PantrySection { + sections.push(crate::web::templates::PantrySection { name: section_name.clone(), items: pantry_items, }); diff --git a/src/server/sync/device_flow.rs b/src/sync/device_flow.rs similarity index 100% rename from src/server/sync/device_flow.rs rename to src/sync/device_flow.rs diff --git a/src/server/sync/endpoints.rs b/src/sync/endpoints.rs similarity index 100% rename from src/server/sync/endpoints.rs rename to src/sync/endpoints.rs diff --git a/src/server/sync/mod.rs b/src/sync/mod.rs similarity index 100% rename from src/server/sync/mod.rs rename to src/sync/mod.rs diff --git a/src/server/sync/runner.rs b/src/sync/runner.rs similarity index 100% rename from src/server/sync/runner.rs rename to src/sync/runner.rs diff --git a/src/server/sync/session.rs b/src/sync/session.rs similarity index 100% rename from src/server/sync/session.rs rename to src/sync/session.rs diff --git a/src/server/builders.rs b/src/web/builders.rs similarity index 98% rename from src/server/builders.rs rename to src/web/builders.rs index 3d32cab7..74299fd2 100644 --- a/src/server/builders.rs +++ b/src/web/builders.rs @@ -5,8 +5,8 @@ //! The builders intentionally avoid any axum / tokio-async types so they can be //! reused from a non-async context (e.g. `cook build web`). -use crate::server::language::FeatureFlags; -use crate::server::templates::*; +use crate::web::language::FeatureFlags; +use crate::web::templates::*; use anyhow::Result; use camino::{Utf8Path, Utf8PathBuf}; use fluent_templates::Loader; @@ -139,7 +139,7 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result) -> Result) -> Result) -> 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 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(TodaysMenu { + menu_name, + menu_path, + date_display: today_display, + }) +} + +#[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()); + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 00000000..7bfa39f1 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,41 @@ +// MIT License +// +// Copyright (c) 2024 cooklang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//! Recipe rendering: Askama templates, view-model builders, i18n and the +//! embedded static assets. +//! +//! Shared by `cook server` (renders them over HTTP) and `cook build` (renders +//! them to a static site), so this layer stays compiled even when the `server` +//! feature is off. + +use rust_embed::RustEmbed; + +pub mod builders; +mod i18n; +pub mod language; +pub mod menus; +pub mod templates; + +/// Static assets (CSS, JS, icons) embedded into the binary at compile time. +#[derive(RustEmbed)] +#[folder = "static/"] +pub struct StaticFiles; diff --git a/src/server/templates.rs b/src/web/templates.rs similarity index 99% rename from src/server/templates.rs rename to src/web/templates.rs index b4af2745..21587ccd 100644 --- a/src/server/templates.rs +++ b/src/web/templates.rs @@ -3,7 +3,7 @@ use fluent_templates::Loader; use serde::{Deserialize, Serialize}; use unic_langid::LanguageIdentifier; -use crate::server::language::FeatureFlags; +use crate::web::language::FeatureFlags; /// Helper struct for translations in templates #[derive(Clone, Debug, Serialize)] @@ -18,7 +18,7 @@ impl Tr { } pub fn t(&self, key: &str) -> String { - crate::server::i18n::LOCALES.lookup(&self.lang, key) + crate::web::i18n::LOCALES.lookup(&self.lang, key) } pub fn lang_string(&self) -> String { diff --git a/templates/menu.html b/templates/menu.html index bc7d6804..a2b263d7 100644 --- a/templates/menu.html +++ b/templates/menu.html @@ -144,7 +144,7 @@

{{ name }}

{% for line in section.lines %} {% if line.len() == 1 %} {% match line[0] %} - {% when crate::server::templates::MenuSectionItem::Text with (text) %} + {% when crate::web::templates::MenuSectionItem::Text with (text) %} {% if text.trim().ends_with(":") %}

{{ text }}

@@ -158,7 +158,7 @@

{{ text }}

{% match line[0] %} - {% when crate::server::templates::MenuSectionItem::RecipeReference with { name, scale } %} + {% when crate::web::templates::MenuSectionItem::RecipeReference with { name, scale } %} @@ -170,7 +170,7 @@

{{ text }}

{% when None %} {% endmatch %}
- {% when crate::server::templates::MenuSectionItem::Ingredient with { name, quantity, unit } %} + {% when crate::web::templates::MenuSectionItem::Ingredient with { name, quantity, unit } %} {{ name }} @@ -195,10 +195,10 @@

{{ text }}

{% for item in line %} {% match item %} - {% when crate::server::templates::MenuSectionItem::Text with (text) %} + {% when crate::web::templates::MenuSectionItem::Text with (text) %} {{ text }} - {% when crate::server::templates::MenuSectionItem::RecipeReference with { name, scale } %} + {% when crate::web::templates::MenuSectionItem::RecipeReference with { name, scale } %} @@ -211,7 +211,7 @@

{{ text }}

{% endmatch %} - {% when crate::server::templates::MenuSectionItem::Ingredient with { name, quantity, unit } %} + {% when crate::web::templates::MenuSectionItem::Ingredient with { name, quantity, unit } %} {{ name }} diff --git a/templates/recipe.html b/templates/recipe.html index 2cbb7f7d..ec690f15 100644 --- a/templates/recipe.html +++ b/templates/recipe.html @@ -317,7 +317,7 @@

{% for item in section.items %} {% match item %} - {% when crate::server::templates::RecipeSectionItem::Step with (step) %} + {% when crate::web::templates::RecipeSectionItem::Step with (step) %}
  • {% match step.image_path %} @@ -331,7 +331,7 @@

    {% for step_item in step.items %} {% match step_item %} - {% when crate::server::templates::StepItem::Text with (text) %}{{ text }}{% when crate::server::templates::StepItem::Ingredient with { name, reference_path } %}{% match reference_path %}{% when Some with (path) %}{{ name }}{% when None %}{{ name }}{% endmatch %}{% when crate::server::templates::StepItem::Cookware with (name) %}{{ name }}{% when crate::server::templates::StepItem::Timer with (name) %}⏱️ {{ name }}{% when crate::server::templates::StepItem::Quantity with (qty) %}{{ qty }}{% when crate::server::templates::StepItem::LineBreak %}
    {% endmatch %}{% endfor %} + {% when crate::web::templates::StepItem::Text with (text) %}{{ text }}{% when crate::web::templates::StepItem::Ingredient with { name, reference_path } %}{% match reference_path %}{% when Some with (path) %}{{ name }}{% when None %}{{ name }}{% endmatch %}{% when crate::web::templates::StepItem::Cookware with (name) %}{{ name }}{% when crate::web::templates::StepItem::Timer with (name) %}⏱️ {{ name }}{% when crate::web::templates::StepItem::Quantity with (qty) %}{{ qty }}{% when crate::web::templates::StepItem::LineBreak %}
    {% endmatch %}{% endfor %}

    {% if step.ingredients.len() > 0 %}
    @@ -346,7 +346,7 @@

    📝 From e73421be94b0da9874e451ff6be7d12fb01c705a Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Mon, 13 Jul 2026 11:22:00 +0100 Subject: [PATCH 2/2] feat: put server, import and lsp behind cargo features cargo install cookcli compiles 412 crates on aarch64. A user who only wants cook recipe and cook shopping-list still paid for an HTTP server, a TLS stack, an HTML scraper and a language server, because only self-update and sync were gated. Gate server, import and lsp, and move reqwest under the features that actually use it (import, sync, self-update). All stay on by default, so cargo install is unchanged; --no-default-features now yields a lean CLI: 412 -> 225 crates, 23 MB -> 9 MB. sync implies server, since the sync loop only ever runs inside it. Also pin cooklang's bundled_units explicitly. It was reaching us only through feature unification from cooklang-language-server and cooklang-reports, so gating those off silently changed cook recipe's unit handling (scalable quantities, unit normalisation). Now it is declared where it is used and output is identical in every combination. Refs #366 --- .gitignore | 3 +++ Cargo.toml | 60 +++++++++++++++++++++++++++++++----------- README.md | 19 ++++++++++--- src/args.rs | 13 ++++++--- src/lib.rs | 3 +++ src/main.rs | 7 +++++ src/sync/mod.rs | 2 ++ src/web/language.rs | 8 +++++- src/web/templates.rs | 9 +++++++ tests/snapshot_test.rs | 8 +++++- 10 files changed, 107 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 60494717..ee7fb491 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ playwright/.cache/ .shopping_list.txt.bak .shopping-list .shopping-checked + +# insta rejects pending snapshots as .snap.new +*.snap.new diff --git a/Cargo.toml b/Cargo.toml index 2b79053e..7706f0ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,37 @@ keywords = ["cooklang", "recipes", "cli", "cooking"] categories = ["command-line-utilities"] [features] -default = ["self-update", "sync"] -self-update = ["dep:self_update"] -sync = ["dep:cooklang-sync-client", "dep:uuid", "dep:tokio-util", "dep:base64", "dep:libsqlite3-sys", "dep:thiserror"] +default = ["self-update", "sync", "server", "import", "lsp"] + +# `cook server` - the axum web UI. Pulls in the whole HTTP server stack. +server = [ + "dep:axum", + "dep:askama_axum", + "dep:tower", + "dep:tower-http", + "dep:notify", + "dep:notify-debouncer-full", + "dep:tokio-stream", + "dep:futures-util", + "dep:open", +] +# `cook import` - scrape a recipe from a website. +import = ["dep:cooklang-import", "dep:reqwest"] +# `cook lsp` - language server for editor integrations. +lsp = ["dep:cooklang-language-server", "dep:tower-lsp"] + +self-update = ["dep:self_update", "dep:reqwest"] +# The sync loop only runs inside the web server, so sync implies server. +sync = [ + "server", + "dep:cooklang-sync-client", + "dep:uuid", + "dep:tokio-util", + "dep:base64", + "dep:libsqlite3-sys", + "dep:thiserror", + "dep:reqwest", +] [lib] name = "cookcli" @@ -32,8 +60,8 @@ anstyle = "1" anstyle-yansi = "2" anyhow = "1" askama = { version = "0.12", features = ["serde-json"] } -askama_axum = "0.4" -axum = { version = "0.7", features = ["ws"] } +askama_axum = { version = "0.4", optional = true } +axum = { version = "0.7", features = ["ws"], optional = true } camino = { version = "1", features = ["serde1"] } chrono = "0.4" clap = { version = "4.5", features = ["derive"] } @@ -44,22 +72,22 @@ base64 = { version = "0.22", optional = true } # depend on cooklang with default features. Declare it where it is actually used. cooklang = { version = "0.18.5", default-features = false, features = ["aisle", "bundled_units", "pantry", "shopping_list"] } cooklang-find = { version = "0.6" } -cooklang-import = "0.9.3" +cooklang-import = { version = "0.9.3", optional = true } cooklang-sync-client = { version = "0.4.11", optional = true } libsqlite3-sys = { version = "0.35", features = ["bundled"], optional = true } -cooklang-language-server = "0.2.3" +cooklang-language-server = { version = "0.2.3", optional = true } cooklang-reports = { version = "0.2.2" } directories = "6" fluent = "0.16" -futures-util = "0.3" +futures-util = { version = "0.3", optional = true } fluent-templates = "0.10" humantime = "2" mime_guess = "2.0" -notify = "8" -notify-debouncer-full = "0.7" -open = "5.3" +notify = { version = "8", optional = true } +notify-debouncer-full = { version = "0.7", optional = true } +open = { version = "5.3", optional = true } regex = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } rust-embed = "8" self_update = { version = "0.41", default-features = false, features = ["archive-tar", "archive-zip", "compression-flate2", "rustls"], optional = true } serde = "1.0" @@ -68,13 +96,13 @@ serde_yaml = "0.9" tabular = { version = "0.2", features = ["ansi-cell"] } textwrap = { version = "0.16", features = ["terminal_size"] } tokio = { version = "1", features = ["full"] } -tokio-stream = { version = "0.1", features = ["sync"] } +tokio-stream = { version = "0.1", features = ["sync"], optional = true } tokio-util = { version = "0.7", optional = true } thiserror = { version = "2", optional = true } toml = "0.9.5" -tower = { version = "0.5", features = ["util"] } -tower-http = { version = "0.5", features = ["fs", "trace", "cors", "normalize-path"] } -tower-lsp = "0.20" +tower = { version = "0.5", features = ["util"], optional = true } +tower-http = { version = "0.5", features = ["fs", "trace", "cors", "normalize-path"], optional = true } +tower-lsp = { version = "0.20", optional = true } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } unic-langid = "0.9" diff --git a/README.md b/README.md index 974a5342..59e5a3dc 100644 --- a/README.md +++ b/README.md @@ -213,17 +213,28 @@ cargo build --release # Binary will be at target/release/cook ``` -#### Building without Self-Update +#### Cargo features -By default, CookCLI includes a self-update feature. To build without this feature (useful for CI/CD pipelines, package managers, or environments where auto-update is not desired): +The optional commands are behind Cargo features, all enabled by default. Turning them off cuts the dependency graph roughly in half (412 crates → 225) and the binary from ~23 MB to ~9 MB — useful for CI/CD, package managers, or small machines where you only want the core recipe tooling. + +| Feature | Command | Notes | +|---|---|---| +| `server` | `cook server` | Web UI (axum + tower). `cook build` still works without it. | +| `import` | `cook import` | Scrape a recipe from a website. | +| `lsp` | `cook lsp` | Language server for editor integrations. | +| `sync` | `cook login` / `cook logout` | Recipe sync. Implies `server` — the sync loop runs inside it. | +| `self-update` | `cook update` | In-place binary upgrade. | ```bash -# Build without self-update feature +# Core CLI only: recipe, shopping-list, search, doctor, pantry, report, build, seed cargo build --release --no-default-features -# This disables the 'self-update' feature flag while keeping all other functionality +# Core plus the web server +cargo build --release --no-default-features --features server ``` +Everything not listed above — parsing, scaling, shopping lists, search, pantry, reports and the static site builder — is always compiled in. + ### Development Setup For development with hot-reload of CSS changes: diff --git a/src/args.rs b/src/args.rs index 991571d0..370622ad 100644 --- a/src/args.rs +++ b/src/args.rs @@ -30,11 +30,15 @@ use clap::{Parser, Subcommand}; +#[cfg(feature = "import")] +use crate::import; +#[cfg(feature = "lsp")] +use crate::lsp; +#[cfg(feature = "server")] +use crate::server; #[cfg(feature = "self-update")] use crate::update; -use crate::{ - build, doctor, import, lsp, pantry, recipe, report, search, seed, server, shopping_list, -}; +use crate::{build, doctor, pantry, recipe, report, search, seed, shopping_list}; #[derive(Parser, Debug)] #[command( @@ -85,6 +89,7 @@ pub enum Command { alias = "s", long_about = "Run a web server to browse and interact with your recipe collection" )] + #[cfg(feature = "server")] Server(server::ServerArgs), /// Build artifacts from your recipe collection @@ -159,6 +164,7 @@ pub enum Command { alias = "i", long_about = "Import recipes from websites and automatically convert them to Cooklang format" )] + #[cfg(feature = "import")] Import(import::ImportArgs), /// Generate custom reports from recipes using templates @@ -231,6 +237,7 @@ pub enum Command { #[command( long_about = "Start the Language Server Protocol server for Cooklang editor integration" )] + #[cfg(feature = "lsp")] Lsp(lsp::LspArgs), /// Sign in to CookCloud diff --git a/src/lib.rs b/src/lib.rs index da07d073..85617b33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,17 +5,20 @@ use camino::{Utf8Path, Utf8PathBuf}; // Commands - make them available as public modules pub mod build; pub mod doctor; +#[cfg(feature = "import")] pub mod import; #[cfg(feature = "sync")] pub mod login; #[cfg(feature = "sync")] pub mod logout; +#[cfg(feature = "lsp")] pub mod lsp; pub mod pantry; pub mod recipe; pub mod report; pub mod search; pub mod seed; +#[cfg(feature = "server")] pub mod server; pub mod shopping_list; #[cfg(feature = "sync")] diff --git a/src/main.rs b/src/main.rs index d3c3cf6b..44c5545a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,17 +37,20 @@ use clap::Parser; // commands mod build; mod doctor; +#[cfg(feature = "import")] mod import; #[cfg(feature = "sync")] mod login; #[cfg(feature = "sync")] mod logout; +#[cfg(feature = "lsp")] mod lsp; mod pantry; mod recipe; mod report; mod search; mod seed; +#[cfg(feature = "server")] mod server; mod shopping_list; #[cfg(feature = "sync")] @@ -74,15 +77,18 @@ pub fn main() -> Result<()> { match args.command { Command::Recipe(args) => recipe::run(&ctx, args), + #[cfg(feature = "server")] Command::Server(args) => server::run(ctx, args), Command::Build(args) => build::run(&ctx, args), Command::ShoppingList(args) => shopping_list::run(&ctx, args), Command::Seed(args) => seed::run(&ctx, args), Command::Search(args) => search::run(&ctx, args), + #[cfg(feature = "import")] Command::Import(args) => import::run(&ctx, args), Command::Report(args) => report::run(&ctx, args), Command::Doctor(args) => doctor::run(&ctx, args), Command::Pantry(args) => pantry::run(&ctx, args), + #[cfg(feature = "lsp")] Command::Lsp(args) => lsp::run(&ctx, args), #[cfg(feature = "sync")] Command::Login(args) => login::run(&ctx, args), @@ -134,6 +140,7 @@ impl Context { fn configure_context() -> Result { let args = CliArgs::parse(); let base_path = match args.command { + #[cfg(feature = "server")] Command::Server(ref server_args) => server_args .get_base_path() .unwrap_or_else(|| Utf8PathBuf::from(".")), diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 9884f7f7..ccac51f5 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -10,6 +10,8 @@ pub use session::SyncSession; use tokio_util::sync::CancellationToken; +/// An in-flight device authorisation, held by the server while the user +/// completes the flow in their browser. #[derive(Clone)] pub struct PendingDeviceFlow { pub user_code: String, diff --git a/src/web/language.rs b/src/web/language.rs index 6d4a28e0..01bbf92b 100644 --- a/src/web/language.rs +++ b/src/web/language.rs @@ -1,4 +1,6 @@ +#[cfg(feature = "server")] use accept_language::parse; +#[cfg(feature = "server")] use axum::{ extract::{Request, State}, http::{header, HeaderMap}, @@ -38,6 +40,7 @@ impl Default for FeatureFlags { /// Parse feature flag cookies from request headers. /// Absent cookie → feature enabled (default true). /// Cookie value "0" → disabled. Any other value → enabled. +#[cfg(feature = "server")] pub fn parse_feature_flags(headers: &HeaderMap) -> FeatureFlags { let mut flags = FeatureFlags::default(); @@ -78,6 +81,7 @@ pub fn parse_supported_language(s: &str) -> Option { /// 1. Check for 'lang' cookie /// 2. Parse Accept-Language header /// 3. Fall back to EN_US +#[cfg(feature = "server")] pub fn get_preferred_language(headers: &HeaderMap) -> LanguageIdentifier { // First, check for language cookie if let Some(cookie_header) = headers.get(header::COOKIE) { @@ -130,6 +134,7 @@ pub fn get_preferred_language(headers: &HeaderMap) -> LanguageIdentifier { } /// Middleware to inject language into request extensions +#[cfg(feature = "server")] pub async fn language_middleware(mut req: Request, next: Next) -> Response { let lang = get_preferred_language(req.headers()); req.extensions_mut().insert(lang); @@ -139,6 +144,7 @@ pub async fn language_middleware(mut req: Request, next: Next) -> Response { /// Middleware that reads feature flag cookies, injects them as a request /// extension, and refreshes the cookie expiry on every response. /// Takes the URL prefix as state so Set-Cookie headers use the correct path. +#[cfg(feature = "server")] pub async fn features_middleware( State(url_prefix): State, mut req: Request, @@ -177,7 +183,7 @@ pub async fn features_middleware( response } -#[cfg(test)] +#[cfg(all(test, feature = "server"))] mod tests { use super::*; diff --git a/src/web/templates.rs b/src/web/templates.rs index 21587ccd..ef3cacde 100644 --- a/src/web/templates.rs +++ b/src/web/templates.rs @@ -21,6 +21,7 @@ impl Tr { crate::web::i18n::LOCALES.lookup(&self.lang, key) } + #[cfg(feature = "server")] pub fn lang_string(&self) -> String { self.lang.to_string() } @@ -38,6 +39,7 @@ mod filters { } } +#[cfg(feature = "server")] #[derive(Template)] #[template(path = "error.html")] pub struct ErrorTemplate { @@ -390,6 +392,7 @@ pub struct MenuTemplate { pub features: FeatureFlags, } +#[cfg(feature = "server")] #[derive(Template)] #[template(path = "shopping_list.html")] pub struct ShoppingListTemplate { @@ -400,6 +403,7 @@ pub struct ShoppingListTemplate { pub features: FeatureFlags, } +#[cfg(feature = "server")] #[derive(Template)] #[template(path = "preferences.html")] pub struct PreferencesTemplate { @@ -418,6 +422,7 @@ pub struct PreferencesTemplate { pub features: FeatureFlags, } +#[cfg(feature = "server")] #[derive(Template)] #[template(path = "pantry.html")] pub struct PantryTemplate { @@ -430,6 +435,7 @@ pub struct PantryTemplate { pub features: FeatureFlags, } +#[cfg(feature = "server")] #[derive(Template)] #[template(path = "edit.html")] pub struct EditTemplate { @@ -444,6 +450,7 @@ pub struct EditTemplate { pub features: FeatureFlags, } +#[cfg(feature = "server")] #[derive(Template)] #[template(path = "new.html")] pub struct NewTemplate { @@ -456,12 +463,14 @@ pub struct NewTemplate { pub features: FeatureFlags, } +#[cfg(feature = "server")] #[derive(Debug, Clone, Serialize)] pub struct PantrySection { pub name: String, pub items: Vec, } +#[cfg(feature = "server")] #[derive(Debug, Clone, Serialize)] pub struct PantryItem { pub name: String, diff --git a/tests/snapshot_test.rs b/tests/snapshot_test.rs index b30f32ab..0bfd6d29 100644 --- a/tests/snapshot_test.rs +++ b/tests/snapshot_test.rs @@ -2,7 +2,10 @@ mod common; use assert_cmd::Command; -use insta::{assert_snapshot, with_settings}; +use insta::assert_snapshot; +// Only used by test_help_output, which needs the optional commands compiled in. +#[cfg(all(feature = "server", feature = "import", feature = "lsp"))] +use insta::with_settings; #[test] fn test_recipe_human_output() { @@ -289,7 +292,10 @@ fn test_scaled_recipe_output() { assert_snapshot!(formatted); } +// The snapshot lists every subcommand, so it only holds when the optional +// commands are compiled in. #[test] +#[cfg(all(feature = "server", feature = "import", feature = "lsp"))] fn test_help_output() { let output = Command::cargo_bin("cook") .unwrap()