cooklang-chef 0.8.1

CLI to manage cooklang recipes
use axum::{
    extract::{Query, State},
    http::HeaderMap,
    response::{Html, IntoResponse, Response},
};
use minijinja::{context, Value};
use serde::Deserialize;

use crate::{
    serve::S,
    util::{is_valid_tag, meta_name},
};

use super::{mj_ok, recipe_entry_context, Searcher};

#[derive(Deserialize)]
pub struct SearchQuery {
    #[serde(default, deserialize_with = "empty_string_as_none")]
    q: Option<String>,
}

fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where
    D: serde::Deserializer<'de>,
    T: std::str::FromStr,
    T::Err: std::fmt::Display,
{
    let opt = Option::<String>::deserialize(de)?;
    match opt.as_deref() {
        None | Some("") => Ok(None),
        Some(s) => std::str::FromStr::from_str(s)
            .map_err(serde::de::Error::custom)
            .map(Some),
    }
}

pub async fn search(
    headers: HeaderMap,
    State(state): State<S>,
    Query(query): Query<SearchQuery>,
) -> Response {
    let srch = Searcher::from(query);

    let recipes = if srch.is_empty() {
        Vec::new()
    } else {
        state
            .recipe_index
            .search(
                |entry, meta| match meta.and_then(|r| r.valid_output()) {
                    Some(m) => {
                        let name = meta_name(m).unwrap_or(entry.name());
                        srch.matches_recipe(name, m.tags().unwrap_or(&[]))
                    }
                    None => false,
                },
                |entry, meta| recipe_entry_context(entry, &state, meta),
                0,
                12,
            )
            .await
    };

    let is_htmx_search = headers.get("HX-Trigger").is_some_and(|v| v == "search");

    let template = if is_htmx_search {
        "components/recipe_grid.html"
    } else {
        "search.html"
    };

    let tmpl = mj_ok!(state.templates.get_template(template));
    let t = Value::from(state.locales.get_from_headers(&headers));
    let res = tmpl.render(context! {
        t,
        recipes,
        search_query => srch.to_query(),
        is_htmx_search,
    });
    let content = mj_ok!(res);

    Html(content).into_response()
}

impl From<SearchQuery> for Searcher {
    fn from(value: SearchQuery) -> Self {
        let mut tags = Vec::new();
        let mut name_parts = Vec::new();
        if let Some(q) = value.q {
            for part in q.split_whitespace() {
                if let Some(tag) = part.strip_prefix("tag:") {
                    if is_valid_tag(tag) {
                        tags.push(tag.to_string())
                    }
                } else {
                    name_parts.push(part.to_lowercase());
                }
            }
        }
        Self { tags, name_parts }
    }
}

impl Searcher {
    fn to_query(&self) -> String {
        let mut q = String::new();
        for part in &self.name_parts {
            q.push_str(part);
            q.push(' ');
        }
        for t in &self.tags {
            q.push_str(&format!("tag:{t}"));
            q.push(' ');
        }
        q.pop();
        q
    }

    fn is_empty(&self) -> bool {
        self.name_parts.is_empty() && self.tags.is_empty()
    }
}