Skip to main content

macro_factor_api/
client.rs

1use std::collections::HashMap;
2
3use anyhow::{anyhow, Result};
4use chrono::{DateTime, Local, NaiveDate, Timelike};
5use reqwest::Client;
6use serde_json::{json, Value};
7
8use crate::auth::FirebaseAuth;
9use crate::firestore::{
10    parse_document, parse_firestore_fields, to_firestore_fields, FirestoreClient,
11};
12use crate::models::*;
13
14const TYPESENSE_HOST: &str = "https://oewdzs50x93n2c4mp.a1.typesense.net";
15const TYPESENSE_API_KEY: &str = "4tKoPwBN6YaPXZDeQ7AyDfZbrjPbGMmG";
16
17#[derive(Clone)]
18pub struct MacroFactorClient {
19    pub auth: FirebaseAuth,
20    pub firestore: FirestoreClient,
21    user_id: Option<String>,
22}
23
24impl MacroFactorClient {
25    pub fn new(refresh_token: String) -> Self {
26        let auth = FirebaseAuth::new(refresh_token);
27        let firestore = FirestoreClient::new(auth.clone());
28        Self {
29            auth,
30            firestore,
31            user_id: None,
32        }
33    }
34
35    /// Sign in with email and password.
36    pub async fn login(email: &str, password: &str) -> Result<Self> {
37        let auth = FirebaseAuth::sign_in_with_email(email, password).await?;
38        let firestore = FirestoreClient::new(auth.clone());
39        Ok(Self {
40            auth,
41            firestore,
42            user_id: None,
43        })
44    }
45
46    pub async fn get_user_id(&mut self) -> Result<String> {
47        if let Some(ref uid) = self.user_id {
48            return Ok(uid.clone());
49        }
50        let uid = self.auth.get_user_id().await?;
51        self.user_id = Some(uid.clone());
52        Ok(uid)
53    }
54
55    /// Get the user profile document.
56    pub async fn get_profile(&mut self) -> Result<Value> {
57        let uid = self.get_user_id().await?;
58        let doc = self
59            .firestore
60            .get_document(&format!("users/{}", uid))
61            .await?;
62        Ok(parse_document(&doc))
63    }
64
65    /// List sub-collections under the user document.
66    pub async fn list_subcollections(&self, document_path: &str) -> Result<Vec<String>> {
67        self.firestore
68            .list_collection_ids(Some(document_path))
69            .await
70    }
71
72    /// Get a few documents from a collection for schema discovery.
73    pub async fn sample_collection(&self, collection_path: &str, limit: u32) -> Result<Vec<Value>> {
74        let (docs, _) = self
75            .firestore
76            .list_documents(collection_path, Some(limit), None)
77            .await?;
78        Ok(docs.iter().map(parse_document).collect())
79    }
80
81    /// Get a raw document by path and return parsed fields.
82    pub async fn get_raw_document(&self, path: &str) -> Result<Value> {
83        let doc = self.firestore.get_document(path).await?;
84        Ok(parse_document(&doc))
85    }
86
87    /// Get scale/weight entries for a date range.
88    /// Data is stored in `scale/{year}` docs with MMDD keys.
89    pub async fn get_weight_entries(
90        &mut self,
91        start: NaiveDate,
92        end: NaiveDate,
93    ) -> Result<Vec<ScaleEntry>> {
94        let uid = self.get_user_id().await?;
95        let mut entries = Vec::new();
96
97        // Collect all years in the range
98        let start_year = start.format("%Y").to_string().parse::<i32>()?;
99        let end_year = end.format("%Y").to_string().parse::<i32>()?;
100
101        for year in start_year..=end_year {
102            let path = format!("users/{}/scale/{}", uid, year);
103            let doc = match self.firestore.get_document(&path).await {
104                Ok(d) => d,
105                Err(_) => continue,
106            };
107
108            if let Some(ref fields) = doc.fields {
109                let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
110                if let Some(map) = parsed.as_object() {
111                    for (key, val) in map {
112                        if key.starts_with('_') || key.len() != 4 {
113                            continue;
114                        }
115                        // Parse MMDD key
116                        let month: u32 = key[..2].parse().unwrap_or(0);
117                        let day: u32 = key[2..].parse().unwrap_or(0);
118                        if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
119                            if date >= start && date <= end {
120                                if let Some(obj) = val.as_object() {
121                                    let weight =
122                                        obj.get("w").and_then(|v| v.as_f64()).unwrap_or(0.0);
123                                    let body_fat = obj.get("f").and_then(|v| v.as_f64());
124                                    let source =
125                                        obj.get("s").and_then(|v| v.as_str()).map(String::from);
126
127                                    entries.push(ScaleEntry {
128                                        date,
129                                        weight,
130                                        body_fat,
131                                        source,
132                                    });
133                                }
134                            }
135                        }
136                    }
137                }
138            }
139        }
140
141        entries.sort_by_key(|e| e.date);
142        Ok(entries)
143    }
144
145    /// Get nutrition summaries for a date range.
146    /// Data is stored in `nutrition/{year}` docs with MMDD keys.
147    pub async fn get_nutrition(
148        &mut self,
149        start: NaiveDate,
150        end: NaiveDate,
151    ) -> Result<Vec<NutritionSummary>> {
152        let uid = self.get_user_id().await?;
153        let mut entries = Vec::new();
154
155        let start_year = start.format("%Y").to_string().parse::<i32>()?;
156        let end_year = end.format("%Y").to_string().parse::<i32>()?;
157
158        for year in start_year..=end_year {
159            let path = format!("users/{}/nutrition/{}", uid, year);
160            let doc = match self.firestore.get_document(&path).await {
161                Ok(d) => d,
162                Err(_) => continue,
163            };
164
165            if let Some(ref fields) = doc.fields {
166                let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
167                if let Some(map) = parsed.as_object() {
168                    for (key, val) in map {
169                        if key.starts_with('_') || key.len() != 4 {
170                            continue;
171                        }
172                        let month: u32 = key[..2].parse().unwrap_or(0);
173                        let day: u32 = key[2..].parse().unwrap_or(0);
174                        if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
175                            if date >= start && date <= end {
176                                if let Some(obj) = val.as_object() {
177                                    let parse_num = |k: &str| -> Option<f64> {
178                                        obj.get(k).and_then(|v| {
179                                            v.as_f64()
180                                                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
181                                        })
182                                    };
183
184                                    entries.push(NutritionSummary {
185                                        date,
186                                        calories: parse_num("k"),
187                                        protein: parse_num("p"),
188                                        carbs: parse_num("c"),
189                                        fat: parse_num("f"),
190                                        sugar: parse_num("269"),
191                                        fiber: parse_num("291"),
192                                        source: obj
193                                            .get("s")
194                                            .and_then(|v| v.as_str())
195                                            .map(String::from),
196                                    });
197                                }
198                            }
199                        }
200                    }
201                }
202            }
203        }
204
205        entries.sort_by_key(|e| e.date);
206        Ok(entries)
207    }
208
209    /// Get food log entries for a specific date.
210    /// Data is stored in `food/{YYYY-MM-DD}` docs.
211    pub async fn get_food_log(&mut self, date: NaiveDate) -> Result<Vec<FoodEntry>> {
212        let uid = self.get_user_id().await?;
213        let date_str = date.format("%Y-%m-%d").to_string();
214        let path = format!("users/{}/food/{}", uid, date_str);
215
216        let doc = match self.firestore.get_document(&path).await {
217            Ok(d) => d,
218            Err(e) if e.to_string().contains("404") => return Ok(Vec::new()),
219            Err(e) => return Err(e),
220        };
221        let mut entries = Vec::new();
222
223        if let Some(ref fields) = doc.fields {
224            let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
225            if let Some(map) = parsed.as_object() {
226                for (key, val) in map {
227                    if key.starts_with('_') {
228                        continue;
229                    }
230                    if let Some(obj) = val.as_object() {
231                        let parse_num = |k: &str| -> Option<f64> {
232                            obj.get(k).and_then(|v| {
233                                v.as_f64()
234                                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
235                            })
236                        };
237                        let parse_str =
238                            |k: &str| obj.get(k).and_then(|v| v.as_str()).map(String::from);
239
240                        let serving_grams = parse_num("g");
241                        let user_qty = parse_num("y");
242                        let unit_weight = parse_num("w");
243
244                        let deleted = obj.get("d").and_then(|v| v.as_bool());
245
246                        entries.push(FoodEntry {
247                            date,
248                            entry_id: key.clone(),
249                            name: parse_str("t"),
250                            brand: parse_str("b"),
251                            calories_raw: parse_num("c"),
252                            protein_raw: parse_num("p"),
253                            carbs_raw: parse_num("e"),
254                            fat_raw: parse_num("f"),
255                            serving_grams,
256                            user_qty,
257                            unit_weight,
258                            quantity: parse_num("q"),
259                            serving_unit: parse_str("s"),
260                            hour: parse_str("h"),
261                            minute: parse_str("mi"),
262                            source_type: parse_str("k"),
263                            food_id: parse_str("id"),
264                            deleted,
265                        });
266                    }
267                }
268            }
269        }
270
271        // Sort by hour:minute
272        entries.sort_by(|a, b| {
273            let time_a = (
274                a.hour.as_deref().unwrap_or("0").parse::<u32>().unwrap_or(0),
275                a.minute
276                    .as_deref()
277                    .unwrap_or("0")
278                    .parse::<u32>()
279                    .unwrap_or(0),
280            );
281            let time_b = (
282                b.hour.as_deref().unwrap_or("0").parse::<u32>().unwrap_or(0),
283                b.minute
284                    .as_deref()
285                    .unwrap_or("0")
286                    .parse::<u32>()
287                    .unwrap_or(0),
288            );
289            time_a.cmp(&time_b)
290        });
291
292        Ok(entries)
293    }
294
295    /// Get step counts for a date range.
296    /// Data is stored in `steps/{year}` docs with MMDD keys.
297    pub async fn get_steps(&mut self, start: NaiveDate, end: NaiveDate) -> Result<Vec<StepEntry>> {
298        let uid = self.get_user_id().await?;
299        let mut entries = Vec::new();
300
301        let start_year = start.format("%Y").to_string().parse::<i32>()?;
302        let end_year = end.format("%Y").to_string().parse::<i32>()?;
303
304        for year in start_year..=end_year {
305            let path = format!("users/{}/steps/{}", uid, year);
306            let doc = match self.firestore.get_document(&path).await {
307                Ok(d) => d,
308                Err(_) => continue,
309            };
310
311            if let Some(ref fields) = doc.fields {
312                let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
313                if let Some(map) = parsed.as_object() {
314                    for (key, val) in map {
315                        if key.starts_with('_') || key.len() != 4 {
316                            continue;
317                        }
318                        let month: u32 = key[..2].parse().unwrap_or(0);
319                        let day: u32 = key[2..].parse().unwrap_or(0);
320                        if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
321                            if date >= start && date <= end {
322                                if let Some(obj) = val.as_object() {
323                                    let steps = obj
324                                        .get("st")
325                                        .and_then(|v| {
326                                            v.as_u64()
327                                                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
328                                        })
329                                        .unwrap_or(0);
330                                    let source =
331                                        obj.get("s").and_then(|v| v.as_str()).map(String::from);
332
333                                    entries.push(StepEntry {
334                                        date,
335                                        steps,
336                                        source,
337                                    });
338                                }
339                            }
340                        }
341                    }
342                }
343            }
344        }
345
346        entries.sort_by_key(|e| e.date);
347        Ok(entries)
348    }
349
350    /// Get the current macro/calorie goals from the user's planner.
351    pub async fn get_goals(&mut self) -> Result<Goals> {
352        let profile = self.get_profile().await?;
353
354        let planner = profile
355            .get("planner")
356            .ok_or_else(|| anyhow!("No planner field in user profile"))?;
357
358        let parse_vec = |key: &str| -> Vec<f64> {
359            planner
360                .get(key)
361                .and_then(|v| v.as_array())
362                .map(|arr| {
363                    arr.iter()
364                        .filter_map(|v| {
365                            v.as_f64()
366                                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
367                        })
368                        .collect()
369                })
370                .unwrap_or_default()
371        };
372
373        Ok(Goals {
374            calories: parse_vec("calories"),
375            protein: parse_vec("protein"),
376            carbs: parse_vec("carbs"),
377            fat: parse_vec("fat"),
378            tdee: planner.get("tdeeValue").and_then(|v| {
379                v.as_f64()
380                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
381            }),
382            program_style: planner
383                .get("programStyle")
384                .and_then(|v| v.as_str())
385                .map(String::from),
386            program_type: planner
387                .get("programType")
388                .and_then(|v| v.as_str())
389                .map(String::from),
390        })
391    }
392
393    /// Log a food entry for a given date and time.
394    ///
395    /// Pass `logged_at` as the local datetime when the food was consumed —
396    /// the caller is responsible for providing the correct timezone.
397    /// Use `chrono::Local::now()` for the current time.
398    ///
399    /// Fields like `calories`, `protein`, `carbs`, `fat` are required.
400    /// The entry will be created with a timestamp-based ID.
401    pub async fn log_food(
402        &mut self,
403        logged_at: DateTime<Local>,
404        name: &str,
405        calories: f64,
406        protein: f64,
407        carbs: f64,
408        fat: f64,
409    ) -> Result<()> {
410        let uid = self.get_user_id().await?;
411        let date_str = logged_at.format("%Y-%m-%d").to_string();
412        let path = format!("users/{}/food/{}", uid, date_str);
413
414        let ts = logged_at.timestamp_millis();
415        let entry_id = format!("{}", ts * 1000);
416        let food_id = format!("{}", ts * 1000 + 10);
417
418        let hour = logged_at.hour().to_string();
419        let minute = logged_at.minute().to_string();
420
421        let entry = json!({
422            "t": name,
423            "b": "Quick Add",
424            "c": format!("{:.1}", calories),
425            "p": format!("{:.1}", protein),
426            "e": format!("{:.1}", carbs),
427            "f": format!("{:.1}", fat),
428            "w": "100.0",
429            "g": "100.0",
430            "q": "1.0",
431            "y": "1.0",
432            "s": "serving",
433            "u": "serving",
434            "h": hour,
435            "mi": minute,
436            "k": "n",
437            "id": food_id,
438            "ca": &entry_id,
439            "ua": &entry_id,
440            "ef": Value::Null,
441            "d": false,
442            "x": "13",
443            "m": [{"m": "serving", "q": "1.0", "w": "100.0"}]
444        });
445
446        let fields = to_firestore_fields(&json!({ &entry_id: entry }));
447
448        // Firestore rejects field paths that start with a digit unless backtick-quoted
449        let field_mask = format!("`{}`", entry_id);
450        self.firestore
451            .patch_document(&path, fields, &[&field_mask])
452            .await?;
453
454        Ok(())
455    }
456
457    /// Log a weight entry for a given date.
458    /// Weight should be in kg.
459    pub async fn log_weight(
460        &mut self,
461        date: NaiveDate,
462        weight_kg: f64,
463        body_fat: Option<f64>,
464    ) -> Result<()> {
465        let uid = self.get_user_id().await?;
466        let year = date.format("%Y").to_string();
467        let mmdd = date.format("%m%d").to_string();
468        let path = format!("users/{}/scale/{}", uid, year);
469
470        let mut entry = json!({
471            "w": weight_kg,
472            "s": "m",
473            "do": null
474        });
475        if let Some(bf) = body_fat {
476            entry["f"] = json!(bf);
477        } else {
478            entry["f"] = Value::Null;
479        }
480
481        let fields = to_firestore_fields(&json!({ &mmdd: entry }));
482
483        let field_mask = format!("`{}`", mmdd);
484        self.firestore
485            .patch_document(&path, fields, &[&field_mask])
486            .await?;
487
488        Ok(())
489    }
490
491    /// Delete a weight entry for a given date.
492    ///
493    /// Removes the MMDD field from the `scale/{year}` document.
494    pub async fn delete_weight_entry(&mut self, date: NaiveDate) -> Result<()> {
495        let uid = self.get_user_id().await?;
496        let year = date.format("%Y").to_string();
497        let mmdd = date.format("%m%d").to_string();
498        let path = format!("users/{}/scale/{}", uid, year);
499
500        let fields = serde_json::Map::new();
501        let field_mask = format!("`{}`", mmdd);
502        self.firestore
503            .patch_document(&path, fields, &[&field_mask])
504            .await?;
505
506        Ok(())
507    }
508
509    /// Import a manual nutrition summary for a given date.
510    ///
511    /// This writes to the `nutrition/{year}` collection, which is used for
512    /// **externally imported** nutrition data (e.g. Apple Health syncs).
513    /// The app computes daily totals from individual food entries automatically —
514    /// you do NOT need to call this after logging food.
515    pub async fn log_nutrition(
516        &mut self,
517        date: NaiveDate,
518        calories: f64,
519        protein: Option<f64>,
520        carbs: Option<f64>,
521        fat: Option<f64>,
522    ) -> Result<()> {
523        let uid = self.get_user_id().await?;
524        let year = date.format("%Y").to_string();
525        let mmdd = date.format("%m%d").to_string();
526        let path = format!("users/{}/nutrition/{}", uid, year);
527
528        let entry = json!({
529            "k": format!("{:.0}", calories),
530            "p": protein.map(|v| format!("{:.0}", v)).unwrap_or_default(),
531            "c": carbs.map(|v| format!("{:.0}", v)).unwrap_or_default(),
532            "f": fat.map(|v| format!("{:.0}", v)).unwrap_or_default(),
533            "s": "m",
534            "do": null
535        });
536
537        let fields = to_firestore_fields(&json!({ &mmdd: entry }));
538
539        let field_mask = format!("`{}`", mmdd);
540        self.firestore
541            .patch_document(&path, fields, &[&field_mask])
542            .await?;
543
544        Ok(())
545    }
546
547    /// Search the food database using Typesense.
548    ///
549    /// Searches both `common_foods` and `branded_foods` collections.
550    /// No authentication required — uses the Typesense API key directly.
551    pub async fn search_foods(&self, query: &str) -> Result<Vec<SearchFoodResult>> {
552        let client = Client::new();
553        let url = format!("{}/multi_search", TYPESENSE_HOST);
554
555        let body = json!({
556            "searches": [
557                {
558                    "collection": "common_foods",
559                    "q": query,
560                    "query_by": "foodDesc",
561                    "per_page": 10
562                },
563                {
564                    "collection": "branded_foods",
565                    "q": query,
566                    "query_by": "foodDesc,brandName",
567                    "per_page": 10
568                }
569            ]
570        });
571
572        let resp = client
573            .post(&url)
574            .header("x-typesense-api-key", TYPESENSE_API_KEY)
575            .json(&body)
576            .send()
577            .await?;
578
579        if !resp.status().is_success() {
580            let status = resp.status();
581            let text = resp.text().await.unwrap_or_default();
582            return Err(anyhow!("Typesense search failed: {} - {}", status, text));
583        }
584
585        let data: Value = resp.json().await?;
586        let mut results = Vec::new();
587
588        if let Some(searches) = data.get("results").and_then(|v| v.as_array()) {
589            for (idx, search) in searches.iter().enumerate() {
590                let branded = idx == 1;
591                if let Some(hits) = search.get("hits").and_then(|v| v.as_array()) {
592                    for hit in hits {
593                        if let Some(doc) = hit.get("document") {
594                            if let Some(result) = parse_typesense_hit(doc, branded) {
595                                results.push(result);
596                            }
597                        }
598                    }
599                }
600            }
601        }
602
603        Ok(results)
604    }
605
606    /// Log a food entry from a search result.
607    ///
608    /// `serving` specifies which serving option to use (from `food.servings` or `food.default_serving`).
609    /// `quantity` is how many of that serving (e.g. 1.0 for one serving).
610    pub async fn log_searched_food(
611        &mut self,
612        logged_at: DateTime<Local>,
613        food: &SearchFoodResult,
614        serving: &FoodServing,
615        quantity: f64,
616    ) -> Result<()> {
617        let uid = self.get_user_id().await?;
618        let date_str = logged_at.format("%Y-%m-%d").to_string();
619        let path = format!("users/{}/food/{}", uid, date_str);
620
621        let ts = logged_at.timestamp_millis();
622        let entry_id = format!("{}", ts * 1000);
623
624        let hour = logged_at.hour().to_string();
625        let minute = logged_at.minute().to_string();
626
627        // Serving gram weight (this becomes the "g" field — the base for macro values)
628        let serving_grams = serving.gram_weight;
629        // Scale factor from per-100g to per-serving
630        let scale = serving_grams / 100.0;
631
632        // Grams per one display unit
633        let unit_weight = serving.gram_weight / serving.amount;
634        // Total display units
635        let total_units = quantity * serving.amount;
636
637        let measurements: Vec<Value> = food
638            .servings
639            .iter()
640            .map(|s| {
641                json!({
642                    "m": s.description,
643                    "q": format!("{:.1}", s.amount),
644                    "w": format!("{}", s.gram_weight)
645                })
646            })
647            .collect();
648
649        let mut entry = json!({
650            "t": food.name,
651            "b": food.brand.as_deref().unwrap_or(""),
652            "c": format!("{}", food.calories_per_100g * scale),
653            "p": format!("{}", food.protein_per_100g * scale),
654            "e": format!("{}", food.carbs_per_100g * scale),
655            "f": format!("{}", food.fat_per_100g * scale),
656            "g": format!("{}", serving_grams),
657            "w": format!("{}", unit_weight),
658            "y": format!("{}", total_units),
659            "q": format!("{}", serving.amount),
660            "s": serving.description,
661            "u": serving.description,
662            "h": hour,
663            "mi": minute,
664            "k": "t",
665            "id": food.food_id,
666            "ca": &entry_id,
667            "ua": &entry_id,
668            "ef": Value::Null,
669            "d": false,
670            "o": false,
671            "fav": false,
672            "x": food.image_id.as_deref().unwrap_or("13"),
673            "m": measurements
674        });
675
676        // Copy all micronutrient values, scaled to serving size
677        if let Some(obj) = entry.as_object_mut() {
678            for (code, val_per_100g) in &food.nutrients_per_100g {
679                // Skip the main macro codes — already handled above
680                if matches!(code.as_str(), "203" | "204" | "205" | "208") {
681                    continue;
682                }
683                obj.insert(code.clone(), json!(format!("{}", val_per_100g * scale)));
684            }
685        }
686
687        let fields = to_firestore_fields(&json!({ &entry_id: entry }));
688
689        let field_mask = format!("`{}`", entry_id);
690        self.firestore
691            .patch_document(&path, fields, &[&field_mask])
692            .await?;
693
694        Ok(())
695    }
696
697    /// Delete a food entry by marking it as deleted.
698    ///
699    /// Sets the `d` (deleted) field to `true` on the entry.
700    pub async fn delete_food_entry(&mut self, date: NaiveDate, entry_id: &str) -> Result<()> {
701        let uid = self.get_user_id().await?;
702        let date_str = date.format("%Y-%m-%d").to_string();
703        let path = format!("users/{}/food/{}", uid, date_str);
704
705        let fields = to_firestore_fields(&json!({
706            entry_id: { "d": true }
707        }));
708
709        let field_mask = format!("`{}`.d", entry_id);
710        self.firestore
711            .patch_document(&path, fields, &[&field_mask])
712            .await?;
713
714        Ok(())
715    }
716}
717
718/// Parse a Typesense document hit into a SearchFoodResult.
719fn parse_typesense_hit(doc: &Value, branded: bool) -> Option<SearchFoodResult> {
720    let food_id = doc.get("id").and_then(|v| v.as_str())?.to_string();
721    let name = doc.get("foodDesc").and_then(|v| v.as_str())?.to_string();
722
723    let brand = doc
724        .get("brandName")
725        .and_then(|v| v.as_str())
726        .filter(|s| !s.is_empty())
727        .map(String::from);
728
729    let nutrient = |code: &str| -> f64 {
730        doc.get(code)
731            .and_then(|v| {
732                v.as_f64()
733                    .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
734            })
735            .unwrap_or(0.0)
736    };
737
738    let calories_per_100g = nutrient("208");
739    let protein_per_100g = nutrient("203");
740    let fat_per_100g = nutrient("204");
741    let carbs_per_100g = nutrient("205");
742
743    let default_serving = doc.get("dfSrv").and_then(|ds| {
744        let desc = ds.get("msreDesc").and_then(|v| v.as_str())?.to_string();
745        let amount = ds.get("amount").and_then(|v| {
746            v.as_f64()
747                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
748        })?;
749        let gram_weight = ds.get("gmWgt").and_then(|v| {
750            v.as_f64()
751                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
752        })?;
753        Some(FoodServing {
754            description: desc,
755            amount,
756            gram_weight,
757        })
758    });
759
760    let servings = doc
761        .get("weights")
762        .and_then(|v| v.as_array())
763        .map(|arr| {
764            arr.iter()
765                .filter_map(|w| {
766                    let desc = w.get("msreDesc").and_then(|v| v.as_str())?.to_string();
767                    let amount = w.get("amount").and_then(|v| {
768                        v.as_f64()
769                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
770                    })?;
771                    let gram_weight = w.get("gmWgt").and_then(|v| {
772                        v.as_f64()
773                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
774                    })?;
775                    Some(FoodServing {
776                        description: desc,
777                        amount,
778                        gram_weight,
779                    })
780                })
781                .collect()
782        })
783        .unwrap_or_default();
784
785    let image_id = doc
786        .get("imageId")
787        .and_then(|v| {
788            v.as_str()
789                .map(String::from)
790                .or_else(|| v.as_i64().map(|n| n.to_string()))
791        })
792        .filter(|s| !s.is_empty());
793
794    // Collect all numeric-keyed nutrient values (USDA nutrient codes)
795    let mut nutrients_per_100g = HashMap::new();
796    if let Some(obj) = doc.as_object() {
797        for (key, val) in obj {
798            if key.chars().all(|c| c.is_ascii_digit()) {
799                if let Some(v) = val
800                    .as_f64()
801                    .or_else(|| val.as_str().and_then(|s| s.parse().ok()))
802                {
803                    nutrients_per_100g.insert(key.clone(), v);
804                }
805            }
806        }
807    }
808
809    let source = doc
810        .get("source")
811        .and_then(|v| v.as_str())
812        .filter(|s| !s.is_empty())
813        .map(String::from);
814
815    Some(SearchFoodResult {
816        food_id,
817        name,
818        brand,
819        calories_per_100g,
820        protein_per_100g,
821        fat_per_100g,
822        carbs_per_100g,
823        default_serving,
824        servings,
825        image_id,
826        nutrients_per_100g,
827        source,
828        branded,
829    })
830}