Skip to main content

grub_core/
service.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use anyhow::Result;
5use chrono::NaiveDate;
6
7use crate::db::Database;
8use crate::mfp_import::{self, MfpImportSummary};
9use crate::models::{
10    DailySummary, DailyTarget, ExportData, Food, ImportSummary, MealEntry, NewFood, NewMealEntry,
11    NewWeightEntry, RecentFood, Recipe, RecipeDetail, RecipeIngredient, SyncPayload,
12    SyncPushRequest, UpdateMealEntry, WeightEntry,
13};
14
15/// Platform-native food lookup provider.
16///
17/// iOS implements this with `URLSession`, Android with Ktor, CLI with reqwest.
18/// Called synchronously from Rust — mobile callers should invoke `GrubService`
19/// methods from a background thread.
20pub trait FoodLookupProvider: Send + Sync {
21    fn search(&self, query: &str) -> Result<Vec<NewFood>>;
22    fn lookup_barcode(&self, barcode: &str) -> Result<Option<NewFood>>;
23}
24
25pub struct GrubService {
26    db: Database,
27}
28
29impl GrubService {
30    pub fn new(db_path: &str) -> Result<Self> {
31        let db = Database::open(Path::new(db_path))?;
32        Ok(Self { db })
33    }
34
35    pub fn new_in_memory() -> Result<Self> {
36        let db = Database::open_in_memory()?;
37        Ok(Self { db })
38    }
39
40    // --- Direct DB operations ---
41
42    pub fn get_daily_summary(&self, date: &str) -> Result<DailySummary> {
43        let date = NaiveDate::parse_from_str(date, "%Y-%m-%d")?;
44        self.db.build_daily_summary(date)
45    }
46
47    pub fn log_meal(
48        &self,
49        date: &str,
50        meal_type: &str,
51        food_id: i64,
52        serving_g: f64,
53    ) -> Result<MealEntry> {
54        self.log_meal_with_display(date, meal_type, food_id, serving_g, None, None)
55    }
56
57    pub fn log_meal_with_display(
58        &self,
59        date: &str,
60        meal_type: &str,
61        food_id: i64,
62        serving_g: f64,
63        display_unit: Option<String>,
64        display_quantity: Option<f64>,
65    ) -> Result<MealEntry> {
66        let date = NaiveDate::parse_from_str(date, "%Y-%m-%d")?;
67        let meal_type = crate::models::validate_meal_type(meal_type)?;
68        self.db.insert_meal_entry(&NewMealEntry {
69            date,
70            meal_type,
71            food_id,
72            serving_g,
73            display_unit,
74            display_quantity,
75        })
76    }
77
78    pub fn delete_meal(&self, id: i64) -> Result<bool> {
79        // Record tombstone before deleting
80        if let Ok(Some(uuid)) = self.db.get_meal_entry_uuid(id) {
81            self.db.record_tombstone(&uuid, "meal_entries")?;
82        }
83        self.db.delete_meal_entry(id)
84    }
85
86    pub fn update_meal(&self, id: i64, update: &UpdateMealEntry) -> Result<MealEntry> {
87        self.db.update_meal_entry(id, update)
88    }
89
90    pub fn get_meal_entry(&self, id: i64) -> Result<MealEntry> {
91        self.db.get_meal_entry(id)
92    }
93
94    pub fn get_food_by_id(&self, id: i64) -> Result<Food> {
95        self.db.get_food_by_id(id)
96    }
97
98    pub fn get_food_by_barcode(&self, barcode: &str) -> Result<Option<Food>> {
99        self.db.get_food_by_barcode(barcode)
100    }
101
102    pub fn search_foods_local(&self, query: &str) -> Result<Vec<Food>> {
103        self.db.search_foods_local(query)
104    }
105
106    pub fn list_foods(&self, search: Option<&str>) -> Result<Vec<Food>> {
107        self.db.list_foods(search)
108    }
109
110    pub fn insert_food(&self, food: &NewFood) -> Result<Food> {
111        self.db.insert_food(food)
112    }
113
114    pub fn upsert_food_by_barcode(&self, food: &NewFood) -> Result<Food> {
115        self.db.upsert_food_by_barcode(food)
116    }
117
118    // --- Targets ---
119
120    pub fn set_target(
121        &self,
122        day_of_week: i64,
123        calories: i64,
124        protein_pct: Option<i64>,
125        carbs_pct: Option<i64>,
126        fat_pct: Option<i64>,
127    ) -> Result<DailyTarget> {
128        self.db
129            .set_target(day_of_week, calories, protein_pct, carbs_pct, fat_pct)
130    }
131
132    pub fn get_target(&self, day_of_week: i64) -> Result<Option<DailyTarget>> {
133        self.db.get_target(day_of_week)
134    }
135
136    pub fn get_all_targets(&self) -> Result<Vec<DailyTarget>> {
137        self.db.get_all_targets()
138    }
139
140    pub fn clear_target(&self, day_of_week: i64) -> Result<bool> {
141        self.db.clear_target(day_of_week)
142    }
143
144    pub fn clear_all_targets(&self) -> Result<bool> {
145        self.db.clear_all_targets()
146    }
147
148    // --- Recipes ---
149
150    pub fn create_recipe(&self, name: &str, portions: f64) -> Result<Recipe> {
151        self.db.create_recipe(name, portions)
152    }
153
154    pub fn get_recipe_detail(&self, recipe_id: i64) -> Result<RecipeDetail> {
155        self.db.get_recipe_detail(recipe_id)
156    }
157
158    pub fn get_recipe_by_food_name(&self, name: &str) -> Result<Recipe> {
159        self.db.get_recipe_by_food_name(name)
160    }
161
162    pub fn add_recipe_ingredient(
163        &self,
164        recipe_id: i64,
165        food_id: i64,
166        quantity_g: f64,
167    ) -> Result<RecipeIngredient> {
168        self.db
169            .add_recipe_ingredient(recipe_id, food_id, quantity_g)
170    }
171
172    pub fn remove_recipe_ingredient(&self, recipe_id: i64, food_name: &str) -> Result<bool> {
173        self.db.remove_recipe_ingredient(recipe_id, food_name)
174    }
175
176    pub fn set_recipe_portions(&self, recipe_id: i64, portions: f64) -> Result<()> {
177        self.db.set_recipe_portions(recipe_id, portions)
178    }
179
180    pub fn list_recipes(&self) -> Result<Vec<RecipeDetail>> {
181        self.db.list_recipes()
182    }
183
184    pub fn delete_recipe(&self, recipe_id: i64) -> Result<()> {
185        // Record tombstones for recipe, its ingredients, and its virtual food
186        if let Ok(Some(recipe_uuid)) = self.db.get_recipe_uuid(recipe_id) {
187            self.db.record_tombstone(&recipe_uuid, "recipes")?;
188        }
189        if let Ok(ingredient_uuids) = self.db.get_recipe_ingredient_uuids(recipe_id) {
190            for uuid in ingredient_uuids {
191                self.db.record_tombstone(&uuid, "recipe_ingredients")?;
192            }
193        }
194        if let Ok(recipe) = self.db.get_recipe_by_id(recipe_id) {
195            let food = self.db.get_food_by_id(recipe.food_id)?;
196            self.db.record_tombstone(&food.uuid, "foods")?;
197        }
198        self.db.delete_recipe(recipe_id)
199    }
200
201    // --- Weight ---
202
203    pub fn log_weight(&self, entry: &NewWeightEntry) -> Result<WeightEntry> {
204        self.db.upsert_weight(entry)
205    }
206
207    pub fn get_weight(&self, date: NaiveDate) -> Result<Option<WeightEntry>> {
208        self.db.get_weight(date)
209    }
210
211    pub fn get_weight_history(&self, days: Option<i64>) -> Result<Vec<WeightEntry>> {
212        self.db.get_weight_history(days)
213    }
214
215    pub fn delete_weight(&self, id: i64) -> Result<()> {
216        self.db.delete_weight(id)
217    }
218
219    // --- UX queries ---
220
221    pub fn get_recently_logged_foods(&self, limit: i64) -> Result<Vec<RecentFood>> {
222        self.db.get_recently_logged_foods(limit)
223    }
224
225    pub fn get_logging_streak(&self) -> Result<i64> {
226        let today = chrono::Local::now().date_naive();
227        self.db.get_logging_streak(today)
228    }
229
230    pub fn get_calorie_average(&self, days: i64) -> Result<f64> {
231        self.db.get_calorie_average(days)
232    }
233
234    // --- Goal weight ---
235
236    pub fn set_goal_weight(&self, kg: f64) -> Result<()> {
237        self.db.set_setting("goal_weight_kg", &kg.to_string())
238    }
239
240    pub fn get_goal_weight(&self) -> Result<Option<f64>> {
241        match self.db.get_setting("goal_weight_kg")? {
242            Some(v) => Ok(Some(v.parse::<f64>()?)),
243            None => Ok(None),
244        }
245    }
246
247    pub fn clear_goal_weight(&self) -> Result<bool> {
248        self.db.delete_setting("goal_weight_kg")
249    }
250
251    // --- Orchestrated lookups (search local, call provider if needed, cache results) ---
252
253    /// Search local DB first, then call the provider for remote results, cache them, and
254    /// return a deduplicated list.
255    pub fn search_and_cache(
256        &self,
257        provider: &dyn FoodLookupProvider,
258        query: &str,
259    ) -> Result<Vec<Food>> {
260        let local = self.db.search_foods_local(query)?;
261        let remote = provider.search(query)?;
262
263        let mut cached_remote: Vec<Food> = Vec::new();
264        for food in &remote {
265            if let Ok(f) = self.db.upsert_food_by_barcode(food) {
266                cached_remote.push(f);
267            } else {
268                let mut no_barcode = food.clone();
269                no_barcode.barcode = None;
270                if let Ok(f) = self.db.insert_food(&no_barcode) {
271                    cached_remote.push(f);
272                }
273            }
274        }
275
276        let mut all = local;
277        let seen: HashSet<i64> = all.iter().map(|f| f.id).collect();
278        for f in cached_remote {
279            if !seen.contains(&f.id) {
280                all.push(f);
281            }
282        }
283
284        Ok(all)
285    }
286
287    /// Look up a barcode: check local cache first, then call the provider, cache and return.
288    pub fn barcode_lookup(
289        &self,
290        provider: &dyn FoodLookupProvider,
291        code: &str,
292    ) -> Result<Option<Food>> {
293        if let Some(cached) = self.db.get_food_by_barcode(code)? {
294            return Ok(Some(cached));
295        }
296
297        let remote = provider.lookup_barcode(code)?;
298        match remote {
299            Some(new_food) => {
300                let food = self.db.upsert_food_by_barcode(&new_food)?;
301                Ok(Some(food))
302            }
303            None => Ok(None),
304        }
305    }
306
307    // --- Sync ---
308
309    pub fn get_device_id(&self) -> Result<String> {
310        self.db.get_or_create_device_id()
311    }
312
313    pub fn clear_tombstones(&self) -> Result<()> {
314        self.db.clear_tombstones()
315    }
316
317    // --- Delta sync ---
318
319    pub fn changes_since(&self, since: Option<&str>) -> Result<SyncPayload> {
320        let server_timestamp = chrono::Utc::now().to_rfc3339();
321        self.db.changes_since(since, &server_timestamp)
322    }
323
324    pub fn apply_remote_changes(&self, request: &SyncPushRequest) -> Result<SyncPayload> {
325        let server_timestamp = chrono::Utc::now().to_rfc3339();
326        // Get server's changes BEFORE applying client changes (avoids echoing)
327        let delta = self
328            .db
329            .changes_since(request.since.as_deref(), &server_timestamp)?;
330        // Apply client changes with LWW
331        self.db.apply_remote_changes(
332            &request.foods,
333            &request.meal_entries,
334            &request.recipes,
335            &request.recipe_ingredients,
336            &request.targets,
337            &request.weight_entries,
338            &request.tombstones,
339        )?;
340        Ok(delta)
341    }
342
343    // --- MFP import ---
344
345    pub fn import_mfp_csv(&self, csv_data: &str, dry_run: bool) -> Result<MfpImportSummary> {
346        let rows = mfp_import::parse_mfp_csv(csv_data.as_bytes())?;
347        mfp_import::import_mfp_meals(&self.db, &rows, dry_run)
348    }
349
350    // --- Export / Import ---
351
352    pub fn export_all(&self) -> Result<ExportData> {
353        self.db.export_all()
354    }
355
356    pub fn import_all(&self, data: &ExportData) -> Result<ImportSummary> {
357        self.db.import_all(data)
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    struct MockProvider {
366        foods: Vec<NewFood>,
367    }
368
369    impl FoodLookupProvider for MockProvider {
370        fn search(&self, _query: &str) -> Result<Vec<NewFood>> {
371            Ok(self.foods.clone())
372        }
373
374        fn lookup_barcode(&self, barcode: &str) -> Result<Option<NewFood>> {
375            Ok(self
376                .foods
377                .iter()
378                .find(|f| f.barcode.as_deref() == Some(barcode))
379                .cloned())
380        }
381    }
382
383    fn sample_food() -> NewFood {
384        NewFood {
385            name: "Test Food".to_string(),
386            brand: Some("Brand".to_string()),
387            barcode: Some("1234567890".to_string()),
388            calories_per_100g: 100.0,
389            protein_per_100g: Some(10.0),
390            carbs_per_100g: Some(20.0),
391            fat_per_100g: Some(5.0),
392            default_serving_g: Some(100.0),
393            source: "openfoodfacts".to_string(),
394        }
395    }
396
397    #[test]
398    fn test_search_and_cache() {
399        let svc = GrubService::new_in_memory().unwrap();
400        let provider = MockProvider {
401            foods: vec![sample_food()],
402        };
403
404        let results = svc.search_and_cache(&provider, "test").unwrap();
405        assert_eq!(results.len(), 1);
406        assert_eq!(results[0].name, "Test Food");
407
408        // Second search should return cached result without hitting provider
409        let empty_provider = MockProvider { foods: vec![] };
410        let results = svc.search_and_cache(&empty_provider, "test").unwrap();
411        assert_eq!(results.len(), 1);
412        assert_eq!(results[0].name, "Test Food");
413    }
414
415    #[test]
416    fn test_barcode_lookup_cache() {
417        let svc = GrubService::new_in_memory().unwrap();
418        let provider = MockProvider {
419            foods: vec![sample_food()],
420        };
421
422        let food = svc
423            .barcode_lookup(&provider, "1234567890")
424            .unwrap()
425            .unwrap();
426        assert_eq!(food.name, "Test Food");
427
428        // Should be cached now
429        let empty_provider = MockProvider { foods: vec![] };
430        let cached = svc
431            .barcode_lookup(&empty_provider, "1234567890")
432            .unwrap()
433            .unwrap();
434        assert_eq!(cached.id, food.id);
435    }
436
437    #[test]
438    fn test_barcode_lookup_not_found() {
439        let svc = GrubService::new_in_memory().unwrap();
440        let provider = MockProvider { foods: vec![] };
441
442        let result = svc.barcode_lookup(&provider, "0000000000").unwrap();
443        assert!(result.is_none());
444    }
445
446    #[test]
447    fn test_log_meal_and_summary() {
448        let svc = GrubService::new_in_memory().unwrap();
449        let food = svc.insert_food(&sample_food()).unwrap();
450
451        let entry = svc.log_meal("2024-06-15", "lunch", food.id, 200.0).unwrap();
452        assert_eq!(entry.meal_type, "lunch");
453        assert_eq!(entry.serving_g, 200.0);
454
455        let summary = svc.get_daily_summary("2024-06-15").unwrap();
456        assert_eq!(summary.meals.len(), 1);
457        assert!((summary.total_calories - 200.0).abs() < 0.01);
458    }
459
460    #[test]
461    fn test_goal_weight_set_get_clear() {
462        let svc = GrubService::new_in_memory().unwrap();
463
464        // Initially none
465        assert!(svc.get_goal_weight().unwrap().is_none());
466
467        // Set
468        svc.set_goal_weight(75.0).unwrap();
469        let gw = svc.get_goal_weight().unwrap().unwrap();
470        assert!((gw - 75.0).abs() < f64::EPSILON);
471
472        // Update
473        svc.set_goal_weight(70.0).unwrap();
474        let gw = svc.get_goal_weight().unwrap().unwrap();
475        assert!((gw - 70.0).abs() < f64::EPSILON);
476
477        // Clear
478        assert!(svc.clear_goal_weight().unwrap());
479        assert!(svc.get_goal_weight().unwrap().is_none());
480
481        // Clear again returns false
482        assert!(!svc.clear_goal_weight().unwrap());
483    }
484}