use grabcli_core::error::{Error, ErrorKind, Result};
use grabcli_core::money::Money;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CartItem {
pub item_id: String,
pub quantity: i64,
#[serde(default)]
pub name: String,
#[serde(default)]
pub unit_price_minor: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub campaign_id: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub comment: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Cart {
pub merchant_id: String,
#[serde(default)]
pub merchant_name: String,
pub items: Vec<CartItem>,
}
impl Cart {
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn add(&mut self, merchant_id: &str, merchant_name: &str, item: CartItem) -> Result<()> {
if !self.is_empty() && self.merchant_id != merchant_id {
return Err(Error::new(
ErrorKind::CartInvalid,
format!(
"the mart cart already has items from {} — run `grab mart cart clear` first",
if self.merchant_name.is_empty() {
&self.merchant_id
} else {
&self.merchant_name
}
),
));
}
if item.quantity < 1 || item.quantity > 99 {
return Err(Error::new(
ErrorKind::CartInvalid,
"quantity must be 1..=99",
));
}
self.merchant_id = merchant_id.to_string();
if !merchant_name.is_empty() {
self.merchant_name = merchant_name.to_string();
}
if let Some(existing) = self.items.iter_mut().find(|i| i.item_id == item.item_id) {
existing.quantity = (existing.quantity + item.quantity).min(99);
existing.unit_price_minor = item.unit_price_minor;
existing.campaign_id = item.campaign_id;
} else {
self.items.push(item);
}
Ok(())
}
pub fn remove(&mut self, item_id: &str) -> bool {
let before = self.items.len();
self.items.retain(|i| i.item_id != item_id);
self.items.len() != before
}
pub fn snapshot_subtotal(&self) -> Money {
Money(
self.items
.iter()
.map(|i| i.unit_price_minor * i.quantity)
.sum(),
)
}
pub fn validate(&self) -> Result<()> {
if self.is_empty() {
return Err(Error::new(ErrorKind::CartInvalid, "mart cart is empty"));
}
for i in &self.items {
if i.quantity < 1 || i.quantity > 99 {
return Err(Error::new(
ErrorKind::CartInvalid,
format!("item {} quantity must be 1..=99", i.item_id),
));
}
}
Ok(())
}
pub fn to_quote_merchant(&self, section: &str) -> Value {
let items: Vec<Value> = self
.items
.iter()
.map(|it| {
let mut v = json!({"ID": it.item_id, "quantity": it.quantity});
if !it.comment.is_empty() {
v["comment"] = json!(it.comment);
}
v
})
.collect();
json!({"ID": self.merchant_id, "items": items, "section": section})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(id: &str, qty: i64) -> CartItem {
CartItem {
item_id: id.into(),
quantity: qty,
name: format!("Item {id}"),
unit_price_minor: 33900,
campaign_id: None,
comment: String::new(),
}
}
#[test]
fn add_merges_and_rejects_second_store() -> Result<()> {
let mut c = Cart::default();
c.add("2-SR", "S&R - Example Branch", item("I1", 1))?;
c.add("2-SR", "", item("I1", 2))?;
assert_eq!(c.items.len(), 1);
assert_eq!(c.items[0].quantity, 3);
assert_eq!(c.merchant_name, "S&R - Example Branch");
assert_eq!(c.snapshot_subtotal(), Money(101700));
let e = c
.add("2-OTHER", "Other", item("I2", 1))
.expect_err("second store");
assert_eq!(e.kind, ErrorKind::CartInvalid);
Ok(())
}
#[test]
fn bad_quantity_and_empty() {
let mut c = Cart::default();
assert!(c.add("2-SR", "S&R", item("I1", 0)).is_err());
assert!(c.validate().is_err());
}
#[test]
fn remove_and_body() -> Result<()> {
let mut c = Cart::default();
c.add("2-SR", "S&R", item("I1", 1))?;
c.add("2-SR", "S&R", item("I2", 2))?;
assert!(c.remove("I1"));
assert!(!c.remove("I1"));
let m = c.to_quote_merchant("SEC");
assert_eq!(m["ID"], "2-SR");
assert_eq!(m["section"], "SEC");
assert_eq!(m["items"][0]["ID"], "I2");
assert_eq!(m["items"][0]["quantity"], 2);
assert!(m["items"][0].get("modifierGroups").is_none());
Ok(())
}
}