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 #[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#[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#[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#[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#[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#[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#[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
442pub 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
458pub 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 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
482pub 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
502pub 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
511pub 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
523pub 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
531pub 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
541pub 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
563pub 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 assert!((target.protein_g.unwrap() - 180.0).abs() < 0.01);
612 assert!((target.carbs_g.unwrap() - 135.0).abs() < 0.01);
614 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 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}