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#[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#[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
33pub 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 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 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; }
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#[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
122fn normalize_date(mfp_date: &str) -> Result<String> {
126 if chrono::NaiveDate::parse_from_str(mfp_date, "%Y-%m-%d").is_ok() {
128 return Ok(mfp_date.to_string());
129 }
130 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 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
141fn to_per_100g(value: f64) -> f64 {
146 value
149}
150
151pub 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 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 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 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 }
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, 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
233fn 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 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 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 let all_foods = db.list_foods(None).unwrap();
363 assert_eq!(all_foods.len(), 5);
364
365 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 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 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 let all_foods = db.list_foods(None).unwrap();
395 assert_eq!(all_foods.len(), 1);
396 }
397}