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
15pub 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 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 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 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 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 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 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 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 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 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 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 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 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 let delta = self
328 .db
329 .changes_since(request.since.as_deref(), &server_timestamp)?;
330 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 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 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 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 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 assert!(svc.get_goal_weight().unwrap().is_none());
466
467 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 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 assert!(svc.clear_goal_weight().unwrap());
479 assert!(svc.get_goal_weight().unwrap().is_none());
480
481 assert!(!svc.clear_goal_weight().unwrap());
483 }
484}