Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::units_convert::convert_units; ← from units.convert ^1.0.0 · built alongside by fune
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<RecipeQuantity> {
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<RecipeQuantity> = 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(),
)
}