Skip to main content

grub_core/
mfp_import.rs

1use std::collections::HashMap;
2use std::io::Read;
3
4use anyhow::{Context, Result, bail};
5
6use crate::db::Database;
7use crate::models::NewFood;
8
9/// A single row parsed from an MFP CSV export.
10#[derive(Debug, Clone)]
11pub struct MfpRow {
12    pub date: String,
13    pub meal: String,
14    pub food_name: String,
15    pub calories: f64,
16    pub fat: f64,
17    pub protein: f64,
18    pub carbs: f64,
19    pub fiber: Option<f64>,
20    pub sugar: Option<f64>,
21}
22
23/// Summary of what an MFP import would do / did.
24#[derive(Debug, Clone)]
25pub struct MfpImportSummary {
26    pub rows_parsed: usize,
27    pub foods_created: usize,
28    pub foods_reused: usize,
29    pub meals_logged: usize,
30    pub dates_spanned: usize,
31}
32
33/// Parse an MFP CSV export from any reader.
34///
35/// Expected header:
36/// `Date,Meal,Food Name,Calories,Fat (g),Protein (g),Carbohydrates (g),Fiber (g),Sugar (g)`
37///
38/// Columns after the first 7 (Carbohydrates) are optional.
39pub fn parse_mfp_csv<R: Read>(reader: R) -> Result<Vec<MfpRow>> {
40    let mut rdr = csv::ReaderBuilder::new()
41        .flexible(true)
42        .trim(csv::Trim::All)
43        .from_reader(reader);
44
45    let headers = rdr.headers().context("Failed to read CSV headers")?.clone();
46
47    // Validate required columns
48    let required = ["Date", "Meal", "Food Name", "Calories"];
49    for name in &required {
50        if !headers.iter().any(|h| h.eq_ignore_ascii_case(name)) {
51            bail!("Missing required column: {name}");
52        }
53    }
54
55    // Build column index map (case-insensitive)
56    let col =
57        |name: &str| -> Option<usize> { headers.iter().position(|h| h.eq_ignore_ascii_case(name)) };
58
59    let idx_date = col("Date").context("Missing 'Date' column")?;
60    let idx_meal = col("Meal").context("Missing 'Meal' column")?;
61    let idx_food = col("Food Name").context("Missing 'Food Name' column")?;
62    let idx_cal = col("Calories").context("Missing 'Calories' column")?;
63    let idx_fat = col("Fat (g)");
64    let idx_protein = col("Protein (g)");
65    let idx_carbs = col("Carbohydrates (g)");
66    let idx_fiber = col("Fiber (g)");
67    let idx_sugar = col("Sugar (g)");
68
69    let mut rows = Vec::new();
70
71    for (line_num, result) in rdr.records().enumerate() {
72        let record = result.with_context(|| format!("Failed to parse CSV row {}", line_num + 2))?;
73
74        let date = record.get(idx_date).unwrap_or("").trim().to_string();
75        let meal = record.get(idx_meal).unwrap_or("").trim().to_string();
76        let food_name = record.get(idx_food).unwrap_or("").trim().to_string();
77
78        if date.is_empty() || food_name.is_empty() {
79            continue; // skip blank rows
80        }
81
82        let parse_f64 = |idx: Option<usize>| -> f64 {
83            idx.and_then(|i| record.get(i))
84                .and_then(|v| v.trim().parse::<f64>().ok())
85                .unwrap_or(0.0)
86        };
87
88        let parse_opt_f64 = |idx: Option<usize>| -> Option<f64> {
89            idx.and_then(|i| record.get(i))
90                .and_then(|v| v.trim().parse::<f64>().ok())
91        };
92
93        let calories = parse_f64(Some(idx_cal));
94
95        rows.push(MfpRow {
96            date,
97            meal,
98            food_name,
99            calories,
100            fat: parse_f64(idx_fat),
101            protein: parse_f64(idx_protein),
102            carbs: parse_f64(idx_carbs),
103            fiber: parse_opt_f64(idx_fiber),
104            sugar: parse_opt_f64(idx_sugar),
105        });
106    }
107
108    Ok(rows)
109}
110
111/// Normalize an MFP meal name to one of grub's valid meal types.
112#[must_use]
113pub fn normalize_meal_type(mfp_meal: &str) -> &'static str {
114    match mfp_meal.to_lowercase().as_str() {
115        "breakfast" => "breakfast",
116        "lunch" => "lunch",
117        "dinner" => "dinner",
118        _ => "snack",
119    }
120}
121
122/// Normalize an MFP date to YYYY-MM-DD format.
123///
124/// MFP exports dates as `YYYY-MM-DD` (or sometimes `M/D/YYYY`).
125fn normalize_date(mfp_date: &str) -> Result<String> {
126    // Try YYYY-MM-DD first
127    if chrono::NaiveDate::parse_from_str(mfp_date, "%Y-%m-%d").is_ok() {
128        return Ok(mfp_date.to_string());
129    }
130    // Try M/D/YYYY
131    if let Ok(d) = chrono::NaiveDate::parse_from_str(mfp_date, "%m/%d/%Y") {
132        return Ok(d.format("%Y-%m-%d").to_string());
133    }
134    // Try D/M/YYYY
135    if let Ok(d) = chrono::NaiveDate::parse_from_str(mfp_date, "%d/%m/%Y") {
136        return Ok(d.format("%Y-%m-%d").to_string());
137    }
138    bail!("Cannot parse date: '{mfp_date}'")
139}
140
141/// Calculate per-100g values from per-serving nutrition.
142///
143/// MFP exports total calories/macros per serving. We assume a default serving
144/// of 100g when no serving weight is available (since MFP doesn't export weight).
145fn to_per_100g(value: f64) -> f64 {
146    // MFP exports per-serving values. Without serving weight info, we store
147    // the values as-is (treating 1 serving = 100g equivalent).
148    value
149}
150
151/// Import parsed MFP rows into the database.
152///
153/// Returns an `MfpImportSummary`. When `dry_run` is true, no data is written.
154pub fn import_mfp_meals(db: &Database, rows: &[MfpRow], dry_run: bool) -> Result<MfpImportSummary> {
155    let mut foods_created: usize = 0;
156    let mut foods_reused: usize = 0;
157    let mut meals_logged: usize = 0;
158    let mut dates: std::collections::HashSet<String> = std::collections::HashSet::new();
159
160    // Cache: food_name → food_id (to avoid repeated DB lookups)
161    let mut food_cache: HashMap<String, i64> = HashMap::new();
162
163    for row in rows {
164        let date = normalize_date(&row.date)?;
165        dates.insert(date.clone());
166
167        let meal_type = normalize_meal_type(&row.meal);
168
169        // Resolve or create food
170        let food_key = row.food_name.to_lowercase();
171        let food_id = if let Some(&id) = food_cache.get(&food_key) {
172            foods_reused += 1;
173            id
174        } else if dry_run {
175            // In dry-run, check if food exists but don't create
176            let existing = deduplicate_food(db, &row.food_name)?;
177            if let Some(f) = existing {
178                food_cache.insert(food_key, f);
179                foods_reused += 1;
180                f
181            } else {
182                foods_created += 1;
183                0 // placeholder
184            }
185        } else {
186            let existing = deduplicate_food(db, &row.food_name)?;
187            if let Some(id) = existing {
188                food_cache.insert(food_key, id);
189                foods_reused += 1;
190                id
191            } else {
192                let new_food = NewFood {
193                    name: row.food_name.clone(),
194                    brand: None,
195                    barcode: None,
196                    calories_per_100g: to_per_100g(row.calories),
197                    protein_per_100g: Some(to_per_100g(row.protein)),
198                    carbs_per_100g: Some(to_per_100g(row.carbs)),
199                    fat_per_100g: Some(to_per_100g(row.fat)),
200                    default_serving_g: Some(100.0),
201                    source: "myfitnesspal".to_string(),
202                };
203                let food = db.insert_food(&new_food)?;
204                food_cache.insert(food_key, food.id);
205                foods_created += 1;
206                food.id
207            }
208        };
209
210        if !dry_run {
211            let parsed_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d")?;
212            db.insert_meal_entry(&crate::models::NewMealEntry {
213                date: parsed_date,
214                meal_type: meal_type.to_string(),
215                food_id,
216                serving_g: 100.0, // 1 serving = 100g equivalent
217                display_unit: Some("serving".to_string()),
218                display_quantity: Some(1.0),
219            })?;
220        }
221        meals_logged += 1;
222    }
223
224    Ok(MfpImportSummary {
225        rows_parsed: rows.len(),
226        foods_created,
227        foods_reused,
228        meals_logged,
229        dates_spanned: dates.len(),
230    })
231}
232
233/// Try to find an existing food by name (case-insensitive).
234fn deduplicate_food(db: &Database, name: &str) -> Result<Option<i64>> {
235    let results = db.search_foods_local(name)?;
236    for food in &results {
237        if food.name.eq_ignore_ascii_case(name) {
238            return Ok(Some(food.id));
239        }
240    }
241    Ok(None)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    const SAMPLE_CSV: &str = "\
249Date,Meal,Food Name,Calories,Fat (g),Protein (g),Carbohydrates (g),Fiber (g),Sugar (g)
2502024-01-15,Breakfast,Oatmeal - Plain,150,3,5,27,4,1
2512024-01-15,Lunch,Chicken Breast - Grilled,165,3.6,31,0,0,0
2522024-01-15,Dinner,Salmon Fillet,208,13,20,0,0,0
2532024-01-16,Breakfast,Greek Yogurt,100,0.7,17,6,0,4
2542024-01-16,Snacks,Almonds - Raw,164,14.2,6,6.1,3.5,1.2
255";
256
257    #[test]
258    fn test_parse_mfp_csv_basic() {
259        let rows = parse_mfp_csv(SAMPLE_CSV.as_bytes()).unwrap();
260        assert_eq!(rows.len(), 5);
261
262        assert_eq!(rows[0].date, "2024-01-15");
263        assert_eq!(rows[0].meal, "Breakfast");
264        assert_eq!(rows[0].food_name, "Oatmeal - Plain");
265        assert!((rows[0].calories - 150.0).abs() < f64::EPSILON);
266        assert!((rows[0].protein - 5.0).abs() < f64::EPSILON);
267        assert!((rows[0].carbs - 27.0).abs() < f64::EPSILON);
268        assert!((rows[0].fat - 3.0).abs() < f64::EPSILON);
269        assert!((rows[0].fiber.unwrap() - 4.0).abs() < f64::EPSILON);
270        assert!((rows[0].sugar.unwrap() - 1.0).abs() < f64::EPSILON);
271
272        assert_eq!(rows[4].food_name, "Almonds - Raw");
273    }
274
275    #[test]
276    fn test_parse_mfp_csv_missing_required_column() {
277        let bad_csv = "Date,Meal,Calories\n2024-01-15,Lunch,100\n";
278        let result = parse_mfp_csv(bad_csv.as_bytes());
279        assert!(result.is_err());
280        assert!(result.unwrap_err().to_string().contains("Food Name"));
281    }
282
283    #[test]
284    fn test_parse_mfp_csv_minimal_columns() {
285        // Only required + macro columns, no Fiber/Sugar
286        let csv = "\
287Date,Meal,Food Name,Calories,Fat (g),Protein (g),Carbohydrates (g)
2882024-01-15,Lunch,Chicken,165,3.6,31,0
289";
290        let rows = parse_mfp_csv(csv.as_bytes()).unwrap();
291        assert_eq!(rows.len(), 1);
292        assert!(rows[0].fiber.is_none());
293        assert!(rows[0].sugar.is_none());
294    }
295
296    #[test]
297    fn test_parse_mfp_csv_skips_blank_rows() {
298        let csv = "\
299Date,Meal,Food Name,Calories,Fat (g),Protein (g),Carbohydrates (g)
3002024-01-15,Lunch,Chicken,165,3.6,31,0
301,,,,,,
3022024-01-15,Dinner,Rice,130,0.3,2.7,28
303";
304        let rows = parse_mfp_csv(csv.as_bytes()).unwrap();
305        assert_eq!(rows.len(), 2);
306    }
307
308    #[test]
309    fn test_normalize_meal_type() {
310        assert_eq!(normalize_meal_type("Breakfast"), "breakfast");
311        assert_eq!(normalize_meal_type("LUNCH"), "lunch");
312        assert_eq!(normalize_meal_type("dinner"), "dinner");
313        assert_eq!(normalize_meal_type("Snacks"), "snack");
314        assert_eq!(normalize_meal_type("Morning Snack"), "snack");
315    }
316
317    #[test]
318    fn test_normalize_date_iso() {
319        assert_eq!(normalize_date("2024-01-15").unwrap(), "2024-01-15");
320    }
321
322    #[test]
323    fn test_normalize_date_us_format() {
324        assert_eq!(normalize_date("1/15/2024").unwrap(), "2024-01-15");
325    }
326
327    #[test]
328    fn test_normalize_date_invalid() {
329        assert!(normalize_date("not-a-date").is_err());
330    }
331
332    #[test]
333    fn test_import_mfp_dry_run() {
334        let db = Database::open_in_memory().unwrap();
335        let rows = parse_mfp_csv(SAMPLE_CSV.as_bytes()).unwrap();
336
337        let summary = import_mfp_meals(&db, &rows, true).unwrap();
338        assert_eq!(summary.rows_parsed, 5);
339        assert_eq!(summary.foods_created, 5);
340        assert_eq!(summary.foods_reused, 0);
341        assert_eq!(summary.meals_logged, 5);
342        assert_eq!(summary.dates_spanned, 2);
343
344        // Dry run should not have created any foods
345        let all_foods = db.list_foods(None).unwrap();
346        assert!(all_foods.is_empty());
347    }
348
349    #[test]
350    fn test_import_mfp_actual() {
351        let db = Database::open_in_memory().unwrap();
352        let rows = parse_mfp_csv(SAMPLE_CSV.as_bytes()).unwrap();
353
354        let summary = import_mfp_meals(&db, &rows, false).unwrap();
355        assert_eq!(summary.rows_parsed, 5);
356        assert_eq!(summary.foods_created, 5);
357        assert_eq!(summary.foods_reused, 0);
358        assert_eq!(summary.meals_logged, 5);
359        assert_eq!(summary.dates_spanned, 2);
360
361        // Foods should be in the DB
362        let all_foods = db.list_foods(None).unwrap();
363        assert_eq!(all_foods.len(), 5);
364
365        // Check source is myfitnesspal
366        assert!(all_foods.iter().all(|f| f.source == "myfitnesspal"));
367    }
368
369    #[test]
370    fn test_import_mfp_deduplication() {
371        let db = Database::open_in_memory().unwrap();
372
373        // First import
374        let csv1 = "\
375Date,Meal,Food Name,Calories,Fat (g),Protein (g),Carbohydrates (g)
3762024-01-15,Lunch,Chicken Breast,165,3.6,31,0
377";
378        let rows1 = parse_mfp_csv(csv1.as_bytes()).unwrap();
379        let s1 = import_mfp_meals(&db, &rows1, false).unwrap();
380        assert_eq!(s1.foods_created, 1);
381        assert_eq!(s1.foods_reused, 0);
382
383        // Second import with same food name
384        let csv2 = "\
385Date,Meal,Food Name,Calories,Fat (g),Protein (g),Carbohydrates (g)
3862024-01-16,Dinner,Chicken Breast,165,3.6,31,0
387";
388        let rows2 = parse_mfp_csv(csv2.as_bytes()).unwrap();
389        let s2 = import_mfp_meals(&db, &rows2, false).unwrap();
390        assert_eq!(s2.foods_created, 0);
391        assert_eq!(s2.foods_reused, 1);
392
393        // Only one food in DB
394        let all_foods = db.list_foods(None).unwrap();
395        assert_eq!(all_foods.len(), 1);
396    }
397}