use super::common::json_error;
use axum::{
extract::{Extension, Json, Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use camino::Utf8PathBuf;
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json;
use std::sync::Arc;
use crate::server::{activity, AppState};
use crate::web::viewer::Viewer;
async fn load_pantry(
state: &AppState,
) -> Result<cooklang::pantry::PantryConf, (StatusCode, Json<serde_json::Value>)> {
let pantry_path = get_pantry_path(state)?;
let content = tokio::fs::read_to_string(pantry_path.as_std_path())
.await
.map_err(|e| {
tracing::error!("Failed to read pantry file: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to read pantry file: {e}")),
)
})?;
let result = cooklang::pantry::parse_lenient(&content);
result.output().cloned().ok_or_else(|| {
(
StatusCode::NOT_FOUND,
json_error("Failed to parse pantry configuration"),
)
})
}
fn get_pantry_path(
state: &AppState,
) -> Result<&Utf8PathBuf, (StatusCode, Json<serde_json::Value>)> {
state.pantry_path.as_ref().ok_or_else(|| {
(
StatusCode::NOT_FOUND,
json_error("Pantry configuration not found"),
)
})
}
#[derive(Debug, Deserialize)]
pub struct AddPantryItem {
pub section: String,
pub name: String,
pub quantity: Option<String>,
pub bought: Option<String>,
pub expire: Option<String>,
pub low: Option<String>,
}
impl AddPantryItem {
fn trimmed(self) -> Result<Self, &'static str> {
let section = self.section.trim().to_string();
let name = self.name.trim().to_string();
if section.is_empty() {
return Err("Section cannot be empty");
}
if name.is_empty() {
return Err("Item name cannot be empty");
}
let attribute = |value: Option<String>| {
value
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
};
Ok(Self {
section,
name,
quantity: attribute(self.quantity),
bought: attribute(self.bought),
expire: attribute(self.expire),
low: attribute(self.low),
})
}
}
#[derive(Debug, Deserialize)]
pub struct UpdatePantryItem {
pub quantity: Option<String>,
pub bought: Option<String>,
pub expire: Option<String>,
pub low: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ApiResponse {
pub success: bool,
pub message: String,
}
pub async fn add_item(
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
Json(item): Json<AddPantryItem>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let item = item
.trimmed()
.map_err(|e| (StatusCode::BAD_REQUEST, json_error(e)))?;
let pantry_path = get_pantry_path(&state)?;
let mut pantry_conf = load_pantry(&state).await.unwrap_or_default();
let new_item = if item.quantity.is_some()
|| item.bought.is_some()
|| item.expire.is_some()
|| item.low.is_some()
{
cooklang::pantry::PantryItem::WithAttributes(cooklang::pantry::ItemWithAttributes {
name: item.name.clone(),
quantity: item.quantity,
bought: item.bought,
expire: item.expire,
low: item.low,
})
} else {
cooklang::pantry::PantryItem::Simple(item.name.clone())
};
pantry_conf
.sections
.entry(item.section.clone())
.or_insert_with(Vec::new)
.push(new_item);
pantry_conf.rebuild_index();
let new_content = serialize_pantry_to_regular_toml(&pantry_conf);
tokio::fs::write(pantry_path.as_std_path(), new_content)
.await
.map_err(|e| {
tracing::error!("Failed to write pantry file: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to write pantry file: {e}")),
)
})?;
activity::record(
&viewer,
format_args!(
"added {} to the {} section of the pantry",
activity::quoted(&item.name),
activity::quoted(&item.section)
),
);
Ok(Json(ApiResponse {
success: true,
message: format!("Added {} to {}", item.name, item.section),
}))
}
pub async fn remove_item(
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
Path((section, name)): Path<(String, String)>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let pantry_path = get_pantry_path(&state)?;
let mut pantry_conf = load_pantry(&state).await?;
if let Some(items) = pantry_conf.sections.get_mut(§ion) {
items.retain(|item| item.name() != name);
if items.is_empty() {
pantry_conf.sections.shift_remove(§ion);
}
} else {
return Err((
StatusCode::NOT_FOUND,
json_error(format!("Section not found: {section}")),
));
}
pantry_conf.rebuild_index();
let new_content = serialize_pantry_to_regular_toml(&pantry_conf);
tokio::fs::write(pantry_path.as_std_path(), new_content)
.await
.map_err(|e| {
tracing::error!("Failed to write pantry file: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to write pantry file: {e}")),
)
})?;
activity::record(
&viewer,
format_args!(
"removed {} from the {} section of the pantry",
activity::quoted(&name),
activity::quoted(§ion)
),
);
Ok(Json(ApiResponse {
success: true,
message: format!("Removed {name} from {section}"),
}))
}
pub async fn update_item(
State(state): State<Arc<AppState>>,
Extension(viewer): Extension<Viewer>,
Path((section, name)): Path<(String, String)>,
Json(update): Json<UpdatePantryItem>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let pantry_path = get_pantry_path(&state)?;
let mut pantry_conf = load_pantry(&state).await?;
if let Some(items) = pantry_conf.sections.get_mut(§ion) {
for item in items.iter_mut() {
if item.name() == name {
let updated_item = match item {
cooklang::pantry::PantryItem::Simple(item_name) => {
if update.quantity.is_some()
|| update.bought.is_some()
|| update.expire.is_some()
|| update.low.is_some()
{
cooklang::pantry::PantryItem::WithAttributes(
cooklang::pantry::ItemWithAttributes {
name: item_name.clone(),
quantity: update.quantity.clone(),
bought: update.bought.clone(),
expire: update.expire.clone(),
low: update.low,
},
)
} else {
cooklang::pantry::PantryItem::Simple(item_name.clone())
}
}
cooklang::pantry::PantryItem::WithAttributes(attrs) => {
cooklang::pantry::PantryItem::WithAttributes(
cooklang::pantry::ItemWithAttributes {
name: attrs.name.clone(),
quantity: update.quantity.clone().or(attrs.quantity.clone()),
bought: update.bought.clone().or(attrs.bought.clone()),
expire: update.expire.clone().or(attrs.expire.clone()),
low: update.low.clone().or(attrs.low.clone()),
},
)
}
};
*item = updated_item;
break;
}
}
} else {
return Err((
StatusCode::NOT_FOUND,
json_error(format!("Section not found: {section}")),
));
}
pantry_conf.rebuild_index();
let new_content = serialize_pantry_to_regular_toml(&pantry_conf);
tokio::fs::write(pantry_path.as_std_path(), new_content)
.await
.map_err(|e| {
tracing::error!("Failed to write pantry file: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
json_error(format!("Failed to write pantry file: {e}")),
)
})?;
activity::record(
&viewer,
format_args!(
"updated {} in the {} section of the pantry",
activity::quoted(&name),
activity::quoted(§ion)
),
);
Ok(Json(ApiResponse {
success: true,
message: format!("Updated {name} in {section}"),
}))
}
pub async fn get_pantry(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let pantry_conf = load_pantry(&state).await?;
Ok(Json(pantry_conf))
}
#[derive(Debug, Deserialize)]
pub struct ExpiringQuery {
pub days: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct ExpiringItemResponse {
pub section: String,
pub name: String,
pub expire: String,
pub days_remaining: i64,
}
#[derive(Debug, Serialize)]
pub struct DepletedItemResponse {
pub section: String,
pub name: String,
pub low: Option<String>,
}
pub async fn get_expiring(
State(state): State<Arc<AppState>>,
Query(query): Query<ExpiringQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let days = query.days.unwrap_or(7);
if days < 0 {
return Err((
StatusCode::BAD_REQUEST,
json_error("days must be non-negative"),
));
}
let pantry_conf = load_pantry(&state).await?;
let today = Local::now().date_naive();
let threshold = today
.checked_add_signed(chrono::Duration::days(days))
.unwrap_or(NaiveDate::MAX);
let mut items = Vec::new();
for (section, section_items) in &pantry_conf.sections {
for item in section_items {
if let Some(expire_str) = item.expire() {
if let Some(date) = parse_date(expire_str) {
if date <= threshold {
let days_remaining = (date - today).num_days();
items.push(ExpiringItemResponse {
section: section.clone(),
name: item.name().to_string(),
expire: date.format("%Y-%m-%d").to_string(),
days_remaining,
});
}
}
}
}
}
items.sort_by_key(|item| item.days_remaining);
Ok(Json(items))
}
pub async fn get_depleted(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let pantry_conf = load_pantry(&state).await?;
let mut items = Vec::new();
for (section, section_items) in &pantry_conf.sections {
for item in section_items {
if item.is_low() {
items.push(DepletedItemResponse {
section: section.clone(),
name: item.name().to_string(),
low: item.low().map(|l| l.to_string()),
});
}
}
}
Ok(Json(items))
}
pub fn parse_date(date_str: &str) -> Option<NaiveDate> {
let formats = [
"%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y", "%Y.%m.%d", "%d-%m-%Y",
];
for format in &formats {
if let Ok(date) = NaiveDate::parse_from_str(date_str, format) {
return Some(date);
}
}
None
}
fn serialize_pantry_to_regular_toml(pantry_conf: &cooklang::pantry::PantryConf) -> String {
cooklang::pantry::to_toml_string(pantry_conf)
}
#[cfg(test)]
mod tests {
use super::AddPantryItem;
fn item(section: &str, name: &str, quantity: Option<&str>) -> AddPantryItem {
AddPantryItem {
section: section.to_string(),
name: name.to_string(),
quantity: quantity.map(str::to_string),
bought: None,
expire: Some(" 2026-01-01 ".to_string()),
low: Some(" ".to_string()),
}
}
#[test]
fn a_new_item_is_trimmed() {
let added = item(" fridge ", " Milk ", Some(" unlim "))
.trimmed()
.unwrap();
assert_eq!(added.section, "fridge");
assert_eq!(added.name, "Milk");
assert_eq!(added.quantity.as_deref(), Some("unlim"));
assert_eq!(added.expire.as_deref(), Some("2026-01-01"));
assert_eq!(added.low, None);
}
#[test]
fn a_blank_name_or_section_is_refused() {
assert!(item("fridge", " ", None).trimmed().is_err());
assert!(item(" ", "Milk", None).trimmed().is_err());
}
}