rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 1b04b3fd172fdf97207c24c8fe11ca87d85841d8
parent bf1e1279d2a44a57bee37053e339602b6ef5f67f
Author: triesap <137732411+triesap@users.noreply.github.com>
Date:   Sat, 26 Apr 2025 20:44:26 +0000

Adds OrderClassified model, updates EventClassified adding price discount tags and calculate order method implementation.

Diffstat:
M.gitignore | 1+
Msrc/events/job_request.rs | 20+++++++++++++++-----
Msrc/handlers/job_request_order.rs | 101+++++++++++++++++++++++++++++++++----------------------------------------------
Msrc/models/event_classified.rs | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/models/mod.rs | 1+
Asrc/models/order_classified.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Msrc/utils/mod.rs | 1+
Msrc/utils/nostr.rs | 88++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Asrc/utils/price.rs | 15+++++++++++++++
Msrc/utils/unit.rs | 34++++++++++++++++++++++++++--------
10 files changed, 452 insertions(+), 118 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -3,6 +3,7 @@ justfile notes*.txt .tmp* .vscode +git-diff.txt .env .env.* diff --git a/src/events/job_request.rs b/src/events/job_request.rs @@ -1,9 +1,12 @@ +use std::time::Duration; + use anyhow::Result; use nostr::event::{Event, EventId, Tag, TagKind}; use nostr::filter::{Alphabet, SingleLetterTag}; use nostr::{event::Kind, key::Keys}; use nostr_sdk::Client; use nostr_sdk::RelayPoolNotification; +use tokio::time::sleep; use tracing::{info, warn}; use crate::KIND_JOB_REQUEST; @@ -11,16 +14,16 @@ use crate::handlers::job_request_order::{JobRequestOrderError, handle_job_reques use crate::handlers::job_request_preview::handle_job_request_preview; use crate::handlers::job_request_quote::handle_job_request_quote; use crate::utils::nostr::{ - NostrTagsResolveError, nostr_event_job_feedback, nostr_filter_kind, nostr_filter_new_events, - nostr_tag_at_value, nostr_tag_first_value, nostr_tag_relays_parse, nostr_tag_slice, - nostr_tags_resolve, + NostrTagsResolveError, NostrUtilsError, nostr_event_job_feedback, nostr_filter_kind, + nostr_filter_new_events, nostr_tag_at_value, nostr_tag_first_value, nostr_tag_relays_parse, + nostr_tag_slice, nostr_tags_resolve, }; use crate::utils::unit::MassUnitError; #[derive(thiserror::Error, Debug)] pub enum JobRequestError { - #[error("Order: {0}")] - JobRequestOrder(#[from] JobRequestOrderError), + #[error("{0}")] + NostrUtilsError(#[from] NostrUtilsError), #[error("{0}")] MassUnit(#[from] MassUnitError), @@ -28,6 +31,9 @@ pub enum JobRequestError { #[error("{0}")] NostrTagsResolve(#[from] NostrTagsResolveError), + #[error("Order: {0}")] + JobRequestOrder(#[from] JobRequestOrderError), + #[error("Invalid job request input type: {0}")] InvalidInputType(String), @@ -303,6 +309,10 @@ async fn process_job_request<F, Fut>( F: FnOnce(Event, Keys, Client, JobRequest, JobRequestInput) -> Fut, Fut: std::future::Future<Output = Result<(), JobRequestError>>, { + if cfg!(debug_assertions) { + sleep(Duration::from_millis(500)).await; + } + let error_event = event.clone(); let error_job_req = job_req.clone(); let error_keys = keys.clone(); diff --git a/src/handlers/job_request_order.rs b/src/handlers/job_request_order.rs @@ -1,6 +1,9 @@ use anyhow::Result; -use nostr::{event::Event, key::Keys}; -use nostr_sdk::Client; +use nostr::{ + event::{Event, Tag, TagKind}, + key::Keys, +}; +use nostr_sdk::{Client, client::Error as NostrClientError}; use serde::Deserialize; use thiserror::Error; use tracing::info; @@ -8,28 +11,28 @@ use tracing::info; use crate::{ events::job_request::{JobRequest, JobRequestError, JobRequestInput}, models::event_classified::EventClassified, - utils::nostr::{NostrEventError, nostr_event_job_result, nostr_fetch_event_by_id}, + utils::nostr::{nostr_event_job_result, nostr_fetch_event_by_id, nostr_send_event}, }; #[derive(Debug, Error)] pub enum JobRequestOrderError { - #[error("Failure to parse the reference event {0}")] - ReferenceEventParse(String), + #[error("Failed to parse reference event: {0}")] + ParseReference(String), - #[error("Failure to fetch the reference event {0}")] - ReferenceEventFetch(String), + #[error("Failed to fetch reference event: {0}")] + FetchReference(String), - #[error("Reference event not found {0}")] - ReferenceEventMissing(String), + #[error("Reference event not found: {0}")] + MissingReference(String), - #[error("Reference event does not satisfy requested {0}")] - ReferenceEventMissingRequested(String), + #[error("Reference event does not meet request requirements: {0}")] + MissingRequested(String), - #[error("Failure building the job response")] - ResponseEventBuildFailure(#[from] NostrEventError), + #[error("Failed to send job response")] + ResponseSend(#[from] NostrClientError), - #[error("Failure sending the job response")] - ResponseEventSendFailure(#[from] nostr_sdk::client::Error), + #[error("Request cannot be satisfied: {0}")] + Unsatisfiable(String), } #[derive(Debug, Deserialize)] @@ -73,51 +76,31 @@ pub async fn handle_job_request_order( _job_req: JobRequest, job_req_input: JobRequestInput, ) -> Result<(), JobRequestError> { - let order_data: JobRequestOrderData = serde_json::from_str(&job_req_input.data)?; - - info!("handle_job_request_order order_data: {:?}", order_data); - - let fetched_ref_event: Option<Event> = - nostr_fetch_event_by_id(client.clone(), &order_data.event.id.clone()) - .await - .map_err(|_| JobRequestOrderError::ReferenceEventFetch(order_data.event.id.clone()))?; - let ref_event: &Event = - fetched_ref_event - .as_ref() - .ok_or(JobRequestOrderError::ReferenceEventMissing( - order_data.event.id.clone(), - ))?; - - let ref_classified = EventClassified::from_event(ref_event) - .map_err(|_| JobRequestOrderError::ReferenceEventParse(order_data.event.id.clone()))?; - - info!( - "handle_job_request_order ref_classified: {:?}", - ref_classified - ); - - if ref_classified.prices.is_empty() { - return Err(JobRequestError::JobRequestOrder( - JobRequestOrderError::ReferenceEventMissingRequested("price".to_string()), - )); - } - - if ref_classified.quantities.is_empty() { - return Err(JobRequestError::JobRequestOrder( - JobRequestOrderError::ReferenceEventMissingRequested("quantity".to_string()), - )); - } - - let payload = "your order was received!"; - let event_result = nostr_event_job_result(&event_job_request.clone(), payload, 0, None, None) - .map_err(JobRequestOrderError::from)?; - let event_id = client - .send_event_builder(event_result) + let order_data: JobRequestOrderData = serde_json::from_str(&job_req_input.data) + .map_err(|e| JobRequestOrderError::ParseReference(e.to_string()))?; + + let ref_id = &order_data.event.id; + let ref_event = nostr_fetch_event_by_id(client.clone(), ref_id) .await - .map_err(JobRequestOrderError::from)?; + .map_err(|_| JobRequestOrderError::FetchReference(ref_id.clone()))?; + + let ref_classified = EventClassified::from_event(&ref_event) + .map_err(|_| JobRequestOrderError::ParseReference(ref_id.clone()))?; + + let order_result = ref_classified.calculate_order(&order_data.order)?; + + let payload = serde_json::to_string(&order_result)?; + let tags = vec![Tag::custom( + TagKind::custom("e_ref"), + [ref_event.id.to_hex()], + )]; + + let job_result_event = + nostr_event_job_result(&event_job_request, payload, 0, None, Some(tags))?; + + let job_result_event_id = nostr_send_event(client, job_result_event).await?; + + info!("job request order result sent: {:?}", job_result_event_id); - info!("handle_job_request_order sent result {:?}", { - event_id.clone() - }); Ok(()) } diff --git a/src/models/event_classified.rs b/src/models/event_classified.rs @@ -2,12 +2,20 @@ use anyhow::Result; use nostr::{EventId, event::Event}; use serde::{Deserialize, Serialize}; -use crate::utils::{ - nostr::{ - nostr_tag_match_geohash, nostr_tag_match_l, nostr_tag_match_location, - nostr_tag_match_summary, nostr_tag_match_title, nostr_tags_match, +use crate::{ + handlers::job_request_order::{JobRequestOrderDataOrder, JobRequestOrderError}, + utils::{ + nostr::{ + nostr_tag_match_geohash, nostr_tag_match_l, nostr_tag_match_location, + nostr_tag_match_summary, nostr_tag_match_title, nostr_tags_match, + }, + unit::{MassUnit, convert_mass}, }, - unit::MassUnit, +}; + +use super::order_classified::{ + OrderClassifiedDiscount, OrderClassifiedPrice, OrderClassifiedQuantity, OrderClassifiedResult, + OrderClassifiedTotal, }; #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -25,6 +33,33 @@ pub struct EventClassifiedLocation { } #[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "type")] +pub enum EventClassifiedDiscount { + #[serde(rename = "subtotal")] + Subtotal { + threshold: f64, + currency: String, + value: f64, + is_percent: bool, + }, + #[serde(rename = "mass")] + Mass { + discount_unit: String, + threshold: f64, + threshold_unit: String, + discount_per_unit: f64, + currency: String, + }, + #[serde(rename = "quantity")] + Quantity { + product_key: String, + min_count: u32, + discount_per_unit: f64, + currency: String, + }, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct EventClassifiedQuantity { pub amount: f64, pub unit: MassUnit, @@ -62,6 +97,7 @@ pub struct EventClassified { pub listing: EventClassifiedListing, pub prices: Vec<EventClassifiedPrice>, pub quantities: Vec<EventClassifiedQuantity>, + pub discounts: Vec<EventClassifiedDiscount>, pub location: Option<EventClassifiedLocation>, pub geolocation: Option<EventClassifiedGeolocation>, } @@ -79,6 +115,7 @@ impl EventClassified { let mut lat: Option<f64> = None; let mut lng: Option<f64> = None; let mut geohash: Option<String> = None; + let mut discounts: Vec<EventClassifiedDiscount> = Vec::new(); for tag in event.tags.iter() { if let Some((key, values)) = nostr_tags_match(tag) { @@ -123,6 +160,44 @@ impl EventClassified { "lot" if !values.is_empty() => listing.lot = Some(values[0].clone()), "profile" if !values.is_empty() => listing.profile = Some(values[0].clone()), "year" if !values.is_empty() => listing.year = Some(values[0].clone()), + "price-discount-subtotal" if values.len() >= 4 => { + let threshold = values[0].parse().unwrap_or(0.0); + let currency = values[1].clone(); + let value = values[2].parse().unwrap_or(0.0); + let is_percent = values[3] == "%"; + discounts.push(EventClassifiedDiscount::Subtotal { + threshold, + currency, + value, + is_percent, + }); + } + "price-discount-mass" if values.len() >= 5 => { + let discount_unit = values[0].clone(); + let threshold = values[1].parse().unwrap_or(0.0); + let threshold_unit = values[2].clone(); + let discount_per_unit = values[3].parse().unwrap_or(0.0); + let currency = values[4].clone(); + discounts.push(EventClassifiedDiscount::Mass { + discount_unit, + threshold, + threshold_unit, + discount_per_unit, + currency, + }); + } + "price-discount-quantity" if values.len() >= 4 => { + let product_key = values[0].clone(); + let min_count = values[1].parse().unwrap_or(0); + let discount_per_unit = values[2].parse().unwrap_or(0.0); + let currency = values[3].clone(); + discounts.push(EventClassifiedDiscount::Quantity { + product_key, + min_count, + discount_per_unit, + currency, + }); + } _ => {} } } @@ -197,8 +272,187 @@ impl EventClassified { listing, prices, quantities, + discounts, location, geolocation, }) } + + pub fn calculate_order( + &self, + order: &JobRequestOrderDataOrder, + ) -> Result<OrderClassifiedResult, JobRequestOrderError> { + let quantity = &order.quantity; + let price = &order.price; + + let qty_unit = quantity + .unit + .parse::<MassUnit>() + .map_err(|_| JobRequestOrderError::Unsatisfiable("invalid quantity unit".into()))?; + let price_unit = price.quantity_unit.parse::<MassUnit>().map_err(|_| { + JobRequestOrderError::Unsatisfiable("invalid price quantity unit".into()) + })?; + + let total_qty = quantity.amount * quantity.count as f64; + + let matched_packaging = self + .quantities + .iter() + .any(|q| q.unit == qty_unit && (q.amount - quantity.amount).abs() < f64::EPSILON); + + if !matched_packaging { + return Err(JobRequestOrderError::Unsatisfiable(format!( + "requested packaging {} {} not available", + quantity.amount, quantity.unit + ))); + } + + let matched_tier = self.prices.iter().find(|p| { + p.quantity_unit == price_unit + && (p.quantity_amount - price.quantity_amount).abs() < f64::EPSILON + && p.currency.to_lowercase() == price.currency.to_lowercase() + }); + + let tier = matched_tier.ok_or_else(|| { + JobRequestOrderError::Unsatisfiable(format!( + "no matching price tier {} {} found", + price.quantity_amount, price.quantity_unit + )) + })?; + + if (tier.amount - price.amount).abs() > f64::EPSILON { + return Err(JobRequestOrderError::Unsatisfiable(format!( + "price mismatch: expected {}, got {}", + tier.amount, price.amount + ))); + } + + let converted_qty = convert_mass(total_qty, &qty_unit, &price_unit); + let unit_price = tier.amount / tier.quantity_amount; + let subtotal = (unit_price * converted_qty * 100.0).round() / 100.0; + + let mut discounts: Vec<OrderClassifiedDiscount> = Vec::new(); + let package_key = format!( + "{}-{}-{}", + quantity.amount, + quantity.unit.to_lowercase(), + quantity.label + ); + + for d in &self.discounts { + match d { + EventClassifiedDiscount::Subtotal { + threshold, + currency, + value, + is_percent, + } => { + if subtotal < *threshold { + continue; + } + let amt = if *is_percent { + (subtotal * value / 100.0 * 100.0).round() / 100.0 + } else { + (*value * 100.0).round() / 100.0 + }; + discounts.push(OrderClassifiedDiscount { + discount_type: "subtotal".into(), + threshold: Some(*threshold), + threshold_unit: None, + discount_per_unit: None, + discount_unit: None, + discount_percent: if *is_percent { Some(*value) } else { None }, + discount_amount: amt, + currency: currency.clone(), + }); + } + EventClassifiedDiscount::Mass { + discount_unit, + threshold, + threshold_unit, + discount_per_unit, + currency, + } => { + let th_unit = threshold_unit.parse::<MassUnit>().map_err(|_| { + JobRequestOrderError::Unsatisfiable("invalid threshold unit".into()) + })?; + let dis_unit = discount_unit.parse::<MassUnit>().map_err(|_| { + JobRequestOrderError::Unsatisfiable("invalid discount unit".into()) + })?; + + let qty_in_th = convert_mass(total_qty, &qty_unit, &th_unit); + if qty_in_th < *threshold { + continue; + } + + let qty_in_dis = convert_mass(total_qty, &qty_unit, &dis_unit); + let amt = (qty_in_dis * discount_per_unit * 100.0).round() / 100.0; + + discounts.push(OrderClassifiedDiscount { + discount_type: "mass".into(), + threshold: Some(*threshold), + threshold_unit: Some(threshold_unit.clone()), + discount_per_unit: Some(*discount_per_unit), + discount_unit: Some(discount_unit.clone()), + discount_percent: None, + discount_amount: amt, + currency: currency.clone(), + }); + } + EventClassifiedDiscount::Quantity { + product_key, + min_count, + discount_per_unit, + currency, + } => { + if product_key != &package_key || quantity.count < *min_count { + continue; + } + + let amt = (*discount_per_unit * quantity.count as f64 * 100.0).round() / 100.0; + + discounts.push(OrderClassifiedDiscount { + discount_type: "quantity".into(), + threshold: Some(*min_count as f64), + threshold_unit: None, + discount_per_unit: Some(*discount_per_unit), + discount_unit: None, + discount_percent: None, + discount_amount: amt, + currency: currency.clone(), + }); + } + } + } + + let total_discount: f64 = discounts.iter().map(|d| d.discount_amount).sum(); + let total = ((subtotal - total_discount) * 100.0).round() / 100.0; + + Ok(OrderClassifiedResult { + quantity: OrderClassifiedQuantity { + amount: quantity.amount, + unit: quantity.unit.clone(), + label: quantity.label.clone(), + }, + price: OrderClassifiedPrice { + amount: tier.amount, + currency: tier.currency.clone(), + quantity_amount: tier.quantity_amount, + quantity_unit: price.quantity_unit.clone(), + }, + discounts, + subtotal: OrderClassifiedTotal { + price_amount: subtotal, + price_currency: tier.currency.clone(), + quantity_amount: total_qty, + quantity_unit: quantity.unit.clone(), + }, + total: OrderClassifiedTotal { + price_amount: total, + price_currency: tier.currency.clone(), + quantity_amount: total_qty, + quantity_unit: quantity.unit.clone(), + }, + }) + } } diff --git a/src/models/mod.rs b/src/models/mod.rs @@ -1 +1,2 @@ pub mod event_classified; +pub mod order_classified; diff --git a/src/models/order_classified.rs b/src/models/order_classified.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrderClassifiedResult { + pub quantity: OrderClassifiedQuantity, + pub price: OrderClassifiedPrice, + pub discounts: Vec<OrderClassifiedDiscount>, + pub subtotal: OrderClassifiedTotal, + pub total: OrderClassifiedTotal, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrderClassifiedQuantity { + pub amount: f64, + pub unit: String, + pub label: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrderClassifiedPrice { + pub amount: f64, + pub currency: String, + pub quantity_amount: f64, + pub quantity_unit: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrderClassifiedDiscount { + pub discount_type: String, + pub threshold: Option<f64>, + pub threshold_unit: Option<String>, + pub discount_per_unit: Option<f64>, + pub discount_unit: Option<String>, + pub discount_percent: Option<f64>, + pub discount_amount: f64, + pub currency: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrderClassifiedTotal { + pub price_amount: f64, + pub price_currency: String, + pub quantity_amount: f64, + pub quantity_unit: String, +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs @@ -1,2 +1,3 @@ pub mod nostr; +pub mod price; pub mod unit; diff --git a/src/utils/nostr.rs b/src/utils/nostr.rs @@ -1,5 +1,6 @@ -use std::borrow::Cow; +use std::{borrow::Cow, time::Duration}; +use crate::events::job_request::JobRequestError; use anyhow::Result; use nostr::{ event::{Event, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard}, @@ -12,10 +13,38 @@ use nostr::{ types::{RelayUrl, Timestamp}, }; use nostr_sdk::Client; -use nostr_sdk::RelayPoolNotification; +use nostr_sdk::prelude::*; use thiserror::Error; -use crate::events::job_request::JobRequestError; +#[derive(Debug, Error)] +pub enum NostrUtilsError { + #[error("Client error: {0}")] + ClientError(#[from] nostr_sdk::client::Error), + + #[error("Event error: {0}")] + EventError(#[from] nostr::event::Error), + + #[error("Event not found: {0}")] + EventNotFound(String), + + #[error("Event builder failure: {0}")] + EventBuildError(#[from] nostr::event::builder::Error), +} + +#[derive(Debug, Error)] +pub enum NostrTagsResolveError { + #[error("Missing public key tag in encrypted event: {0:?}")] + MissingPTag(nostr::Event), + + #[error("Encrypted event recipient mismatch")] + NotRecipient, + + #[error("Decryption error: {0}")] + DecryptionError(String), + + #[error("Failed to parse decrypted tag JSON: {0}")] + ParseError(#[from] serde_json::Error), +} pub fn nostr_kind(kind: u16) -> Kind { Kind::Custom(kind) @@ -103,19 +132,13 @@ pub fn nostr_tag_match_summary(tag: &Tag) -> Option<String> { } } -#[derive(Debug, thiserror::Error)] -pub enum NostrEventError { - #[error("Failed to build job result event: {0}")] - BuildError(#[from] nostr::event::builder::Error), -} - pub fn nostr_event_job_result( job_request: &Event, payload: impl Into<String>, millisats: u64, bolt11: Option<String>, tags: Option<Vec<Tag>>, -) -> Result<EventBuilder, NostrEventError> { +) -> Result<EventBuilder, NostrUtilsError> { let builder = EventBuilder::job_result(job_request.clone(), payload, millisats, bolt11)? .tags(tags.unwrap_or_default()); Ok(builder) @@ -126,7 +149,7 @@ pub fn nostr_event_job_feedback( error: JobRequestError, status: &str, tags: Option<Vec<Tag>>, -) -> Result<EventBuilder, NostrEventError> { +) -> Result<EventBuilder, NostrUtilsError> { let status = status .parse::<DataVendingMachineStatus>() .unwrap_or(DataVendingMachineStatus::Error); @@ -136,38 +159,21 @@ pub fn nostr_event_job_feedback( Ok(builder) } -pub async fn nostr_fetch_event_by_id(client: Client, id: &str) -> Result<Option<Event>> { - let event_id = EventId::from_hex(id)?; - let filter = Filter::new().id(event_id); - - client.connect().await; - client.subscribe(filter, None).await?; - - let mut notifications = client.notifications(); - while let Ok(n) = notifications.recv().await { - if let RelayPoolNotification::Event { event, .. } = n { - if event.id == event_id { - return Ok(Some(*event)); - } - } - } - - Ok(None) +pub async fn nostr_send_event( + client: Client, + event: EventBuilder, +) -> Result<Output<EventId>, NostrUtilsError> { + Ok(client.send_event_builder(event).await?) } -#[derive(Debug, Error)] -pub enum NostrTagsResolveError { - #[error("Missing public key tag in encrypted event: {0:?}")] - MissingPTag(nostr::Event), - - #[error("Encrypted event recipient mismatch")] - NotRecipient, - - #[error("Decryption error: {0}")] - DecryptionError(String), - - #[error("Failed to parse decrypted tag JSON: {0}")] - ParseError(#[from] serde_json::Error), +pub async fn nostr_fetch_event_by_id(client: Client, id: &str) -> Result<Event, NostrUtilsError> { + let event_id = EventId::parse(id)?; + let filter = Filter::new().id(event_id); + let events = client.fetch_events(filter, Duration::from_secs(10)).await?; + let event = events + .first() + .ok_or_else(|| NostrUtilsError::EventNotFound(event_id.to_hex()))?; + Ok(event.clone()) } pub fn nostr_tags_resolve(event: &Event, keys: &Keys) -> Result<Vec<Tag>, NostrTagsResolveError> { diff --git a/src/utils/price.rs b/src/utils/price.rs @@ -0,0 +1,15 @@ +use super::unit::{MassUnit, convert_mass}; + +pub fn calculate_total_price( + quantity_amount: f64, + quantity_unit: &MassUnit, + quantity_count: u32, + price_amount: f64, + price_quantity_amount: f64, + price_quantity_unit: &MassUnit, +) -> f64 { + let total_mass = quantity_amount * quantity_count as f64; + let total_mass_in_price_unit = convert_mass(total_mass, quantity_unit, price_quantity_unit); + let price_per_quantity_unit = price_amount / price_quantity_amount; + price_per_quantity_unit * total_mass_in_price_unit +} diff --git a/src/utils/unit.rs b/src/utils/unit.rs @@ -11,27 +11,38 @@ pub enum MassUnitError { InvalidAmount(f64), } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "lowercase")] pub enum MassUnit { G, Kg, + Oz, Lb, } impl MassUnit { - pub fn to_grams(&self, amount: f64) -> Result<f64, MassUnitError> { - if !amount.is_finite() || amount.is_nan() { + pub fn to_grams(&self) -> f64 { + match self { + MassUnit::G => 1.0, + MassUnit::Kg => 1000.0, + MassUnit::Oz => 28.3495, + MassUnit::Lb => 453.592, + } + } + + pub fn amount_in_grams(&self, amount: f64) -> Result<f64, MassUnitError> { + if !amount.is_finite() { return Err(MassUnitError::InvalidAmount(amount)); } - let grams = match self { - MassUnit::G => amount, - MassUnit::Kg => amount * 1000.0, - MassUnit::Lb => amount * 453.592, + let factor = match self { + MassUnit::G => 1.0, + MassUnit::Kg => 1000.0, + MassUnit::Oz => 28.3495, + MassUnit::Lb => 453.592, }; - Ok(grams) + Ok(amount * factor) } } @@ -40,6 +51,7 @@ impl fmt::Display for MassUnit { let unit_str = match self { MassUnit::G => "g", MassUnit::Kg => "kg", + MassUnit::Oz => "oz", MassUnit::Lb => "lb", }; write!(f, "{unit_str}") @@ -53,8 +65,14 @@ impl FromStr for MassUnit { match s { "g" => Ok(MassUnit::G), "kg" => Ok(MassUnit::Kg), + "oz" => Ok(MassUnit::Oz), "lb" => Ok(MassUnit::Lb), other => Err(MassUnitError::InvalidUnit(other.to_string())), } } } + +pub fn convert_mass(amount: f64, from_unit: &MassUnit, to_unit: &MassUnit) -> f64 { + let amount_g = amount * from_unit.to_grams(); + amount_g / to_unit.to_grams() +}