use super::funejson::Value; use super::units_convert::convert_units; fn is_decimal(text: &str) -> bool { let mut parts = text.splitn(2, '.'); let whole = parts.next().unwrap_or(""); let ok_whole = !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()); match parts.next() { None => ok_whole, Some(f) => ok_whole && !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()), } } fn metric(unit: &str) -> Option<(&'static str, &'static str)> { match unit { "mg" | "g" | "kg" => Some(("g", "kg")), "mL" | "L" => Some(("mL", "L")), _ => None, } } /// Digits and 10^scale of decimal text. The inputs are bounded (15 digits in, /// 12 decimal places out of units.convert), so this always fits in i128. fn parse_decimal(text: &str) -> (i128, i128) { let (whole, fraction) = text.split_once('.').unwrap_or((text, "")); let digits: i128 = format!("{}{}", whole, fraction).parse().expect("decimal digits"); (digits, 10i128.pow(fraction.len() as u32)) } fn half_up(numerator: i128, denominator: i128) -> i128 { (numerator * 2 + denominator) / (denominator * 2) } fn format_scaled(value: i128, decimals: u32) -> String { let unit = 10i128.pow(decimals); let whole = (value / unit).to_string(); if decimals == 0 { return whole; } let fraction = format!("{:0width$}", value % unit, width = decimals as usize); let fraction = fraction.trim_end_matches('0'); if fraction.is_empty() { whole } else { format!("{}.{}", whole, fraction) } } fn check_portions(name: &str, value: i64) { if !(1..=10000).contains(&value) { panic!("{} must be a whole number from 1 to 10000, received {}", name, value); } } /// Scale a recipe from one number of portions to another. /// /// The scaling is exact (decimal text times a fraction), and each quantity is /// rounded once, to the precision a kitchen weighs to: whole grams or /// millilitres from 10 up, one decimal place from 1 to 10, two below 1. /// Metric amounts move between g and kg, mL and L, at 1000. Counted items /// (each) round up, since half an egg short is short. Any other unit keeps its /// name and is rounded to two decimal places. /// /// # Panics /// Panics on portions outside 1 to 10000, a malformed quantity or an empty unit. pub fn scale_recipe(ingredients: &[RecipeQuantity], from_portions: i64, to_portions: i64) -> Vec { check_portions("fromPortions", from_portions); check_portions("toPortions", to_portions); let to = to_portions as i128; let from = from_portions as i128; ingredients .iter() .map(|ingredient| { let name = ingredient.name.clone(); let quantity = ingredient.quantity.as_str(); let unit = ingredient.unit.as_str(); if !is_decimal(quantity) { panic!( "quantity must be a non-negative decimal like \"12.5\", received \"{}\" for \"{}\"", quantity, name ); } let (whole, fraction) = quantity.split_once('.').unwrap_or((quantity, "")); let trimmed_fraction = fraction.trim_end_matches('0'); let significant = format!("{}{}", whole, trimmed_fraction); if significant.trim_start_matches('0').len() > 15 || trimmed_fraction.len() > 15 { panic!( "quantity \"{}\" for \"{}\" has too many digits: at most 15 significant digits and 15 decimal places", quantity, name ); } if unit.is_empty() { panic!("unit must not be empty for \"{}\"", name); } if let Some((small, large)) = metric(unit) { let (n, scale) = parse_decimal(&convert_units(quantity, unit, small, 12)); let num = n * to; let den = scale * from; let decimals: u32 = if num < den { 2 } else if num < den * 10 { 1 } else { 0 }; let rounded = half_up(num * 10i128.pow(decimals), den); if decimals == 0 && rounded >= 1000 { return RecipeQuantity { name, quantity: convert_units(&rounded.to_string(), small, large, 3), unit: large.to_string(), }; } return RecipeQuantity { name, quantity: format_scaled(rounded, decimals), unit: small.to_string() }; } // Trailing zeros dropped, so "1.000000000000000000000" stays small. let normal = if trimmed_fraction.is_empty() { whole.to_string() } else { format!("{}.{}", whole, trimmed_fraction) }; let (n, scale) = parse_decimal(&normal); let num = n * to; let den = scale * from; if unit == "each" { return RecipeQuantity { name, quantity: ((num + den - 1) / den).to_string(), unit: unit.to_string() }; } RecipeQuantity { name, quantity: format_scaled(half_up(num * 100, den), 2), unit: unit.to_string() } }) .collect() } pub fn recipe_quantity_from_value(v: &Value) -> RecipeQuantity { RecipeQuantity { name: v.get("name").as_str().to_string(), quantity: v.get("quantity").as_str().to_string(), unit: v.get("unit").as_str().to_string(), } } pub fn recipe_quantity_to_value(q: &RecipeQuantity) -> Value { Value::obj(vec![ ("name", Value::str(&q.name)), ("quantity", Value::str(&q.quantity)), ("unit", Value::str(&q.unit)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let ingredients: Vec = args[0].as_arr().iter().map(recipe_quantity_from_value).collect(); Value::Arr( scale_recipe(&ingredients, args[1].as_i64(), args[2].as_i64()) .iter() .map(recipe_quantity_to_value) .collect(), ) }