1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use chrono::{Datelike, Local, NaiveDate};
6use rusqlite::{Connection, params};
7use uuid::Uuid;
8
9use crate::models::{
10 DailySummary, DailyTarget, ExportData, ExportMealEntry, ExportRecipe, ExportRecipeIngredient,
11 ExportTarget, ExportWeightEntry, Food, ImportSummary, MEAL_TYPES, MealEntry, MealGroup,
12 NewFood, NewMealEntry, NewWeightEntry, RecentFood, Recipe, RecipeDetail, RecipeIngredient,
13 SyncPayload, SyncTombstone, UpdateMealEntry, WeightEntry,
14};
15
16pub struct Database {
17 conn: Connection,
18}
19
20impl Database {
21 pub fn open(path: &Path) -> Result<Self> {
22 let conn = Connection::open(path)
23 .with_context(|| format!("Failed to open database: {}", path.display()))?;
24 let db = Database { conn };
25 db.migrate()?;
26 Ok(db)
27 }
28
29 pub fn open_in_memory() -> Result<Self> {
30 let conn = Connection::open_in_memory()?;
31 let db = Database { conn };
32 db.migrate()?;
33 Ok(db)
34 }
35
36 #[allow(clippy::too_many_lines)]
37 fn migrate(&self) -> Result<()> {
38 let version: i64 = self
39 .conn
40 .pragma_query_value(None, "user_version", |row| row.get(0))?;
41
42 if version < 1 {
43 self.conn.execute_batch(
44 "CREATE TABLE IF NOT EXISTS foods (
45 id INTEGER PRIMARY KEY AUTOINCREMENT,
46 name TEXT NOT NULL,
47 brand TEXT,
48 barcode TEXT UNIQUE,
49 calories_per_100g REAL NOT NULL,
50 protein_per_100g REAL,
51 carbs_per_100g REAL,
52 fat_per_100g REAL,
53 default_serving_g REAL,
54 source TEXT NOT NULL,
55 created_at TEXT NOT NULL
56 );
57
58 CREATE TABLE IF NOT EXISTS meal_entries (
59 id INTEGER PRIMARY KEY AUTOINCREMENT,
60 date TEXT NOT NULL,
61 meal_type TEXT NOT NULL,
62 food_id INTEGER NOT NULL REFERENCES foods(id),
63 serving_g REAL NOT NULL,
64 created_at TEXT NOT NULL
65 );
66
67 CREATE TABLE IF NOT EXISTS recipes (
68 id INTEGER PRIMARY KEY AUTOINCREMENT,
69 food_id INTEGER NOT NULL UNIQUE REFERENCES foods(id),
70 portions REAL NOT NULL DEFAULT 1.0,
71 created_at TEXT NOT NULL
72 );
73
74 CREATE TABLE IF NOT EXISTS recipe_ingredients (
75 id INTEGER PRIMARY KEY AUTOINCREMENT,
76 recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
77 food_id INTEGER NOT NULL REFERENCES foods(id),
78 quantity_g REAL NOT NULL
79 );
80
81 CREATE INDEX IF NOT EXISTS idx_meal_entries_date ON meal_entries(date);
82 CREATE INDEX IF NOT EXISTS idx_foods_barcode ON foods(barcode);
83 CREATE INDEX IF NOT EXISTS idx_foods_name ON foods(name);
84 CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe ON recipe_ingredients(recipe_id);
85
86 CREATE TABLE IF NOT EXISTS targets (
87 day_of_week INTEGER PRIMARY KEY CHECK (day_of_week BETWEEN 0 AND 6),
88 calories INTEGER NOT NULL,
89 protein_pct INTEGER,
90 carbs_pct INTEGER,
91 fat_pct INTEGER,
92 updated_at TEXT NOT NULL
93 );
94
95 PRAGMA user_version = 1;",
96 )?;
97 }
98
99 if version < 2 {
100 self.conn.execute_batch(
102 "ALTER TABLE foods ADD COLUMN uuid TEXT;
103 ALTER TABLE foods ADD COLUMN updated_at TEXT;
104 ALTER TABLE meal_entries ADD COLUMN uuid TEXT;
105 ALTER TABLE meal_entries ADD COLUMN updated_at TEXT;
106 ALTER TABLE recipes ADD COLUMN uuid TEXT;
107 ALTER TABLE recipes ADD COLUMN updated_at TEXT;
108 ALTER TABLE recipe_ingredients ADD COLUMN uuid TEXT;
109 ALTER TABLE recipe_ingredients ADD COLUMN updated_at TEXT;",
110 )?;
111
112 let now = Local::now().to_rfc3339();
114 for table in &["foods", "meal_entries", "recipes"] {
115 let ids: Vec<i64> = {
116 let mut stmt = self.conn.prepare(&format!("SELECT id FROM {table}"))?;
117 stmt.query_map([], |row| row.get(0))?
118 .collect::<Result<Vec<_>, _>>()?
119 };
120 for id in ids {
121 let uuid = Uuid::new_v4().to_string();
122 let created_at: Option<String> = self
124 .conn
125 .query_row(
126 &format!("SELECT created_at FROM {table} WHERE id = ?1"),
127 params![id],
128 |row| row.get(0),
129 )
130 .ok();
131 let updated_at = created_at.unwrap_or_else(|| now.clone());
132 self.conn.execute(
133 &format!("UPDATE {table} SET uuid = ?1, updated_at = ?2 WHERE id = ?3"),
134 params![uuid, updated_at, id],
135 )?;
136 }
137 }
138 {
140 let ids: Vec<i64> = {
141 let mut stmt = self.conn.prepare("SELECT id FROM recipe_ingredients")?;
142 stmt.query_map([], |row| row.get(0))?
143 .collect::<Result<Vec<_>, _>>()?
144 };
145 for id in ids {
146 let uuid = Uuid::new_v4().to_string();
147 self.conn.execute(
148 "UPDATE recipe_ingredients SET uuid = ?1, updated_at = ?2 WHERE id = ?3",
149 params![uuid, now, id],
150 )?;
151 }
152 }
153
154 self.conn.execute_batch(
156 "CREATE UNIQUE INDEX idx_foods_uuid ON foods(uuid);
157 CREATE UNIQUE INDEX idx_meal_entries_uuid ON meal_entries(uuid);
158 CREATE UNIQUE INDEX idx_recipes_uuid ON recipes(uuid);
159 CREATE UNIQUE INDEX idx_recipe_ingredients_uuid ON recipe_ingredients(uuid);
160
161 CREATE TABLE sync_tombstones (
162 uuid TEXT NOT NULL,
163 table_name TEXT NOT NULL,
164 deleted_at TEXT NOT NULL
165 );
166 CREATE INDEX idx_tombstones_uuid ON sync_tombstones(uuid);
167
168 CREATE TABLE config (
169 key TEXT PRIMARY KEY,
170 value TEXT NOT NULL
171 );
172
173 PRAGMA user_version = 2;",
174 )?;
175 }
176
177 if version < 3 {
178 self.conn.execute_batch(
179 "ALTER TABLE meal_entries ADD COLUMN display_unit TEXT;
180 ALTER TABLE meal_entries ADD COLUMN display_quantity REAL;
181 PRAGMA user_version = 3;",
182 )?;
183 }
184
185 if version < 4 {
186 let has_old_schema: bool = self.conn.prepare("SELECT id FROM targets LIMIT 0").is_ok();
189
190 if has_old_schema {
191 self.conn.execute_batch(
193 "CREATE TABLE targets_new (
194 day_of_week INTEGER PRIMARY KEY CHECK (day_of_week BETWEEN 0 AND 6),
195 calories INTEGER NOT NULL,
196 protein_pct INTEGER,
197 carbs_pct INTEGER,
198 fat_pct INTEGER,
199 updated_at TEXT NOT NULL
200 );
201
202 INSERT OR IGNORE INTO targets_new (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
203 SELECT d.day, t.calories, t.protein_pct, t.carbs_pct, t.fat_pct, t.updated_at
204 FROM targets t, (SELECT 0 AS day UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6) d
205 WHERE t.id = 1;
206
207 DROP TABLE targets;
208 ALTER TABLE targets_new RENAME TO targets;",
209 )?;
210 }
211
212 self.conn.execute_batch("PRAGMA user_version = 4;")?;
213 }
214
215 if version < 5 {
216 self.conn.execute_batch(
217 "CREATE TABLE IF NOT EXISTS weight_entries (
218 id INTEGER PRIMARY KEY,
219 uuid TEXT NOT NULL DEFAULT (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6)))),
220 date TEXT NOT NULL UNIQUE,
221 weight_kg REAL NOT NULL,
222 source TEXT NOT NULL DEFAULT 'manual',
223 notes TEXT,
224 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
225 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
226 );
227
228 PRAGMA user_version = 5;",
229 )?;
230 }
231
232 if version < 6 {
233 self.conn.execute_batch(
234 "CREATE TABLE IF NOT EXISTS user_settings (
235 key TEXT PRIMARY KEY NOT NULL,
236 value TEXT NOT NULL,
237 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
238 );
239
240 PRAGMA user_version = 6;",
241 )?;
242 }
243
244 Ok(())
245 }
246
247 fn food_from_row(row: &rusqlite::Row) -> rusqlite::Result<Food> {
250 Ok(Food {
251 id: row.get(0)?,
252 name: row.get(1)?,
253 brand: row.get(2)?,
254 barcode: row.get(3)?,
255 calories_per_100g: row.get(4)?,
256 protein_per_100g: row.get(5)?,
257 carbs_per_100g: row.get(6)?,
258 fat_per_100g: row.get(7)?,
259 default_serving_g: row.get(8)?,
260 source: row.get(9)?,
261 created_at: row.get(10)?,
262 uuid: row.get::<_, Option<String>>(11)?.unwrap_or_default(),
263 updated_at: row.get::<_, Option<String>>(12)?.unwrap_or_default(),
264 })
265 }
266
267 fn meal_entry_from_row(row: &rusqlite::Row) -> rusqlite::Result<MealEntry> {
274 let serving_g: f64 = row.get(5)?;
275 let cal_100: f64 = row.get(12)?;
276 let pro_100: Option<f64> = row.get(13)?;
277 let carb_100: Option<f64> = row.get(14)?;
278 let fat_100: Option<f64> = row.get(15)?;
279 Ok(MealEntry {
280 id: row.get(0)?,
281 uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
282 date: row.get(2)?,
283 meal_type: row.get(3)?,
284 food_id: row.get(4)?,
285 serving_g,
286 display_unit: row.get(6)?,
287 display_quantity: row.get(7)?,
288 created_at: row.get(8)?,
289 updated_at: row.get::<_, Option<String>>(9)?.unwrap_or_default(),
290 food_name: Some(row.get(10)?),
291 food_brand: row.get(11)?,
292 calories: Some(cal_100 * serving_g / 100.0),
293 protein: pro_100.map(|v| v * serving_g / 100.0),
294 carbs: carb_100.map(|v| v * serving_g / 100.0),
295 fat: fat_100.map(|v| v * serving_g / 100.0),
296 })
297 }
298
299 pub fn insert_food(&self, food: &NewFood) -> Result<Food> {
302 let now = Local::now().to_rfc3339();
303 let uuid = Uuid::new_v4().to_string();
304 self.conn.execute(
305 "INSERT INTO foods (name, brand, barcode, calories_per_100g, protein_per_100g, carbs_per_100g, fat_per_100g, default_serving_g, source, created_at, uuid, updated_at)
306 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
307 params![
308 food.name,
309 food.brand,
310 food.barcode,
311 food.calories_per_100g,
312 food.protein_per_100g,
313 food.carbs_per_100g,
314 food.fat_per_100g,
315 food.default_serving_g,
316 food.source,
317 now,
318 uuid,
319 now,
320 ],
321 )?;
322 let id = self.conn.last_insert_rowid();
323 self.get_food_by_id(id)
324 }
325
326 pub fn upsert_food_by_barcode(&self, food: &NewFood) -> Result<Food> {
327 if let Some(barcode) = &food.barcode {
328 if let Some(existing) = self.get_food_by_barcode(barcode)? {
329 return Ok(existing);
330 }
331 }
332 self.insert_food(food)
333 }
334
335 pub fn get_food_by_id(&self, id: i64) -> Result<Food> {
336 self.conn
337 .query_row(
338 "SELECT * FROM foods WHERE id = ?1",
339 params![id],
340 Self::food_from_row,
341 )
342 .context("Food not found")
343 }
344
345 pub fn get_food_by_barcode(&self, barcode: &str) -> Result<Option<Food>> {
346 let mut stmt = self
347 .conn
348 .prepare("SELECT * FROM foods WHERE barcode = ?1")?;
349 let mut rows = stmt.query(params![barcode])?;
350 if let Some(row) = rows.next()? {
351 Ok(Some(Self::food_from_row(row)?))
352 } else {
353 Ok(None)
354 }
355 }
356
357 pub fn search_foods_local(&self, query: &str) -> Result<Vec<Food>> {
358 let escaped = query
359 .replace('\\', "\\\\")
360 .replace('%', "\\%")
361 .replace('_', "\\_");
362 let pattern = format!("%{escaped}%");
363 let mut stmt = self.conn.prepare(
364 "SELECT * FROM foods WHERE name LIKE ?1 ESCAPE '\\' OR brand LIKE ?1 ESCAPE '\\' ORDER BY name LIMIT 20",
365 )?;
366 let foods = stmt
367 .query_map(params![pattern], Self::food_from_row)?
368 .collect::<Result<Vec<_>, _>>()?;
369 Ok(foods)
370 }
371
372 pub fn list_foods(&self, search: Option<&str>) -> Result<Vec<Food>> {
373 if let Some(query) = search {
374 return self.search_foods_local(query);
375 }
376 let mut stmt = self
377 .conn
378 .prepare("SELECT * FROM foods ORDER BY name LIMIT 100")?;
379 let foods = stmt
380 .query_map([], Self::food_from_row)?
381 .collect::<Result<Vec<_>, _>>()?;
382 Ok(foods)
383 }
384
385 pub fn insert_meal_entry(&self, entry: &NewMealEntry) -> Result<MealEntry> {
388 let now = Local::now().to_rfc3339();
389 let uuid = Uuid::new_v4().to_string();
390 let date_str = entry.date.format("%Y-%m-%d").to_string();
391 self.conn.execute(
392 "INSERT INTO meal_entries (date, meal_type, food_id, serving_g, display_unit, display_quantity, created_at, uuid, updated_at)
393 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
394 params![
395 date_str,
396 entry.meal_type,
397 entry.food_id,
398 entry.serving_g,
399 entry.display_unit,
400 entry.display_quantity,
401 now,
402 uuid,
403 now,
404 ],
405 )?;
406 let id = self.conn.last_insert_rowid();
407 self.get_meal_entry(id)
408 }
409
410 pub fn get_meal_entry(&self, id: i64) -> Result<MealEntry> {
411 self.conn
412 .query_row(
413 "SELECT me.id, me.uuid, me.date, me.meal_type, me.food_id, me.serving_g,
414 me.display_unit, me.display_quantity, me.created_at, me.updated_at,
415 f.name, f.brand, f.calories_per_100g, f.protein_per_100g, f.carbs_per_100g, f.fat_per_100g
416 FROM meal_entries me
417 JOIN foods f ON me.food_id = f.id
418 WHERE me.id = ?1",
419 params![id],
420 Self::meal_entry_from_row,
421 )
422 .context("Meal entry not found")
423 }
424
425 pub fn delete_meal_entry(&self, id: i64) -> Result<bool> {
426 let rows = self
427 .conn
428 .execute("DELETE FROM meal_entries WHERE id = ?1", params![id])?;
429 Ok(rows > 0)
430 }
431
432 pub fn update_meal_entry(&self, id: i64, update: &UpdateMealEntry) -> Result<MealEntry> {
433 self.get_meal_entry(id)?;
435
436 let now = Local::now().to_rfc3339();
437 if let Some(serving_g) = update.serving_g {
438 self.conn.execute(
439 "UPDATE meal_entries SET serving_g = ?1, updated_at = ?2 WHERE id = ?3",
440 params![serving_g, now, id],
441 )?;
442 }
443 if let Some(ref meal_type) = update.meal_type {
444 self.conn.execute(
445 "UPDATE meal_entries SET meal_type = ?1, updated_at = ?2 WHERE id = ?3",
446 params![meal_type, now, id],
447 )?;
448 }
449 if let Some(date) = update.date {
450 let date_str = date.format("%Y-%m-%d").to_string();
451 self.conn.execute(
452 "UPDATE meal_entries SET date = ?1, updated_at = ?2 WHERE id = ?3",
453 params![date_str, now, id],
454 )?;
455 }
456 if let Some(ref display_unit) = update.display_unit {
457 self.conn.execute(
458 "UPDATE meal_entries SET display_unit = ?1, updated_at = ?2 WHERE id = ?3",
459 params![display_unit, now, id],
460 )?;
461 }
462 if let Some(ref display_quantity) = update.display_quantity {
463 self.conn.execute(
464 "UPDATE meal_entries SET display_quantity = ?1, updated_at = ?2 WHERE id = ?3",
465 params![display_quantity, now, id],
466 )?;
467 }
468
469 self.get_meal_entry(id)
470 }
471
472 pub fn get_entries_for_date(&self, date: NaiveDate) -> Result<Vec<MealEntry>> {
473 let date_str = date.format("%Y-%m-%d").to_string();
474 let mut stmt = self.conn.prepare(
475 "SELECT me.id, me.uuid, me.date, me.meal_type, me.food_id, me.serving_g,
476 me.display_unit, me.display_quantity, me.created_at, me.updated_at,
477 f.name, f.brand, f.calories_per_100g, f.protein_per_100g, f.carbs_per_100g, f.fat_per_100g
478 FROM meal_entries me
479 JOIN foods f ON me.food_id = f.id
480 WHERE me.date = ?1
481 ORDER BY me.id",
482 )?;
483 let entries = stmt
484 .query_map(params![date_str], Self::meal_entry_from_row)?
485 .collect::<Result<Vec<_>, _>>()?;
486 Ok(entries)
487 }
488
489 pub fn get_entries_for_date_and_meal(
490 &self,
491 date: NaiveDate,
492 meal_type: &str,
493 ) -> Result<Vec<MealEntry>> {
494 let date_str = date.format("%Y-%m-%d").to_string();
495 let mut stmt = self.conn.prepare(
496 "SELECT me.id, me.uuid, me.date, me.meal_type, me.food_id, me.serving_g,
497 me.display_unit, me.display_quantity, me.created_at, me.updated_at,
498 f.name, f.brand, f.calories_per_100g, f.protein_per_100g, f.carbs_per_100g, f.fat_per_100g
499 FROM meal_entries me
500 JOIN foods f ON me.food_id = f.id
501 WHERE me.date = ?1 AND me.meal_type = ?2
502 ORDER BY me.id",
503 )?;
504 let entries = stmt
505 .query_map(params![date_str, meal_type], Self::meal_entry_from_row)?
506 .collect::<Result<Vec<_>, _>>()?;
507 Ok(entries)
508 }
509
510 pub fn set_target(
513 &self,
514 day_of_week: i64,
515 calories: i64,
516 protein_pct: Option<i64>,
517 carbs_pct: Option<i64>,
518 fat_pct: Option<i64>,
519 ) -> Result<DailyTarget> {
520 let now = Local::now().to_rfc3339();
521 self.conn.execute(
522 "INSERT OR REPLACE INTO targets (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
523 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
524 params![day_of_week, calories, protein_pct, carbs_pct, fat_pct, now],
525 )?;
526 Ok(DailyTarget::from_db(
527 day_of_week,
528 calories,
529 protein_pct,
530 carbs_pct,
531 fat_pct,
532 ))
533 }
534
535 pub fn get_target(&self, day_of_week: i64) -> Result<Option<DailyTarget>> {
536 let mut stmt = self.conn.prepare(
537 "SELECT day_of_week, calories, protein_pct, carbs_pct, fat_pct FROM targets WHERE day_of_week = ?1",
538 )?;
539 let mut rows = stmt.query(params![day_of_week])?;
540 if let Some(row) = rows.next()? {
541 let day: i64 = row.get(0)?;
542 let calories: i64 = row.get(1)?;
543 let protein_pct: Option<i64> = row.get(2)?;
544 let carbs_pct: Option<i64> = row.get(3)?;
545 let fat_pct: Option<i64> = row.get(4)?;
546 Ok(Some(DailyTarget::from_db(
547 day,
548 calories,
549 protein_pct,
550 carbs_pct,
551 fat_pct,
552 )))
553 } else {
554 Ok(None)
555 }
556 }
557
558 pub fn get_all_targets(&self) -> Result<Vec<DailyTarget>> {
559 let mut stmt = self.conn.prepare(
560 "SELECT day_of_week, calories, protein_pct, carbs_pct, fat_pct FROM targets ORDER BY day_of_week",
561 )?;
562 let targets = stmt
563 .query_map([], |row| {
564 let day: i64 = row.get(0)?;
565 let calories: i64 = row.get(1)?;
566 let protein_pct: Option<i64> = row.get(2)?;
567 let carbs_pct: Option<i64> = row.get(3)?;
568 let fat_pct: Option<i64> = row.get(4)?;
569 Ok(DailyTarget::from_db(
570 day,
571 calories,
572 protein_pct,
573 carbs_pct,
574 fat_pct,
575 ))
576 })?
577 .collect::<Result<Vec<_>, _>>()?;
578 Ok(targets)
579 }
580
581 pub fn clear_target(&self, day_of_week: i64) -> Result<bool> {
582 let rows = self.conn.execute(
583 "DELETE FROM targets WHERE day_of_week = ?1",
584 params![day_of_week],
585 )?;
586 Ok(rows > 0)
587 }
588
589 pub fn clear_all_targets(&self) -> Result<bool> {
590 let rows = self.conn.execute("DELETE FROM targets", [])?;
591 Ok(rows > 0)
592 }
593
594 pub fn create_recipe(&self, name: &str, portions: f64) -> Result<Recipe> {
597 let now = Local::now().to_rfc3339();
598 let uuid = Uuid::new_v4().to_string();
599 let food = self.insert_food(&NewFood {
601 name: name.to_string(),
602 brand: None,
603 barcode: None,
604 calories_per_100g: 0.0,
605 protein_per_100g: Some(0.0),
606 carbs_per_100g: Some(0.0),
607 fat_per_100g: Some(0.0),
608 default_serving_g: Some(0.0),
609 source: "recipe".to_string(),
610 })?;
611
612 self.conn.execute(
613 "INSERT INTO recipes (food_id, portions, created_at, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
614 params![food.id, portions, now, uuid, now],
615 )?;
616 let id = self.conn.last_insert_rowid();
617 Ok(Recipe {
618 id,
619 uuid,
620 food_id: food.id,
621 portions,
622 created_at: now.clone(),
623 updated_at: now,
624 })
625 }
626
627 pub fn get_recipe_by_id(&self, id: i64) -> Result<Recipe> {
628 self.conn
629 .query_row(
630 "SELECT id, uuid, food_id, portions, created_at, updated_at FROM recipes WHERE id = ?1",
631 params![id],
632 |row| {
633 Ok(Recipe {
634 id: row.get(0)?,
635 uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
636 food_id: row.get(2)?,
637 portions: row.get(3)?,
638 created_at: row.get(4)?,
639 updated_at: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
640 })
641 },
642 )
643 .context("Recipe not found")
644 }
645
646 pub fn get_recipe_by_food_name(&self, name: &str) -> Result<Recipe> {
647 self.conn
648 .query_row(
649 "SELECT r.id, r.uuid, r.food_id, r.portions, r.created_at, r.updated_at
650 FROM recipes r JOIN foods f ON r.food_id = f.id
651 WHERE LOWER(f.name) = LOWER(?1)",
652 params![name],
653 |row| {
654 Ok(Recipe {
655 id: row.get(0)?,
656 uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
657 food_id: row.get(2)?,
658 portions: row.get(3)?,
659 created_at: row.get(4)?,
660 updated_at: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
661 })
662 },
663 )
664 .context(format!("Recipe '{name}' not found"))
665 }
666
667 pub fn add_recipe_ingredient(
668 &self,
669 recipe_id: i64,
670 food_id: i64,
671 quantity_g: f64,
672 ) -> Result<RecipeIngredient> {
673 let now = Local::now().to_rfc3339();
674 let uuid = Uuid::new_v4().to_string();
675 self.conn.execute(
676 "INSERT INTO recipe_ingredients (recipe_id, food_id, quantity_g, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
677 params![recipe_id, food_id, quantity_g, uuid, now],
678 )?;
679 let id = self.conn.last_insert_rowid();
680
681 self.recompute_recipe_food(recipe_id)?;
683
684 Ok(RecipeIngredient {
685 id,
686 uuid,
687 recipe_id,
688 food_id,
689 quantity_g,
690 food_name: None,
691 food_brand: None,
692 calories: None,
693 protein: None,
694 carbs: None,
695 fat: None,
696 })
697 }
698
699 pub fn remove_recipe_ingredient(&self, recipe_id: i64, food_name: &str) -> Result<bool> {
700 let rows = self.conn.execute(
701 "DELETE FROM recipe_ingredients WHERE recipe_id = ?1 AND food_id IN (
702 SELECT id FROM foods WHERE LOWER(name) = LOWER(?2)
703 )",
704 params![recipe_id, food_name],
705 )?;
706 if rows > 0 {
707 self.recompute_recipe_food(recipe_id)?;
708 }
709 Ok(rows > 0)
710 }
711
712 pub fn set_recipe_portions(&self, recipe_id: i64, portions: f64) -> Result<()> {
713 let now = Local::now().to_rfc3339();
714 self.conn.execute(
715 "UPDATE recipes SET portions = ?1, updated_at = ?2 WHERE id = ?3",
716 params![portions, now, recipe_id],
717 )?;
718 self.recompute_recipe_food(recipe_id)?;
719 Ok(())
720 }
721
722 pub fn get_recipe_ingredients(&self, recipe_id: i64) -> Result<Vec<RecipeIngredient>> {
723 let mut stmt = self.conn.prepare(
724 "SELECT ri.id, ri.uuid, ri.recipe_id, ri.food_id, ri.quantity_g,
725 f.name, f.brand, f.calories_per_100g, f.protein_per_100g, f.carbs_per_100g, f.fat_per_100g
726 FROM recipe_ingredients ri
727 JOIN foods f ON ri.food_id = f.id
728 WHERE ri.recipe_id = ?1
729 ORDER BY ri.id",
730 )?;
731 let ingredients = stmt
732 .query_map(params![recipe_id], |row| {
733 let qty: f64 = row.get(4)?;
734 let cal_100: f64 = row.get(7)?;
735 let pro_100: Option<f64> = row.get(8)?;
736 let carb_100: Option<f64> = row.get(9)?;
737 let fat_100: Option<f64> = row.get(10)?;
738 Ok(RecipeIngredient {
739 id: row.get(0)?,
740 uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
741 recipe_id: row.get(2)?,
742 food_id: row.get(3)?,
743 quantity_g: qty,
744 food_name: Some(row.get(5)?),
745 food_brand: row.get(6)?,
746 calories: Some(cal_100 * qty / 100.0),
747 protein: pro_100.map(|v| v * qty / 100.0),
748 carbs: carb_100.map(|v| v * qty / 100.0),
749 fat: fat_100.map(|v| v * qty / 100.0),
750 })
751 })?
752 .collect::<Result<Vec<_>, _>>()?;
753 Ok(ingredients)
754 }
755
756 pub fn get_recipe_detail(&self, recipe_id: i64) -> Result<RecipeDetail> {
757 let recipe = self.get_recipe_by_id(recipe_id)?;
758 let food = self.get_food_by_id(recipe.food_id)?;
759 let ingredients = self.get_recipe_ingredients(recipe_id)?;
760
761 let total_weight: f64 = ingredients.iter().map(|i| i.quantity_g).sum();
762 let total_cal: f64 = ingredients.iter().filter_map(|i| i.calories).sum();
763 let total_pro: f64 = ingredients.iter().filter_map(|i| i.protein).sum();
764 let total_carbs: f64 = ingredients.iter().filter_map(|i| i.carbs).sum();
765 let total_fat: f64 = ingredients.iter().filter_map(|i| i.fat).sum();
766
767 Ok(RecipeDetail {
768 id: recipe.id,
769 uuid: recipe.uuid,
770 food_id: recipe.food_id,
771 name: food.name,
772 portions: recipe.portions,
773 total_weight_g: total_weight,
774 per_portion_g: if recipe.portions > 0.0 {
775 total_weight / recipe.portions
776 } else {
777 0.0
778 },
779 ingredients,
780 per_portion_calories: if recipe.portions > 0.0 {
781 total_cal / recipe.portions
782 } else {
783 0.0
784 },
785 per_portion_protein: if recipe.portions > 0.0 {
786 total_pro / recipe.portions
787 } else {
788 0.0
789 },
790 per_portion_carbs: if recipe.portions > 0.0 {
791 total_carbs / recipe.portions
792 } else {
793 0.0
794 },
795 per_portion_fat: if recipe.portions > 0.0 {
796 total_fat / recipe.portions
797 } else {
798 0.0
799 },
800 calories_per_100g: food.calories_per_100g,
801 protein_per_100g: food.protein_per_100g.unwrap_or(0.0),
802 carbs_per_100g: food.carbs_per_100g.unwrap_or(0.0),
803 fat_per_100g: food.fat_per_100g.unwrap_or(0.0),
804 })
805 }
806
807 pub fn list_recipes(&self) -> Result<Vec<RecipeDetail>> {
808 let mut stmt = self.conn.prepare("SELECT id FROM recipes ORDER BY id")?;
809 let ids: Vec<i64> = stmt
810 .query_map([], |row| row.get(0))?
811 .collect::<Result<Vec<_>, _>>()?;
812 let mut details = Vec::new();
813 for id in ids {
814 details.push(self.get_recipe_detail(id)?);
815 }
816 Ok(details)
817 }
818
819 pub fn delete_recipe(&self, recipe_id: i64) -> Result<()> {
820 let recipe = self.get_recipe_by_id(recipe_id)?;
821 self.conn.execute(
823 "DELETE FROM recipe_ingredients WHERE recipe_id = ?1",
824 params![recipe_id],
825 )?;
826 self.conn
827 .execute("DELETE FROM recipes WHERE id = ?1", params![recipe_id])?;
828 self.conn
830 .execute("DELETE FROM foods WHERE id = ?1", params![recipe.food_id])?;
831 Ok(())
832 }
833
834 fn recompute_recipe_food(&self, recipe_id: i64) -> Result<()> {
835 let recipe = self.get_recipe_by_id(recipe_id)?;
836 let ingredients = self.get_recipe_ingredients(recipe_id)?;
837
838 let total_weight: f64 = ingredients.iter().map(|i| i.quantity_g).sum();
839 let total_cal: f64 = ingredients.iter().filter_map(|i| i.calories).sum();
840 let total_pro: f64 = ingredients.iter().filter_map(|i| i.protein).sum();
841 let total_carbs: f64 = ingredients.iter().filter_map(|i| i.carbs).sum();
842 let total_fat: f64 = ingredients.iter().filter_map(|i| i.fat).sum();
843
844 let (cal_100, pro_100, carb_100, fat_100, serving_g) = if total_weight > 0.0 {
845 (
846 total_cal * 100.0 / total_weight,
847 total_pro * 100.0 / total_weight,
848 total_carbs * 100.0 / total_weight,
849 total_fat * 100.0 / total_weight,
850 total_weight / recipe.portions,
851 )
852 } else {
853 (0.0, 0.0, 0.0, 0.0, 0.0)
854 };
855
856 let now = Local::now().to_rfc3339();
857 self.conn.execute(
858 "UPDATE foods SET calories_per_100g = ?1, protein_per_100g = ?2, carbs_per_100g = ?3,
859 fat_per_100g = ?4, default_serving_g = ?5, updated_at = ?6 WHERE id = ?7",
860 params![
861 cal_100,
862 pro_100,
863 carb_100,
864 fat_100,
865 serving_g,
866 now,
867 recipe.food_id
868 ],
869 )?;
870 Ok(())
871 }
872
873 pub fn record_tombstone(&self, uuid: &str, table_name: &str) -> Result<()> {
876 let now = Local::now().to_rfc3339();
877 self.conn.execute(
878 "INSERT INTO sync_tombstones (uuid, table_name, deleted_at) VALUES (?1, ?2, ?3)",
879 params![uuid, table_name, now],
880 )?;
881 Ok(())
882 }
883
884 pub fn get_tombstones(&self) -> Result<Vec<SyncTombstone>> {
885 let mut stmt = self
886 .conn
887 .prepare("SELECT uuid, table_name, deleted_at FROM sync_tombstones")?;
888 let tombstones = stmt
889 .query_map([], |row| {
890 Ok(SyncTombstone {
891 uuid: row.get(0)?,
892 table_name: row.get(1)?,
893 deleted_at: row.get(2)?,
894 })
895 })?
896 .collect::<Result<Vec<_>, _>>()?;
897 Ok(tombstones)
898 }
899
900 pub fn get_tombstones_since(&self, since: &str) -> Result<Vec<SyncTombstone>> {
901 let mut stmt = self.conn.prepare(
902 "SELECT uuid, table_name, deleted_at FROM sync_tombstones WHERE deleted_at > ?1",
903 )?;
904 let tombstones = stmt
905 .query_map(params![since], |row| {
906 Ok(SyncTombstone {
907 uuid: row.get(0)?,
908 table_name: row.get(1)?,
909 deleted_at: row.get(2)?,
910 })
911 })?
912 .collect::<Result<Vec<_>, _>>()?;
913 Ok(tombstones)
914 }
915
916 pub fn clear_tombstones(&self) -> Result<()> {
917 self.conn.execute("DELETE FROM sync_tombstones", [])?;
918 Ok(())
919 }
920
921 pub fn get_foods_since(&self, since: &str) -> Result<Vec<Food>> {
924 let mut stmt = self
925 .conn
926 .prepare("SELECT * FROM foods WHERE updated_at > ?1 ORDER BY id")?;
927 let foods = stmt
928 .query_map(params![since], Self::food_from_row)?
929 .collect::<Result<Vec<_>, _>>()?;
930 Ok(foods)
931 }
932
933 pub fn get_all_foods(&self) -> Result<Vec<Food>> {
934 let mut stmt = self.conn.prepare("SELECT * FROM foods ORDER BY id")?;
935 let foods = stmt
936 .query_map([], Self::food_from_row)?
937 .collect::<Result<Vec<_>, _>>()?;
938 Ok(foods)
939 }
940
941 pub fn get_meal_entries_since(&self, since: &str) -> Result<Vec<ExportMealEntry>> {
942 let mut stmt = self.conn.prepare(
943 "SELECT me.id, me.uuid, me.date, me.meal_type, me.food_id, me.serving_g,
944 me.display_unit, me.display_quantity, me.created_at,
945 me.updated_at, f.uuid as food_uuid
946 FROM meal_entries me JOIN foods f ON me.food_id = f.id
947 WHERE me.updated_at > ?1
948 ORDER BY me.id",
949 )?;
950 let entries = stmt
951 .query_map(params![since], Self::export_meal_entry_from_row)?
952 .collect::<Result<Vec<_>, _>>()?;
953 Ok(entries)
954 }
955
956 pub fn get_all_meal_entries_export(&self) -> Result<Vec<ExportMealEntry>> {
957 let mut stmt = self.conn.prepare(
958 "SELECT me.id, me.uuid, me.date, me.meal_type, me.food_id, me.serving_g,
959 me.display_unit, me.display_quantity, me.created_at,
960 me.updated_at, f.uuid as food_uuid
961 FROM meal_entries me JOIN foods f ON me.food_id = f.id
962 ORDER BY me.id",
963 )?;
964 let entries = stmt
965 .query_map([], Self::export_meal_entry_from_row)?
966 .collect::<Result<Vec<_>, _>>()?;
967 Ok(entries)
968 }
969
970 fn export_meal_entry_from_row(row: &rusqlite::Row) -> rusqlite::Result<ExportMealEntry> {
971 Ok(ExportMealEntry {
972 id: row.get(0)?,
973 uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
974 date: row.get(2)?,
975 meal_type: row.get(3)?,
976 food_id: row.get(4)?,
977 serving_g: row.get(5)?,
978 display_unit: row.get(6)?,
979 display_quantity: row.get(7)?,
980 created_at: row.get(8)?,
981 updated_at: row.get::<_, Option<String>>(9)?.unwrap_or_default(),
982 food_uuid: row.get::<_, Option<String>>(10)?.unwrap_or_default(),
983 })
984 }
985
986 fn export_recipe_from_row(row: &rusqlite::Row) -> rusqlite::Result<ExportRecipe> {
987 Ok(ExportRecipe {
988 id: row.get(0)?,
989 uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
990 food_id: row.get(2)?,
991 portions: row.get(3)?,
992 created_at: row.get(4)?,
993 updated_at: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
994 food_uuid: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
995 })
996 }
997
998 fn export_recipe_ingredient_from_row(
999 row: &rusqlite::Row,
1000 ) -> rusqlite::Result<ExportRecipeIngredient> {
1001 Ok(ExportRecipeIngredient {
1002 id: row.get(0)?,
1003 uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
1004 recipe_id: row.get(2)?,
1005 food_id: row.get(3)?,
1006 quantity_g: row.get(4)?,
1007 recipe_uuid: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
1008 food_uuid: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
1009 })
1010 }
1011
1012 fn export_target_from_row(row: &rusqlite::Row) -> rusqlite::Result<ExportTarget> {
1013 Ok(ExportTarget {
1014 day_of_week: row.get(0)?,
1015 calories: row.get(1)?,
1016 protein_pct: row.get(2)?,
1017 carbs_pct: row.get(3)?,
1018 fat_pct: row.get(4)?,
1019 updated_at: row.get(5)?,
1020 })
1021 }
1022
1023 fn export_weight_entry_from_row(row: &rusqlite::Row) -> rusqlite::Result<ExportWeightEntry> {
1024 Ok(ExportWeightEntry {
1025 uuid: row.get(0)?,
1026 date: row.get(1)?,
1027 weight_kg: row.get(2)?,
1028 source: row.get(3)?,
1029 notes: row.get(4)?,
1030 created_at: row.get(5)?,
1031 updated_at: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
1032 })
1033 }
1034
1035 pub fn get_recipes_since(&self, since: &str) -> Result<Vec<ExportRecipe>> {
1036 let mut stmt = self.conn.prepare(
1037 "SELECT r.id, r.uuid, r.food_id, r.portions, r.created_at, r.updated_at, f.uuid as food_uuid
1038 FROM recipes r JOIN foods f ON r.food_id = f.id
1039 WHERE r.updated_at > ?1
1040 ORDER BY r.id",
1041 )?;
1042 let recipes = stmt
1043 .query_map(params![since], Self::export_recipe_from_row)?
1044 .collect::<Result<Vec<_>, _>>()?;
1045 Ok(recipes)
1046 }
1047
1048 pub fn get_all_recipes_export(&self) -> Result<Vec<ExportRecipe>> {
1049 let mut stmt = self.conn.prepare(
1050 "SELECT r.id, r.uuid, r.food_id, r.portions, r.created_at, r.updated_at, f.uuid as food_uuid
1051 FROM recipes r JOIN foods f ON r.food_id = f.id
1052 ORDER BY r.id",
1053 )?;
1054 let recipes = stmt
1055 .query_map([], Self::export_recipe_from_row)?
1056 .collect::<Result<Vec<_>, _>>()?;
1057 Ok(recipes)
1058 }
1059
1060 pub fn get_recipe_ingredients_since(&self, since: &str) -> Result<Vec<ExportRecipeIngredient>> {
1061 let mut stmt = self.conn.prepare(
1062 "SELECT ri.id, ri.uuid, ri.recipe_id, ri.food_id, ri.quantity_g,
1063 r.uuid as recipe_uuid, f.uuid as food_uuid
1064 FROM recipe_ingredients ri
1065 JOIN recipes r ON ri.recipe_id = r.id
1066 JOIN foods f ON ri.food_id = f.id
1067 WHERE ri.updated_at > ?1
1068 ORDER BY ri.id",
1069 )?;
1070 let ingredients = stmt
1071 .query_map(params![since], Self::export_recipe_ingredient_from_row)?
1072 .collect::<Result<Vec<_>, _>>()?;
1073 Ok(ingredients)
1074 }
1075
1076 pub fn get_all_recipe_ingredients_export(&self) -> Result<Vec<ExportRecipeIngredient>> {
1077 let mut stmt = self.conn.prepare(
1078 "SELECT ri.id, ri.uuid, ri.recipe_id, ri.food_id, ri.quantity_g,
1079 r.uuid as recipe_uuid, f.uuid as food_uuid
1080 FROM recipe_ingredients ri
1081 JOIN recipes r ON ri.recipe_id = r.id
1082 JOIN foods f ON ri.food_id = f.id
1083 ORDER BY ri.id",
1084 )?;
1085 let ingredients = stmt
1086 .query_map([], Self::export_recipe_ingredient_from_row)?
1087 .collect::<Result<Vec<_>, _>>()?;
1088 Ok(ingredients)
1089 }
1090
1091 pub fn get_targets_since(&self, since: &str) -> Result<Vec<ExportTarget>> {
1092 let mut stmt = self.conn.prepare(
1093 "SELECT day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at
1094 FROM targets WHERE updated_at > ?1
1095 ORDER BY day_of_week",
1096 )?;
1097 let targets = stmt
1098 .query_map(params![since], Self::export_target_from_row)?
1099 .collect::<Result<Vec<_>, _>>()?;
1100 Ok(targets)
1101 }
1102
1103 pub fn get_all_targets_export(&self) -> Result<Vec<ExportTarget>> {
1104 let mut stmt = self.conn.prepare(
1105 "SELECT day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at
1106 FROM targets ORDER BY day_of_week",
1107 )?;
1108 let targets = stmt
1109 .query_map([], Self::export_target_from_row)?
1110 .collect::<Result<Vec<_>, _>>()?;
1111 Ok(targets)
1112 }
1113
1114 pub fn get_weight_entries_since(&self, since: &str) -> Result<Vec<ExportWeightEntry>> {
1115 let mut stmt = self.conn.prepare(
1116 "SELECT uuid, date, weight_kg, source, notes, created_at, updated_at
1117 FROM weight_entries WHERE updated_at > ?1
1118 ORDER BY date",
1119 )?;
1120 let entries = stmt
1121 .query_map(params![since], Self::export_weight_entry_from_row)?
1122 .collect::<Result<Vec<_>, _>>()?;
1123 Ok(entries)
1124 }
1125
1126 pub fn get_all_weight_entries_export(&self) -> Result<Vec<ExportWeightEntry>> {
1127 let mut stmt = self.conn.prepare(
1128 "SELECT uuid, date, weight_kg, source, notes, created_at, updated_at
1129 FROM weight_entries ORDER BY date",
1130 )?;
1131 let entries = stmt
1132 .query_map([], Self::export_weight_entry_from_row)?
1133 .collect::<Result<Vec<_>, _>>()?;
1134 Ok(entries)
1135 }
1136
1137 pub fn changes_since(
1138 &self,
1139 since: Option<&str>,
1140 server_timestamp: &str,
1141 ) -> Result<SyncPayload> {
1142 let (foods, meal_entries, recipes, recipe_ingredients, targets, weight_entries, tombstones) =
1143 match since {
1144 Some(ts) => (
1145 self.get_foods_since(ts)?,
1146 self.get_meal_entries_since(ts)?,
1147 self.get_recipes_since(ts)?,
1148 self.get_recipe_ingredients_since(ts)?,
1149 self.get_targets_since(ts)?,
1150 self.get_weight_entries_since(ts)?,
1151 self.get_tombstones_since(ts)?,
1152 ),
1153 None => (
1154 self.get_all_foods()?,
1155 self.get_all_meal_entries_export()?,
1156 self.get_all_recipes_export()?,
1157 self.get_all_recipe_ingredients_export()?,
1158 self.get_all_targets_export()?,
1159 self.get_all_weight_entries_export()?,
1160 self.get_tombstones()?,
1161 ),
1162 };
1163 Ok(SyncPayload {
1164 foods,
1165 meal_entries,
1166 recipes,
1167 recipe_ingredients,
1168 targets,
1169 weight_entries,
1170 tombstones,
1171 server_timestamp: server_timestamp.to_string(),
1172 })
1173 }
1174
1175 #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1176 pub fn apply_remote_changes(
1177 &self,
1178 foods: &[Food],
1179 meal_entries: &[ExportMealEntry],
1180 recipes: &[ExportRecipe],
1181 recipe_ingredients: &[ExportRecipeIngredient],
1182 targets: &[ExportTarget],
1183 weight_entries: &[ExportWeightEntry],
1184 tombstones: &[SyncTombstone],
1185 ) -> Result<()> {
1186 let mut food_uuid_to_local_id: HashMap<String, i64> = HashMap::new();
1188 for food in foods {
1189 if food.uuid.is_empty() {
1190 continue;
1191 }
1192 if let Some(existing) = self.get_food_by_uuid(&food.uuid)? {
1193 food_uuid_to_local_id.insert(food.uuid.clone(), existing.id);
1194 if food.updated_at > existing.updated_at {
1195 self.conn.execute(
1196 "UPDATE foods SET name=?1, brand=?2, barcode=?3, calories_per_100g=?4,
1197 protein_per_100g=?5, carbs_per_100g=?6, fat_per_100g=?7,
1198 default_serving_g=?8, source=?9, updated_at=?10 WHERE uuid=?11",
1199 params![
1200 food.name,
1201 food.brand,
1202 food.barcode,
1203 food.calories_per_100g,
1204 food.protein_per_100g,
1205 food.carbs_per_100g,
1206 food.fat_per_100g,
1207 food.default_serving_g,
1208 food.source,
1209 food.updated_at,
1210 food.uuid,
1211 ],
1212 )?;
1213 }
1214 } else {
1215 self.conn.execute(
1216 "INSERT INTO foods (name, brand, barcode, calories_per_100g,
1217 protein_per_100g, carbs_per_100g, fat_per_100g,
1218 default_serving_g, source, created_at, uuid, updated_at)
1219 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1220 params![
1221 food.name,
1222 food.brand,
1223 food.barcode,
1224 food.calories_per_100g,
1225 food.protein_per_100g,
1226 food.carbs_per_100g,
1227 food.fat_per_100g,
1228 food.default_serving_g,
1229 food.source,
1230 food.created_at,
1231 food.uuid,
1232 food.updated_at,
1233 ],
1234 )?;
1235 let new_id = self.conn.last_insert_rowid();
1236 food_uuid_to_local_id.insert(food.uuid.clone(), new_id);
1237 }
1238 }
1239
1240 for entry in meal_entries {
1242 if entry.uuid.is_empty() {
1243 continue;
1244 }
1245 let local_food_id = if entry.food_uuid.is_empty() {
1246 None
1247 } else {
1248 food_uuid_to_local_id
1249 .get(&entry.food_uuid)
1250 .copied()
1251 .or_else(|| {
1252 self.get_food_by_uuid(&entry.food_uuid)
1253 .ok()
1254 .flatten()
1255 .map(|f| f.id)
1256 })
1257 };
1258 let Some(food_id) = local_food_id else {
1259 continue;
1260 };
1261
1262 if let Some(existing_id) = self.get_meal_entry_by_uuid(&entry.uuid)? {
1263 let existing_updated: String = self.conn.query_row(
1264 "SELECT COALESCE(updated_at, '') FROM meal_entries WHERE id = ?1",
1265 params![existing_id],
1266 |row| row.get(0),
1267 )?;
1268 if entry.updated_at > existing_updated {
1269 self.conn.execute(
1270 "UPDATE meal_entries SET date=?1, meal_type=?2, food_id=?3, serving_g=?4, display_unit=?5, display_quantity=?6, updated_at=?7 WHERE id=?8",
1271 params![entry.date, entry.meal_type, food_id, entry.serving_g, entry.display_unit, entry.display_quantity, entry.updated_at, existing_id],
1272 )?;
1273 }
1274 } else {
1275 self.conn.execute(
1276 "INSERT INTO meal_entries (date, meal_type, food_id, serving_g, display_unit, display_quantity, created_at, uuid, updated_at)
1277 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1278 params![entry.date, entry.meal_type, food_id, entry.serving_g, entry.display_unit, entry.display_quantity, entry.created_at, entry.uuid, entry.updated_at],
1279 )?;
1280 }
1281 }
1282
1283 let mut recipe_uuid_to_local_id: HashMap<String, i64> = HashMap::new();
1285 for recipe in recipes {
1286 if recipe.uuid.is_empty() {
1287 continue;
1288 }
1289 let local_food_id = if recipe.food_uuid.is_empty() {
1290 None
1291 } else {
1292 food_uuid_to_local_id
1293 .get(&recipe.food_uuid)
1294 .copied()
1295 .or_else(|| {
1296 self.get_food_by_uuid(&recipe.food_uuid)
1297 .ok()
1298 .flatten()
1299 .map(|f| f.id)
1300 })
1301 };
1302 let Some(food_id) = local_food_id else {
1303 continue;
1304 };
1305
1306 if let Some(existing) = self.get_recipe_by_uuid(&recipe.uuid)? {
1307 recipe_uuid_to_local_id.insert(recipe.uuid.clone(), existing.id);
1308 if recipe.updated_at > existing.updated_at {
1309 self.conn.execute(
1310 "UPDATE recipes SET food_id=?1, portions=?2, updated_at=?3 WHERE id=?4",
1311 params![food_id, recipe.portions, recipe.updated_at, existing.id],
1312 )?;
1313 }
1314 } else {
1315 self.conn.execute(
1316 "INSERT INTO recipes (food_id, portions, created_at, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
1317 params![food_id, recipe.portions, recipe.created_at, recipe.uuid, recipe.updated_at],
1318 )?;
1319 let new_id = self.conn.last_insert_rowid();
1320 recipe_uuid_to_local_id.insert(recipe.uuid.clone(), new_id);
1321 }
1322 }
1323
1324 let mut recipes_to_recompute: std::collections::HashSet<i64> =
1326 std::collections::HashSet::new();
1327 for ing in recipe_ingredients {
1328 if ing.uuid.is_empty() {
1329 continue;
1330 }
1331 let local_recipe_id = if ing.recipe_uuid.is_empty() {
1332 None
1333 } else {
1334 recipe_uuid_to_local_id
1335 .get(&ing.recipe_uuid)
1336 .copied()
1337 .or_else(|| {
1338 self.get_recipe_by_uuid(&ing.recipe_uuid)
1339 .ok()
1340 .flatten()
1341 .map(|r| r.id)
1342 })
1343 };
1344 let local_food_id = if ing.food_uuid.is_empty() {
1345 None
1346 } else {
1347 food_uuid_to_local_id
1348 .get(&ing.food_uuid)
1349 .copied()
1350 .or_else(|| {
1351 self.get_food_by_uuid(&ing.food_uuid)
1352 .ok()
1353 .flatten()
1354 .map(|f| f.id)
1355 })
1356 };
1357 let (Some(recipe_id), Some(food_id)) = (local_recipe_id, local_food_id) else {
1358 continue;
1359 };
1360
1361 if let Some(existing_id) = self.get_recipe_ingredient_by_uuid(&ing.uuid)? {
1362 self.conn.execute(
1363 "UPDATE recipe_ingredients SET recipe_id=?1, food_id=?2, quantity_g=?3 WHERE id=?4",
1364 params![recipe_id, food_id, ing.quantity_g, existing_id],
1365 )?;
1366 } else {
1367 let now = Local::now().to_rfc3339();
1368 self.conn.execute(
1369 "INSERT INTO recipe_ingredients (recipe_id, food_id, quantity_g, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
1370 params![recipe_id, food_id, ing.quantity_g, ing.uuid, now],
1371 )?;
1372 }
1373 recipes_to_recompute.insert(recipe_id);
1374 }
1375
1376 for recipe_id in &recipes_to_recompute {
1378 self.recompute_recipe_food(*recipe_id)?;
1379 }
1380
1381 for incoming_target in targets {
1383 let local_updated: Option<String> = self
1384 .conn
1385 .query_row(
1386 "SELECT updated_at FROM targets WHERE day_of_week = ?1",
1387 params![incoming_target.day_of_week],
1388 |row| row.get(0),
1389 )
1390 .ok();
1391 let should_update = match (&incoming_target.updated_at, &local_updated) {
1392 (Some(incoming), Some(local)) => incoming > local,
1393 (Some(_), None) | (None, _) => true,
1394 };
1395 if should_update {
1396 let updated_at = incoming_target
1397 .updated_at
1398 .clone()
1399 .unwrap_or_else(|| Local::now().to_rfc3339());
1400 self.conn.execute(
1401 "INSERT OR REPLACE INTO targets (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
1402 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1403 params![
1404 incoming_target.day_of_week,
1405 incoming_target.calories,
1406 incoming_target.protein_pct,
1407 incoming_target.carbs_pct,
1408 incoming_target.fat_pct,
1409 updated_at,
1410 ],
1411 )?;
1412 }
1413 }
1414
1415 let mut dummy_recompute = std::collections::HashSet::new();
1417 for tombstone in tombstones {
1418 self.apply_tombstone(tombstone, &mut dummy_recompute)?;
1419 let exists: i64 = self
1421 .conn
1422 .query_row(
1423 "SELECT COUNT(*) FROM sync_tombstones WHERE uuid = ?1 AND table_name = ?2",
1424 params![tombstone.uuid, tombstone.table_name],
1425 |row| row.get(0),
1426 )
1427 .unwrap_or(0);
1428 if exists == 0 {
1429 self.conn.execute(
1430 "INSERT INTO sync_tombstones (uuid, table_name, deleted_at) VALUES (?1, ?2, ?3)",
1431 params![tombstone.uuid, tombstone.table_name, tombstone.deleted_at],
1432 )?;
1433 }
1434 }
1435
1436 for entry in weight_entries {
1438 if entry.uuid.is_empty() {
1439 continue;
1440 }
1441 let existing: Option<(String, String)> = self
1442 .conn
1443 .query_row(
1444 "SELECT uuid, updated_at FROM weight_entries WHERE date = ?1",
1445 params![entry.date],
1446 |row| Ok((row.get(0)?, row.get(1)?)),
1447 )
1448 .ok();
1449 if let Some((_existing_uuid, existing_updated)) = existing {
1450 if entry.updated_at > existing_updated {
1451 self.conn.execute(
1452 "UPDATE weight_entries SET uuid=?1, weight_kg=?2, source=?3, notes=?4, updated_at=?5 WHERE date=?6",
1453 params![entry.uuid, entry.weight_kg, entry.source, entry.notes, entry.updated_at, entry.date],
1454 )?;
1455 }
1456 } else {
1457 self.conn.execute(
1458 "INSERT INTO weight_entries (uuid, date, weight_kg, source, notes, created_at, updated_at)
1459 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1460 params![entry.uuid, entry.date, entry.weight_kg, entry.source, entry.notes, entry.created_at, entry.updated_at],
1461 )?;
1462 }
1463 }
1464
1465 Ok(())
1466 }
1467
1468 pub fn get_or_create_device_id(&self) -> Result<String> {
1469 let mut stmt = self
1470 .conn
1471 .prepare("SELECT value FROM config WHERE key = 'device_id'")?;
1472 let mut rows = stmt.query([])?;
1473 if let Some(row) = rows.next()? {
1474 return Ok(row.get(0)?);
1475 }
1476 drop(rows);
1477 drop(stmt);
1478
1479 let device_id = Uuid::new_v4().to_string();
1480 self.conn.execute(
1481 "INSERT INTO config (key, value) VALUES ('device_id', ?1)",
1482 params![device_id],
1483 )?;
1484 Ok(device_id)
1485 }
1486
1487 pub fn get_food_by_uuid(&self, uuid: &str) -> Result<Option<Food>> {
1488 let mut stmt = self.conn.prepare("SELECT * FROM foods WHERE uuid = ?1")?;
1489 let mut rows = stmt.query(params![uuid])?;
1490 if let Some(row) = rows.next()? {
1491 Ok(Some(Self::food_from_row(row)?))
1492 } else {
1493 Ok(None)
1494 }
1495 }
1496
1497 fn get_meal_entry_by_uuid(&self, uuid: &str) -> Result<Option<i64>> {
1498 let mut stmt = self
1499 .conn
1500 .prepare("SELECT id FROM meal_entries WHERE uuid = ?1")?;
1501 let mut rows = stmt.query(params![uuid])?;
1502 if let Some(row) = rows.next()? {
1503 Ok(Some(row.get(0)?))
1504 } else {
1505 Ok(None)
1506 }
1507 }
1508
1509 fn get_recipe_by_uuid(&self, uuid: &str) -> Result<Option<Recipe>> {
1510 let mut stmt = self.conn.prepare(
1511 "SELECT id, uuid, food_id, portions, created_at, updated_at FROM recipes WHERE uuid = ?1",
1512 )?;
1513 let mut rows = stmt.query(params![uuid])?;
1514 if let Some(row) = rows.next()? {
1515 Ok(Some(Recipe {
1516 id: row.get(0)?,
1517 uuid: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
1518 food_id: row.get(2)?,
1519 portions: row.get(3)?,
1520 created_at: row.get(4)?,
1521 updated_at: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
1522 }))
1523 } else {
1524 Ok(None)
1525 }
1526 }
1527
1528 fn get_recipe_ingredient_by_uuid(&self, uuid: &str) -> Result<Option<i64>> {
1529 let mut stmt = self
1530 .conn
1531 .prepare("SELECT id FROM recipe_ingredients WHERE uuid = ?1")?;
1532 let mut rows = stmt.query(params![uuid])?;
1533 if let Some(row) = rows.next()? {
1534 Ok(Some(row.get(0)?))
1535 } else {
1536 Ok(None)
1537 }
1538 }
1539
1540 pub fn get_meal_entry_uuid(&self, id: i64) -> Result<Option<String>> {
1541 self.conn
1542 .query_row(
1543 "SELECT uuid FROM meal_entries WHERE id = ?1",
1544 params![id],
1545 |row| row.get(0),
1546 )
1547 .context("Meal entry not found")
1548 .map(Some)
1549 }
1550
1551 pub fn get_recipe_uuid(&self, id: i64) -> Result<Option<String>> {
1552 self.conn
1553 .query_row(
1554 "SELECT uuid FROM recipes WHERE id = ?1",
1555 params![id],
1556 |row| row.get(0),
1557 )
1558 .context("Recipe not found")
1559 .map(Some)
1560 }
1561
1562 pub fn get_recipe_ingredient_uuids(&self, recipe_id: i64) -> Result<Vec<String>> {
1563 let mut stmt = self
1564 .conn
1565 .prepare("SELECT uuid FROM recipe_ingredients WHERE recipe_id = ?1")?;
1566 let uuids = stmt
1567 .query_map(params![recipe_id], |row| row.get(0))?
1568 .collect::<Result<Vec<String>, _>>()?;
1569 Ok(uuids)
1570 }
1571
1572 #[allow(clippy::too_many_lines)]
1575 pub fn export_all(&self) -> Result<ExportData> {
1576 let device_id = self.get_or_create_device_id()?;
1577 let foods = self.get_all_foods()?;
1578 let meal_entries = self.get_all_meal_entries_export()?;
1579 let recipes = self.get_all_recipes_export()?;
1580 let recipe_ingredients = self.get_all_recipe_ingredients_export()?;
1581 let targets = self.get_all_targets_export()?;
1582 let weight_entries = self.get_all_weight_entries_export()?;
1583 let tombstones = self.get_tombstones()?;
1584
1585 let exported_at = Local::now().to_rfc3339();
1586 Ok(ExportData {
1587 version: 3,
1588 exported_at,
1589 device_id: Some(device_id),
1590 foods,
1591 meal_entries,
1592 recipes,
1593 recipe_ingredients,
1594 target: None,
1595 targets,
1596 weight_entries,
1597 tombstones: Some(tombstones),
1598 })
1599 }
1600
1601 pub fn import_all(&self, data: &ExportData) -> Result<ImportSummary> {
1602 if data.version >= 2 {
1603 self.merge_import(data)
1604 } else {
1605 self.import_v1(data)
1606 }
1607 }
1608
1609 fn import_v1(&self, data: &ExportData) -> Result<ImportSummary> {
1610 let foods_imported = self.import_foods(&data.foods)?;
1611 let meal_entries_imported = self.import_meal_entries(&data.meal_entries)?;
1612 let (recipes_imported, recipe_ingredients_imported) =
1613 self.import_recipes(&data.recipes, &data.recipe_ingredients)?;
1614 let targets_imported = self.import_targets(data)?;
1615 let weight_entries_imported = self.import_weight_entries(&data.weight_entries)?;
1616
1617 Ok(ImportSummary {
1618 foods_imported,
1619 meal_entries_imported,
1620 recipes_imported,
1621 recipe_ingredients_imported,
1622 targets_imported,
1623 weight_entries_imported,
1624 tombstones_processed: 0,
1625 })
1626 }
1627
1628 #[allow(clippy::cast_possible_wrap)]
1629 fn import_foods(&self, foods: &[Food]) -> Result<i64> {
1630 let mut count: i64 = 0;
1631 for food in foods {
1632 let exists = self
1633 .conn
1634 .query_row(
1635 "SELECT COUNT(*) FROM foods WHERE id = ?1",
1636 params![food.id],
1637 |row| row.get::<_, i64>(0),
1638 )
1639 .unwrap_or(0);
1640 if exists > 0 {
1641 self.conn.execute(
1642 "UPDATE foods SET name=?1, brand=?2, barcode=?3, calories_per_100g=?4,
1643 protein_per_100g=?5, carbs_per_100g=?6, fat_per_100g=?7,
1644 default_serving_g=?8, source=?9 WHERE id=?10",
1645 params![
1646 food.name,
1647 food.brand,
1648 food.barcode,
1649 food.calories_per_100g,
1650 food.protein_per_100g,
1651 food.carbs_per_100g,
1652 food.fat_per_100g,
1653 food.default_serving_g,
1654 food.source,
1655 food.id,
1656 ],
1657 )?;
1658 } else {
1659 self.insert_food_for_import(food)?;
1660 }
1661 count += 1;
1662 }
1663 Ok(count)
1664 }
1665
1666 fn insert_food_for_import(&self, food: &Food) -> Result<()> {
1667 if let Some(barcode) = &food.barcode {
1668 let barcode_exists = self
1669 .conn
1670 .query_row(
1671 "SELECT COUNT(*) FROM foods WHERE barcode = ?1",
1672 params![barcode],
1673 |row| row.get::<_, i64>(0),
1674 )
1675 .unwrap_or(0);
1676 if barcode_exists > 0 {
1677 return Ok(());
1678 }
1679 }
1680 self.conn.execute(
1681 "INSERT INTO foods (id, name, brand, barcode, calories_per_100g,
1682 protein_per_100g, carbs_per_100g, fat_per_100g,
1683 default_serving_g, source, created_at)
1684 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1685 params![
1686 food.id,
1687 food.name,
1688 food.brand,
1689 food.barcode,
1690 food.calories_per_100g,
1691 food.protein_per_100g,
1692 food.carbs_per_100g,
1693 food.fat_per_100g,
1694 food.default_serving_g,
1695 food.source,
1696 food.created_at,
1697 ],
1698 )?;
1699 Ok(())
1700 }
1701
1702 #[allow(clippy::cast_possible_wrap)]
1703 fn import_meal_entries(&self, entries: &[ExportMealEntry]) -> Result<i64> {
1704 let mut count: i64 = 0;
1705 for entry in entries {
1706 self.conn.execute(
1707 "INSERT OR REPLACE INTO meal_entries (id, date, meal_type, food_id, serving_g, display_unit, display_quantity, created_at)
1708 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1709 params![
1710 entry.id,
1711 entry.date,
1712 entry.meal_type,
1713 entry.food_id,
1714 entry.serving_g,
1715 entry.display_unit,
1716 entry.display_quantity,
1717 entry.created_at,
1718 ],
1719 )?;
1720 count += 1;
1721 }
1722 Ok(count)
1723 }
1724
1725 #[allow(clippy::cast_possible_wrap)]
1726 fn import_recipes(
1727 &self,
1728 recipes: &[ExportRecipe],
1729 ingredients: &[ExportRecipeIngredient],
1730 ) -> Result<(i64, i64)> {
1731 let mut recipe_count: i64 = 0;
1732 let mut ingredient_count: i64 = 0;
1733
1734 for recipe in recipes {
1735 self.conn.execute(
1736 "INSERT OR REPLACE INTO recipes (id, food_id, portions, created_at)
1737 VALUES (?1, ?2, ?3, ?4)",
1738 params![
1739 recipe.id,
1740 recipe.food_id,
1741 recipe.portions,
1742 recipe.created_at
1743 ],
1744 )?;
1745 self.conn.execute(
1746 "DELETE FROM recipe_ingredients WHERE recipe_id = ?1",
1747 params![recipe.id],
1748 )?;
1749 recipe_count += 1;
1750 }
1751
1752 for ing in ingredients {
1753 self.conn.execute(
1754 "INSERT INTO recipe_ingredients (id, recipe_id, food_id, quantity_g)
1755 VALUES (?1, ?2, ?3, ?4)",
1756 params![ing.id, ing.recipe_id, ing.food_id, ing.quantity_g],
1757 )?;
1758 ingredient_count += 1;
1759 }
1760
1761 Ok((recipe_count, ingredient_count))
1762 }
1763
1764 #[allow(clippy::cast_possible_wrap)]
1765 fn import_targets(&self, data: &ExportData) -> Result<i64> {
1766 let now = Local::now().to_rfc3339();
1767
1768 if !data.targets.is_empty() {
1769 let mut count: i64 = 0;
1770 for target in &data.targets {
1771 self.conn.execute(
1772 "INSERT OR REPLACE INTO targets (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
1773 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1774 params![
1775 target.day_of_week,
1776 target.calories,
1777 target.protein_pct,
1778 target.carbs_pct,
1779 target.fat_pct,
1780 now,
1781 ],
1782 )?;
1783 count += 1;
1784 }
1785 Ok(count)
1786 } else if let Some(legacy) = &data.target {
1787 for day in 0..7_i64 {
1789 self.conn.execute(
1790 "INSERT OR REPLACE INTO targets (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
1791 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1792 params![
1793 day,
1794 legacy.calories,
1795 legacy.protein_pct,
1796 legacy.carbs_pct,
1797 legacy.fat_pct,
1798 now,
1799 ],
1800 )?;
1801 }
1802 Ok(7)
1803 } else {
1804 Ok(0)
1805 }
1806 }
1807
1808 #[allow(clippy::cast_possible_wrap)]
1809 fn import_weight_entries(&self, entries: &[ExportWeightEntry]) -> Result<i64> {
1810 let mut count: i64 = 0;
1811 for entry in entries {
1812 self.conn.execute(
1813 "INSERT OR REPLACE INTO weight_entries (uuid, date, weight_kg, source, notes, created_at, updated_at)
1814 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1815 params![
1816 entry.uuid,
1817 entry.date,
1818 entry.weight_kg,
1819 entry.source,
1820 entry.notes,
1821 entry.created_at,
1822 entry.updated_at,
1823 ],
1824 )?;
1825 count += 1;
1826 }
1827 Ok(count)
1828 }
1829
1830 #[allow(clippy::cast_possible_wrap, clippy::too_many_lines)]
1831 fn merge_import(&self, data: &ExportData) -> Result<ImportSummary> {
1832 let mut foods_imported: i64 = 0;
1833 let mut meal_entries_imported: i64 = 0;
1834 let mut recipes_imported: i64 = 0;
1835 let mut recipe_ingredients_imported: i64 = 0;
1836 let mut tombstones_processed: i64 = 0;
1837
1838 let mut food_uuid_to_local_id: HashMap<String, i64> = HashMap::new();
1840 for food in &data.foods {
1841 if food.uuid.is_empty() {
1842 continue;
1843 }
1844 if let Some(existing) = self.get_food_by_uuid(&food.uuid)? {
1845 food_uuid_to_local_id.insert(food.uuid.clone(), existing.id);
1846 if food.updated_at > existing.updated_at {
1847 self.conn.execute(
1848 "UPDATE foods SET name=?1, brand=?2, barcode=?3, calories_per_100g=?4,
1849 protein_per_100g=?5, carbs_per_100g=?6, fat_per_100g=?7,
1850 default_serving_g=?8, source=?9, updated_at=?10 WHERE uuid=?11",
1851 params![
1852 food.name,
1853 food.brand,
1854 food.barcode,
1855 food.calories_per_100g,
1856 food.protein_per_100g,
1857 food.carbs_per_100g,
1858 food.fat_per_100g,
1859 food.default_serving_g,
1860 food.source,
1861 food.updated_at,
1862 food.uuid,
1863 ],
1864 )?;
1865 foods_imported += 1;
1866 }
1867 } else {
1868 self.conn.execute(
1869 "INSERT INTO foods (name, brand, barcode, calories_per_100g,
1870 protein_per_100g, carbs_per_100g, fat_per_100g,
1871 default_serving_g, source, created_at, uuid, updated_at)
1872 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1873 params![
1874 food.name,
1875 food.brand,
1876 food.barcode,
1877 food.calories_per_100g,
1878 food.protein_per_100g,
1879 food.carbs_per_100g,
1880 food.fat_per_100g,
1881 food.default_serving_g,
1882 food.source,
1883 food.created_at,
1884 food.uuid,
1885 food.updated_at,
1886 ],
1887 )?;
1888 let new_id = self.conn.last_insert_rowid();
1889 food_uuid_to_local_id.insert(food.uuid.clone(), new_id);
1890 foods_imported += 1;
1891 }
1892 }
1893
1894 for entry in &data.meal_entries {
1896 if entry.uuid.is_empty() {
1897 continue;
1898 }
1899 let local_food_id = if entry.food_uuid.is_empty() {
1900 None
1901 } else {
1902 food_uuid_to_local_id.get(&entry.food_uuid).copied()
1903 };
1904 let Some(food_id) = local_food_id else {
1905 continue;
1906 };
1907
1908 if let Some(existing_id) = self.get_meal_entry_by_uuid(&entry.uuid)? {
1909 let existing_updated: String = self.conn.query_row(
1910 "SELECT COALESCE(updated_at, '') FROM meal_entries WHERE id = ?1",
1911 params![existing_id],
1912 |row| row.get(0),
1913 )?;
1914 if entry.updated_at > existing_updated {
1915 self.conn.execute(
1916 "UPDATE meal_entries SET date=?1, meal_type=?2, food_id=?3, serving_g=?4, display_unit=?5, display_quantity=?6, updated_at=?7 WHERE id=?8",
1917 params![entry.date, entry.meal_type, food_id, entry.serving_g, entry.display_unit, entry.display_quantity, entry.updated_at, existing_id],
1918 )?;
1919 meal_entries_imported += 1;
1920 }
1921 } else {
1922 self.conn.execute(
1923 "INSERT INTO meal_entries (date, meal_type, food_id, serving_g, display_unit, display_quantity, created_at, uuid, updated_at)
1924 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1925 params![entry.date, entry.meal_type, food_id, entry.serving_g, entry.display_unit, entry.display_quantity, entry.created_at, entry.uuid, entry.updated_at],
1926 )?;
1927 meal_entries_imported += 1;
1928 }
1929 }
1930
1931 let mut recipe_uuid_to_local_id: HashMap<String, i64> = HashMap::new();
1933 for recipe in &data.recipes {
1934 if recipe.uuid.is_empty() {
1935 continue;
1936 }
1937 let local_food_id = if recipe.food_uuid.is_empty() {
1938 None
1939 } else {
1940 food_uuid_to_local_id.get(&recipe.food_uuid).copied()
1941 };
1942 let Some(food_id) = local_food_id else {
1943 continue;
1944 };
1945
1946 if let Some(existing) = self.get_recipe_by_uuid(&recipe.uuid)? {
1947 recipe_uuid_to_local_id.insert(recipe.uuid.clone(), existing.id);
1948 if recipe.updated_at > existing.updated_at {
1949 self.conn.execute(
1950 "UPDATE recipes SET food_id=?1, portions=?2, updated_at=?3 WHERE id=?4",
1951 params![food_id, recipe.portions, recipe.updated_at, existing.id],
1952 )?;
1953 recipes_imported += 1;
1954 }
1955 } else {
1956 self.conn.execute(
1957 "INSERT INTO recipes (food_id, portions, created_at, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
1958 params![food_id, recipe.portions, recipe.created_at, recipe.uuid, recipe.updated_at],
1959 )?;
1960 let new_id = self.conn.last_insert_rowid();
1961 recipe_uuid_to_local_id.insert(recipe.uuid.clone(), new_id);
1962 recipes_imported += 1;
1963 }
1964 }
1965
1966 let mut recipes_to_recompute: std::collections::HashSet<i64> =
1968 std::collections::HashSet::new();
1969 for ing in &data.recipe_ingredients {
1970 if ing.uuid.is_empty() {
1971 continue;
1972 }
1973 let local_recipe_id = if ing.recipe_uuid.is_empty() {
1974 None
1975 } else {
1976 recipe_uuid_to_local_id.get(&ing.recipe_uuid).copied()
1977 };
1978 let local_food_id = if ing.food_uuid.is_empty() {
1979 None
1980 } else {
1981 food_uuid_to_local_id.get(&ing.food_uuid).copied()
1982 };
1983 let (Some(recipe_id), Some(food_id)) = (local_recipe_id, local_food_id) else {
1984 continue;
1985 };
1986
1987 if let Some(existing_id) = self.get_recipe_ingredient_by_uuid(&ing.uuid)? {
1988 self.conn.execute(
1989 "UPDATE recipe_ingredients SET recipe_id=?1, food_id=?2, quantity_g=?3 WHERE id=?4",
1990 params![recipe_id, food_id, ing.quantity_g, existing_id],
1991 )?;
1992 recipe_ingredients_imported += 1;
1993 } else {
1994 let now = Local::now().to_rfc3339();
1995 self.conn.execute(
1996 "INSERT INTO recipe_ingredients (recipe_id, food_id, quantity_g, uuid, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
1997 params![recipe_id, food_id, ing.quantity_g, ing.uuid, now],
1998 )?;
1999 recipe_ingredients_imported += 1;
2000 }
2001 recipes_to_recompute.insert(recipe_id);
2002 }
2003
2004 for recipe_id in &recipes_to_recompute {
2006 self.recompute_recipe_food(*recipe_id)?;
2007 }
2008
2009 let mut targets_imported: i64 = 0;
2011 let targets_to_merge: Vec<ExportTarget> = if !data.targets.is_empty() {
2013 data.targets.clone()
2014 } else if let Some(legacy) = &data.target {
2015 (0..7_i64)
2017 .map(|day| ExportTarget {
2018 day_of_week: day,
2019 calories: legacy.calories,
2020 protein_pct: legacy.protein_pct,
2021 carbs_pct: legacy.carbs_pct,
2022 fat_pct: legacy.fat_pct,
2023 updated_at: legacy.updated_at.clone(),
2024 })
2025 .collect()
2026 } else {
2027 Vec::new()
2028 };
2029 for incoming_target in &targets_to_merge {
2030 let local_updated: Option<String> = self
2031 .conn
2032 .query_row(
2033 "SELECT updated_at FROM targets WHERE day_of_week = ?1",
2034 params![incoming_target.day_of_week],
2035 |row| row.get(0),
2036 )
2037 .ok();
2038 let should_update = match (&incoming_target.updated_at, &local_updated) {
2039 (Some(incoming), Some(local)) => incoming > local,
2040 (Some(_), None) | (None, _) => true,
2041 };
2042 if should_update {
2043 let updated_at = incoming_target
2044 .updated_at
2045 .clone()
2046 .unwrap_or_else(|| Local::now().to_rfc3339());
2047 self.conn.execute(
2048 "INSERT OR REPLACE INTO targets (day_of_week, calories, protein_pct, carbs_pct, fat_pct, updated_at)
2049 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2050 params![
2051 incoming_target.day_of_week,
2052 incoming_target.calories,
2053 incoming_target.protein_pct,
2054 incoming_target.carbs_pct,
2055 incoming_target.fat_pct,
2056 updated_at,
2057 ],
2058 )?;
2059 targets_imported += 1;
2060 }
2061 }
2062
2063 if let Some(tombstones) = &data.tombstones {
2065 for tombstone in tombstones {
2066 let deleted = self.apply_tombstone(tombstone, &mut recipes_to_recompute)?;
2067 if deleted {
2068 tombstones_processed += 1;
2069 }
2070 }
2071 }
2072
2073 if let Some(tombstones) = &data.tombstones {
2075 for tombstone in tombstones {
2076 let exists: i64 = self
2077 .conn
2078 .query_row(
2079 "SELECT COUNT(*) FROM sync_tombstones WHERE uuid = ?1 AND table_name = ?2",
2080 params![tombstone.uuid, tombstone.table_name],
2081 |row| row.get(0),
2082 )
2083 .unwrap_or(0);
2084 if exists == 0 {
2085 self.conn.execute(
2086 "INSERT INTO sync_tombstones (uuid, table_name, deleted_at) VALUES (?1, ?2, ?3)",
2087 params![tombstone.uuid, tombstone.table_name, tombstone.deleted_at],
2088 )?;
2089 }
2090 }
2091 }
2092
2093 for recipe_id in recipes_to_recompute {
2095 if self.get_recipe_by_id(recipe_id).is_ok() {
2096 self.recompute_recipe_food(recipe_id)?;
2097 }
2098 }
2099
2100 let mut weight_entries_imported: i64 = 0;
2102 for entry in &data.weight_entries {
2103 if entry.uuid.is_empty() {
2104 continue;
2105 }
2106 let existing: Option<(String, String)> = self
2107 .conn
2108 .query_row(
2109 "SELECT uuid, updated_at FROM weight_entries WHERE date = ?1",
2110 params![entry.date],
2111 |row| Ok((row.get(0)?, row.get(1)?)),
2112 )
2113 .ok();
2114 if let Some((_existing_uuid, existing_updated)) = existing {
2115 if entry.updated_at > existing_updated {
2116 self.conn.execute(
2117 "UPDATE weight_entries SET uuid=?1, weight_kg=?2, source=?3, notes=?4, updated_at=?5 WHERE date=?6",
2118 params![entry.uuid, entry.weight_kg, entry.source, entry.notes, entry.updated_at, entry.date],
2119 )?;
2120 weight_entries_imported += 1;
2121 }
2122 } else {
2123 self.conn.execute(
2124 "INSERT INTO weight_entries (uuid, date, weight_kg, source, notes, created_at, updated_at)
2125 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2126 params![entry.uuid, entry.date, entry.weight_kg, entry.source, entry.notes, entry.created_at, entry.updated_at],
2127 )?;
2128 weight_entries_imported += 1;
2129 }
2130 }
2131
2132 Ok(ImportSummary {
2133 foods_imported,
2134 meal_entries_imported,
2135 recipes_imported,
2136 recipe_ingredients_imported,
2137 targets_imported,
2138 weight_entries_imported,
2139 tombstones_processed,
2140 })
2141 }
2142
2143 fn apply_tombstone(
2144 &self,
2145 tombstone: &SyncTombstone,
2146 recipes_to_recompute: &mut std::collections::HashSet<i64>,
2147 ) -> Result<bool> {
2148 match tombstone.table_name.as_str() {
2149 "foods" => {
2150 if let Some(food) = self.get_food_by_uuid(&tombstone.uuid)? {
2151 if food.updated_at < tombstone.deleted_at {
2152 self.conn.execute(
2153 "DELETE FROM foods WHERE uuid = ?1",
2154 params![tombstone.uuid],
2155 )?;
2156 return Ok(true);
2157 }
2158 }
2159 Ok(false)
2160 }
2161 "meal_entries" => {
2162 let local: Option<(i64, String)> = self
2163 .conn
2164 .query_row(
2165 "SELECT id, COALESCE(updated_at, '') FROM meal_entries WHERE uuid = ?1",
2166 params![tombstone.uuid],
2167 |row| Ok((row.get(0)?, row.get(1)?)),
2168 )
2169 .ok();
2170 if let Some((id, updated_at)) = local {
2171 if updated_at < tombstone.deleted_at {
2172 self.conn
2173 .execute("DELETE FROM meal_entries WHERE id = ?1", params![id])?;
2174 return Ok(true);
2175 }
2176 }
2177 Ok(false)
2178 }
2179 "recipes" => {
2180 if let Some(recipe) = self.get_recipe_by_uuid(&tombstone.uuid)? {
2181 if recipe.updated_at < tombstone.deleted_at {
2182 self.conn.execute(
2183 "DELETE FROM recipe_ingredients WHERE recipe_id = ?1",
2184 params![recipe.id],
2185 )?;
2186 self.conn
2187 .execute("DELETE FROM recipes WHERE id = ?1", params![recipe.id])?;
2188 self.conn
2189 .execute("DELETE FROM foods WHERE id = ?1", params![recipe.food_id])?;
2190 return Ok(true);
2191 }
2192 }
2193 Ok(false)
2194 }
2195 "recipe_ingredients" => {
2196 let local: Option<(i64, String, i64)> = self
2197 .conn
2198 .query_row(
2199 "SELECT id, COALESCE(updated_at, ''), recipe_id FROM recipe_ingredients WHERE uuid = ?1",
2200 params![tombstone.uuid],
2201 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2202 )
2203 .ok();
2204 if let Some((id, updated_at, recipe_id)) = local {
2205 if updated_at < tombstone.deleted_at {
2206 self.conn
2207 .execute("DELETE FROM recipe_ingredients WHERE id = ?1", params![id])?;
2208 recipes_to_recompute.insert(recipe_id);
2209 return Ok(true);
2210 }
2211 }
2212 Ok(false)
2213 }
2214 _ => Ok(false),
2215 }
2216 }
2217
2218 pub fn upsert_weight(&self, entry: &NewWeightEntry) -> Result<WeightEntry> {
2221 let now = Local::now().to_rfc3339();
2222 let uuid = Uuid::new_v4().to_string();
2223 let date_str = entry.date.format("%Y-%m-%d").to_string();
2224 self.conn.execute(
2225 "INSERT INTO weight_entries (uuid, date, weight_kg, source, notes, created_at, updated_at)
2226 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
2227 ON CONFLICT(date) DO UPDATE SET
2228 weight_kg = excluded.weight_kg,
2229 source = excluded.source,
2230 notes = excluded.notes,
2231 updated_at = excluded.updated_at",
2232 params![uuid, date_str, entry.weight_kg, entry.source, entry.notes, now, now],
2233 )?;
2234 self.get_weight(entry.date)?
2235 .context("Weight entry not found after upsert")
2236 }
2237
2238 pub fn get_weight(&self, date: NaiveDate) -> Result<Option<WeightEntry>> {
2239 let date_str = date.format("%Y-%m-%d").to_string();
2240 let mut stmt = self.conn.prepare(
2241 "SELECT id, uuid, date, weight_kg, source, notes, created_at, updated_at
2242 FROM weight_entries WHERE date = ?1",
2243 )?;
2244 let mut rows = stmt.query(params![date_str])?;
2245 if let Some(row) = rows.next()? {
2246 Ok(Some(Self::weight_entry_from_row(row)?))
2247 } else {
2248 Ok(None)
2249 }
2250 }
2251
2252 pub fn get_weight_history(&self, days: Option<i64>) -> Result<Vec<WeightEntry>> {
2253 let query = match days {
2254 Some(n) => format!(
2255 "SELECT id, uuid, date, weight_kg, source, notes, created_at, updated_at
2256 FROM weight_entries ORDER BY date DESC LIMIT {n}"
2257 ),
2258 None => "SELECT id, uuid, date, weight_kg, source, notes, created_at, updated_at
2259 FROM weight_entries ORDER BY date DESC"
2260 .to_string(),
2261 };
2262 let mut stmt = self.conn.prepare(&query)?;
2263 let entries = stmt
2264 .query_map([], Self::weight_entry_from_row)?
2265 .collect::<Result<Vec<_>, _>>()?;
2266 Ok(entries)
2267 }
2268
2269 pub fn delete_weight(&self, id: i64) -> Result<()> {
2270 let rows = self
2271 .conn
2272 .execute("DELETE FROM weight_entries WHERE id = ?1", params![id])?;
2273 if rows == 0 {
2274 anyhow::bail!("Weight entry not found");
2275 }
2276 Ok(())
2277 }
2278
2279 fn weight_entry_from_row(row: &rusqlite::Row) -> rusqlite::Result<WeightEntry> {
2280 let date_str: String = row.get(2)?;
2281 let date = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
2282 .unwrap_or_else(|_| NaiveDate::from_ymd_opt(2000, 1, 1).expect("valid date"));
2283 Ok(WeightEntry {
2284 id: row.get(0)?,
2285 uuid: row.get(1)?,
2286 date,
2287 weight_kg: row.get(3)?,
2288 source: row.get(4)?,
2289 notes: row.get(5)?,
2290 created_at: row.get(6)?,
2291 updated_at: row.get(7)?,
2292 })
2293 }
2294
2295 pub fn get_recently_logged_foods(&self, limit: i64) -> Result<Vec<RecentFood>> {
2298 let mut stmt = self.conn.prepare(
2299 "SELECT f.id, f.name, f.brand, f.barcode, f.calories_per_100g,
2300 f.protein_per_100g, f.carbs_per_100g, f.fat_per_100g,
2301 f.default_serving_g, f.source, f.created_at, f.uuid, f.updated_at,
2302 latest.last_serving_g, latest.last_meal_type,
2303 counts.log_count, counts.last_date
2304 FROM foods f
2305 JOIN (
2306 SELECT food_id, COUNT(*) as log_count, MAX(date) as last_date
2307 FROM meal_entries
2308 GROUP BY food_id
2309 ) counts ON f.id = counts.food_id
2310 JOIN (
2311 SELECT me.food_id, me.serving_g as last_serving_g, me.meal_type as last_meal_type
2312 FROM meal_entries me
2313 INNER JOIN (
2314 SELECT food_id, MAX(id) as max_id
2315 FROM meal_entries
2316 WHERE (food_id, date) IN (
2317 SELECT food_id, MAX(date) FROM meal_entries GROUP BY food_id
2318 )
2319 GROUP BY food_id
2320 ) latest_ids ON me.id = latest_ids.max_id
2321 ) latest ON f.id = latest.food_id
2322 ORDER BY counts.last_date DESC, counts.log_count DESC
2323 LIMIT ?1",
2324 )?;
2325 let foods = stmt
2326 .query_map(params![limit], |row| {
2327 let food = Self::food_from_row(row)?;
2328 Ok(RecentFood {
2329 food,
2330 last_serving_g: row.get(13)?,
2331 last_meal_type: row.get(14)?,
2332 log_count: row.get(15)?,
2333 last_logged: row.get(16)?,
2334 })
2335 })?
2336 .collect::<Result<Vec<_>, _>>()?;
2337 Ok(foods)
2338 }
2339
2340 pub fn get_logging_streak(&self, today: NaiveDate) -> Result<i64> {
2341 let mut stmt = self
2343 .conn
2344 .prepare("SELECT DISTINCT date FROM meal_entries ORDER BY date DESC")?;
2345 let dates: Vec<String> = stmt
2346 .query_map([], |row| row.get(0))?
2347 .collect::<Result<Vec<_>, _>>()?;
2348
2349 if dates.is_empty() {
2350 return Ok(0);
2351 }
2352
2353 let today_str = today.format("%Y-%m-%d").to_string();
2354 let yesterday = today - chrono::Duration::days(1);
2355 let yesterday_str = yesterday.format("%Y-%m-%d").to_string();
2356
2357 let start_date = if dates.first().is_some_and(|d| d == &today_str) {
2359 today
2360 } else if dates.first().is_some_and(|d| d == &yesterday_str) {
2361 yesterday
2362 } else {
2363 return Ok(0);
2364 };
2365
2366 let mut streak: i64 = 0;
2367 for date_str in &dates {
2368 let expected = (start_date - chrono::Duration::days(streak))
2369 .format("%Y-%m-%d")
2370 .to_string();
2371 if date_str == &expected {
2372 streak += 1;
2373 } else {
2374 break;
2375 }
2376 }
2377
2378 Ok(streak)
2379 }
2380
2381 #[allow(clippy::cast_precision_loss)]
2382 pub fn get_calorie_average(&self, days: i64) -> Result<f64> {
2383 let today = Local::now().date_naive();
2384 let start_date = today - chrono::Duration::days(days - 1);
2385 let start_str = start_date.format("%Y-%m-%d").to_string();
2386 let end_str = today.format("%Y-%m-%d").to_string();
2387
2388 let result: Option<f64> = self.conn.query_row(
2389 "SELECT AVG(daily_total) FROM (
2390 SELECT SUM(f.calories_per_100g * me.serving_g / 100.0) as daily_total
2391 FROM meal_entries me
2392 JOIN foods f ON me.food_id = f.id
2393 WHERE me.date >= ?1 AND me.date <= ?2
2394 GROUP BY me.date
2395 )",
2396 params![start_str, end_str],
2397 |row| row.get(0),
2398 )?;
2399
2400 Ok(result.unwrap_or(0.0))
2401 }
2402
2403 pub fn set_setting(&self, key: &str, value: &str) -> Result<()> {
2406 let now = Local::now().to_rfc3339();
2407 self.conn.execute(
2408 "INSERT INTO user_settings (key, value, updated_at)
2409 VALUES (?1, ?2, ?3)
2410 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
2411 params![key, value, now],
2412 )?;
2413 Ok(())
2414 }
2415
2416 pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
2417 let mut stmt = self
2418 .conn
2419 .prepare("SELECT value FROM user_settings WHERE key = ?1")?;
2420 let mut rows = stmt.query(params![key])?;
2421 if let Some(row) = rows.next()? {
2422 Ok(Some(row.get(0)?))
2423 } else {
2424 Ok(None)
2425 }
2426 }
2427
2428 pub fn delete_setting(&self, key: &str) -> Result<bool> {
2429 let rows = self
2430 .conn
2431 .execute("DELETE FROM user_settings WHERE key = ?1", params![key])?;
2432 Ok(rows > 0)
2433 }
2434
2435 pub fn build_daily_summary(&self, date: NaiveDate) -> Result<DailySummary> {
2436 let entries = self.get_entries_for_date(date)?;
2437 let mut meals: Vec<MealGroup> = Vec::new();
2438
2439 for meal_type in MEAL_TYPES {
2440 let meal_entries: Vec<MealEntry> = entries
2441 .iter()
2442 .filter(|e| e.meal_type == *meal_type)
2443 .cloned()
2444 .collect();
2445
2446 if meal_entries.is_empty() {
2447 continue;
2448 }
2449
2450 let subtotal_calories: f64 = meal_entries.iter().filter_map(|e| e.calories).sum();
2451 let subtotal_protein: f64 = meal_entries.iter().filter_map(|e| e.protein).sum();
2452 let subtotal_carbs: f64 = meal_entries.iter().filter_map(|e| e.carbs).sum();
2453 let subtotal_fat: f64 = meal_entries.iter().filter_map(|e| e.fat).sum();
2454
2455 meals.push(MealGroup {
2456 meal_type: meal_type.to_string(),
2457 entries: meal_entries,
2458 subtotal_calories,
2459 subtotal_protein,
2460 subtotal_carbs,
2461 subtotal_fat,
2462 });
2463 }
2464
2465 let total_calories: f64 = meals.iter().map(|m| m.subtotal_calories).sum();
2466 let total_protein: f64 = meals.iter().map(|m| m.subtotal_protein).sum();
2467 let total_carbs: f64 = meals.iter().map(|m| m.subtotal_carbs).sum();
2468 let total_fat: f64 = meals.iter().map(|m| m.subtotal_fat).sum();
2469
2470 let day_of_week = i64::from(date.weekday().num_days_from_monday());
2471 let target = self.get_target(day_of_week)?;
2472
2473 Ok(DailySummary {
2474 date: date.format("%Y-%m-%d").to_string(),
2475 meals,
2476 total_calories,
2477 total_protein,
2478 total_carbs,
2479 total_fat,
2480 target,
2481 })
2482 }
2483}
2484
2485#[cfg(test)]
2486mod tests {
2487 use super::*;
2488 use crate::models::{NewFood, NewMealEntry, UpdateMealEntry};
2489
2490 fn sample_food() -> NewFood {
2491 NewFood {
2492 name: "Chicken Breast".to_string(),
2493 brand: Some("Acme".to_string()),
2494 barcode: Some("1234567890".to_string()),
2495 calories_per_100g: 165.0,
2496 protein_per_100g: Some(31.0),
2497 carbs_per_100g: Some(0.0),
2498 fat_per_100g: Some(3.6),
2499 default_serving_g: Some(150.0),
2500 source: "manual".to_string(),
2501 }
2502 }
2503
2504 #[test]
2505 fn test_insert_and_get_food() {
2506 let db = Database::open_in_memory().unwrap();
2507 let food = db.insert_food(&sample_food()).unwrap();
2508
2509 assert_eq!(food.name, "Chicken Breast");
2510 assert_eq!(food.brand.as_deref(), Some("Acme"));
2511 assert_eq!(food.barcode.as_deref(), Some("1234567890"));
2512 assert_eq!(food.calories_per_100g, 165.0);
2513 assert_eq!(food.protein_per_100g, Some(31.0));
2514 assert_eq!(food.source, "manual");
2515
2516 let fetched = db.get_food_by_id(food.id).unwrap();
2517 assert_eq!(fetched.id, food.id);
2518 assert_eq!(fetched.name, "Chicken Breast");
2519 }
2520
2521 #[test]
2522 fn test_upsert_food_by_barcode() {
2523 let db = Database::open_in_memory().unwrap();
2524 let food1 = db.upsert_food_by_barcode(&sample_food()).unwrap();
2525 let food2 = db.upsert_food_by_barcode(&sample_food()).unwrap();
2526
2527 assert_eq!(food1.id, food2.id);
2529 }
2530
2531 #[test]
2532 fn test_search_foods_local() {
2533 let db = Database::open_in_memory().unwrap();
2534 db.insert_food(&sample_food()).unwrap();
2535 db.insert_food(&NewFood {
2536 name: "Brown Rice".to_string(),
2537 brand: None,
2538 barcode: None,
2539 calories_per_100g: 112.0,
2540 protein_per_100g: Some(2.6),
2541 carbs_per_100g: Some(23.5),
2542 fat_per_100g: Some(0.9),
2543 default_serving_g: None,
2544 source: "manual".to_string(),
2545 })
2546 .unwrap();
2547
2548 let results = db.search_foods_local("chicken").unwrap();
2549 assert_eq!(results.len(), 1);
2550 assert_eq!(results[0].name, "Chicken Breast");
2551
2552 let results = db.search_foods_local("rice").unwrap();
2553 assert_eq!(results.len(), 1);
2554 assert_eq!(results[0].name, "Brown Rice");
2555
2556 let results = db.search_foods_local("pizza").unwrap();
2557 assert!(results.is_empty());
2558 }
2559
2560 #[test]
2561 fn test_list_foods() {
2562 let db = Database::open_in_memory().unwrap();
2563 db.insert_food(&sample_food()).unwrap();
2564 db.insert_food(&NewFood {
2565 name: "Brown Rice".to_string(),
2566 brand: None,
2567 barcode: None,
2568 calories_per_100g: 112.0,
2569 protein_per_100g: None,
2570 carbs_per_100g: None,
2571 fat_per_100g: None,
2572 default_serving_g: None,
2573 source: "manual".to_string(),
2574 })
2575 .unwrap();
2576
2577 let all = db.list_foods(None).unwrap();
2579 assert_eq!(all.len(), 2);
2580
2581 let filtered = db.list_foods(Some("rice")).unwrap();
2583 assert_eq!(filtered.len(), 1);
2584 assert_eq!(filtered[0].name, "Brown Rice");
2585 }
2586
2587 #[test]
2588 fn test_insert_and_get_meal_entry() {
2589 let db = Database::open_in_memory().unwrap();
2590 let food = db.insert_food(&sample_food()).unwrap();
2591
2592 let entry = db
2593 .insert_meal_entry(&NewMealEntry {
2594 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
2595 meal_type: "lunch".to_string(),
2596 food_id: food.id,
2597 serving_g: 200.0,
2598 display_unit: None,
2599 display_quantity: None,
2600 })
2601 .unwrap();
2602
2603 assert_eq!(entry.meal_type, "lunch");
2604 assert_eq!(entry.serving_g, 200.0);
2605 assert_eq!(entry.food_name.as_deref(), Some("Chicken Breast"));
2606 let cal = entry.calories.unwrap();
2608 assert!((cal - 330.0).abs() < 0.01);
2609 let pro = entry.protein.unwrap();
2611 assert!((pro - 62.0).abs() < 0.01);
2612 }
2613
2614 #[test]
2615 fn test_delete_meal_entry() {
2616 let db = Database::open_in_memory().unwrap();
2617 let food = db.insert_food(&sample_food()).unwrap();
2618 let entry = db
2619 .insert_meal_entry(&NewMealEntry {
2620 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
2621 meal_type: "lunch".to_string(),
2622 food_id: food.id,
2623 serving_g: 100.0,
2624 display_unit: None,
2625 display_quantity: None,
2626 })
2627 .unwrap();
2628
2629 assert!(db.delete_meal_entry(entry.id).unwrap());
2630 assert!(!db.delete_meal_entry(entry.id).unwrap());
2632 }
2633
2634 #[test]
2635 fn test_get_entries_for_date() {
2636 let db = Database::open_in_memory().unwrap();
2637 let food = db.insert_food(&sample_food()).unwrap();
2638 let date1 = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2639 let date2 = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap();
2640
2641 db.insert_meal_entry(&NewMealEntry {
2642 date: date1,
2643 meal_type: "breakfast".to_string(),
2644 food_id: food.id,
2645 serving_g: 100.0,
2646 display_unit: None,
2647 display_quantity: None,
2648 })
2649 .unwrap();
2650 db.insert_meal_entry(&NewMealEntry {
2651 date: date2,
2652 meal_type: "lunch".to_string(),
2653 food_id: food.id,
2654 serving_g: 150.0,
2655 display_unit: None,
2656 display_quantity: None,
2657 })
2658 .unwrap();
2659
2660 let entries = db.get_entries_for_date(date1).unwrap();
2661 assert_eq!(entries.len(), 1);
2662 assert_eq!(entries[0].meal_type, "breakfast");
2663
2664 let entries = db.get_entries_for_date(date2).unwrap();
2665 assert_eq!(entries.len(), 1);
2666 assert_eq!(entries[0].meal_type, "lunch");
2667 }
2668
2669 #[test]
2670 fn test_get_entries_for_date_and_meal() {
2671 let db = Database::open_in_memory().unwrap();
2672 let food = db.insert_food(&sample_food()).unwrap();
2673 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2674
2675 db.insert_meal_entry(&NewMealEntry {
2676 date,
2677 meal_type: "breakfast".to_string(),
2678 food_id: food.id,
2679 serving_g: 100.0,
2680 display_unit: None,
2681 display_quantity: None,
2682 })
2683 .unwrap();
2684 db.insert_meal_entry(&NewMealEntry {
2685 date,
2686 meal_type: "lunch".to_string(),
2687 food_id: food.id,
2688 serving_g: 200.0,
2689 display_unit: None,
2690 display_quantity: None,
2691 })
2692 .unwrap();
2693
2694 let breakfast = db.get_entries_for_date_and_meal(date, "breakfast").unwrap();
2695 assert_eq!(breakfast.len(), 1);
2696 assert_eq!(breakfast[0].serving_g, 100.0);
2697
2698 let lunch = db.get_entries_for_date_and_meal(date, "lunch").unwrap();
2699 assert_eq!(lunch.len(), 1);
2700 assert_eq!(lunch[0].serving_g, 200.0);
2701
2702 let dinner = db.get_entries_for_date_and_meal(date, "dinner").unwrap();
2703 assert!(dinner.is_empty());
2704 }
2705
2706 #[test]
2707 fn test_build_daily_summary() {
2708 let db = Database::open_in_memory().unwrap();
2709 let food = db.insert_food(&sample_food()).unwrap();
2710 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2711
2712 db.insert_meal_entry(&NewMealEntry {
2714 date,
2715 meal_type: "breakfast".to_string(),
2716 food_id: food.id,
2717 serving_g: 100.0,
2718 display_unit: None,
2719 display_quantity: None,
2720 })
2721 .unwrap();
2722 db.insert_meal_entry(&NewMealEntry {
2724 date,
2725 meal_type: "lunch".to_string(),
2726 food_id: food.id,
2727 serving_g: 200.0,
2728 display_unit: None,
2729 display_quantity: None,
2730 })
2731 .unwrap();
2732
2733 let summary = db.build_daily_summary(date).unwrap();
2734 assert_eq!(summary.meals.len(), 2);
2735 assert_eq!(summary.meals[0].meal_type, "breakfast");
2736 assert_eq!(summary.meals[1].meal_type, "lunch");
2737 assert!((summary.meals[0].subtotal_calories - 165.0).abs() < 0.01);
2738 assert!((summary.meals[1].subtotal_calories - 330.0).abs() < 0.01);
2739 assert!((summary.total_calories - 495.0).abs() < 0.01);
2740 assert!((summary.total_protein - 93.0).abs() < 0.01); }
2742
2743 #[test]
2744 fn test_build_daily_summary_empty() {
2745 let db = Database::open_in_memory().unwrap();
2746 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2747
2748 let summary = db.build_daily_summary(date).unwrap();
2749 assert!(summary.meals.is_empty());
2750 assert_eq!(summary.total_calories, 0.0);
2751 assert_eq!(summary.total_protein, 0.0);
2752 assert_eq!(summary.total_carbs, 0.0);
2753 assert_eq!(summary.total_fat, 0.0);
2754 assert!(summary.target.is_none());
2755 }
2756
2757 #[test]
2758 fn test_set_and_get_target() {
2759 let db = Database::open_in_memory().unwrap();
2760
2761 assert!(db.get_target(0).unwrap().is_none());
2763
2764 let target = db
2766 .set_target(0, 1800, Some(40), Some(30), Some(30))
2767 .unwrap();
2768 assert_eq!(target.day_of_week, 0);
2769 assert_eq!(target.calories, 1800);
2770 assert_eq!(target.protein_pct, Some(40));
2771 assert!((target.protein_g.unwrap() - 180.0).abs() < 0.01);
2772 assert!((target.carbs_g.unwrap() - 135.0).abs() < 0.01);
2773 assert!((target.fat_g.unwrap() - 60.0).abs() < 0.01);
2774
2775 let fetched = db.get_target(0).unwrap().unwrap();
2777 assert_eq!(fetched.calories, 1800);
2778 assert_eq!(fetched.protein_pct, Some(40));
2779
2780 assert!(db.get_target(1).unwrap().is_none());
2782
2783 let sat = db.set_target(5, 2200, None, None, None).unwrap();
2785 assert_eq!(sat.day_of_week, 5);
2786 assert_eq!(sat.calories, 2200);
2787
2788 let updated = db.set_target(0, 2000, None, None, None).unwrap();
2790 assert_eq!(updated.calories, 2000);
2791 assert!(updated.protein_pct.is_none());
2792
2793 let fetched = db.get_target(0).unwrap().unwrap();
2795 assert_eq!(fetched.calories, 2000);
2796
2797 let all = db.get_all_targets().unwrap();
2799 assert_eq!(all.len(), 2);
2800 assert_eq!(all[0].day_of_week, 0);
2801 assert_eq!(all[1].day_of_week, 5);
2802 }
2803
2804 #[test]
2805 fn test_clear_target() {
2806 let db = Database::open_in_memory().unwrap();
2807
2808 assert!(!db.clear_target(0).unwrap());
2810
2811 db.set_target(0, 1800, None, None, None).unwrap();
2813 db.set_target(1, 1900, None, None, None).unwrap();
2814 assert!(db.get_target(0).unwrap().is_some());
2815 assert!(db.get_target(1).unwrap().is_some());
2816
2817 assert!(db.clear_target(0).unwrap());
2819 assert!(db.get_target(0).unwrap().is_none());
2820 assert!(db.get_target(1).unwrap().is_some());
2821
2822 db.set_target(0, 1800, None, None, None).unwrap();
2824 assert!(db.clear_all_targets().unwrap());
2825 assert!(db.get_all_targets().unwrap().is_empty());
2826
2827 assert!(!db.clear_all_targets().unwrap());
2829 }
2830
2831 #[test]
2832 fn test_summary_includes_target() {
2833 let db = Database::open_in_memory().unwrap();
2834 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2836
2837 let summary = db.build_daily_summary(date).unwrap();
2839 assert!(summary.target.is_none());
2840
2841 db.set_target(5, 1800, Some(40), Some(30), Some(30))
2843 .unwrap();
2844 let summary = db.build_daily_summary(date).unwrap();
2845 let target = summary.target.unwrap();
2846 assert_eq!(target.calories, 1800);
2847 assert_eq!(target.day_of_week, 5);
2848 assert!((target.protein_g.unwrap() - 180.0).abs() < 0.01);
2849
2850 db.set_target(0, 2500, None, None, None).unwrap();
2852 let summary = db.build_daily_summary(date).unwrap();
2853 let target = summary.target.unwrap();
2854 assert_eq!(target.calories, 1800); }
2856
2857 #[test]
2858 fn test_update_meal_entry_serving() {
2859 let db = Database::open_in_memory().unwrap();
2860 let food = db.insert_food(&sample_food()).unwrap();
2861 let entry = db
2862 .insert_meal_entry(&NewMealEntry {
2863 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
2864 meal_type: "lunch".to_string(),
2865 food_id: food.id,
2866 serving_g: 100.0,
2867 display_unit: None,
2868 display_quantity: None,
2869 })
2870 .unwrap();
2871
2872 let updated = db
2873 .update_meal_entry(
2874 entry.id,
2875 &UpdateMealEntry {
2876 serving_g: Some(250.0),
2877 meal_type: None,
2878 date: None,
2879 display_unit: None,
2880 display_quantity: None,
2881 },
2882 )
2883 .unwrap();
2884
2885 assert_eq!(updated.serving_g, 250.0);
2886 assert_eq!(updated.meal_type, "lunch");
2887 assert!((updated.calories.unwrap() - 412.5).abs() < 0.01);
2889 }
2890
2891 #[test]
2892 fn test_update_meal_entry_meal_type() {
2893 let db = Database::open_in_memory().unwrap();
2894 let food = db.insert_food(&sample_food()).unwrap();
2895 let entry = db
2896 .insert_meal_entry(&NewMealEntry {
2897 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
2898 meal_type: "lunch".to_string(),
2899 food_id: food.id,
2900 serving_g: 100.0,
2901 display_unit: None,
2902 display_quantity: None,
2903 })
2904 .unwrap();
2905
2906 let updated = db
2907 .update_meal_entry(
2908 entry.id,
2909 &UpdateMealEntry {
2910 serving_g: None,
2911 meal_type: Some("dinner".to_string()),
2912 date: None,
2913 display_unit: None,
2914 display_quantity: None,
2915 },
2916 )
2917 .unwrap();
2918
2919 assert_eq!(updated.meal_type, "dinner");
2920 assert_eq!(updated.serving_g, 100.0);
2921 }
2922
2923 #[test]
2924 fn test_update_meal_entry_not_found() {
2925 let db = Database::open_in_memory().unwrap();
2926 let result = db.update_meal_entry(
2927 999,
2928 &UpdateMealEntry {
2929 serving_g: Some(100.0),
2930 meal_type: None,
2931 date: None,
2932 display_unit: None,
2933 display_quantity: None,
2934 },
2935 );
2936 assert!(result.is_err());
2937 }
2938
2939 #[test]
2940 fn test_update_meal_entry_noop() {
2941 let db = Database::open_in_memory().unwrap();
2942 let food = db.insert_food(&sample_food()).unwrap();
2943 let entry = db
2944 .insert_meal_entry(&NewMealEntry {
2945 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
2946 meal_type: "lunch".to_string(),
2947 food_id: food.id,
2948 serving_g: 100.0,
2949 display_unit: None,
2950 display_quantity: None,
2951 })
2952 .unwrap();
2953
2954 let updated = db
2955 .update_meal_entry(
2956 entry.id,
2957 &UpdateMealEntry {
2958 serving_g: None,
2959 meal_type: None,
2960 date: None,
2961 display_unit: None,
2962 display_quantity: None,
2963 },
2964 )
2965 .unwrap();
2966
2967 assert_eq!(updated.serving_g, 100.0);
2968 assert_eq!(updated.meal_type, "lunch");
2969 }
2970
2971 fn sample_ingredient_rice() -> NewFood {
2974 NewFood {
2975 name: "Brown Rice".to_string(),
2976 brand: None,
2977 barcode: None,
2978 calories_per_100g: 112.0,
2979 protein_per_100g: Some(2.6),
2980 carbs_per_100g: Some(23.5),
2981 fat_per_100g: Some(0.9),
2982 default_serving_g: None,
2983 source: "manual".to_string(),
2984 }
2985 }
2986
2987 #[test]
2988 fn test_create_recipe() {
2989 let db = Database::open_in_memory().unwrap();
2990 let recipe = db.create_recipe("Chicken and Rice", 4.0).unwrap();
2991 assert_eq!(recipe.portions, 4.0);
2992
2993 let food = db.get_food_by_id(recipe.food_id).unwrap();
2995 assert_eq!(food.name, "Chicken and Rice");
2996 assert_eq!(food.source, "recipe");
2997 }
2998
2999 #[test]
3000 fn test_recipe_add_ingredient_recomputes() {
3001 let db = Database::open_in_memory().unwrap();
3002 let chicken = db.insert_food(&sample_food()).unwrap();
3003 let rice = db.insert_food(&sample_ingredient_rice()).unwrap();
3004 let recipe = db.create_recipe("Chicken and Rice", 2.0).unwrap();
3005
3006 db.add_recipe_ingredient(recipe.id, chicken.id, 200.0)
3008 .unwrap();
3009 db.add_recipe_ingredient(recipe.id, rice.id, 300.0).unwrap();
3011
3012 let detail = db.get_recipe_detail(recipe.id).unwrap();
3013 assert_eq!(detail.ingredients.len(), 2);
3014 assert!((detail.total_weight_g - 500.0).abs() < 0.01);
3015 assert!((detail.per_portion_g - 250.0).abs() < 0.01);
3016
3017 let expected_total_cal = 330.0 + 336.0;
3019 let expected_per_portion_cal = expected_total_cal / 2.0;
3020 assert!((detail.per_portion_calories - expected_per_portion_cal).abs() < 0.01);
3021
3022 let food = db.get_food_by_id(recipe.food_id).unwrap();
3024 let expected_cal_100 = expected_total_cal * 100.0 / 500.0;
3025 assert!((food.calories_per_100g - expected_cal_100).abs() < 0.01);
3026 assert!((food.default_serving_g.unwrap() - 250.0).abs() < 0.01);
3028 }
3029
3030 #[test]
3031 fn test_recipe_set_portions() {
3032 let db = Database::open_in_memory().unwrap();
3033 let chicken = db.insert_food(&sample_food()).unwrap();
3034 let recipe = db.create_recipe("Just Chicken", 2.0).unwrap();
3035 db.add_recipe_ingredient(recipe.id, chicken.id, 400.0)
3036 .unwrap();
3037
3038 db.set_recipe_portions(recipe.id, 4.0).unwrap();
3040
3041 let food = db.get_food_by_id(recipe.food_id).unwrap();
3042 assert!((food.default_serving_g.unwrap() - 100.0).abs() < 0.01);
3044 assert!((food.calories_per_100g - 165.0).abs() < 0.01);
3046 }
3047
3048 #[test]
3049 fn test_recipe_remove_ingredient() {
3050 let db = Database::open_in_memory().unwrap();
3051 let chicken = db.insert_food(&sample_food()).unwrap();
3052 let rice = db.insert_food(&sample_ingredient_rice()).unwrap();
3053 let recipe = db.create_recipe("Mixed", 1.0).unwrap();
3054 db.add_recipe_ingredient(recipe.id, chicken.id, 100.0)
3055 .unwrap();
3056 db.add_recipe_ingredient(recipe.id, rice.id, 100.0).unwrap();
3057
3058 assert!(
3059 db.remove_recipe_ingredient(recipe.id, "Brown Rice")
3060 .unwrap()
3061 );
3062 let detail = db.get_recipe_detail(recipe.id).unwrap();
3063 assert_eq!(detail.ingredients.len(), 1);
3064 assert!((detail.total_weight_g - 100.0).abs() < 0.01);
3065 }
3066
3067 #[test]
3068 fn test_recipe_log_as_food() {
3069 let db = Database::open_in_memory().unwrap();
3070 let chicken = db.insert_food(&sample_food()).unwrap();
3071 let recipe = db.create_recipe("Meal Prep Chicken", 4.0).unwrap();
3072 db.add_recipe_ingredient(recipe.id, chicken.id, 800.0)
3073 .unwrap();
3074
3075 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
3077 let food = db.get_food_by_id(recipe.food_id).unwrap();
3078 let serving = food.default_serving_g.unwrap(); assert!((serving - 200.0).abs() < 0.01);
3080
3081 let entry = db
3082 .insert_meal_entry(&NewMealEntry {
3083 date,
3084 meal_type: "dinner".to_string(),
3085 food_id: recipe.food_id,
3086 serving_g: serving,
3087 display_unit: None,
3088 display_quantity: None,
3089 })
3090 .unwrap();
3091
3092 assert!((entry.calories.unwrap() - 330.0).abs() < 0.01);
3094
3095 let summary = db.build_daily_summary(date).unwrap();
3097 assert!((summary.total_calories - 330.0).abs() < 0.01);
3098 }
3099
3100 #[test]
3101 fn test_delete_recipe() {
3102 let db = Database::open_in_memory().unwrap();
3103 let chicken = db.insert_food(&sample_food()).unwrap();
3104 let recipe = db.create_recipe("To Delete", 1.0).unwrap();
3105 db.add_recipe_ingredient(recipe.id, chicken.id, 100.0)
3106 .unwrap();
3107 let food_id = recipe.food_id;
3108
3109 db.delete_recipe(recipe.id).unwrap();
3110 assert!(db.get_food_by_id(food_id).is_err());
3112 assert!(db.get_recipe_by_id(recipe.id).is_err());
3114 }
3115
3116 #[test]
3117 fn test_list_recipes() {
3118 let db = Database::open_in_memory().unwrap();
3119 assert!(db.list_recipes().unwrap().is_empty());
3120
3121 db.create_recipe("Recipe A", 2.0).unwrap();
3122 db.create_recipe("Recipe B", 4.0).unwrap();
3123 let recipes = db.list_recipes().unwrap();
3124 assert_eq!(recipes.len(), 2);
3125 }
3126
3127 #[test]
3130 fn test_export_all_empty() {
3131 let db = Database::open_in_memory().unwrap();
3132 let export = db.export_all().unwrap();
3133 assert_eq!(export.version, 3);
3134 assert!(export.device_id.is_some());
3135 assert!(export.foods.is_empty());
3136 assert!(export.meal_entries.is_empty());
3137 assert!(export.recipes.is_empty());
3138 assert!(export.recipe_ingredients.is_empty());
3139 assert!(export.targets.is_empty());
3140 }
3141
3142 #[test]
3143 fn test_export_all_with_data() {
3144 let db = Database::open_in_memory().unwrap();
3145 let food = db.insert_food(&sample_food()).unwrap();
3146 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
3147 db.insert_meal_entry(&NewMealEntry {
3148 date,
3149 meal_type: "lunch".to_string(),
3150 food_id: food.id,
3151 serving_g: 200.0,
3152 display_unit: None,
3153 display_quantity: None,
3154 })
3155 .unwrap();
3156 db.set_target(0, 2000, Some(30), Some(40), Some(30))
3157 .unwrap();
3158
3159 let export = db.export_all().unwrap();
3160 assert_eq!(export.foods.len(), 1);
3161 assert_eq!(export.meal_entries.len(), 1);
3162 assert_eq!(export.targets.len(), 1);
3163 assert_eq!(export.targets[0].calories, 2000);
3164 assert_eq!(export.targets[0].day_of_week, 0);
3165 }
3166
3167 #[test]
3168 fn test_import_into_empty_db() {
3169 let db = Database::open_in_memory().unwrap();
3170
3171 let source_db = Database::open_in_memory().unwrap();
3173 let food = source_db.insert_food(&sample_food()).unwrap();
3174 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
3175 source_db
3176 .insert_meal_entry(&NewMealEntry {
3177 date,
3178 meal_type: "lunch".to_string(),
3179 food_id: food.id,
3180 serving_g: 200.0,
3181 display_unit: None,
3182 display_quantity: None,
3183 })
3184 .unwrap();
3185 source_db
3186 .set_target(0, 2000, Some(30), Some(40), Some(30))
3187 .unwrap();
3188
3189 let export = source_db.export_all().unwrap();
3190 let summary = db.import_all(&export).unwrap();
3191
3192 assert_eq!(summary.foods_imported, 1);
3193 assert_eq!(summary.meal_entries_imported, 1);
3194 assert_eq!(summary.targets_imported, 1);
3195
3196 let imported_food = db.get_food_by_id(food.id).unwrap();
3198 assert_eq!(imported_food.name, "Chicken Breast");
3199 let target = db.get_target(0).unwrap().unwrap();
3200 assert_eq!(target.calories, 2000);
3201 }
3202
3203 #[test]
3204 fn test_import_upsert_existing() {
3205 let db = Database::open_in_memory().unwrap();
3206 let food = db.insert_food(&sample_food()).unwrap();
3207
3208 let export = db.export_all().unwrap();
3210 let mut modified = export;
3211 modified.foods[0].name = "Updated Chicken".to_string();
3212 modified.foods[0].updated_at = "2099-01-01T00:00:00+00:00".to_string();
3213
3214 let summary = db.import_all(&modified).unwrap();
3215 assert_eq!(summary.foods_imported, 1);
3216
3217 let updated_food = db.get_food_by_id(food.id).unwrap();
3218 assert_eq!(updated_food.name, "Updated Chicken");
3219 }
3220
3221 #[test]
3222 fn test_get_recipe_by_food_name() {
3223 let db = Database::open_in_memory().unwrap();
3224 let recipe = db.create_recipe("My Stew", 3.0).unwrap();
3225
3226 let found = db.get_recipe_by_food_name("my stew").unwrap();
3228 assert_eq!(found.id, recipe.id);
3229 let found = db.get_recipe_by_food_name("MY STEW").unwrap();
3230 assert_eq!(found.id, recipe.id);
3231
3232 assert!(db.get_recipe_by_food_name("nonexistent").is_err());
3234 }
3235
3236 #[test]
3239 fn test_insert_food_generates_uuid() {
3240 let db = Database::open_in_memory().unwrap();
3241 let food = db.insert_food(&sample_food()).unwrap();
3242 assert!(!food.uuid.is_empty());
3243 assert!(!food.updated_at.is_empty());
3244 assert!(uuid::Uuid::parse_str(&food.uuid).is_ok());
3246 }
3247
3248 #[test]
3249 fn test_insert_meal_generates_uuid() {
3250 let db = Database::open_in_memory().unwrap();
3251 let food = db.insert_food(&sample_food()).unwrap();
3252 let entry = db
3253 .insert_meal_entry(&NewMealEntry {
3254 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3255 meal_type: "lunch".to_string(),
3256 food_id: food.id,
3257 serving_g: 200.0,
3258 display_unit: None,
3259 display_quantity: None,
3260 })
3261 .unwrap();
3262 assert!(!entry.uuid.is_empty());
3263 assert!(!entry.updated_at.is_empty());
3264 assert!(uuid::Uuid::parse_str(&entry.uuid).is_ok());
3265 }
3266
3267 #[test]
3268 fn test_update_meal_updates_timestamp() {
3269 let db = Database::open_in_memory().unwrap();
3270 let food = db.insert_food(&sample_food()).unwrap();
3271 let entry = db
3272 .insert_meal_entry(&NewMealEntry {
3273 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3274 meal_type: "lunch".to_string(),
3275 food_id: food.id,
3276 serving_g: 100.0,
3277 display_unit: None,
3278 display_quantity: None,
3279 })
3280 .unwrap();
3281 let original_updated = entry.updated_at.clone();
3282
3283 std::thread::sleep(std::time::Duration::from_millis(10));
3285
3286 let updated = db
3287 .update_meal_entry(
3288 entry.id,
3289 &UpdateMealEntry {
3290 serving_g: Some(250.0),
3291 meal_type: None,
3292 date: None,
3293 display_unit: None,
3294 display_quantity: None,
3295 },
3296 )
3297 .unwrap();
3298 assert!(updated.updated_at >= original_updated);
3299 assert_eq!(updated.uuid, entry.uuid); }
3301
3302 #[test]
3303 fn test_merge_foods_new() {
3304 let db = Database::open_in_memory().unwrap();
3305 let incoming_uuid = Uuid::new_v4().to_string();
3306 let now = Local::now().to_rfc3339();
3307
3308 let import_data = ExportData {
3309 version: 2,
3310 exported_at: now.clone(),
3311 device_id: Some("other-device".to_string()),
3312 foods: vec![Food {
3313 id: 999,
3314 uuid: incoming_uuid.clone(),
3315 name: "Remote Food".to_string(),
3316 brand: None,
3317 barcode: None,
3318 calories_per_100g: 100.0,
3319 protein_per_100g: Some(10.0),
3320 carbs_per_100g: Some(20.0),
3321 fat_per_100g: Some(5.0),
3322 default_serving_g: None,
3323 source: "manual".to_string(),
3324 created_at: now.clone(),
3325 updated_at: now,
3326 }],
3327 meal_entries: vec![],
3328 recipes: vec![],
3329 recipe_ingredients: vec![],
3330 target: None,
3331 targets: vec![],
3332 weight_entries: vec![],
3333 tombstones: None,
3334 };
3335
3336 let summary = db.import_all(&import_data).unwrap();
3337 assert_eq!(summary.foods_imported, 1);
3338
3339 let found = db.get_food_by_uuid(&incoming_uuid).unwrap().unwrap();
3341 assert_eq!(found.name, "Remote Food");
3342 }
3343
3344 #[test]
3345 fn test_merge_foods_newer_wins() {
3346 let db = Database::open_in_memory().unwrap();
3347 let food = db.insert_food(&sample_food()).unwrap();
3348
3349 let import_data = ExportData {
3350 version: 2,
3351 exported_at: Local::now().to_rfc3339(),
3352 device_id: Some("other-device".to_string()),
3353 foods: vec![Food {
3354 id: 999,
3355 uuid: food.uuid.clone(),
3356 name: "Updated Name".to_string(),
3357 brand: None,
3358 barcode: None,
3359 calories_per_100g: 200.0,
3360 protein_per_100g: Some(20.0),
3361 carbs_per_100g: Some(10.0),
3362 fat_per_100g: Some(5.0),
3363 default_serving_g: None,
3364 source: "manual".to_string(),
3365 created_at: food.created_at.clone(),
3366 updated_at: "2099-01-01T00:00:00+00:00".to_string(),
3367 }],
3368 meal_entries: vec![],
3369 recipes: vec![],
3370 recipe_ingredients: vec![],
3371 target: None,
3372 targets: vec![],
3373 weight_entries: vec![],
3374 tombstones: None,
3375 };
3376
3377 let summary = db.import_all(&import_data).unwrap();
3378 assert_eq!(summary.foods_imported, 1);
3379
3380 let updated = db.get_food_by_id(food.id).unwrap();
3381 assert_eq!(updated.name, "Updated Name");
3382 assert_eq!(updated.calories_per_100g, 200.0);
3383 }
3384
3385 #[test]
3386 fn test_merge_foods_older_skipped() {
3387 let db = Database::open_in_memory().unwrap();
3388 let food = db.insert_food(&sample_food()).unwrap();
3389
3390 let import_data = ExportData {
3391 version: 2,
3392 exported_at: Local::now().to_rfc3339(),
3393 device_id: Some("other-device".to_string()),
3394 foods: vec![Food {
3395 id: 999,
3396 uuid: food.uuid.clone(),
3397 name: "Should Not Update".to_string(),
3398 brand: None,
3399 barcode: None,
3400 calories_per_100g: 200.0,
3401 protein_per_100g: None,
3402 carbs_per_100g: None,
3403 fat_per_100g: None,
3404 default_serving_g: None,
3405 source: "manual".to_string(),
3406 created_at: food.created_at.clone(),
3407 updated_at: "2000-01-01T00:00:00+00:00".to_string(),
3408 }],
3409 meal_entries: vec![],
3410 recipes: vec![],
3411 recipe_ingredients: vec![],
3412 target: None,
3413 targets: vec![],
3414 weight_entries: vec![],
3415 tombstones: None,
3416 };
3417
3418 let summary = db.import_all(&import_data).unwrap();
3419 assert_eq!(summary.foods_imported, 0);
3420
3421 let unchanged = db.get_food_by_id(food.id).unwrap();
3422 assert_eq!(unchanged.name, "Chicken Breast");
3423 }
3424
3425 #[test]
3426 fn test_merge_meal_entries() {
3427 let db = Database::open_in_memory().unwrap();
3428 let food = db.insert_food(&sample_food()).unwrap();
3429 let entry_uuid = Uuid::new_v4().to_string();
3430
3431 let import_data = ExportData {
3432 version: 2,
3433 exported_at: Local::now().to_rfc3339(),
3434 device_id: Some("other-device".to_string()),
3435 foods: vec![food.clone()],
3436 meal_entries: vec![ExportMealEntry {
3437 id: 999,
3438 uuid: entry_uuid.clone(),
3439 date: "2024-06-15".to_string(),
3440 meal_type: "lunch".to_string(),
3441 food_id: 999,
3442 food_uuid: food.uuid.clone(),
3443 serving_g: 200.0,
3444 display_unit: None,
3445 display_quantity: None,
3446 created_at: Local::now().to_rfc3339(),
3447 updated_at: Local::now().to_rfc3339(),
3448 }],
3449 recipes: vec![],
3450 recipe_ingredients: vec![],
3451 target: None,
3452 targets: vec![],
3453 weight_entries: vec![],
3454 tombstones: None,
3455 };
3456
3457 let summary = db.import_all(&import_data).unwrap();
3458 assert_eq!(summary.meal_entries_imported, 1);
3459
3460 let entries = db
3462 .get_entries_for_date(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap())
3463 .unwrap();
3464 assert_eq!(entries.len(), 1);
3465 assert_eq!(entries[0].uuid, entry_uuid);
3466 assert_eq!(entries[0].serving_g, 200.0);
3467 }
3468
3469 #[test]
3470 fn test_merge_recipes() {
3471 let db = Database::open_in_memory().unwrap();
3472 let chicken = db.insert_food(&sample_food()).unwrap();
3473
3474 let recipe_food = db
3476 .insert_food(&NewFood {
3477 name: "Remote Recipe".to_string(),
3478 brand: None,
3479 barcode: None,
3480 calories_per_100g: 150.0,
3481 protein_per_100g: Some(20.0),
3482 carbs_per_100g: Some(10.0),
3483 fat_per_100g: Some(5.0),
3484 default_serving_g: Some(200.0),
3485 source: "recipe".to_string(),
3486 })
3487 .unwrap();
3488
3489 let recipe_uuid = Uuid::new_v4().to_string();
3490 let ing_uuid = Uuid::new_v4().to_string();
3491 let now = Local::now().to_rfc3339();
3492
3493 let import_data = ExportData {
3494 version: 2,
3495 exported_at: now.clone(),
3496 device_id: Some("other-device".to_string()),
3497 foods: vec![chicken.clone(), recipe_food.clone()],
3498 meal_entries: vec![],
3499 recipes: vec![ExportRecipe {
3500 id: 999,
3501 uuid: recipe_uuid.clone(),
3502 food_id: 999,
3503 food_uuid: recipe_food.uuid.clone(),
3504 portions: 4.0,
3505 created_at: now.clone(),
3506 updated_at: now.clone(),
3507 }],
3508 recipe_ingredients: vec![ExportRecipeIngredient {
3509 id: 999,
3510 uuid: ing_uuid,
3511 recipe_id: 999,
3512 recipe_uuid: recipe_uuid.clone(),
3513 food_id: 999,
3514 food_uuid: chicken.uuid.clone(),
3515 quantity_g: 400.0,
3516 }],
3517 target: None,
3518 targets: vec![],
3519 weight_entries: vec![],
3520 tombstones: None,
3521 };
3522
3523 let summary = db.import_all(&import_data).unwrap();
3524 assert_eq!(summary.recipes_imported, 1);
3525 assert_eq!(summary.recipe_ingredients_imported, 1);
3526 }
3527
3528 #[test]
3529 fn test_merge_tombstones() {
3530 let db = Database::open_in_memory().unwrap();
3531 let food = db.insert_food(&sample_food()).unwrap();
3532 let entry = db
3533 .insert_meal_entry(&NewMealEntry {
3534 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3535 meal_type: "lunch".to_string(),
3536 food_id: food.id,
3537 serving_g: 200.0,
3538 display_unit: None,
3539 display_quantity: None,
3540 })
3541 .unwrap();
3542
3543 let import_data = ExportData {
3544 version: 2,
3545 exported_at: Local::now().to_rfc3339(),
3546 device_id: Some("other-device".to_string()),
3547 foods: vec![],
3548 meal_entries: vec![],
3549 recipes: vec![],
3550 recipe_ingredients: vec![],
3551 target: None,
3552 targets: vec![],
3553 weight_entries: vec![],
3554 tombstones: Some(vec![SyncTombstone {
3555 uuid: entry.uuid.clone(),
3556 table_name: "meal_entries".to_string(),
3557 deleted_at: "2099-01-01T00:00:00+00:00".to_string(),
3558 }]),
3559 };
3560
3561 let summary = db.import_all(&import_data).unwrap();
3562 assert_eq!(summary.tombstones_processed, 1);
3563
3564 let entries = db
3566 .get_entries_for_date(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap())
3567 .unwrap();
3568 assert!(entries.is_empty());
3569
3570 let tombstones = db.get_tombstones().unwrap();
3572 assert_eq!(tombstones.len(), 1);
3573 assert_eq!(tombstones[0].uuid, entry.uuid);
3574 }
3575
3576 #[test]
3577 fn test_merge_tombstone_older_than_record() {
3578 let db = Database::open_in_memory().unwrap();
3579 let food = db.insert_food(&sample_food()).unwrap();
3580 let entry = db
3581 .insert_meal_entry(&NewMealEntry {
3582 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3583 meal_type: "lunch".to_string(),
3584 food_id: food.id,
3585 serving_g: 200.0,
3586 display_unit: None,
3587 display_quantity: None,
3588 })
3589 .unwrap();
3590
3591 let import_data = ExportData {
3593 version: 2,
3594 exported_at: Local::now().to_rfc3339(),
3595 device_id: Some("other-device".to_string()),
3596 foods: vec![],
3597 meal_entries: vec![],
3598 recipes: vec![],
3599 recipe_ingredients: vec![],
3600 target: None,
3601 targets: vec![],
3602 weight_entries: vec![],
3603 tombstones: Some(vec![SyncTombstone {
3604 uuid: entry.uuid.clone(),
3605 table_name: "meal_entries".to_string(),
3606 deleted_at: "2000-01-01T00:00:00+00:00".to_string(),
3607 }]),
3608 };
3609
3610 let summary = db.import_all(&import_data).unwrap();
3611 assert_eq!(summary.tombstones_processed, 0);
3612
3613 let entries = db
3615 .get_entries_for_date(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap())
3616 .unwrap();
3617 assert_eq!(entries.len(), 1);
3618 }
3619
3620 #[test]
3621 fn test_v1_import_still_works() {
3622 let db = Database::open_in_memory().unwrap();
3623
3624 let v1_data = ExportData {
3626 version: 1,
3627 exported_at: Local::now().to_rfc3339(),
3628 device_id: None,
3629 foods: vec![Food {
3630 id: 1,
3631 uuid: String::new(),
3632 name: "V1 Food".to_string(),
3633 brand: None,
3634 barcode: None,
3635 calories_per_100g: 100.0,
3636 protein_per_100g: None,
3637 carbs_per_100g: None,
3638 fat_per_100g: None,
3639 default_serving_g: None,
3640 source: "manual".to_string(),
3641 created_at: Local::now().to_rfc3339(),
3642 updated_at: String::new(),
3643 }],
3644 meal_entries: vec![],
3645 recipes: vec![],
3646 recipe_ingredients: vec![],
3647 target: None,
3648 targets: vec![],
3649 weight_entries: vec![],
3650 tombstones: None,
3651 };
3652
3653 let summary = db.import_all(&v1_data).unwrap();
3654 assert_eq!(summary.foods_imported, 1);
3655 assert_eq!(summary.tombstones_processed, 0);
3656
3657 let food = db.get_food_by_id(1).unwrap();
3658 assert_eq!(food.name, "V1 Food");
3659 }
3660
3661 #[test]
3662 fn test_device_id_persistence() {
3663 let db = Database::open_in_memory().unwrap();
3664 let id1 = db.get_or_create_device_id().unwrap();
3665 let id2 = db.get_or_create_device_id().unwrap();
3666 assert_eq!(id1, id2);
3667 assert!(uuid::Uuid::parse_str(&id1).is_ok());
3668 }
3669
3670 #[test]
3671 fn test_export_v2_format() {
3672 let db = Database::open_in_memory().unwrap();
3673 let food = db.insert_food(&sample_food()).unwrap();
3674 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
3675 db.insert_meal_entry(&NewMealEntry {
3676 date,
3677 meal_type: "lunch".to_string(),
3678 food_id: food.id,
3679 serving_g: 200.0,
3680 display_unit: None,
3681 display_quantity: None,
3682 })
3683 .unwrap();
3684
3685 let export = db.export_all().unwrap();
3686 assert_eq!(export.version, 3);
3687 assert!(export.device_id.is_some());
3688 assert!(!export.foods[0].uuid.is_empty());
3689 assert!(!export.foods[0].updated_at.is_empty());
3690 assert!(!export.meal_entries[0].uuid.is_empty());
3691 assert!(!export.meal_entries[0].food_uuid.is_empty());
3692 assert_eq!(export.meal_entries[0].food_uuid, food.uuid);
3693 assert!(export.tombstones.is_some());
3694 }
3695
3696 #[test]
3697 fn test_migration_v2_generates_uuids() {
3698 let db = Database::open_in_memory().unwrap();
3702 let food = db.insert_food(&sample_food()).unwrap();
3703 assert!(!food.uuid.is_empty());
3704
3705 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
3706 let entry = db
3707 .insert_meal_entry(&NewMealEntry {
3708 date,
3709 meal_type: "lunch".to_string(),
3710 food_id: food.id,
3711 serving_g: 100.0,
3712 display_unit: None,
3713 display_quantity: None,
3714 })
3715 .unwrap();
3716 assert!(!entry.uuid.is_empty());
3717
3718 let recipe = db.create_recipe("Test Recipe", 2.0).unwrap();
3719 assert!(!recipe.uuid.is_empty());
3720 }
3721
3722 #[test]
3723 fn test_tombstone_crud() {
3724 let db = Database::open_in_memory().unwrap();
3725
3726 assert!(db.get_tombstones().unwrap().is_empty());
3728
3729 db.record_tombstone("uuid-1", "foods").unwrap();
3731 db.record_tombstone("uuid-2", "meal_entries").unwrap();
3732
3733 let tombstones = db.get_tombstones().unwrap();
3734 assert_eq!(tombstones.len(), 2);
3735
3736 db.clear_tombstones().unwrap();
3738 assert!(db.get_tombstones().unwrap().is_empty());
3739 }
3740
3741 #[test]
3744 fn test_get_foods_since() {
3745 let db = Database::open_in_memory().unwrap();
3746
3747 let food1 = db.insert_food(&sample_food()).unwrap();
3749 let food2 = db
3750 .insert_food(&NewFood {
3751 name: "Brown Rice".to_string(),
3752 brand: None,
3753 barcode: None,
3754 calories_per_100g: 112.0,
3755 protein_per_100g: Some(2.6),
3756 carbs_per_100g: Some(23.5),
3757 fat_per_100g: Some(0.9),
3758 default_serving_g: None,
3759 source: "manual".to_string(),
3760 })
3761 .unwrap();
3762
3763 let all = db.get_foods_since("1970-01-01T00:00:00+00:00").unwrap();
3765 assert_eq!(all.len(), 2);
3766
3767 let none = db.get_foods_since("2099-01-01T00:00:00+00:00").unwrap();
3769 assert!(none.is_empty());
3770
3771 let all_foods = db.get_all_foods().unwrap();
3773 assert_eq!(all_foods.len(), 2);
3774 assert_eq!(all_foods[0].id, food1.id);
3775 assert_eq!(all_foods[1].id, food2.id);
3776 }
3777
3778 #[test]
3779 fn test_get_meal_entries_since() {
3780 let db = Database::open_in_memory().unwrap();
3781 let food = db.insert_food(&sample_food()).unwrap();
3782
3783 db.insert_meal_entry(&NewMealEntry {
3784 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3785 meal_type: "lunch".to_string(),
3786 food_id: food.id,
3787 serving_g: 200.0,
3788 display_unit: None,
3789 display_quantity: None,
3790 })
3791 .unwrap();
3792
3793 let all = db
3795 .get_meal_entries_since("1970-01-01T00:00:00+00:00")
3796 .unwrap();
3797 assert_eq!(all.len(), 1);
3798 assert!(!all[0].food_uuid.is_empty());
3799
3800 let none = db
3802 .get_meal_entries_since("2099-01-01T00:00:00+00:00")
3803 .unwrap();
3804 assert!(none.is_empty());
3805
3806 let all_export = db.get_all_meal_entries_export().unwrap();
3808 assert_eq!(all_export.len(), 1);
3809 }
3810
3811 #[test]
3812 fn test_get_tombstones_since() {
3813 let db = Database::open_in_memory().unwrap();
3814
3815 db.record_tombstone("uuid-1", "foods").unwrap();
3816 db.record_tombstone("uuid-2", "meal_entries").unwrap();
3817
3818 let all = db
3819 .get_tombstones_since("1970-01-01T00:00:00+00:00")
3820 .unwrap();
3821 assert_eq!(all.len(), 2);
3822
3823 let none = db
3824 .get_tombstones_since("2099-01-01T00:00:00+00:00")
3825 .unwrap();
3826 assert!(none.is_empty());
3827 }
3828
3829 #[test]
3830 fn test_changes_since_full() {
3831 let db = Database::open_in_memory().unwrap();
3832 let food = db.insert_food(&sample_food()).unwrap();
3833 db.insert_meal_entry(&NewMealEntry {
3834 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
3835 meal_type: "lunch".to_string(),
3836 food_id: food.id,
3837 serving_g: 200.0,
3838 display_unit: None,
3839 display_quantity: None,
3840 })
3841 .unwrap();
3842 db.record_tombstone("dead-uuid", "foods").unwrap();
3843
3844 let payload = db.changes_since(None, "2024-06-15T12:00:00Z").unwrap();
3846 assert_eq!(payload.foods.len(), 1);
3847 assert_eq!(payload.meal_entries.len(), 1);
3848 assert_eq!(payload.tombstones.len(), 1);
3849 assert_eq!(payload.server_timestamp, "2024-06-15T12:00:00Z");
3850 }
3851
3852 #[test]
3853 fn test_changes_since_incremental() {
3854 let db = Database::open_in_memory().unwrap();
3855 let food = db.insert_food(&sample_food()).unwrap();
3856
3857 let mid_timestamp = "2099-01-01T00:00:00+00:00";
3859
3860 let payload = db.changes_since(Some(mid_timestamp), "now").unwrap();
3862 assert!(payload.foods.is_empty());
3863 assert!(payload.meal_entries.is_empty());
3864 assert!(payload.tombstones.is_empty());
3865
3866 let payload = db
3868 .changes_since(Some("1970-01-01T00:00:00+00:00"), "now")
3869 .unwrap();
3870 assert_eq!(payload.foods.len(), 1);
3871 assert_eq!(payload.foods[0].id, food.id);
3872 }
3873
3874 #[test]
3875 fn test_changes_since_includes_all_entity_types() {
3876 let db = Database::open_in_memory().unwrap();
3877 let food = db.insert_food(&sample_food()).unwrap();
3878
3879 let recipe = db.create_recipe("Test Recipe", 4.0).unwrap();
3881 db.add_recipe_ingredient(recipe.id, food.id, 200.0).unwrap();
3882
3883 db.set_target(0, 2000, Some(40), Some(30), Some(30))
3885 .unwrap();
3886
3887 db.upsert_weight(&NewWeightEntry {
3889 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
3890 weight_kg: 80.0,
3891 source: "manual".to_string(),
3892 notes: None,
3893 })
3894 .unwrap();
3895
3896 let payload = db.changes_since(None, "now").unwrap();
3897 assert_eq!(payload.foods.len(), 2);
3899 assert_eq!(payload.recipes.len(), 1);
3900 assert_eq!(payload.recipe_ingredients.len(), 1);
3901 assert_eq!(payload.targets.len(), 1);
3902 assert_eq!(payload.weight_entries.len(), 1);
3903 }
3904
3905 #[test]
3906 fn test_changes_since_incremental_new_entity_types() {
3907 let db = Database::open_in_memory().unwrap();
3908 db.insert_food(&sample_food()).unwrap();
3909
3910 db.create_recipe("Test Recipe", 4.0).unwrap();
3912
3913 db.set_target(0, 2000, Some(40), Some(30), Some(30))
3915 .unwrap();
3916
3917 let payload = db
3919 .changes_since(Some("2099-01-01T00:00:00+00:00"), "now")
3920 .unwrap();
3921 assert!(payload.recipes.is_empty());
3922 assert!(payload.targets.is_empty());
3923 assert!(payload.weight_entries.is_empty());
3924 assert!(payload.recipe_ingredients.is_empty());
3925
3926 let payload = db
3928 .changes_since(Some("1970-01-01T00:00:00+00:00"), "now")
3929 .unwrap();
3930 assert_eq!(payload.foods.len(), 2); assert_eq!(payload.recipes.len(), 1);
3932 assert_eq!(payload.targets.len(), 1);
3933 }
3934
3935 #[test]
3936 fn test_apply_remote_changes_new_food() {
3937 let db = Database::open_in_memory().unwrap();
3938
3939 let incoming_food = Food {
3940 id: 0,
3941 uuid: "remote-uuid-1".to_string(),
3942 name: "Remote Food".to_string(),
3943 brand: Some("Remote Brand".to_string()),
3944 barcode: None,
3945 calories_per_100g: 200.0,
3946 protein_per_100g: Some(20.0),
3947 carbs_per_100g: Some(10.0),
3948 fat_per_100g: Some(5.0),
3949 default_serving_g: Some(100.0),
3950 source: "openfoodfacts".to_string(),
3951 created_at: "2024-01-01T00:00:00+00:00".to_string(),
3952 updated_at: "2024-06-01T00:00:00+00:00".to_string(),
3953 };
3954
3955 db.apply_remote_changes(&[incoming_food], &[], &[], &[], &[], &[], &[])
3956 .unwrap();
3957
3958 let food = db.get_food_by_uuid("remote-uuid-1").unwrap().unwrap();
3959 assert_eq!(food.name, "Remote Food");
3960 }
3961
3962 #[test]
3963 fn test_apply_remote_changes_lww_food() {
3964 let db = Database::open_in_memory().unwrap();
3965 let local = db.insert_food(&sample_food()).unwrap();
3966
3967 let incoming = Food {
3968 id: 0,
3969 uuid: local.uuid.clone(),
3970 name: "Updated Name".to_string(),
3971 brand: Some("New Brand".to_string()),
3972 barcode: local.barcode.clone(),
3973 calories_per_100g: 999.0,
3974 protein_per_100g: Some(99.0),
3975 carbs_per_100g: Some(0.0),
3976 fat_per_100g: Some(0.0),
3977 default_serving_g: Some(100.0),
3978 source: "manual".to_string(),
3979 created_at: local.created_at.clone(),
3980 updated_at: "2099-01-01T00:00:00+00:00".to_string(),
3981 };
3982
3983 db.apply_remote_changes(&[incoming], &[], &[], &[], &[], &[], &[])
3984 .unwrap();
3985
3986 let updated = db.get_food_by_uuid(&local.uuid).unwrap().unwrap();
3987 assert_eq!(updated.name, "Updated Name");
3988 assert_eq!(updated.calories_per_100g, 999.0);
3989 }
3990
3991 #[test]
3992 fn test_apply_remote_changes_lww_food_older_ignored() {
3993 let db = Database::open_in_memory().unwrap();
3994 let local = db.insert_food(&sample_food()).unwrap();
3995
3996 let incoming = Food {
3997 id: 0,
3998 uuid: local.uuid.clone(),
3999 name: "Old Name".to_string(),
4000 brand: None,
4001 barcode: None,
4002 calories_per_100g: 1.0,
4003 protein_per_100g: None,
4004 carbs_per_100g: None,
4005 fat_per_100g: None,
4006 default_serving_g: None,
4007 source: "manual".to_string(),
4008 created_at: "2000-01-01T00:00:00+00:00".to_string(),
4009 updated_at: "2000-01-01T00:00:00+00:00".to_string(),
4010 };
4011
4012 db.apply_remote_changes(&[incoming], &[], &[], &[], &[], &[], &[])
4013 .unwrap();
4014
4015 let unchanged = db.get_food_by_uuid(&local.uuid).unwrap().unwrap();
4016 assert_eq!(unchanged.name, "Chicken Breast");
4017 }
4018
4019 #[test]
4020 fn test_apply_remote_changes_meal_entry() {
4021 let db = Database::open_in_memory().unwrap();
4022 let food = db.insert_food(&sample_food()).unwrap();
4023
4024 let incoming_entry = crate::models::ExportMealEntry {
4025 id: 0,
4026 uuid: "remote-meal-uuid-1".to_string(),
4027 date: "2024-06-15".to_string(),
4028 meal_type: "lunch".to_string(),
4029 food_id: 0,
4030 food_uuid: food.uuid.clone(),
4031 serving_g: 250.0,
4032 display_unit: None,
4033 display_quantity: None,
4034 created_at: "2024-06-15T12:00:00+00:00".to_string(),
4035 updated_at: "2024-06-15T12:00:00+00:00".to_string(),
4036 };
4037
4038 db.apply_remote_changes(&[], &[incoming_entry], &[], &[], &[], &[], &[])
4039 .unwrap();
4040
4041 let entries = db.get_all_meal_entries_export().unwrap();
4042 assert_eq!(entries.len(), 1);
4043 assert_eq!(entries[0].uuid, "remote-meal-uuid-1");
4044 assert_eq!(entries[0].serving_g, 250.0);
4045 }
4046
4047 #[test]
4048 fn test_apply_remote_changes_tombstone() {
4049 let db = Database::open_in_memory().unwrap();
4050 let food = db.insert_food(&sample_food()).unwrap();
4051
4052 let tombstone = SyncTombstone {
4053 uuid: food.uuid.clone(),
4054 table_name: "foods".to_string(),
4055 deleted_at: "2099-01-01T00:00:00+00:00".to_string(),
4056 };
4057
4058 db.apply_remote_changes(&[], &[], &[], &[], &[], &[], &[tombstone])
4059 .unwrap();
4060
4061 assert!(db.get_food_by_uuid(&food.uuid).unwrap().is_none());
4062
4063 let stored = db.get_tombstones().unwrap();
4064 assert_eq!(stored.len(), 1);
4065 assert_eq!(stored[0].uuid, food.uuid);
4066 }
4067
4068 #[test]
4069 fn test_apply_remote_changes_recipes() {
4070 let db = Database::open_in_memory().unwrap();
4071
4072 let recipe_food = db
4074 .insert_food(&NewFood {
4075 name: "Remote Recipe".to_string(),
4076 brand: None,
4077 barcode: None,
4078 calories_per_100g: 150.0,
4079 protein_per_100g: Some(20.0),
4080 carbs_per_100g: Some(10.0),
4081 fat_per_100g: Some(5.0),
4082 default_serving_g: Some(200.0),
4083 source: "recipe".to_string(),
4084 })
4085 .unwrap();
4086 let ingredient_food = db.insert_food(&sample_food()).unwrap();
4087
4088 let recipe_uuid = Uuid::new_v4().to_string();
4089 let ing_uuid = Uuid::new_v4().to_string();
4090 let now = Local::now().to_rfc3339();
4091
4092 let recipes = vec![ExportRecipe {
4093 id: 0,
4094 uuid: recipe_uuid.clone(),
4095 food_id: 0,
4096 food_uuid: recipe_food.uuid.clone(),
4097 portions: 4.0,
4098 created_at: now.clone(),
4099 updated_at: now.clone(),
4100 }];
4101
4102 let recipe_ingredients = vec![ExportRecipeIngredient {
4103 id: 0,
4104 uuid: ing_uuid,
4105 recipe_id: 0,
4106 recipe_uuid: recipe_uuid.clone(),
4107 food_id: 0,
4108 food_uuid: ingredient_food.uuid.clone(),
4109 quantity_g: 400.0,
4110 }];
4111
4112 db.apply_remote_changes(&[], &[], &recipes, &recipe_ingredients, &[], &[], &[])
4113 .unwrap();
4114
4115 let imported_recipe = db.get_recipe_by_uuid(&recipe_uuid).unwrap().unwrap();
4117 assert!((imported_recipe.portions - 4.0).abs() < f64::EPSILON);
4118
4119 let ingredients = db.get_recipe_ingredients(imported_recipe.id).unwrap();
4121 assert_eq!(ingredients.len(), 1);
4122 assert!((ingredients[0].quantity_g - 400.0).abs() < f64::EPSILON);
4123 }
4124
4125 #[test]
4126 fn test_apply_remote_changes_targets_lww() {
4127 let db = Database::open_in_memory().unwrap();
4128
4129 db.set_target(0, 1800, Some(40), Some(30), Some(30))
4131 .unwrap();
4132
4133 let targets = vec![ExportTarget {
4135 day_of_week: 0,
4136 calories: 2200,
4137 protein_pct: Some(35),
4138 carbs_pct: Some(40),
4139 fat_pct: Some(25),
4140 updated_at: Some("2099-01-01T00:00:00+00:00".to_string()),
4141 }];
4142
4143 db.apply_remote_changes(&[], &[], &[], &[], &targets, &[], &[])
4144 .unwrap();
4145
4146 let target = db.get_target(0).unwrap().unwrap();
4147 assert_eq!(target.calories, 2200);
4148 assert_eq!(target.protein_pct, Some(35));
4149 }
4150
4151 #[test]
4152 fn test_apply_remote_changes_targets_older_ignored() {
4153 let db = Database::open_in_memory().unwrap();
4154
4155 db.set_target(0, 1800, Some(40), Some(30), Some(30))
4157 .unwrap();
4158
4159 let targets = vec![ExportTarget {
4161 day_of_week: 0,
4162 calories: 1200,
4163 protein_pct: None,
4164 carbs_pct: None,
4165 fat_pct: None,
4166 updated_at: Some("2000-01-01T00:00:00+00:00".to_string()),
4167 }];
4168
4169 db.apply_remote_changes(&[], &[], &[], &[], &targets, &[], &[])
4170 .unwrap();
4171
4172 let target = db.get_target(0).unwrap().unwrap();
4173 assert_eq!(target.calories, 1800); }
4175
4176 #[test]
4177 fn test_apply_remote_changes_weight_entries_lww() {
4178 let db = Database::open_in_memory().unwrap();
4179
4180 db.upsert_weight(&NewWeightEntry {
4182 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
4183 weight_kg: 80.0,
4184 source: "manual".to_string(),
4185 notes: None,
4186 })
4187 .unwrap();
4188
4189 let weights = vec![ExportWeightEntry {
4191 uuid: Uuid::new_v4().to_string(),
4192 date: "2025-01-15".to_string(),
4193 weight_kg: 79.5,
4194 source: "scale".to_string(),
4195 notes: Some("Smart scale reading".to_string()),
4196 created_at: "2025-01-15T08:00:00+00:00".to_string(),
4197 updated_at: "2099-01-01T00:00:00+00:00".to_string(),
4198 }];
4199
4200 db.apply_remote_changes(&[], &[], &[], &[], &[], &weights, &[])
4201 .unwrap();
4202
4203 let entry = db
4204 .get_weight(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
4205 .unwrap()
4206 .unwrap();
4207 assert!((entry.weight_kg - 79.5).abs() < f64::EPSILON);
4208 assert_eq!(entry.source, "scale");
4209 }
4210
4211 #[test]
4212 fn test_apply_remote_changes_weight_entries_older_ignored() {
4213 let db = Database::open_in_memory().unwrap();
4214
4215 db.upsert_weight(&NewWeightEntry {
4217 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
4218 weight_kg: 80.0,
4219 source: "manual".to_string(),
4220 notes: None,
4221 })
4222 .unwrap();
4223
4224 let weights = vec![ExportWeightEntry {
4226 uuid: Uuid::new_v4().to_string(),
4227 date: "2025-01-15".to_string(),
4228 weight_kg: 75.0,
4229 source: "old_scale".to_string(),
4230 notes: None,
4231 created_at: "2020-01-01T00:00:00+00:00".to_string(),
4232 updated_at: "2020-01-01T00:00:00+00:00".to_string(),
4233 }];
4234
4235 db.apply_remote_changes(&[], &[], &[], &[], &[], &weights, &[])
4236 .unwrap();
4237
4238 let entry = db
4239 .get_weight(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
4240 .unwrap()
4241 .unwrap();
4242 assert!((entry.weight_kg - 80.0).abs() < f64::EPSILON); }
4244
4245 #[test]
4246 fn test_apply_remote_changes_recipe_tombstone() {
4247 let db = Database::open_in_memory().unwrap();
4248 let food = db.insert_food(&sample_food()).unwrap();
4249 let recipe = db.create_recipe("To Delete", 2.0).unwrap();
4250 db.add_recipe_ingredient(recipe.id, food.id, 100.0).unwrap();
4251
4252 let tombstone = SyncTombstone {
4253 uuid: recipe.uuid.clone(),
4254 table_name: "recipes".to_string(),
4255 deleted_at: "2099-01-01T00:00:00+00:00".to_string(),
4256 };
4257
4258 db.apply_remote_changes(&[], &[], &[], &[], &[], &[], &[tombstone])
4259 .unwrap();
4260
4261 assert!(db.get_recipe_by_uuid(&recipe.uuid).unwrap().is_none());
4262 }
4263
4264 fn sample_weight_entry(date: NaiveDate) -> NewWeightEntry {
4267 NewWeightEntry {
4268 date,
4269 weight_kg: 80.5,
4270 source: "manual".to_string(),
4271 notes: Some("Morning weigh-in".to_string()),
4272 }
4273 }
4274
4275 #[test]
4276 fn test_upsert_weight_creates_new_entry() {
4277 let db = Database::open_in_memory().unwrap();
4278 let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
4279 let entry = db.upsert_weight(&sample_weight_entry(date)).unwrap();
4280
4281 assert_eq!(entry.date, date);
4282 assert!((entry.weight_kg - 80.5).abs() < f64::EPSILON);
4283 assert_eq!(entry.source, "manual");
4284 assert_eq!(entry.notes.as_deref(), Some("Morning weigh-in"));
4285 assert!(!entry.uuid.is_empty());
4286 }
4287
4288 #[test]
4289 fn test_upsert_weight_replaces_existing_for_same_date() {
4290 let db = Database::open_in_memory().unwrap();
4291 let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
4292
4293 let first = db.upsert_weight(&sample_weight_entry(date)).unwrap();
4294 assert!((first.weight_kg - 80.5).abs() < f64::EPSILON);
4295
4296 let updated = db
4297 .upsert_weight(&NewWeightEntry {
4298 date,
4299 weight_kg: 79.8,
4300 source: "manual".to_string(),
4301 notes: Some("Evening weigh-in".to_string()),
4302 })
4303 .unwrap();
4304
4305 assert!((updated.weight_kg - 79.8).abs() < f64::EPSILON);
4306 assert_eq!(updated.notes.as_deref(), Some("Evening weigh-in"));
4307
4308 let history = db.get_weight_history(None).unwrap();
4310 assert_eq!(history.len(), 1);
4311 }
4312
4313 #[test]
4314 fn test_get_weight_returns_none_for_missing_date() {
4315 let db = Database::open_in_memory().unwrap();
4316 let date = NaiveDate::from_ymd_opt(2025, 6, 1).unwrap();
4317 let result = db.get_weight(date).unwrap();
4318 assert!(result.is_none());
4319 }
4320
4321 #[test]
4322 fn test_get_weight_returns_entry_for_existing_date() {
4323 let db = Database::open_in_memory().unwrap();
4324 let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
4325 db.upsert_weight(&sample_weight_entry(date)).unwrap();
4326
4327 let result = db.get_weight(date).unwrap();
4328 assert!(result.is_some());
4329 let entry = result.unwrap();
4330 assert_eq!(entry.date, date);
4331 assert!((entry.weight_kg - 80.5).abs() < f64::EPSILON);
4332 }
4333
4334 #[test]
4335 fn test_get_weight_history_ordered_by_date_desc() {
4336 let db = Database::open_in_memory().unwrap();
4337 let dates = [
4338 NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
4339 NaiveDate::from_ymd_opt(2025, 1, 12).unwrap(),
4340 NaiveDate::from_ymd_opt(2025, 1, 11).unwrap(),
4341 ];
4342 for date in &dates {
4343 db.upsert_weight(&sample_weight_entry(*date)).unwrap();
4344 }
4345
4346 let history = db.get_weight_history(None).unwrap();
4347 assert_eq!(history.len(), 3);
4348 assert_eq!(history[0].date, dates[1]); assert_eq!(history[1].date, dates[2]); assert_eq!(history[2].date, dates[0]); }
4352
4353 #[test]
4354 fn test_get_weight_history_with_days_limit() {
4355 let db = Database::open_in_memory().unwrap();
4356 for day in 1..=5 {
4357 let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
4358 db.upsert_weight(&sample_weight_entry(date)).unwrap();
4359 }
4360
4361 let history = db.get_weight_history(Some(3)).unwrap();
4362 assert_eq!(history.len(), 3);
4363 assert_eq!(
4365 history[0].date,
4366 NaiveDate::from_ymd_opt(2025, 1, 5).unwrap()
4367 );
4368 }
4369
4370 #[test]
4371 fn test_delete_weight() {
4372 let db = Database::open_in_memory().unwrap();
4373 let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
4374 let entry = db.upsert_weight(&sample_weight_entry(date)).unwrap();
4375
4376 db.delete_weight(entry.id).unwrap();
4377 let result = db.get_weight(date).unwrap();
4378 assert!(result.is_none());
4379 }
4380
4381 #[test]
4382 fn test_delete_weight_not_found() {
4383 let db = Database::open_in_memory().unwrap();
4384 let result = db.delete_weight(9999);
4385 assert!(result.is_err());
4386 }
4387
4388 #[test]
4389 fn test_export_import_roundtrip_includes_weight_entries() {
4390 let db = Database::open_in_memory().unwrap();
4391
4392 for day in 1..=3 {
4394 let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
4395 db.upsert_weight(&NewWeightEntry {
4396 date,
4397 weight_kg: 80.0 + f64::from(day),
4398 source: "manual".to_string(),
4399 notes: None,
4400 })
4401 .unwrap();
4402 }
4403
4404 let exported = db.export_all().unwrap();
4405 assert_eq!(exported.weight_entries.len(), 3);
4406
4407 let db2 = Database::open_in_memory().unwrap();
4409 let summary = db2.import_all(&exported).unwrap();
4410 assert_eq!(summary.weight_entries_imported, 3);
4411
4412 let history = db2.get_weight_history(None).unwrap();
4413 assert_eq!(history.len(), 3);
4414 }
4415
4416 #[test]
4417 fn test_merge_import_weight_lww() {
4418 let db = Database::open_in_memory().unwrap();
4419
4420 let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
4422 let entry = db.upsert_weight(&sample_weight_entry(date)).unwrap();
4423
4424 let import_data = ExportData {
4426 version: 2,
4427 exported_at: "2025-01-16T00:00:00Z".to_string(),
4428 device_id: None,
4429 foods: vec![],
4430 meal_entries: vec![],
4431 recipes: vec![],
4432 recipe_ingredients: vec![],
4433 target: None,
4434 targets: vec![],
4435 weight_entries: vec![crate::models::ExportWeightEntry {
4436 uuid: "new-uuid".to_string(),
4437 date: "2025-01-15".to_string(),
4438 weight_kg: 79.0,
4439 source: "apple_health".to_string(),
4440 notes: Some("From Apple Health".to_string()),
4441 created_at: entry.created_at.clone(),
4442 updated_at: "2099-01-01T00:00:00Z".to_string(),
4443 }],
4444 tombstones: None,
4445 };
4446
4447 let summary = db.import_all(&import_data).unwrap();
4448 assert_eq!(summary.weight_entries_imported, 1);
4449
4450 let updated = db.get_weight(date).unwrap().unwrap();
4451 assert!((updated.weight_kg - 79.0).abs() < f64::EPSILON);
4452 assert_eq!(updated.source, "apple_health");
4453 }
4454
4455 #[test]
4456 fn test_migration_creates_weight_entries_table() {
4457 let db = Database::open_in_memory().unwrap();
4458 let count: i64 = db
4460 .conn
4461 .query_row("SELECT COUNT(*) FROM weight_entries", [], |row| row.get(0))
4462 .unwrap();
4463 assert_eq!(count, 0);
4464 }
4465
4466 #[test]
4469 fn test_recently_logged_foods_empty() {
4470 let db = Database::open_in_memory().unwrap();
4471 let result = db.get_recently_logged_foods(10).unwrap();
4472 assert!(result.is_empty());
4473 }
4474
4475 #[test]
4476 fn test_recently_logged_foods_single_entry() {
4477 let db = Database::open_in_memory().unwrap();
4478 let food = db.insert_food(&sample_food()).unwrap();
4479 db.insert_meal_entry(&NewMealEntry {
4480 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4481 meal_type: "lunch".to_string(),
4482 food_id: food.id,
4483 serving_g: 200.0,
4484 display_unit: None,
4485 display_quantity: None,
4486 })
4487 .unwrap();
4488
4489 let result = db.get_recently_logged_foods(10).unwrap();
4490 assert_eq!(result.len(), 1);
4491 assert_eq!(result[0].food.id, food.id);
4492 assert!((result[0].last_serving_g - 200.0).abs() < f64::EPSILON);
4493 assert_eq!(result[0].last_meal_type, "lunch");
4494 assert_eq!(result[0].log_count, 1);
4495 assert_eq!(result[0].last_logged, "2024-06-15");
4496 }
4497
4498 #[test]
4499 fn test_recently_logged_foods_ordering_and_dedup() {
4500 let db = Database::open_in_memory().unwrap();
4501 let chicken = db.insert_food(&sample_food()).unwrap();
4502 let rice = db
4503 .insert_food(&NewFood {
4504 name: "Brown Rice".to_string(),
4505 brand: None,
4506 barcode: None,
4507 calories_per_100g: 112.0,
4508 protein_per_100g: Some(2.6),
4509 carbs_per_100g: Some(23.5),
4510 fat_per_100g: Some(0.9),
4511 default_serving_g: None,
4512 source: "manual".to_string(),
4513 })
4514 .unwrap();
4515
4516 for day in [10, 12, 14] {
4518 db.insert_meal_entry(&NewMealEntry {
4519 date: NaiveDate::from_ymd_opt(2024, 6, day).unwrap(),
4520 meal_type: "lunch".to_string(),
4521 food_id: chicken.id,
4522 serving_g: 150.0,
4523 display_unit: None,
4524 display_quantity: None,
4525 })
4526 .unwrap();
4527 }
4528
4529 db.insert_meal_entry(&NewMealEntry {
4531 date: NaiveDate::from_ymd_opt(2024, 6, 16).unwrap(),
4532 meal_type: "dinner".to_string(),
4533 food_id: rice.id,
4534 serving_g: 250.0,
4535 display_unit: None,
4536 display_quantity: None,
4537 })
4538 .unwrap();
4539
4540 let result = db.get_recently_logged_foods(10).unwrap();
4541 assert_eq!(result.len(), 2);
4542 assert_eq!(result[0].food.name, "Brown Rice");
4544 assert_eq!(result[0].log_count, 1);
4545 assert_eq!(result[1].food.name, "Chicken Breast");
4547 assert_eq!(result[1].log_count, 3);
4548 }
4549
4550 #[test]
4551 fn test_recently_logged_foods_limit() {
4552 let db = Database::open_in_memory().unwrap();
4553 let food = db.insert_food(&sample_food()).unwrap();
4554 let rice = db
4555 .insert_food(&NewFood {
4556 name: "Brown Rice".to_string(),
4557 brand: None,
4558 barcode: None,
4559 calories_per_100g: 112.0,
4560 protein_per_100g: Some(2.6),
4561 carbs_per_100g: Some(23.5),
4562 fat_per_100g: Some(0.9),
4563 default_serving_g: None,
4564 source: "manual".to_string(),
4565 })
4566 .unwrap();
4567
4568 db.insert_meal_entry(&NewMealEntry {
4569 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4570 meal_type: "lunch".to_string(),
4571 food_id: food.id,
4572 serving_g: 100.0,
4573 display_unit: None,
4574 display_quantity: None,
4575 })
4576 .unwrap();
4577 db.insert_meal_entry(&NewMealEntry {
4578 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4579 meal_type: "dinner".to_string(),
4580 food_id: rice.id,
4581 serving_g: 200.0,
4582 display_unit: None,
4583 display_quantity: None,
4584 })
4585 .unwrap();
4586
4587 let result = db.get_recently_logged_foods(1).unwrap();
4588 assert_eq!(result.len(), 1);
4589 }
4590
4591 #[test]
4592 fn test_recently_logged_foods_uses_most_recent_entry() {
4593 let db = Database::open_in_memory().unwrap();
4594 let food = db.insert_food(&sample_food()).unwrap();
4595
4596 db.insert_meal_entry(&NewMealEntry {
4598 date: NaiveDate::from_ymd_opt(2024, 6, 10).unwrap(),
4599 meal_type: "breakfast".to_string(),
4600 food_id: food.id,
4601 serving_g: 100.0,
4602 display_unit: None,
4603 display_quantity: None,
4604 })
4605 .unwrap();
4606
4607 db.insert_meal_entry(&NewMealEntry {
4609 date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
4610 meal_type: "dinner".to_string(),
4611 food_id: food.id,
4612 serving_g: 250.0,
4613 display_unit: None,
4614 display_quantity: None,
4615 })
4616 .unwrap();
4617
4618 let result = db.get_recently_logged_foods(10).unwrap();
4619 assert_eq!(result.len(), 1);
4620 assert!((result[0].last_serving_g - 250.0).abs() < f64::EPSILON);
4622 assert_eq!(result[0].last_meal_type, "dinner");
4623 assert_eq!(result[0].last_logged, "2024-06-15");
4624 assert_eq!(result[0].log_count, 2);
4625 }
4626
4627 #[test]
4630 fn test_logging_streak_zero_days() {
4631 let db = Database::open_in_memory().unwrap();
4632 let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4633 assert_eq!(db.get_logging_streak(today).unwrap(), 0);
4634 }
4635
4636 #[test]
4637 fn test_logging_streak_one_day_today() {
4638 let db = Database::open_in_memory().unwrap();
4639 let food = db.insert_food(&sample_food()).unwrap();
4640 let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4641
4642 db.insert_meal_entry(&NewMealEntry {
4643 date: today,
4644 meal_type: "lunch".to_string(),
4645 food_id: food.id,
4646 serving_g: 100.0,
4647 display_unit: None,
4648 display_quantity: None,
4649 })
4650 .unwrap();
4651
4652 assert_eq!(db.get_logging_streak(today).unwrap(), 1);
4653 }
4654
4655 #[test]
4656 fn test_logging_streak_starts_from_yesterday() {
4657 let db = Database::open_in_memory().unwrap();
4658 let food = db.insert_food(&sample_food()).unwrap();
4659 let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4660 let yesterday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
4661
4662 db.insert_meal_entry(&NewMealEntry {
4664 date: yesterday,
4665 meal_type: "lunch".to_string(),
4666 food_id: food.id,
4667 serving_g: 100.0,
4668 display_unit: None,
4669 display_quantity: None,
4670 })
4671 .unwrap();
4672
4673 assert_eq!(db.get_logging_streak(today).unwrap(), 1);
4674 }
4675
4676 #[test]
4677 fn test_logging_streak_multiple_consecutive_days() {
4678 let db = Database::open_in_memory().unwrap();
4679 let food = db.insert_food(&sample_food()).unwrap();
4680 let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4681
4682 for day in 11..=15 {
4684 db.insert_meal_entry(&NewMealEntry {
4685 date: NaiveDate::from_ymd_opt(2024, 6, day).unwrap(),
4686 meal_type: "lunch".to_string(),
4687 food_id: food.id,
4688 serving_g: 100.0,
4689 display_unit: None,
4690 display_quantity: None,
4691 })
4692 .unwrap();
4693 }
4694
4695 assert_eq!(db.get_logging_streak(today).unwrap(), 5);
4696 }
4697
4698 #[test]
4699 fn test_logging_streak_gap_in_middle() {
4700 let db = Database::open_in_memory().unwrap();
4701 let food = db.insert_food(&sample_food()).unwrap();
4702 let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4703
4704 for day in [15, 14, 12] {
4706 db.insert_meal_entry(&NewMealEntry {
4707 date: NaiveDate::from_ymd_opt(2024, 6, day).unwrap(),
4708 meal_type: "lunch".to_string(),
4709 food_id: food.id,
4710 serving_g: 100.0,
4711 display_unit: None,
4712 display_quantity: None,
4713 })
4714 .unwrap();
4715 }
4716
4717 assert_eq!(db.get_logging_streak(today).unwrap(), 2);
4719 }
4720
4721 #[test]
4722 fn test_logging_streak_no_recent_entries() {
4723 let db = Database::open_in_memory().unwrap();
4724 let food = db.insert_food(&sample_food()).unwrap();
4725 let today = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
4726
4727 db.insert_meal_entry(&NewMealEntry {
4729 date: NaiveDate::from_ymd_opt(2024, 6, 10).unwrap(),
4730 meal_type: "lunch".to_string(),
4731 food_id: food.id,
4732 serving_g: 100.0,
4733 display_unit: None,
4734 display_quantity: None,
4735 })
4736 .unwrap();
4737
4738 assert_eq!(db.get_logging_streak(today).unwrap(), 0);
4739 }
4740
4741 #[test]
4744 fn test_calorie_average_no_entries() {
4745 let db = Database::open_in_memory().unwrap();
4746 let avg = db.get_calorie_average(7).unwrap();
4747 assert!((avg - 0.0).abs() < f64::EPSILON);
4748 }
4749
4750 #[test]
4751 fn test_calorie_average_single_day() {
4752 let db = Database::open_in_memory().unwrap();
4753 let food = db.insert_food(&sample_food()).unwrap();
4754 let today = Local::now().date_naive();
4755
4756 db.insert_meal_entry(&NewMealEntry {
4758 date: today,
4759 meal_type: "lunch".to_string(),
4760 food_id: food.id,
4761 serving_g: 200.0,
4762 display_unit: None,
4763 display_quantity: None,
4764 })
4765 .unwrap();
4766
4767 let avg = db.get_calorie_average(7).unwrap();
4768 assert!((avg - 330.0).abs() < 0.01);
4769 }
4770
4771 #[test]
4772 fn test_calorie_average_multiple_days() {
4773 let db = Database::open_in_memory().unwrap();
4774 let food = db.insert_food(&sample_food()).unwrap();
4775 let today = Local::now().date_naive();
4776
4777 db.insert_meal_entry(&NewMealEntry {
4779 date: today,
4780 meal_type: "lunch".to_string(),
4781 food_id: food.id,
4782 serving_g: 200.0,
4783 display_unit: None,
4784 display_quantity: None,
4785 })
4786 .unwrap();
4787
4788 let yesterday = today - chrono::Duration::days(1);
4790 db.insert_meal_entry(&NewMealEntry {
4791 date: yesterday,
4792 meal_type: "lunch".to_string(),
4793 food_id: food.id,
4794 serving_g: 100.0,
4795 display_unit: None,
4796 display_quantity: None,
4797 })
4798 .unwrap();
4799
4800 let avg = db.get_calorie_average(7).unwrap();
4802 assert!((avg - 247.5).abs() < 0.01);
4803 }
4804
4805 #[test]
4806 fn test_calorie_average_skips_zero_days() {
4807 let db = Database::open_in_memory().unwrap();
4808 let food = db.insert_food(&sample_food()).unwrap();
4809 let today = Local::now().date_naive();
4810
4811 db.insert_meal_entry(&NewMealEntry {
4813 date: today,
4814 meal_type: "lunch".to_string(),
4815 food_id: food.id,
4816 serving_g: 200.0,
4817 display_unit: None,
4818 display_quantity: None,
4819 })
4820 .unwrap();
4821
4822 let avg = db.get_calorie_average(7).unwrap();
4825 assert!((avg - 330.0).abs() < 0.01);
4826 }
4827
4828 #[test]
4831 fn test_user_settings_set_get() {
4832 let db = Database::open_in_memory().unwrap();
4833 db.set_setting("test_key", "test_value").unwrap();
4834 let val = db.get_setting("test_key").unwrap();
4835 assert_eq!(val.as_deref(), Some("test_value"));
4836 }
4837
4838 #[test]
4839 fn test_user_settings_get_nonexistent() {
4840 let db = Database::open_in_memory().unwrap();
4841 let val = db.get_setting("nonexistent").unwrap();
4842 assert!(val.is_none());
4843 }
4844
4845 #[test]
4846 fn test_user_settings_upsert() {
4847 let db = Database::open_in_memory().unwrap();
4848 db.set_setting("key", "value1").unwrap();
4849 db.set_setting("key", "value2").unwrap();
4850 let val = db.get_setting("key").unwrap();
4851 assert_eq!(val.as_deref(), Some("value2"));
4852 }
4853
4854 #[test]
4855 fn test_user_settings_delete() {
4856 let db = Database::open_in_memory().unwrap();
4857 db.set_setting("key", "value").unwrap();
4858 assert!(db.delete_setting("key").unwrap());
4859 assert!(db.get_setting("key").unwrap().is_none());
4860 assert!(!db.delete_setting("key").unwrap());
4862 }
4863
4864 #[test]
4865 fn test_migration_creates_user_settings_table() {
4866 let db = Database::open_in_memory().unwrap();
4867 let count: i64 = db
4868 .conn
4869 .query_row("SELECT COUNT(*) FROM user_settings", [], |row| row.get(0))
4870 .unwrap();
4871 assert_eq!(count, 0);
4872 }
4873}