cookcli 0.38.1

A command-line interface for managing and working with Cooklang recipes
Documentation
use crate::server::activity;
use crate::web::viewer::Viewer;
use crate::{
    server::{
        handlers::{
            common::{check_path, normalize_tags, recipe_file, RecipeFile},
            recipe_rename::RECIPE_FILES,
        },
        AppState,
    },
    util::PARSER,
};
use axum::{
    extract::{Extension, Path, Query, State},
    http::StatusCode,
    Json,
};
use camino::{Utf8Path, Utf8PathBuf};
use cooklang_find;
use serde::{Deserialize, Serialize};
use serde_json;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;

#[derive(Deserialize)]
pub struct RecipeQuery {
    scale: Option<f64>,
}

#[derive(Debug, Deserialize)]
pub struct SearchQuery {
    q: String,
}

fn json_error(msg: impl std::fmt::Display) -> Json<serde_json::Value> {
    Json(serde_json::json!({ "error": msg.to_string() }))
}

pub async fn all_recipes(
    State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
    let recipes = cooklang_find::build_tree(&state.base_path).map_err(|e| {
        tracing::error!("Failed to build recipe tree: {:?}", e);
        (StatusCode::INTERNAL_SERVER_ERROR, json_error(&e))
    })?;

    let mut recipes = serde_json::to_value(recipes).map_err(|e| {
        tracing::error!("Failed to serialize recipes: {:?}", e);
        (StatusCode::INTERNAL_SERVER_ERROR, json_error(&e))
    })?;
    normalize_tree_tags(&mut recipes);
    relative_tree_paths(&mut recipes, &state.base_path);

    Ok(Json(recipes))
}

/// Rewrites each node's `path`, and its recipe's `source.path`, relative to
/// the recipe directory; the root's becomes `""`.
///
/// `cooklang_find` hands them over absolute. This answer goes to anyone who
/// may read, signed in or not, and where the collection sits on the server,
/// under which account, is none of their business; a client only needs the
/// path within the collection.
fn relative_tree_paths(node: &mut serde_json::Value, base: &Utf8Path) {
    for pointer in ["/path", "/recipe/source/path"] {
        if let Some(path) = node.pointer_mut(pointer) {
            if let Some(text) = path.as_str() {
                *path = relative_path(text, base).into();
            }
        }
    }
    if let Some(children) = node.get_mut("children").and_then(|c| c.as_object_mut()) {
        for child in children.values_mut() {
            relative_tree_paths(child, base);
        }
    }
}

/// `path` relative to `base`, with `/` between components on every platform.
/// A path outside `base`, which `cooklang_find` does not produce, comes back
/// as its file name alone rather than as where it is.
fn relative_path(path: &str, base: &Utf8Path) -> String {
    let path = Utf8Path::new(path);
    match path.strip_prefix(base) {
        Ok(inside) => inside.as_str().replace('\\', "/"),
        Err(_) => path.file_name().unwrap_or_default().to_string(),
    }
}

/// Normalises `tags` on every recipe node of a serialised recipe tree.
///
/// Directory nodes carry a null `recipe` and only recurse.
fn normalize_tree_tags(node: &mut serde_json::Value) {
    if let Some(metadata) = node.pointer_mut("/recipe/metadata") {
        normalize_tags(metadata);
    }
    if let Some(children) = node.get_mut("children").and_then(|c| c.as_object_mut()) {
        for child in children.values_mut() {
            normalize_tree_tags(child);
        }
    }
}

pub async fn recipe(
    Path(path): Path<String>,
    State(state): State<Arc<AppState>>,
    Query(query): Query<RecipeQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
    check_path(&path)?;

    let entry = cooklang_find::get_recipe(vec![&state.base_path], &Utf8PathBuf::from(&path))
        .map_err(|e| {
            tracing::error!("Recipe not found: {path}");
            (
                StatusCode::NOT_FOUND,
                json_error(format!("Recipe not found: {path}: {e}")),
            )
        })?;

    let recipe =
        crate::util::parse_recipe_from_entry(&entry, query.scale.unwrap_or(1.0)).map_err(|e| {
            tracing::error!("Failed to parse recipe: {e}");
            (StatusCode::INTERNAL_SERVER_ERROR, json_error(&e))
        })?;

    // The same URL the recipe page shows: percent-encoded, under the prefix.
    let image_path = entry.title_image().clone().and_then(|img_path| {
        crate::web::builders::get_image_path(&state.base_path, &state.url_prefix, img_path)
    });

    #[derive(Serialize)]
    struct ApiRecipe {
        #[serde(flatten)]
        recipe: Arc<cooklang::Recipe>,
        grouped_ingredients: Vec<serde_json::Value>,
    }

    let grouped_ingredients = recipe
        .group_ingredients(PARSER.converter())
        .into_iter()
        .map(|entry| {
            serde_json::json!({
                "index": entry.index,
                // Not `into_vec()`: that yields the components in the group's
                // own random order, so an ingredient measured two ways came
                // back differently on every request.
                "quantities": crate::util::format::quantity::ordered_components(&entry.quantity)
            })
        })
        .collect();

    let api_recipe = ApiRecipe {
        recipe,
        grouped_ingredients,
    };

    let mut value = serde_json::json!({
        "recipe": api_recipe,
        "image": image_path,
        "scale": query.scale.unwrap_or(1.0),
        // TODO: add more metadata if needed
    });
    if let Some(metadata) = value.pointer_mut("/recipe/metadata/map") {
        normalize_tags(metadata);
    }

    Ok(Json(value))
}

pub async fn recipe_raw(
    Path(path): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
    let RecipeFile::Existing(file_path) = recipe_file(&state.base_path, &path)? else {
        tracing::error!("Recipe file not found: {path}");
        return Err((
            StatusCode::NOT_FOUND,
            json_error(format!("Recipe file not found: {path}")),
        ));
    };

    tokio::fs::read_to_string(&file_path).await.map_err(|e| {
        tracing::error!("Failed to read recipe file {}: {}", file_path, e);
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            json_error(format!("Failed to read recipe file: {e}")),
        )
    })
}

pub async fn recipe_save(
    Path(path): Path<String>,
    State(state): State<Arc<AppState>>,
    Extension(viewer): Extension<Viewer>,
    body: String,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
    let _writing = RECIPE_FILES.lock().await;
    let (file_path, created) = match recipe_file(&state.base_path, &path)? {
        RecipeFile::Existing(file_path) => (file_path, false),
        RecipeFile::Missing(file_path) => (file_path, true),
    };

    // Atomic write: write to temp file, then rename
    let temp_path = file_path.with_extension("tmp");

    let mut temp_file = tokio::fs::File::create(&temp_path).await.map_err(|e| {
        tracing::error!("Failed to create temp file {}: {}", temp_path, e);
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            json_error(format!(
                "Failed to save recipe: {e}. Check that the recipes folder has write permissions."
            )),
        )
    })?;

    temp_file.write_all(body.as_bytes()).await.map_err(|e| {
        tracing::error!("Failed to write to temp file {}: {}", temp_path, e);
        // Fire-and-forget cleanup - spawn so we don't block the error path
        let temp_path_clone = temp_path.clone();
        tokio::spawn(async move {
            let _ = tokio::fs::remove_file(&temp_path_clone).await;
        });
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            json_error(format!("Failed to write recipe: {e}")),
        )
    })?;

    // Replace the original file with the temp file. `rename_replace_async`
    // uses an atomic rename on capable platforms but copies + removes on
    // Android, where the aarch64 `rename()` libc wrapper hits the
    // seccomp-blocked `renameat2` syscall (SIGSYS / "Bad system call").
    // See https://github.com/cooklang/cookcli/issues/349.
    crate::server::fs_atomic::rename_replace_async(temp_path.clone(), file_path.clone())
        .await
        .map_err(|e| {
            tracing::error!("Failed to rename temp file to {}: {}", file_path, e);
            let temp_path_clone = temp_path.clone();
            tokio::spawn(async move {
                let _ = tokio::fs::remove_file(&temp_path_clone).await;
            });
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                json_error(format!("Failed to save recipe: {e}")),
            )
        })?;

    activity::record(
        &viewer,
        format_args!(
            "{} {}",
            if created { "created" } else { "updated" },
            activity::file(&state.base_path, &file_path)
        ),
    );

    Ok(Json(serde_json::json!({
        "status": "success",
        "path": path
    })))
}

pub async fn reload() -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
    // Since the server reads from disk on each request, there's no cache to clear.
    // This endpoint just returns success to indicate the reload was processed.
    tracing::info!("Reload requested - recipes will be refreshed from disk on next request");
    Ok(Json(serde_json::json!({
        "status": "success",
        "message": "Recipes will be refreshed from disk on next request"
    })))
}

pub async fn search(
    State(state): State<Arc<AppState>>,
    Query(query): Query<SearchQuery>,
) -> Result<Json<Vec<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
    // Through core rather than `cooklang_find::search` directly, so this agrees
    // with `cook search`. Calling the library here left the web UI on the old
    // union-over-terms behaviour after the CLI moved to AND (#425), which is
    // the kind of split the two would never have been noticed to have.
    let hits = cookcli_core::search::search(
        &cookcli_core::Context::new(state.base_path.clone()),
        cookcli_core::search::SearchRequest {
            query: query.q.clone(),
            base_dir: None,
        },
    )
    .map_err(|e| {
        tracing::error!("Failed to search recipes: {:?}", e);
        (StatusCode::INTERNAL_SERVER_ERROR, json_error(&e))
    })?
    .into_value();

    let results = hits
        .into_iter()
        .map(|hit| {
            serde_json::json!({
                "name": hit.name,
                "path": hit.relative_path.to_string()
            })
        })
        .collect();

    Ok(Json(results))
}

pub async fn recipe_delete(
    Path(path): Path<String>,
    State(state): State<Arc<AppState>>,
    Extension(viewer): Extension<Viewer>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
    let _writing = RECIPE_FILES.lock().await;
    let RecipeFile::Existing(file_path) = recipe_file(&state.base_path, &path)? else {
        tracing::error!("Recipe file not found for deletion: {path}");
        return Err((
            StatusCode::NOT_FOUND,
            json_error(format!("Recipe file not found: {path}")),
        ));
    };

    // Delete the file
    tokio::fs::remove_file(&file_path).await.map_err(|e| {
        tracing::error!("Failed to delete recipe file {}: {}", file_path, e);
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            json_error(format!("Failed to delete recipe: {e}")),
        )
    })?;

    activity::record(
        &viewer,
        format_args!("deleted {}", activity::file(&state.base_path, &file_path)),
    );

    Ok(Json(serde_json::json!({
        "status": "success",
        "path": path
    })))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn tree_paths_become_relative_to_the_collection() {
        let base = Utf8Path::new("/srv/cook/recipes");
        let mut tree = json!({
            "name": "recipes",
            "path": "/srv/cook/recipes",
            "recipe": null,
            "children": {
                "Breakfast": {
                    "name": "Breakfast",
                    "path": "/srv/cook/recipes/Breakfast",
                    "recipe": null,
                    "children": {
                        "Pancakes": {
                            "name": "Pancakes",
                            "path": "/srv/cook/recipes/Breakfast/Pancakes.cook",
                            "recipe": {
                                "metadata": {},
                                "source": {
                                    "path": "/srv/cook/recipes/Breakfast/Pancakes.cook",
                                    "source_type": "Path"
                                }
                            },
                            "children": {}
                        }
                    }
                }
            }
        });

        relative_tree_paths(&mut tree, base);

        assert_eq!(tree["path"], "");
        let breakfast = &tree["children"]["Breakfast"];
        assert_eq!(breakfast["path"], "Breakfast");
        let pancakes = &breakfast["children"]["Pancakes"];
        assert_eq!(pancakes["path"], "Breakfast/Pancakes.cook");
        assert_eq!(
            pancakes["recipe"]["source"]["path"],
            "Breakfast/Pancakes.cook"
        );
        assert_eq!(pancakes["recipe"]["source"]["source_type"], "Path");
        assert!(!tree.to_string().contains("/srv/cook"), "{tree}");
    }

    #[test]
    fn a_path_outside_the_collection_keeps_only_its_name() {
        let base = Utf8Path::new("/srv/cook/recipes");
        assert_eq!(relative_path("/home/someone/Soup.cook", base), "Soup.cook");
        assert_eq!(
            relative_path("/srv/cook/recipes-old/Soup.cook", base),
            "Soup.cook"
        );
    }
}