Skip to main content

cooklang/
scale.rs

1//! Support for recipe scaling
2
3use crate::{convert::Converter, quantity::Value, Quantity, Recipe};
4use thiserror::Error;
5
6/// Error type for scaling operations
7#[derive(Debug, Error, serde::Serialize, serde::Deserialize)]
8#[cfg_attr(feature = "ts", derive(tsify::Tsify))]
9pub enum ScaleError {
10    /// The recipe has no valid numeric servings value
11    #[error("Cannot scale recipe: servings metadata is not a valid number")]
12    InvalidServings,
13
14    /// The recipe has no valid yield metadata
15    #[error("Cannot scale recipe: yield metadata is missing or invalid")]
16    InvalidYield,
17
18    /// The units don't match between target and current yield
19    #[error("Cannot scale recipe: unit mismatch (expected {expected}, got {got})")]
20    UnitMismatch { expected: String, got: String },
21}
22
23impl Recipe {
24    /// Scale a recipe
25    ///
26    pub fn scale(&mut self, factor: f64, converter: &Converter) {
27        let scale_quantity = |q: &mut Quantity| {
28            if q.scalable {
29                q.value.scale(factor);
30                let _ = q.fit(converter);
31            }
32        };
33
34        // Update metadata with new servings (only if numeric)
35        if let Some(current_servings) = self.metadata.servings() {
36            if let Some(base) = current_servings.as_number() {
37                let new_servings = (base as f64 * factor).round() as u32;
38                if let Some(servings_value) =
39                    self.metadata.get_mut(crate::metadata::StdKey::Servings)
40                {
41                    // Preserve the original type (string or number)
42                    match servings_value {
43                        serde_yaml::Value::String(_) => {
44                            *servings_value = serde_yaml::Value::String(new_servings.to_string());
45                        }
46                        _ => {
47                            *servings_value =
48                                serde_yaml::Value::Number(serde_yaml::Number::from(new_servings));
49                        }
50                    }
51                }
52            }
53        }
54
55        self.ingredients
56            .iter_mut()
57            .filter_map(|i| i.quantity.as_mut())
58            .for_each(scale_quantity);
59        self.cookware
60            .iter_mut()
61            .filter_map(|i| i.quantity.as_mut())
62            .for_each(scale_quantity);
63        self.timers
64            .iter_mut()
65            .filter_map(|i| i.quantity.as_mut())
66            .for_each(scale_quantity);
67    }
68
69    /// Scale to a specific number of servings
70    ///
71    /// - `target` is the wanted number of servings.
72    ///
73    /// Returns an error if the recipe doesn't have a valid numeric servings value.
74    pub fn scale_to_servings(
75        &mut self,
76        target: u32,
77        converter: &Converter,
78    ) -> Result<(), ScaleError> {
79        let current_servings = self
80            .metadata
81            .servings()
82            .ok_or(ScaleError::InvalidServings)?;
83
84        let base = current_servings
85            .as_number()
86            .ok_or(ScaleError::InvalidServings)?;
87
88        let factor = target as f64 / base as f64;
89        self.scale(factor, converter);
90
91        // Update servings metadata to the target value
92        if let Some(servings_value) = self.metadata.get_mut(crate::metadata::StdKey::Servings) {
93            // Preserve the original type (string or number)
94            match servings_value {
95                serde_yaml::Value::String(_) => {
96                    *servings_value = serde_yaml::Value::String(target.to_string());
97                }
98                _ => {
99                    *servings_value = serde_yaml::Value::Number(serde_yaml::Number::from(target));
100                }
101            }
102        }
103        Ok(())
104    }
105
106    /// Scale to a target value with optional unit
107    ///
108    /// This function intelligently chooses the appropriate scaling method:
109    /// - If `target_unit` is `Some("servings")`, scales by servings
110    /// - If `target_unit` is `Some(other_unit)`, scales by yield with that unit
111    /// - If `target_unit` is `None`, applies direct scaling factor
112    ///
113    /// # Arguments
114    /// - `target_value` - The target value (servings count, yield amount, or scaling factor)
115    /// - `target_unit` - Optional unit ("servings" for servings-based, other for yield-based, None for direct factor)
116    /// - `converter` - Unit converter for fitting quantities
117    ///
118    /// # Returns
119    /// - `Ok(())` on successful scaling
120    /// - `Err(ScaleError)` if scaling cannot be performed
121    pub fn scale_to_target(
122        &mut self,
123        target_value: f64,
124        target_unit: Option<&str>,
125        converter: &Converter,
126    ) -> Result<(), ScaleError> {
127        match target_unit {
128            Some("servings") | Some("serving") => {
129                // Scale by servings - convert f64 to u32
130                let servings = target_value.round() as u32;
131                self.scale_to_servings(servings, converter)
132            }
133            Some(unit) => {
134                // Scale by yield with the specified unit
135                self.scale_to_yield(target_value, unit, converter)
136            }
137            None => {
138                // Direct scaling factor
139                self.scale(target_value, converter);
140                Ok(())
141            }
142        }
143    }
144
145    /// Scale to a specific yield amount with unit
146    ///
147    /// - `target_value` is the wanted yield amount
148    /// - `target_unit` is the unit for the yield
149    ///
150    /// Returns an error if:
151    /// - The recipe doesn't have yield metadata
152    /// - The yield metadata is not in the correct format
153    /// - The units don't match
154    pub fn scale_to_yield(
155        &mut self,
156        target_value: f64,
157        target_unit: &str,
158        converter: &Converter,
159    ) -> Result<(), ScaleError> {
160        // Get current yield from metadata
161        // TODO: use std keys
162        let yield_value = self.metadata.get("yield").ok_or(ScaleError::InvalidYield)?;
163
164        let yield_str = yield_value
165            .as_str()
166            .ok_or(ScaleError::InvalidYield)?
167            .to_string(); // Clone to avoid borrowing issues
168
169        // Parse yield value - only support "1000%g" format
170        let parts: Vec<&str> = yield_str.split('%').collect();
171        if parts.len() != 2 {
172            return Err(ScaleError::InvalidYield);
173        }
174        let current_value = parts[0]
175            .parse::<f64>()
176            .map_err(|_| ScaleError::InvalidYield)?;
177        let current_unit = parts[1].to_string();
178
179        // Check that units match
180        if current_unit != target_unit {
181            return Err(ScaleError::UnitMismatch {
182                expected: target_unit.to_string(),
183                got: current_unit.to_string(),
184            });
185        }
186
187        let factor = target_value / current_value;
188        self.scale(factor, converter);
189
190        // Update yield metadata to the target value (always use % format)
191        // TODO: use std keys
192        if let Some(yield_meta) = self.metadata.get_mut("yield") {
193            *yield_meta = serde_yaml::Value::String(format!("{target_value}%{target_unit}"));
194        }
195
196        Ok(())
197    }
198}
199
200impl Value {
201    fn scale(&mut self, factor: f64) {
202        match self {
203            Value::Number(n) => {
204                *n = (n.value() * factor).into();
205            }
206            Value::Range { start, end } => {
207                *start = (start.value() * factor).into();
208                *end = (end.value() * factor).into();
209            }
210            Value::Text(_) => {}
211        }
212    }
213}