Skip to main content

grub_core/
models.rs

1use anyhow::{Result, bail};
2use chrono::NaiveDate;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Food {
7    pub id: i64,
8    #[serde(default)]
9    pub uuid: String,
10    pub name: String,
11    pub brand: Option<String>,
12    pub barcode: Option<String>,
13    pub calories_per_100g: f64,
14    pub protein_per_100g: Option<f64>,
15    pub carbs_per_100g: Option<f64>,
16    pub fat_per_100g: Option<f64>,
17    pub default_serving_g: Option<f64>,
18    pub source: String,
19    pub created_at: String,
20    #[serde(default)]
21    pub updated_at: String,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct MealEntry {
26    pub id: i64,
27    #[serde(default)]
28    pub uuid: String,
29    pub date: String,
30    pub meal_type: String,
31    pub food_id: i64,
32    pub serving_g: f64,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub display_unit: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub display_quantity: Option<f64>,
37    pub created_at: String,
38    #[serde(default)]
39    pub updated_at: String,
40    // Joined fields for display
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub food_name: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub food_brand: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub calories: Option<f64>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub protein: Option<f64>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub carbs: Option<f64>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub fat: Option<f64>,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct DailySummary {
57    pub date: String,
58    pub meals: Vec<MealGroup>,
59    pub total_calories: f64,
60    pub total_protein: f64,
61    pub total_carbs: f64,
62    pub total_fat: f64,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub target: Option<DailyTarget>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct MealGroup {
69    pub meal_type: String,
70    pub entries: Vec<MealEntry>,
71    pub subtotal_calories: f64,
72    pub subtotal_protein: f64,
73    pub subtotal_carbs: f64,
74    pub subtotal_fat: f64,
75}
76
77#[derive(Debug, Clone)]
78pub struct NewFood {
79    pub name: String,
80    pub brand: Option<String>,
81    pub barcode: Option<String>,
82    pub calories_per_100g: f64,
83    pub protein_per_100g: Option<f64>,
84    pub carbs_per_100g: Option<f64>,
85    pub fat_per_100g: Option<f64>,
86    pub default_serving_g: Option<f64>,
87    pub source: String,
88}
89
90#[derive(Debug, Clone)]
91pub struct NewMealEntry {
92    pub date: NaiveDate,
93    pub meal_type: String,
94    pub food_id: i64,
95    pub serving_g: f64,
96    pub display_unit: Option<String>,
97    pub display_quantity: Option<f64>,
98}
99
100#[derive(Debug, Clone)]
101pub struct UpdateMealEntry {
102    pub serving_g: Option<f64>,
103    pub meal_type: Option<String>,
104    pub date: Option<NaiveDate>,
105    pub display_unit: Option<Option<String>>,
106    pub display_quantity: Option<Option<f64>>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct DailyTarget {
111    pub day_of_week: i64,
112    pub calories: i64,
113    pub protein_pct: Option<i64>,
114    pub carbs_pct: Option<i64>,
115    pub fat_pct: Option<i64>,
116    #[serde(skip_deserializing)]
117    pub protein_g: Option<f64>,
118    #[serde(skip_deserializing)]
119    pub carbs_g: Option<f64>,
120    #[serde(skip_deserializing)]
121    pub fat_g: Option<f64>,
122}
123
124impl DailyTarget {
125    #[must_use]
126    #[allow(clippy::cast_precision_loss)]
127    pub fn from_db(
128        day_of_week: i64,
129        calories: i64,
130        protein_pct: Option<i64>,
131        carbs_pct: Option<i64>,
132        fat_pct: Option<i64>,
133    ) -> Self {
134        let cal = calories as f64;
135        let protein_g = protein_pct.map(|p| cal * p as f64 / 100.0 / 4.0);
136        let carbs_g = carbs_pct.map(|c| cal * c as f64 / 100.0 / 4.0);
137        let fat_g = fat_pct.map(|f| cal * f as f64 / 100.0 / 9.0);
138        Self {
139            day_of_week,
140            calories,
141            protein_pct,
142            carbs_pct,
143            fat_pct,
144            protein_g,
145            carbs_g,
146            fat_g,
147        }
148    }
149}
150
151pub fn validate_macro_split(protein: i64, carbs: i64, fat: i64) -> Result<()> {
152    if protein < 0 || carbs < 0 || fat < 0 {
153        bail!("Macro percentages must be non-negative");
154    }
155    if protein > 100 || carbs > 100 || fat > 100 {
156        bail!("Each macro percentage must be between 0 and 100");
157    }
158    let sum = protein + carbs + fat;
159    if sum != 100 {
160        bail!("Macro percentages must sum to 100 (got {sum})");
161    }
162    Ok(())
163}
164
165#[derive(Debug, Clone, Serialize)]
166pub struct Recipe {
167    pub id: i64,
168    #[serde(default)]
169    pub uuid: String,
170    pub food_id: i64,
171    pub portions: f64,
172    pub created_at: String,
173    #[serde(default)]
174    pub updated_at: String,
175}
176
177#[derive(Debug, Clone, Serialize)]
178pub struct RecipeIngredient {
179    pub id: i64,
180    #[serde(default)]
181    pub uuid: String,
182    pub recipe_id: i64,
183    pub food_id: i64,
184    pub quantity_g: f64,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub food_name: Option<String>,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub food_brand: Option<String>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub calories: Option<f64>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub protein: Option<f64>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub carbs: Option<f64>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub fat: Option<f64>,
197}
198
199#[derive(Debug, Clone, Serialize)]
200pub struct RecipeDetail {
201    pub id: i64,
202    #[serde(default)]
203    pub uuid: String,
204    pub food_id: i64,
205    pub name: String,
206    pub portions: f64,
207    pub total_weight_g: f64,
208    pub per_portion_g: f64,
209    pub ingredients: Vec<RecipeIngredient>,
210    pub per_portion_calories: f64,
211    pub per_portion_protein: f64,
212    pub per_portion_carbs: f64,
213    pub per_portion_fat: f64,
214    pub calories_per_100g: f64,
215    pub protein_per_100g: f64,
216    pub carbs_per_100g: f64,
217    pub fat_per_100g: f64,
218}
219
220// --- UX query types ---
221
222#[derive(Debug, Clone, Serialize)]
223pub struct RecentFood {
224    pub food: Food,
225    pub last_serving_g: f64,
226    pub last_meal_type: String,
227    pub log_count: i64,
228    pub last_logged: String,
229}
230
231// --- Weight tracking types ---
232
233#[derive(Debug, Clone, Serialize)]
234pub struct WeightEntry {
235    pub id: i64,
236    pub uuid: String,
237    pub date: NaiveDate,
238    pub weight_kg: f64,
239    pub source: String,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub notes: Option<String>,
242    pub created_at: String,
243    pub updated_at: String,
244}
245
246#[derive(Debug, Clone)]
247pub struct NewWeightEntry {
248    pub date: NaiveDate,
249    pub weight_kg: f64,
250    pub source: String,
251    pub notes: Option<String>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct ExportWeightEntry {
256    pub uuid: String,
257    pub date: String,
258    pub weight_kg: f64,
259    pub source: String,
260    #[serde(skip_serializing_if = "Option::is_none", default)]
261    pub notes: Option<String>,
262    pub created_at: String,
263    #[serde(default)]
264    pub updated_at: String,
265}
266
267// --- Export / Import types ---
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct ExportMealEntry {
271    pub id: i64,
272    #[serde(default)]
273    pub uuid: String,
274    pub date: String,
275    pub meal_type: String,
276    pub food_id: i64,
277    #[serde(default)]
278    pub food_uuid: String,
279    pub serving_g: f64,
280    #[serde(skip_serializing_if = "Option::is_none", default)]
281    pub display_unit: Option<String>,
282    #[serde(skip_serializing_if = "Option::is_none", default)]
283    pub display_quantity: Option<f64>,
284    pub created_at: String,
285    #[serde(default)]
286    pub updated_at: String,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct ExportRecipe {
291    pub id: i64,
292    #[serde(default)]
293    pub uuid: String,
294    pub food_id: i64,
295    #[serde(default)]
296    pub food_uuid: String,
297    pub portions: f64,
298    pub created_at: String,
299    #[serde(default)]
300    pub updated_at: String,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct ExportRecipeIngredient {
305    pub id: i64,
306    #[serde(default)]
307    pub uuid: String,
308    pub recipe_id: i64,
309    #[serde(default)]
310    pub recipe_uuid: String,
311    pub food_id: i64,
312    #[serde(default)]
313    pub food_uuid: String,
314    pub quantity_g: f64,
315}
316
317/// Legacy export target without `day_of_week` (for backward compatibility with old exports).
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct LegacyExportTarget {
320    pub calories: i64,
321    pub protein_pct: Option<i64>,
322    pub carbs_pct: Option<i64>,
323    pub fat_pct: Option<i64>,
324    #[serde(default)]
325    pub updated_at: Option<String>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct ExportTarget {
330    pub day_of_week: i64,
331    pub calories: i64,
332    pub protein_pct: Option<i64>,
333    pub carbs_pct: Option<i64>,
334    pub fat_pct: Option<i64>,
335    #[serde(default)]
336    pub updated_at: Option<String>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct ExportData {
341    pub version: i64,
342    pub exported_at: String,
343    #[serde(default)]
344    pub device_id: Option<String>,
345    pub foods: Vec<Food>,
346    pub meal_entries: Vec<ExportMealEntry>,
347    pub recipes: Vec<ExportRecipe>,
348    pub recipe_ingredients: Vec<ExportRecipeIngredient>,
349    #[serde(default, skip_serializing)]
350    pub target: Option<LegacyExportTarget>,
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub targets: Vec<ExportTarget>,
353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
354    pub weight_entries: Vec<ExportWeightEntry>,
355    #[serde(default)]
356    pub tombstones: Option<Vec<SyncTombstone>>,
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
360#[allow(clippy::struct_field_names)]
361pub struct ImportSummary {
362    pub foods_imported: i64,
363    pub meal_entries_imported: i64,
364    pub recipes_imported: i64,
365    pub recipe_ingredients_imported: i64,
366    pub targets_imported: i64,
367    pub weight_entries_imported: i64,
368    pub tombstones_processed: i64,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct SyncTombstone {
373    pub uuid: String,
374    pub table_name: String,
375    pub deleted_at: String,
376}
377
378// --- Delta sync types ---
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct SyncPayload {
382    pub foods: Vec<Food>,
383    pub meal_entries: Vec<ExportMealEntry>,
384    pub recipes: Vec<ExportRecipe>,
385    pub recipe_ingredients: Vec<ExportRecipeIngredient>,
386    pub targets: Vec<ExportTarget>,
387    pub weight_entries: Vec<ExportWeightEntry>,
388    pub tombstones: Vec<SyncTombstone>,
389    pub server_timestamp: String,
390}
391
392#[derive(Debug, Clone, Deserialize)]
393pub struct SyncPushRequest {
394    #[serde(default)]
395    pub since: Option<String>,
396    #[serde(default)]
397    pub foods: Vec<Food>,
398    #[serde(default)]
399    pub meal_entries: Vec<ExportMealEntry>,
400    #[serde(default)]
401    pub recipes: Vec<ExportRecipe>,
402    #[serde(default)]
403    pub recipe_ingredients: Vec<ExportRecipeIngredient>,
404    #[serde(default)]
405    pub targets: Vec<ExportTarget>,
406    #[serde(default)]
407    pub weight_entries: Vec<ExportWeightEntry>,
408    #[serde(default)]
409    pub tombstones: Vec<SyncTombstone>,
410}
411
412#[derive(Debug, Clone, Deserialize)]
413pub struct CooklangIngredient {
414    pub name: String,
415    pub quantity: Option<serde_json::Value>,
416    pub units: Option<String>,
417}
418
419/// Convert a quantity with a unit to grams.
420/// Volume-based conversions assume water density (1 ml = 1 g).
421/// Returns `(grams, is_approximate)` where `is_approximate` is true for volume conversions.
422#[must_use]
423pub fn convert_to_grams(quantity: f64, unit: &str) -> Option<(f64, bool)> {
424    let lower = unit.to_lowercase();
425    match lower.as_str() {
426        "g" | "gram" | "grams" => Some((quantity, false)),
427        "kg" | "kilogram" | "kilograms" => Some((quantity * 1000.0, false)),
428        "lb" | "lbs" | "pound" | "pounds" => Some((quantity * 454.0, false)),
429        "oz" | "ounce" | "ounces" => Some((quantity * 28.35, false)),
430        "tbsp" | "tablespoon" | "tablespoons" => Some((quantity * 15.0, true)),
431        "tsp" | "teaspoon" | "teaspoons" => Some((quantity * 5.0, true)),
432        "ml" | "milliliter" | "milliliters" | "millilitre" | "millilitres" => {
433            Some((quantity, true))
434        }
435        "l" | "liter" | "liters" | "litre" | "litres" => Some((quantity * 1000.0, true)),
436        _ => None,
437    }
438}
439
440pub const MEAL_TYPES: &[&str] = &["breakfast", "lunch", "dinner", "snack"];
441
442/// Valid table names for sync tombstones.
443pub const VALID_TOMBSTONE_TABLES: &[&str] =
444    &["foods", "meal_entries", "recipes", "recipe_ingredients"];
445
446pub fn validate_meal_type(meal: &str) -> anyhow::Result<String> {
447    let lower = meal.to_lowercase();
448    if MEAL_TYPES.contains(&lower.as_str()) {
449        Ok(lower)
450    } else {
451        anyhow::bail!(
452            "Invalid meal type '{meal}'. Must be one of: {}",
453            MEAL_TYPES.join(", ")
454        )
455    }
456}
457
458/// Validate a sync tombstone: `table_name` must be in the allowed list,
459/// `deleted_at` must be valid RFC 3339, and future timestamps are capped to now.
460pub fn validate_tombstone(tombstone: &mut SyncTombstone) -> anyhow::Result<()> {
461    if !VALID_TOMBSTONE_TABLES.contains(&tombstone.table_name.as_str()) {
462        anyhow::bail!(
463            "Invalid tombstone table_name '{}'. Must be one of: {}",
464            tombstone.table_name,
465            VALID_TOMBSTONE_TABLES.join(", ")
466        );
467    }
468    // Parse and validate timestamp, cap future-dated to now
469    let ts = chrono::DateTime::parse_from_rfc3339(&tombstone.deleted_at).map_err(|_| {
470        anyhow::anyhow!(
471            "Invalid tombstone deleted_at '{}'. Must be RFC 3339 format",
472            tombstone.deleted_at
473        )
474    })?;
475    let now = chrono::Utc::now();
476    if ts > now {
477        tombstone.deleted_at = now.to_rfc3339();
478    }
479    Ok(())
480}
481
482/// Validate imported food data: name must not be empty, calories must not be negative.
483pub fn validate_food_data(food: &Food) -> anyhow::Result<()> {
484    if food.name.trim().is_empty() {
485        anyhow::bail!("Food name must not be empty");
486    }
487    if food.calories_per_100g < 0.0 {
488        anyhow::bail!("calories_per_100g must not be negative");
489    }
490    if food.protein_per_100g.is_some_and(|v| v < 0.0) {
491        anyhow::bail!("protein_per_100g must not be negative");
492    }
493    if food.carbs_per_100g.is_some_and(|v| v < 0.0) {
494        anyhow::bail!("carbs_per_100g must not be negative");
495    }
496    if food.fat_per_100g.is_some_and(|v| v < 0.0) {
497        anyhow::bail!("fat_per_100g must not be negative");
498    }
499    Ok(())
500}
501
502/// Validate an imported meal entry: `meal_type` and `serving_g`.
503pub fn validate_meal_entry_data(meal_type: &str, serving_g: f64) -> anyhow::Result<()> {
504    validate_meal_type(meal_type)?;
505    if serving_g <= 0.0 {
506        anyhow::bail!("serving_g must be greater than 0");
507    }
508    Ok(())
509}
510
511/// Validate an exported/synced meal entry: `meal_type`, `serving_g`, and date format.
512pub fn validate_export_meal_entry(entry: &ExportMealEntry) -> anyhow::Result<()> {
513    validate_meal_entry_data(&entry.meal_type, entry.serving_g)?;
514    NaiveDate::parse_from_str(&entry.date, "%Y-%m-%d").map_err(|_| {
515        anyhow::anyhow!(
516            "Invalid meal entry date '{}'. Must be YYYY-MM-DD",
517            entry.date
518        )
519    })?;
520    Ok(())
521}
522
523/// Validate an exported/synced recipe: portions must be positive.
524pub fn validate_export_recipe(recipe: &ExportRecipe) -> anyhow::Result<()> {
525    if recipe.portions <= 0.0 {
526        anyhow::bail!("Recipe portions must be greater than 0");
527    }
528    Ok(())
529}
530
531/// Validate an exported/synced recipe ingredient: quantity must be positive.
532pub fn validate_export_recipe_ingredient(
533    ingredient: &ExportRecipeIngredient,
534) -> anyhow::Result<()> {
535    if ingredient.quantity_g <= 0.0 {
536        anyhow::bail!("Recipe ingredient quantity_g must be greater than 0");
537    }
538    Ok(())
539}
540
541/// Validate an exported/synced target: day 0-6, calories > 0, macro split if present.
542pub fn validate_export_target(target: &ExportTarget) -> anyhow::Result<()> {
543    if !(0..=6).contains(&target.day_of_week) {
544        anyhow::bail!("Target day_of_week must be between 0 (Monday) and 6 (Sunday)");
545    }
546    if target.calories <= 0 {
547        anyhow::bail!("Target calories must be greater than 0");
548    }
549    match (target.protein_pct, target.carbs_pct, target.fat_pct) {
550        (None, None, None) => {}
551        (Some(p), Some(c), Some(f)) => {
552            validate_macro_split(p, c, f)?;
553        }
554        _ => {
555            anyhow::bail!(
556                "If setting macro percentages, all three (protein_pct, carbs_pct, fat_pct) must be provided"
557            );
558        }
559    }
560    Ok(())
561}
562
563/// Validate an exported/synced weight entry: weight > 0, valid date.
564pub fn validate_export_weight_entry(entry: &ExportWeightEntry) -> anyhow::Result<()> {
565    if entry.weight_kg <= 0.0 {
566        anyhow::bail!("weight_kg must be greater than 0");
567    }
568    NaiveDate::parse_from_str(&entry.date, "%Y-%m-%d").map_err(|_| {
569        anyhow::anyhow!(
570            "Invalid weight entry date '{}'. Must be YYYY-MM-DD",
571            entry.date
572        )
573    })?;
574    Ok(())
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn test_valid_meal_types() {
583        assert_eq!(validate_meal_type("breakfast").unwrap(), "breakfast");
584        assert_eq!(validate_meal_type("lunch").unwrap(), "lunch");
585        assert_eq!(validate_meal_type("dinner").unwrap(), "dinner");
586        assert_eq!(validate_meal_type("snack").unwrap(), "snack");
587    }
588
589    #[test]
590    fn test_invalid_meal_type() {
591        assert!(validate_meal_type("brunch").is_err());
592        assert!(validate_meal_type("").is_err());
593    }
594
595    #[test]
596    fn test_meal_type_case_insensitive() {
597        assert_eq!(validate_meal_type("Lunch").unwrap(), "lunch");
598        assert_eq!(validate_meal_type("BREAKFAST").unwrap(), "breakfast");
599        assert_eq!(validate_meal_type("Dinner").unwrap(), "dinner");
600    }
601
602    #[test]
603    fn test_daily_target_from_db_with_macros() {
604        let target = DailyTarget::from_db(0, 1800, Some(40), Some(30), Some(30));
605        assert_eq!(target.day_of_week, 0);
606        assert_eq!(target.calories, 1800);
607        assert_eq!(target.protein_pct, Some(40));
608        assert_eq!(target.carbs_pct, Some(30));
609        assert_eq!(target.fat_pct, Some(30));
610        // 1800 * 40% / 4 = 180g protein
611        assert!((target.protein_g.unwrap() - 180.0).abs() < 0.01);
612        // 1800 * 30% / 4 = 135g carbs
613        assert!((target.carbs_g.unwrap() - 135.0).abs() < 0.01);
614        // 1800 * 30% / 9 = 60g fat
615        assert!((target.fat_g.unwrap() - 60.0).abs() < 0.01);
616    }
617
618    #[test]
619    fn test_daily_target_from_db_calories_only() {
620        let target = DailyTarget::from_db(3, 2000, None, None, None);
621        assert_eq!(target.day_of_week, 3);
622        assert_eq!(target.calories, 2000);
623        assert!(target.protein_g.is_none());
624        assert!(target.carbs_g.is_none());
625        assert!(target.fat_g.is_none());
626    }
627
628    #[test]
629    fn test_validate_macro_split_valid() {
630        assert!(validate_macro_split(40, 30, 30).is_ok());
631        assert!(validate_macro_split(33, 34, 33).is_ok());
632        assert!(validate_macro_split(100, 0, 0).is_ok());
633    }
634
635    #[test]
636    fn test_validate_macro_split_invalid_sum() {
637        assert!(validate_macro_split(40, 30, 20).is_err());
638        assert!(validate_macro_split(50, 50, 50).is_err());
639    }
640
641    #[test]
642    fn test_validate_macro_split_negative() {
643        assert!(validate_macro_split(-10, 60, 50).is_err());
644    }
645
646    #[test]
647    fn test_convert_to_grams_weight_units() {
648        let (g, approx) = convert_to_grams(1.0, "g").unwrap();
649        assert!((g - 1.0).abs() < f64::EPSILON);
650        assert!(!approx);
651
652        let (g, approx) = convert_to_grams(2.0, "kg").unwrap();
653        assert!((g - 2000.0).abs() < f64::EPSILON);
654        assert!(!approx);
655
656        let (g, _) = convert_to_grams(1.0, "lb").unwrap();
657        assert!((g - 454.0).abs() < f64::EPSILON);
658
659        let (g, _) = convert_to_grams(1.0, "oz").unwrap();
660        assert!((g - 28.35).abs() < f64::EPSILON);
661    }
662
663    #[test]
664    fn test_convert_to_grams_volume_units() {
665        let (g, approx) = convert_to_grams(1.0, "tbsp").unwrap();
666        assert!((g - 15.0).abs() < f64::EPSILON);
667        assert!(approx);
668
669        let (g, approx) = convert_to_grams(1.0, "tsp").unwrap();
670        assert!((g - 5.0).abs() < f64::EPSILON);
671        assert!(approx);
672
673        let (g, approx) = convert_to_grams(500.0, "ml").unwrap();
674        assert!((g - 500.0).abs() < f64::EPSILON);
675        assert!(approx);
676
677        let (g, _) = convert_to_grams(1.0, "l").unwrap();
678        assert!((g - 1000.0).abs() < f64::EPSILON);
679    }
680
681    #[test]
682    fn test_convert_to_grams_cups_not_supported() {
683        assert!(convert_to_grams(1.0, "cup").is_none());
684        assert!(convert_to_grams(1.0, "cups").is_none());
685    }
686
687    #[test]
688    fn test_convert_to_grams_case_insensitive() {
689        assert!(convert_to_grams(1.0, "G").is_some());
690        assert!(convert_to_grams(1.0, "Kg").is_some());
691        assert!(convert_to_grams(1.0, "TBSP").is_some());
692    }
693
694    #[test]
695    fn test_convert_to_grams_unknown_unit() {
696        assert!(convert_to_grams(1.0, "piece").is_none());
697        assert!(convert_to_grams(1.0, "").is_none());
698    }
699
700    #[test]
701    fn test_validate_tombstone_valid_tables() {
702        for table in VALID_TOMBSTONE_TABLES {
703            let mut t = SyncTombstone {
704                uuid: "test-uuid".to_string(),
705                table_name: table.to_string(),
706                deleted_at: "2024-01-01T00:00:00Z".to_string(),
707            };
708            assert!(validate_tombstone(&mut t).is_ok());
709        }
710    }
711
712    #[test]
713    fn test_validate_tombstone_invalid_table() {
714        let mut t = SyncTombstone {
715            uuid: "test-uuid".to_string(),
716            table_name: "users".to_string(),
717            deleted_at: "2024-01-01T00:00:00Z".to_string(),
718        };
719        assert!(validate_tombstone(&mut t).is_err());
720    }
721
722    #[test]
723    fn test_validate_tombstone_caps_future_timestamp() {
724        let mut t = SyncTombstone {
725            uuid: "test-uuid".to_string(),
726            table_name: "foods".to_string(),
727            deleted_at: "2099-01-01T00:00:00Z".to_string(),
728        };
729        validate_tombstone(&mut t).unwrap();
730        // Should be capped to approximately now, not 2099
731        assert!(t.deleted_at < "2099-01-01T00:00:00Z".to_string());
732    }
733
734    #[test]
735    fn test_validate_food_data_valid() {
736        let food = Food {
737            id: 1,
738            uuid: "test".to_string(),
739            name: "Chicken".to_string(),
740            brand: None,
741            barcode: None,
742            calories_per_100g: 165.0,
743            protein_per_100g: Some(31.0),
744            carbs_per_100g: Some(0.0),
745            fat_per_100g: Some(3.6),
746            default_serving_g: Some(100.0),
747            source: "manual".to_string(),
748            created_at: String::new(),
749            updated_at: String::new(),
750        };
751        assert!(validate_food_data(&food).is_ok());
752    }
753
754    #[test]
755    fn test_validate_food_data_empty_name() {
756        let food = Food {
757            id: 1,
758            uuid: "test".to_string(),
759            name: "  ".to_string(),
760            brand: None,
761            barcode: None,
762            calories_per_100g: 100.0,
763            protein_per_100g: None,
764            carbs_per_100g: None,
765            fat_per_100g: None,
766            default_serving_g: None,
767            source: "manual".to_string(),
768            created_at: String::new(),
769            updated_at: String::new(),
770        };
771        assert!(validate_food_data(&food).is_err());
772    }
773
774    #[test]
775    fn test_validate_food_data_negative_calories() {
776        let food = Food {
777            id: 1,
778            uuid: "test".to_string(),
779            name: "Bad Food".to_string(),
780            brand: None,
781            barcode: None,
782            calories_per_100g: -50.0,
783            protein_per_100g: None,
784            carbs_per_100g: None,
785            fat_per_100g: None,
786            default_serving_g: None,
787            source: "manual".to_string(),
788            created_at: String::new(),
789            updated_at: String::new(),
790        };
791        assert!(validate_food_data(&food).is_err());
792    }
793
794    #[test]
795    fn test_validate_meal_entry_data_valid() {
796        assert!(validate_meal_entry_data("lunch", 200.0).is_ok());
797    }
798
799    #[test]
800    fn test_validate_meal_entry_data_invalid_type() {
801        assert!(validate_meal_entry_data("brunch", 200.0).is_err());
802    }
803
804    #[test]
805    fn test_validate_meal_entry_data_zero_serving() {
806        assert!(validate_meal_entry_data("lunch", 0.0).is_err());
807    }
808
809    #[test]
810    fn test_validate_meal_entry_data_negative_serving() {
811        assert!(validate_meal_entry_data("lunch", -100.0).is_err());
812    }
813
814    #[test]
815    fn test_validate_tombstone_rejects_malformed_timestamp() {
816        let mut t = SyncTombstone {
817            uuid: "test-uuid".to_string(),
818            table_name: "foods".to_string(),
819            deleted_at: "not-a-date".to_string(),
820        };
821        assert!(validate_tombstone(&mut t).is_err());
822    }
823
824    #[test]
825    fn test_validate_export_meal_entry_valid() {
826        let entry = ExportMealEntry {
827            id: 1,
828            uuid: "test".to_string(),
829            date: "2024-06-15".to_string(),
830            meal_type: "lunch".to_string(),
831            food_id: 1,
832            food_uuid: String::new(),
833            serving_g: 200.0,
834            display_unit: None,
835            display_quantity: None,
836            created_at: String::new(),
837            updated_at: String::new(),
838        };
839        assert!(validate_export_meal_entry(&entry).is_ok());
840    }
841
842    #[test]
843    fn test_validate_export_meal_entry_invalid_date() {
844        let entry = ExportMealEntry {
845            id: 1,
846            uuid: "test".to_string(),
847            date: "not-a-date".to_string(),
848            meal_type: "lunch".to_string(),
849            food_id: 1,
850            food_uuid: String::new(),
851            serving_g: 200.0,
852            display_unit: None,
853            display_quantity: None,
854            created_at: String::new(),
855            updated_at: String::new(),
856        };
857        assert!(validate_export_meal_entry(&entry).is_err());
858    }
859
860    #[test]
861    fn test_validate_export_recipe_valid() {
862        let recipe = ExportRecipe {
863            id: 1,
864            uuid: "test".to_string(),
865            food_id: 1,
866            food_uuid: String::new(),
867            portions: 4.0,
868            created_at: String::new(),
869            updated_at: String::new(),
870        };
871        assert!(validate_export_recipe(&recipe).is_ok());
872    }
873
874    #[test]
875    fn test_validate_export_recipe_zero_portions() {
876        let recipe = ExportRecipe {
877            id: 1,
878            uuid: "test".to_string(),
879            food_id: 1,
880            food_uuid: String::new(),
881            portions: 0.0,
882            created_at: String::new(),
883            updated_at: String::new(),
884        };
885        assert!(validate_export_recipe(&recipe).is_err());
886    }
887
888    #[test]
889    fn test_validate_export_recipe_negative_portions() {
890        let recipe = ExportRecipe {
891            id: 1,
892            uuid: "test".to_string(),
893            food_id: 1,
894            food_uuid: String::new(),
895            portions: -1.0,
896            created_at: String::new(),
897            updated_at: String::new(),
898        };
899        assert!(validate_export_recipe(&recipe).is_err());
900    }
901
902    #[test]
903    fn test_validate_export_recipe_ingredient_valid() {
904        let ing = ExportRecipeIngredient {
905            id: 1,
906            uuid: "test".to_string(),
907            recipe_id: 1,
908            recipe_uuid: String::new(),
909            food_id: 1,
910            food_uuid: String::new(),
911            quantity_g: 100.0,
912        };
913        assert!(validate_export_recipe_ingredient(&ing).is_ok());
914    }
915
916    #[test]
917    fn test_validate_export_recipe_ingredient_zero() {
918        let ing = ExportRecipeIngredient {
919            id: 1,
920            uuid: "test".to_string(),
921            recipe_id: 1,
922            recipe_uuid: String::new(),
923            food_id: 1,
924            food_uuid: String::new(),
925            quantity_g: 0.0,
926        };
927        assert!(validate_export_recipe_ingredient(&ing).is_err());
928    }
929
930    #[test]
931    fn test_validate_export_target_valid() {
932        let target = ExportTarget {
933            day_of_week: 0,
934            calories: 2000,
935            protein_pct: Some(30),
936            carbs_pct: Some(40),
937            fat_pct: Some(30),
938            updated_at: None,
939        };
940        assert!(validate_export_target(&target).is_ok());
941    }
942
943    #[test]
944    fn test_validate_export_target_calories_only() {
945        let target = ExportTarget {
946            day_of_week: 3,
947            calories: 1800,
948            protein_pct: None,
949            carbs_pct: None,
950            fat_pct: None,
951            updated_at: None,
952        };
953        assert!(validate_export_target(&target).is_ok());
954    }
955
956    #[test]
957    fn test_validate_export_target_invalid_day() {
958        let target = ExportTarget {
959            day_of_week: 7,
960            calories: 2000,
961            protein_pct: None,
962            carbs_pct: None,
963            fat_pct: None,
964            updated_at: None,
965        };
966        assert!(validate_export_target(&target).is_err());
967    }
968
969    #[test]
970    fn test_validate_export_target_zero_calories() {
971        let target = ExportTarget {
972            day_of_week: 0,
973            calories: 0,
974            protein_pct: None,
975            carbs_pct: None,
976            fat_pct: None,
977            updated_at: None,
978        };
979        assert!(validate_export_target(&target).is_err());
980    }
981
982    #[test]
983    fn test_validate_export_target_partial_macros() {
984        let target = ExportTarget {
985            day_of_week: 0,
986            calories: 2000,
987            protein_pct: Some(30),
988            carbs_pct: None,
989            fat_pct: Some(30),
990            updated_at: None,
991        };
992        assert!(validate_export_target(&target).is_err());
993    }
994
995    #[test]
996    fn test_validate_export_target_macros_not_100() {
997        let target = ExportTarget {
998            day_of_week: 0,
999            calories: 2000,
1000            protein_pct: Some(30),
1001            carbs_pct: Some(30),
1002            fat_pct: Some(30),
1003            updated_at: None,
1004        };
1005        assert!(validate_export_target(&target).is_err());
1006    }
1007
1008    #[test]
1009    fn test_validate_export_weight_entry_valid() {
1010        let entry = ExportWeightEntry {
1011            uuid: "test".to_string(),
1012            date: "2024-06-15".to_string(),
1013            weight_kg: 75.0,
1014            source: "manual".to_string(),
1015            notes: None,
1016            created_at: String::new(),
1017            updated_at: String::new(),
1018        };
1019        assert!(validate_export_weight_entry(&entry).is_ok());
1020    }
1021
1022    #[test]
1023    fn test_validate_export_weight_entry_zero_weight() {
1024        let entry = ExportWeightEntry {
1025            uuid: "test".to_string(),
1026            date: "2024-06-15".to_string(),
1027            weight_kg: 0.0,
1028            source: "manual".to_string(),
1029            notes: None,
1030            created_at: String::new(),
1031            updated_at: String::new(),
1032        };
1033        assert!(validate_export_weight_entry(&entry).is_err());
1034    }
1035
1036    #[test]
1037    fn test_validate_export_weight_entry_invalid_date() {
1038        let entry = ExportWeightEntry {
1039            uuid: "test".to_string(),
1040            date: "bad-date".to_string(),
1041            weight_kg: 75.0,
1042            source: "manual".to_string(),
1043            notes: None,
1044            created_at: String::new(),
1045            updated_at: String::new(),
1046        };
1047        assert!(validate_export_weight_entry(&entry).is_err());
1048    }
1049}