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 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 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 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 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 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 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 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 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 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 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 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 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 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 async fn write_food_entry(&mut self, logged_at: DateTime<Local>, entry: Value) -> Result<()> {
397 let uid = self.get_user_id().await?;
398 let date_str = logged_at.format("%Y-%m-%d").to_string();
399 let path = format!("users/{}/food/{}", uid, date_str);
400
401 let ts = logged_at.timestamp_millis();
402 let entry_id = format!("{}", ts * 1000);
403
404 let fields = to_firestore_fields(&json!({ &entry_id: entry }));
405 let field_mask = format!("`{}`", entry_id);
406 self.firestore
407 .patch_document(&path, fields, &[&field_mask])
408 .await?;
409
410 Ok(())
411 }
412
413 pub async fn log_food(
417 &mut self,
418 logged_at: DateTime<Local>,
419 name: &str,
420 calories: f64,
421 protein: f64,
422 carbs: f64,
423 fat: f64,
424 ) -> Result<()> {
425 let ts = logged_at.timestamp_millis();
426 let food_id = format!("{}", ts * 1000 + 10);
427 let entry_id = format!("{}", ts * 1000);
428 let ua_id = format!("{}", ts * 1000 + 1);
429 let hour = logged_at.hour().to_string();
430 let minute = logged_at.minute().to_string();
431
432 let entry = json!({
433 "t": name,
434 "b": "Quick Add",
435 "c": format!("{:.1}", calories),
436 "p": format!("{:.1}", protein),
437 "e": format!("{:.1}", carbs),
438 "f": format!("{:.1}", fat),
439 "w": "100.0",
440 "g": "100.0",
441 "q": "1.0",
442 "y": "1.0",
443 "s": "serving",
444 "u": "serving",
445 "h": hour,
446 "mi": minute,
447 "k": "n",
448 "id": food_id,
449 "ca": &entry_id,
450 "ua": &ua_id,
451 "ef": false,
452 "d": false,
453 "x": "13",
454 "m": [{"m": "serving", "q": "1.0", "w": "100.0"}]
455 });
456
457 self.write_food_entry(logged_at, entry).await
458 }
459
460 pub async fn log_weight(
463 &mut self,
464 date: NaiveDate,
465 weight_kg: f64,
466 body_fat: Option<f64>,
467 ) -> Result<()> {
468 let uid = self.get_user_id().await?;
469 let year = date.format("%Y").to_string();
470 let mmdd = date.format("%m%d").to_string();
471 let path = format!("users/{}/scale/{}", uid, year);
472
473 let mut entry = json!({
474 "w": weight_kg,
475 "s": "m",
476 "do": null
477 });
478 if let Some(bf) = body_fat {
479 entry["f"] = json!(bf);
480 } else {
481 entry["f"] = Value::Null;
482 }
483
484 let fields = to_firestore_fields(&json!({ &mmdd: entry }));
485
486 let field_mask = format!("`{}`", mmdd);
487 self.firestore
488 .patch_document(&path, fields, &[&field_mask])
489 .await?;
490
491 Ok(())
492 }
493
494 pub async fn delete_weight_entry(&mut self, date: NaiveDate) -> Result<()> {
498 let uid = self.get_user_id().await?;
499 let year = date.format("%Y").to_string();
500 let mmdd = date.format("%m%d").to_string();
501 let path = format!("users/{}/scale/{}", uid, year);
502
503 let fields = serde_json::Map::new();
504 let field_mask = format!("`{}`", mmdd);
505 self.firestore
506 .patch_document(&path, fields, &[&field_mask])
507 .await?;
508
509 Ok(())
510 }
511
512 pub async fn log_nutrition(
519 &mut self,
520 date: NaiveDate,
521 calories: f64,
522 protein: Option<f64>,
523 carbs: Option<f64>,
524 fat: Option<f64>,
525 ) -> Result<()> {
526 let uid = self.get_user_id().await?;
527 let year = date.format("%Y").to_string();
528 let mmdd = date.format("%m%d").to_string();
529 let path = format!("users/{}/nutrition/{}", uid, year);
530
531 let entry = json!({
532 "k": format!("{:.0}", calories),
533 "p": protein.map(|v| format!("{:.0}", v)).unwrap_or_default(),
534 "c": carbs.map(|v| format!("{:.0}", v)).unwrap_or_default(),
535 "f": fat.map(|v| format!("{:.0}", v)).unwrap_or_default(),
536 "s": "m",
537 "do": null
538 });
539
540 let fields = to_firestore_fields(&json!({ &mmdd: entry }));
541
542 let field_mask = format!("`{}`", mmdd);
543 self.firestore
544 .patch_document(&path, fields, &[&field_mask])
545 .await?;
546
547 Ok(())
548 }
549
550 pub async fn search_foods(&self, query: &str) -> Result<Vec<SearchFoodResult>> {
555 let client = Client::new();
556 let url = format!("{}/multi_search", TYPESENSE_HOST);
557
558 let body = json!({
559 "searches": [
560 {
561 "collection": "common_foods",
562 "q": query,
563 "query_by": "foodDesc",
564 "per_page": 10
565 },
566 {
567 "collection": "branded_foods",
568 "q": query,
569 "query_by": "foodDesc,brandName",
570 "per_page": 10
571 }
572 ]
573 });
574
575 let resp = client
576 .post(&url)
577 .header("x-typesense-api-key", TYPESENSE_API_KEY)
578 .json(&body)
579 .send()
580 .await?;
581
582 if !resp.status().is_success() {
583 let status = resp.status();
584 let text = resp.text().await.unwrap_or_default();
585 return Err(anyhow!("Typesense search failed: {} - {}", status, text));
586 }
587
588 let data: Value = resp.json().await?;
589 let mut results = Vec::new();
590
591 if let Some(searches) = data.get("results").and_then(|v| v.as_array()) {
592 for (idx, search) in searches.iter().enumerate() {
593 let branded = idx == 1;
594 if let Some(hits) = search.get("hits").and_then(|v| v.as_array()) {
595 for hit in hits {
596 if let Some(doc) = hit.get("document") {
597 if let Some(result) = parse_typesense_hit(doc, branded) {
598 results.push(result);
599 }
600 }
601 }
602 }
603 }
604 }
605
606 Ok(results)
607 }
608
609 pub async fn log_searched_food(
616 &mut self,
617 logged_at: DateTime<Local>,
618 food: &SearchFoodResult,
619 serving: &FoodServing,
620 quantity: f64,
621 ) -> Result<()> {
622 let ts = logged_at.timestamp_millis();
623 let entry_id = format!("{}", ts * 1000);
624 let ua_id = format!("{}", ts * 1000 + 1);
625 let hour = logged_at.hour().to_string();
626 let minute = logged_at.minute().to_string();
627
628 let serving_grams = serving.gram_weight;
630 let scale = serving_grams / 100.0;
632
633 let unit_weight = serving.gram_weight / serving.amount;
635 let total_units = quantity * serving.amount;
637
638 let measurements: Vec<Value> = food
639 .servings
640 .iter()
641 .map(|s| {
642 json!({
643 "m": s.description,
644 "q": format!("{:.1}", s.amount),
645 "w": format!("{}", s.gram_weight)
646 })
647 })
648 .collect();
649
650 let mut entry = json!({
651 "t": food.name,
652 "b": food.brand.as_deref().unwrap_or(""),
653 "c": format!("{}", food.calories_per_100g * scale),
654 "p": format!("{}", food.protein_per_100g * scale),
655 "e": format!("{}", food.carbs_per_100g * scale),
656 "f": format!("{}", food.fat_per_100g * scale),
657 "g": format!("{}", serving_grams),
658 "w": format!("{}", unit_weight),
659 "y": format!("{}", total_units),
660 "q": format!("{}", serving.amount),
661 "s": serving.description,
662 "u": serving.description,
663 "h": hour,
664 "mi": minute,
665 "k": "t",
666 "id": food.food_id,
667 "ca": &entry_id,
668 "ua": &ua_id,
669 "ef": false,
670 "d": false,
671 "o": false,
672 "fav": false,
673 "x": food.image_id.as_deref().unwrap_or("13"),
674 "m": measurements
675 });
676
677 if let Some(obj) = entry.as_object_mut() {
679 for (code, val_per_100g) in &food.nutrients_per_100g {
680 if matches!(code.as_str(), "203" | "204" | "205" | "208") {
682 continue;
683 }
684 obj.insert(code.clone(), json!(format!("{}", val_per_100g * scale)));
685 }
686 }
687
688 self.write_food_entry(logged_at, entry).await
689 }
690
691 pub async fn delete_food_entry(&mut self, date: NaiveDate, entry_id: &str) -> Result<()> {
695 let uid = self.get_user_id().await?;
696 let date_str = date.format("%Y-%m-%d").to_string();
697 let path = format!("users/{}/food/{}", uid, date_str);
698
699 let fields = serde_json::Map::new();
701 let field_mask = format!("`{}`", entry_id);
702 self.firestore
703 .patch_document(&path, fields, &[&field_mask])
704 .await?;
705
706 Ok(())
707 }
708
709 pub async fn sync_day(&mut self, date: NaiveDate) -> Result<()> {
715 let uid = self.get_user_id().await?;
716 let entries = self.get_food_log(date).await?;
717
718 let mut total_k = 0.0;
719 let mut total_p = 0.0;
720 let mut total_c = 0.0;
721 let mut total_f = 0.0;
722 let mut micros: HashMap<String, f64> = HashMap::new();
723
724 for entry in &entries {
725 if entry.deleted == Some(true) {
726 continue;
727 }
728 total_k += entry.calories().unwrap_or(0.0);
729 total_p += entry.protein().unwrap_or(0.0);
730 total_c += entry.carbs().unwrap_or(0.0);
731 total_f += entry.fat().unwrap_or(0.0);
732 }
733
734 let date_str = date.format("%Y-%m-%d").to_string();
736 let food_path = format!("users/{}/food/{}", uid, date_str);
737 if let Ok(raw) = self.get_raw_document(&food_path).await {
738 if let Some(map) = raw.as_object() {
739 for (key, val) in map {
740 if key.starts_with('_') {
741 continue;
742 }
743 if let Some(obj) = val.as_object() {
744 if obj.get("d").and_then(|v| v.as_bool()) == Some(true) {
746 continue;
747 }
748 let multiplier = Self::compute_multiplier(obj);
749 for (field, fval) in obj {
750 if !field.chars().all(|c| c.is_ascii_digit()) {
751 continue;
752 }
753 if matches!(field.as_str(), "208" | "203" | "204" | "205") {
755 continue;
756 }
757 if let Some(v) = fval
758 .as_f64()
759 .or_else(|| fval.as_str().and_then(|s| s.parse().ok()))
760 {
761 let scaled = v * multiplier;
762 *micros.entry(field.clone()).or_default() += scaled;
763 }
764 }
765 }
766 }
767 }
768 }
769
770 let all_codes = [
772 "209", "221", "255", "262", "269", "291", "301", "303", "304", "305", "306", "307",
773 "309", "312", "315", "317", "320", "323", "328", "401", "404", "405", "406", "410",
774 "415", "417", "418", "421", "430", "501", "502", "503", "504", "505", "506", "507",
775 "508", "509", "510", "512", "539", "601", "606", "621", "629", "645", "646", "693",
776 "851", "901", "902",
777 ];
778
779 let mut entry = serde_json::Map::new();
780 entry.insert("k".to_string(), json!(format!("{}", total_k)));
781 entry.insert("p".to_string(), json!(format!("{}", total_p)));
782 entry.insert("c".to_string(), json!(format!("{}", total_c)));
783 entry.insert("f".to_string(), json!(format!("{}", total_f)));
784
785 for code in &all_codes {
786 let code_str = code.to_string();
787 if let Some(v) = micros.get(&code_str) {
788 entry.insert(code_str, json!(format!("{}", v)));
789 } else {
790 entry.insert(code_str, Value::Null);
791 }
792 }
793
794 let year = date.format("%Y").to_string();
795 let mmdd = date.format("%m%d").to_string();
796 let path = format!("users/{}/micro/{}", uid, year);
797
798 let fields = to_firestore_fields(&json!({ &mmdd: Value::Object(entry) }));
799 let field_mask = format!("`{}`", mmdd);
800 self.firestore
801 .patch_document(&path, fields, &[&field_mask])
802 .await?;
803
804 Ok(())
805 }
806
807 fn compute_multiplier(obj: &serde_json::Map<String, Value>) -> f64 {
809 let parse = |k: &str| -> Option<f64> {
810 obj.get(k).and_then(|v| {
811 v.as_f64()
812 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
813 })
814 };
815 match (parse("g"), parse("y"), parse("w")) {
816 (Some(g), Some(y), Some(w)) if g > 0.0 => (y * w) / g,
817 _ => 1.0,
818 }
819 }
820}
821
822fn parse_typesense_hit(doc: &Value, branded: bool) -> Option<SearchFoodResult> {
824 let food_id = doc.get("id").and_then(|v| v.as_str())?.to_string();
825 let name = doc.get("foodDesc").and_then(|v| v.as_str())?.to_string();
826
827 let brand = doc
828 .get("brandName")
829 .and_then(|v| v.as_str())
830 .filter(|s| !s.is_empty())
831 .map(String::from);
832
833 let nutrient = |code: &str| -> f64 {
834 doc.get(code)
835 .and_then(|v| {
836 v.as_f64()
837 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
838 })
839 .unwrap_or(0.0)
840 };
841
842 let calories_per_100g = nutrient("208");
843 let protein_per_100g = nutrient("203");
844 let fat_per_100g = nutrient("204");
845 let carbs_per_100g = nutrient("205");
846
847 let default_serving = doc.get("dfSrv").and_then(|ds| {
848 let desc = ds.get("msreDesc").and_then(|v| v.as_str())?.to_string();
849 let amount = ds.get("amount").and_then(|v| {
850 v.as_f64()
851 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
852 })?;
853 let gram_weight = ds.get("gmWgt").and_then(|v| {
854 v.as_f64()
855 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
856 })?;
857 Some(FoodServing {
858 description: desc,
859 amount,
860 gram_weight,
861 })
862 });
863
864 let servings = doc
865 .get("weights")
866 .and_then(|v| v.as_array())
867 .map(|arr| {
868 arr.iter()
869 .filter_map(|w| {
870 let desc = w.get("msreDesc").and_then(|v| v.as_str())?.to_string();
871 let amount = w.get("amount").and_then(|v| {
872 v.as_f64()
873 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
874 })?;
875 let gram_weight = w.get("gmWgt").and_then(|v| {
876 v.as_f64()
877 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
878 })?;
879 Some(FoodServing {
880 description: desc,
881 amount,
882 gram_weight,
883 })
884 })
885 .collect()
886 })
887 .unwrap_or_default();
888
889 let image_id = doc
890 .get("imageId")
891 .and_then(|v| {
892 v.as_str()
893 .map(String::from)
894 .or_else(|| v.as_i64().map(|n| n.to_string()))
895 })
896 .filter(|s| !s.is_empty());
897
898 let mut nutrients_per_100g = HashMap::new();
900 if let Some(obj) = doc.as_object() {
901 for (key, val) in obj {
902 if key.chars().all(|c| c.is_ascii_digit()) {
903 if let Some(v) = val
904 .as_f64()
905 .or_else(|| val.as_str().and_then(|s| s.parse().ok()))
906 {
907 nutrients_per_100g.insert(key.clone(), v);
908 }
909 }
910 }
911 }
912
913 let source = doc
914 .get("source")
915 .and_then(|v| v.as_str())
916 .filter(|s| !s.is_empty())
917 .map(String::from);
918
919 Some(SearchFoodResult {
920 food_id,
921 name,
922 brand,
923 calories_per_100g,
924 protein_per_100g,
925 fat_per_100g,
926 carbs_per_100g,
927 default_serving,
928 servings,
929 image_id,
930 nutrients_per_100g,
931 source,
932 branded,
933 })
934}