use super::common::{json_error, ApiError};
use axum::{
extract::{Extension, Json, State},
http::StatusCode,
};
use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::server::aisle_file::{revision, AisleFile, EditError};
use crate::server::{activity, fs_atomic, AppState};
use crate::web::viewer::Viewer;
#[derive(Debug, Serialize)]
pub struct Aisles {
pub configured: bool,
pub path: Option<String>,
pub revision: Option<String>,
pub aisles: Vec<Aisle>,
pub warnings: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct Aisle {
pub name: String,
pub ingredients: Vec<Ingredient>,
}
#[derive(Debug, Serialize)]
pub struct Ingredient {
pub names: Vec<String>,
}
impl Aisles {
fn unconfigured() -> Self {
Self {
configured: false,
path: None,
revision: None,
aisles: Vec::new(),
warnings: Vec::new(),
}
}
fn from_text(path: &Utf8PathBuf, text: &str) -> Self {
let parsed = cooklang::aisle::parse_lenient(text);
let aisles = parsed
.output()
.map(|conf| {
conf.categories
.iter()
.map(|category| Aisle {
name: category.name.to_string(),
ingredients: category
.ingredients
.iter()
.map(|ingredient| Ingredient {
names: ingredient
.names
.iter()
.filter(|name| !name.is_empty())
.map(|name| name.to_string())
.collect(),
})
.filter(|ingredient| !ingredient.names.is_empty())
.collect(),
})
.collect()
})
.unwrap_or_default();
Self {
configured: true,
path: Some(path.to_string()),
revision: Some(revision(text)),
aisles,
warnings: parsed
.report()
.warnings()
.map(|warning| warning.to_string())
.collect(),
}
}
}
async fn read(state: &AppState) -> Result<Option<(Utf8PathBuf, String)>, ApiError> {
let Some(path) = state.aisle_file() else {
return Ok(None);
};
match tokio::fs::read_to_string(&path).await {
Ok(text) => Ok(Some((path, text))),
Err(e) => {
tracing::error!("Failed to read aisle file {path}: {e}");
Err((
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to read the aisle file: {e}")),
))
}
}
}
async fn read_existing(state: &AppState) -> Result<(Utf8PathBuf, String), ApiError> {
read(state).await?.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
json_error("There is no aisle file yet; create one first"),
)
})
}
async fn write(path: &Utf8PathBuf, text: String) -> Result<(), ApiError> {
fs_atomic::write_replace_async(path.clone(), text.into_bytes())
.await
.map_err(|e| {
tracing::error!("Failed to write aisle file {path}: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to write the aisle file: {e}")),
)
})
}
fn check_revision(text: &str, sent: Option<&str>) -> Result<(), ApiError> {
let current = revision(text);
match sent {
Some(sent) if sent != current => Err((
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "The aisle file changed since this page loaded it",
"revision": current,
})),
)),
_ => Ok(()),
}
}
fn edit_error(error: EditError) -> ApiError {
let status = match error {
EditError::NotFound(_) => StatusCode::NOT_FOUND,
EditError::InvalidName(_) | EditError::Taken(_) => StatusCode::BAD_REQUEST,
};
(status, json_error(error))
}
fn parse_problem(text: &str) -> Option<String> {
use cooklang::aisle::AisleConfError;
let error = cooklang::aisle::parse(text).err()?;
let at = match &error {
AisleConfError::Parse { span, .. } => span.start(),
AisleConfError::DuplicateCategory { second_span, .. }
| AisleConfError::DuplicateIngredient { second_span, .. } => second_span.start(),
};
let line = text[..at.min(text.len())].matches('\n').count() + 1;
Some(format!("Line {line}: {error}"))
}
pub async fn load(state: &AppState) -> Result<Aisles, ApiError> {
Ok(match read(state).await? {
Some((path, text)) => Aisles::from_text(&path, &text),
None => Aisles::unconfigured(),
})
}
pub async fn get_aisles(State(state): State<Arc<AppState>>) -> Result<Json<Aisles>, ApiError> {
Ok(Json(load(&state).await?))
}
pub async fn create_aisles(
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
) -> Result<(StatusCode, Json<Aisles>), ApiError> {
let _guard = state.aisle_lock.lock().await;
if let Some(path) = state.aisle_file() {
return Err((
StatusCode::CONFLICT,
json_error(format!("There is already an aisle file at {path}")),
));
}
let path = state.local_aisle_file();
let created = async {
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir).await?;
}
tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.await
}
.await;
if let Err(e) = created {
tracing::error!("Failed to create aisle file {path}: {e}");
let status = if e.kind() == std::io::ErrorKind::AlreadyExists {
StatusCode::CONFLICT
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
return Err((
status,
json_error(format!("Failed to create the aisle file: {e}")),
));
}
activity::record(
&viewer,
format_args!(
"created the aisle file {}",
activity::file(&state.base_path, &path)
),
);
Ok((StatusCode::CREATED, Json(Aisles::from_text(&path, ""))))
}
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Change {
AddAisle {
name: String,
position: Option<usize>,
},
RenameAisle {
name: String,
new_name: String,
},
RemoveAisle {
name: String,
},
MoveAisle {
name: String,
position: usize,
},
AddIngredient {
aisle: String,
names: Vec<String>,
},
UpdateIngredient {
name: String,
names: Vec<String>,
aisle: Option<String>,
},
RemoveIngredient {
name: String,
},
AddNames {
name: String,
names: Vec<String>,
},
MergeIngredients {
names: Vec<String>,
main: String,
},
}
impl Change {
fn apply(&self, file: &mut AisleFile) -> Result<(), EditError> {
match self {
Change::AddAisle { name, position } => file.add_aisle(name, *position),
Change::RenameAisle { name, new_name } => file.rename_aisle(name, new_name),
Change::RemoveAisle { name } => file.remove_aisle(name),
Change::MoveAisle { name, position } => file.move_aisle(name, *position),
Change::AddIngredient { aisle, names } => file.add_ingredient(aisle, names),
Change::UpdateIngredient { name, names, aisle } => {
file.update_ingredient(name, names, aisle.as_deref())
}
Change::RemoveIngredient { name } => file.remove_ingredient(name),
Change::AddNames { name, names } => file.add_names(name, names),
Change::MergeIngredients { names, main } => file.merge_ingredients(names, main),
}
}
fn record(&self, viewer: &Viewer) {
use activity::quoted;
match self {
Change::AddAisle { name, .. } => {
activity::record(viewer, format_args!("added the aisle {}", quoted(name)))
}
Change::RenameAisle { name, new_name } => activity::record(
viewer,
format_args!("renamed the aisle {} to {}", quoted(name), quoted(new_name)),
),
Change::RemoveAisle { name } => {
activity::record(viewer, format_args!("removed the aisle {}", quoted(name)))
}
Change::MoveAisle { name, position } => activity::record(
viewer,
format_args!("moved the aisle {} to place {}", quoted(name), position + 1),
),
Change::AddIngredient { aisle, names } => activity::record(
viewer,
format_args!(
"added {} to the aisle {}",
quoted(&names.join(" | ")),
quoted(aisle)
),
),
Change::UpdateIngredient {
name,
names,
aisle: Some(aisle),
} => activity::record(
viewer,
format_args!(
"changed the aisle entry {} to {} in {}",
quoted(name),
quoted(&names.join(" | ")),
quoted(aisle)
),
),
Change::UpdateIngredient { name, names, .. } => activity::record(
viewer,
format_args!(
"changed the aisle entry {} to {}",
quoted(name),
quoted(&names.join(" | "))
),
),
Change::RemoveIngredient { name } => activity::record(
viewer,
format_args!("removed {} from the aisles", quoted(name)),
),
Change::AddNames { name, names } => activity::record(
viewer,
format_args!(
"added {} as other names of {}",
quoted(&names.join(" | ")),
quoted(name)
),
),
Change::MergeIngredients { names, main } => activity::record(
viewer,
format_args!("grouped {} as {}", quoted(&names.join(" | ")), quoted(main)),
),
}
}
}
#[derive(Debug)]
pub struct ChangeRequest {
pub revision: Option<String>,
pub changes: Vec<Change>,
}
impl<'de> Deserialize<'de> for ChangeRequest {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Many {
revision: Option<String>,
changes: Vec<Change>,
}
#[derive(Deserialize)]
struct One {
revision: Option<String>,
#[serde(flatten)]
change: Change,
}
use serde::de::Error;
let value = serde_json::Value::deserialize(deserializer)?;
if value.get("changes").is_some() {
let Many { revision, changes } = Many::deserialize(value).map_err(D::Error::custom)?;
Ok(Self { revision, changes })
} else {
let One { revision, change } = One::deserialize(value).map_err(D::Error::custom)?;
Ok(Self {
revision,
changes: vec![change],
})
}
}
}
pub async fn change_aisles(
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
Json(request): Json<ChangeRequest>,
) -> Result<Json<Aisles>, ApiError> {
let _guard = state.aisle_lock.lock().await;
let (path, text) = read_existing(&state).await?;
check_revision(&text, request.revision.as_deref())?;
if request.changes.is_empty() {
return Err((StatusCode::BAD_REQUEST, json_error("No changes to make")));
}
let mut file = AisleFile::parse(&text);
let several = request.changes.len() > 1;
for (i, change) in request.changes.iter().enumerate() {
change.apply(&mut file).map_err(|error| {
let (status, Json(mut body)) = edit_error(error);
if several {
body["error"] = format!(
"Change {}: {}",
i + 1,
body["error"].as_str().unwrap_or_default()
)
.into();
}
(status, Json(body))
})?;
}
let changed = file.to_text();
if parse_problem(&text).is_none() {
if let Some(problem) = parse_problem(&changed) {
tracing::error!("Refused an aisle edit that would break the file: {problem}");
return Err((
StatusCode::BAD_REQUEST,
json_error(format!("That change would break the aisle file: {problem}")),
));
}
}
write(&path, changed.clone()).await?;
for change in &request.changes {
change.record(&viewer);
}
Ok(Json(Aisles::from_text(&path, &changed)))
}
#[derive(Debug, Serialize)]
pub struct RawAisles {
pub path: String,
pub content: String,
pub revision: String,
}
pub async fn get_raw_aisles(
State(state): State<Arc<AppState>>,
) -> Result<Json<RawAisles>, ApiError> {
let (path, text) = read_existing(&state).await?;
Ok(Json(RawAisles {
path: path.to_string(),
revision: revision(&text),
content: text,
}))
}
#[derive(Debug, Deserialize)]
pub struct RawAislesUpdate {
pub content: String,
pub revision: Option<String>,
}
pub async fn put_raw_aisles(
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
Json(update): Json<RawAislesUpdate>,
) -> Result<Json<Aisles>, ApiError> {
let _guard = state.aisle_lock.lock().await;
let (path, text) = read_existing(&state).await?;
check_revision(&text, update.revision.as_deref())?;
if let Some(problem) = parse_problem(&update.content) {
return Err((StatusCode::BAD_REQUEST, json_error(problem)));
}
write(&path, update.content.clone()).await?;
activity::record(
&viewer,
format_args!(
"rewrote the aisle file {}",
activity::file(&state.base_path, &path)
),
);
Ok(Json(Aisles::from_text(&path, &update.content)))
}
#[derive(Debug, Serialize)]
pub struct Uncategorized {
pub total_recipes: usize,
pub ingredients: Vec<UncategorizedIngredient>,
}
#[derive(Debug, Serialize)]
pub struct UncategorizedIngredient {
pub name: String,
pub recipes: Vec<RecipeLink>,
}
#[derive(Debug, Serialize)]
pub struct RecipeLink {
pub path: String,
pub link: String,
}
pub async fn get_uncategorized(
State(state): State<Arc<AppState>>,
) -> Result<Json<Uncategorized>, ApiError> {
let base = state.base_path.clone();
let aisle = match state.aisle_file() {
Some(path) => cookcli_core::source::ConfigSource::Path(path),
None => cookcli_core::source::ConfigSource::None,
};
let outcome = tokio::task::spawn_blocking(move || {
let ctx = cookcli_core::Context::discover(base).with_aisle(aisle);
cookcli_core::doctor::aisle_coverage(&ctx, Default::default())
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to scan the recipes: {e}")),
)
})?
.map_err(|e| {
tracing::error!("Failed to check the recipes against the aisles: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to scan the recipes: {e}")),
)
})?;
let coverage = outcome.value;
Ok(Json(Uncategorized {
total_recipes: coverage.total_recipes,
ingredients: coverage
.unknown_entries()
.map(|ingredient| UncategorizedIngredient {
name: ingredient.name.clone(),
recipes: ingredient
.recipes
.iter()
.map(|path| RecipeLink {
path: path.to_string(),
link: path
.as_str()
.strip_suffix(".cook")
.unwrap_or(path.as_str())
.to_string(),
})
.collect(),
})
.collect(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_parse_problem_names_its_line() {
assert_eq!(parse_problem("[a]\nx\n"), None);
let problem = parse_problem("[a]\nx\n\n[b]\ny\nx\n").unwrap();
assert!(problem.starts_with("Line 6:"), "{problem}");
let problem = parse_problem("[a]\n[b]\n[a]\n").unwrap();
assert!(problem.starts_with("Line 3:"), "{problem}");
}
#[test]
fn a_change_reads_as_one_flat_object() {
let request: ChangeRequest = serde_json::from_str(
r#"{"revision": "abc", "action": "add_ingredient", "aisle": "dairy", "names": ["eggs"]}"#,
)
.unwrap();
assert_eq!(request.revision.as_deref(), Some("abc"));
assert!(matches!(
request.changes[..],
[Change::AddIngredient { .. }]
));
}
#[test]
fn several_changes_read_as_a_list() {
let request: ChangeRequest = serde_json::from_str(
r#"{"changes": [
{"action": "add_aisle", "name": "frozen"},
{"action": "merge_ingredients", "names": ["butter", "cold butter"], "main": "butter"}
]}"#,
)
.unwrap();
assert_eq!(request.revision, None);
assert!(matches!(
request.changes[..],
[Change::AddAisle { .. }, Change::MergeIngredients { .. }]
));
let error = serde_json::from_str::<ChangeRequest>(r#"{"action": "add_aisle"}"#)
.unwrap_err()
.to_string();
assert!(error.contains("missing field `name`"), "{error}");
}
}