1use std::collections::HashMap;
8
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct SearchCriteria {
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub breed_group_id: Option<i64>,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub breed_id: Option<i64>,
40
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub born_after: Option<String>,
44
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub born_before: Option<String>,
48
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub gender: Option<String>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub proven_only: Option<bool>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub status: Option<String>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub flock_id: Option<String>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub trait_ranges: Option<HashMap<String, TraitRangeFilter>>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct TraitRangeFilter {
73 pub min: f64,
75 pub max: f64,
77}
78
79impl SearchCriteria {
80 #[must_use]
82 pub const fn new() -> Self {
83 Self {
84 breed_group_id: None,
85 breed_id: None,
86 born_after: None,
87 born_before: None,
88 gender: None,
89 proven_only: None,
90 status: None,
91 flock_id: None,
92 trait_ranges: None,
93 }
94 }
95
96 #[must_use]
98 pub const fn with_breed_group_id(mut self, id: i64) -> Self {
99 self.breed_group_id = Some(id);
100 self
101 }
102
103 #[must_use]
105 pub const fn with_breed_id(mut self, id: i64) -> Self {
106 self.breed_id = Some(id);
107 self
108 }
109
110 #[must_use]
112 pub fn with_born_after(mut self, date: impl Into<String>) -> Self {
113 self.born_after = Some(date.into());
114 self
115 }
116
117 #[must_use]
119 pub fn with_born_before(mut self, date: impl Into<String>) -> Self {
120 self.born_before = Some(date.into());
121 self
122 }
123
124 #[must_use]
126 pub fn with_gender(mut self, gender: impl Into<String>) -> Self {
127 self.gender = Some(gender.into());
128 self
129 }
130
131 #[must_use]
133 pub const fn with_proven_only(mut self, proven: bool) -> Self {
134 self.proven_only = Some(proven);
135 self
136 }
137
138 #[must_use]
140 pub fn with_status(mut self, status: impl Into<String>) -> Self {
141 self.status = Some(status.into());
142 self
143 }
144
145 #[must_use]
147 pub fn with_flock_id(mut self, flock_id: impl Into<String>) -> Self {
148 self.flock_id = Some(flock_id.into());
149 self
150 }
151
152 #[must_use]
154 pub fn with_trait_ranges(mut self, ranges: HashMap<String, TraitRangeFilter>) -> Self {
155 self.trait_ranges = Some(ranges);
156 self
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct Breed {
167 pub id: i64,
169 pub name: String,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct BreedGroup {
176 pub id: i64,
178 pub name: String,
180 pub breeds: Vec<Breed>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Trait {
191 pub name: String,
193 pub value: f64,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub accuracy: Option<i32>,
198 #[serde(skip_serializing_if = "Option::is_none")]
200 pub units: Option<String>,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct TraitRange {
206 pub trait_name: String,
208 pub min_value: f64,
210 pub max_value: f64,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub unit: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, Default)]
223pub struct ContactInfo {
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub farm_name: Option<String>,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub contact_name: Option<String>,
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub phone: Option<String>,
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub email: Option<String>,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub address: Option<String>,
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub city: Option<String>,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub state: Option<String>,
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub zip_code: Option<String>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct AnimalDetails {
257 pub lpn_id: String,
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub breed: Option<String>,
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub breed_group: Option<String>,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub date_of_birth: Option<String>,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub gender: Option<String>,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub status: Option<String>,
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub sire: Option<String>,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub dam: Option<String>,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub registration_number: Option<String>,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub total_progeny: Option<i64>,
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub flock_count: Option<i64>,
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub genotyped: Option<String>,
292 pub traits: HashMap<String, Trait>,
294 #[serde(skip_serializing_if = "Option::is_none")]
296 pub contact_info: Option<ContactInfo>,
297}
298
299const TRAIT_MAPPING: &[(&str, &str, &str)] = &[
302 ("bwt", "BWT", "accbwt"),
303 ("wwt", "WWT", "accwwt"),
304 ("pwwt", "PWWT", "accpwwt"),
305 ("ywt", "YWT", "accywt"),
306 ("fat", "FAT", "accfat"),
307 ("emd", "EMD", "accemd"),
308 ("nlb", "NLB", "accnlb"),
309 ("nwt", "NWT", "accnwt"),
310 ("pwt", "PWT", "accpwt"),
311 ("dag", "DAG", "accdag"),
312 ("wgr", "WGR", "accwgr"),
313 ("wec", "WEC", "accwec"),
314 ("fec", "FEC", "accfec"),
315];
316
317#[allow(clippy::cast_possible_truncation)]
321fn convert_accuracy(acc: f64) -> i32 {
322 if acc <= 1.0 {
323 (acc * 100.0) as i32
324 } else {
325 acc as i32
326 }
327}
328
329fn extract_traits_nested(
331 sr: &serde_json::Map<String, serde_json::Value>,
332) -> HashMap<String, Trait> {
333 let mut traits = HashMap::new();
334 for &(trait_key, trait_name, acc_key) in TRAIT_MAPPING {
335 let Some(val) = sr.get(trait_key).and_then(serde_json::Value::as_f64) else {
336 continue;
337 };
338 let accuracy = sr
339 .get(acc_key)
340 .and_then(serde_json::Value::as_f64)
341 .map(convert_accuracy);
342 traits.insert(
343 trait_name.to_string(),
344 Trait {
345 name: trait_name.to_string(),
346 value: val,
347 accuracy,
348 units: None,
349 },
350 );
351 }
352 traits
353}
354
355fn extract_contact_info(c: &serde_json::Value) -> Option<ContactInfo> {
357 if c.is_null() || !c.is_object() {
358 return None;
359 }
360 Some(ContactInfo {
361 farm_name: c
362 .get("farmName")
363 .or_else(|| c.get("FarmName"))
364 .and_then(serde_json::Value::as_str)
365 .map(String::from),
366 contact_name: c
367 .get("customerName")
368 .or_else(|| c.get("ContactName"))
369 .and_then(serde_json::Value::as_str)
370 .map(String::from),
371 phone: c
372 .get("phone")
373 .or_else(|| c.get("Phone"))
374 .and_then(serde_json::Value::as_str)
375 .map(String::from),
376 email: c
377 .get("email")
378 .or_else(|| c.get("Email"))
379 .and_then(serde_json::Value::as_str)
380 .map(String::from),
381 address: c
382 .get("address")
383 .or_else(|| c.get("Address"))
384 .and_then(serde_json::Value::as_str)
385 .map(String::from),
386 city: c
387 .get("city")
388 .or_else(|| c.get("City"))
389 .and_then(serde_json::Value::as_str)
390 .map(String::from),
391 state: c
392 .get("state")
393 .or_else(|| c.get("State"))
394 .and_then(serde_json::Value::as_str)
395 .map(String::from),
396 zip_code: c
397 .get("zipCode")
398 .or_else(|| c.get("ZipCode"))
399 .and_then(serde_json::Value::as_str)
400 .map(String::from),
401 })
402}
403
404#[allow(clippy::cast_possible_truncation)]
406fn extract_traits_legacy(data: &serde_json::Value) -> HashMap<String, Trait> {
407 let Some(obj) = data.get("Traits").and_then(serde_json::Value::as_object) else {
408 return HashMap::new();
409 };
410 let mut traits = HashMap::new();
411 for (name, td) in obj {
412 let Some(td_obj) = td.as_object() else {
413 continue;
414 };
415 let value = td_obj
416 .get("Value")
417 .and_then(serde_json::Value::as_f64)
418 .unwrap_or(0.0);
419 let accuracy = td_obj
420 .get("Accuracy")
421 .and_then(|a| a.as_f64().map(|v| v as i32));
422 traits.insert(
423 name.clone(),
424 Trait {
425 name: name.clone(),
426 value,
427 accuracy,
428 units: None,
429 },
430 );
431 }
432 traits
433}
434
435fn extract_progeny_traits(item: &serde_json::Value) -> HashMap<String, f64> {
437 let Some(obj) = item.get("Traits").and_then(serde_json::Value::as_object) else {
438 return HashMap::new();
439 };
440 obj.iter()
441 .filter_map(|(k, v)| v.as_f64().map(|f| (k.clone(), f)))
442 .collect()
443}
444
445impl AnimalDetails {
446 pub fn from_api_response(data: &serde_json::Value) -> crate::Result<Self> {
455 let is_nested = data
456 .get("data")
457 .and_then(serde_json::Value::as_object)
458 .is_some();
459
460 if is_nested {
461 Ok(Self::from_nested_format(data))
462 } else if data.get("lpnId").is_some() {
463 Ok(Self::from_search_result(data))
465 } else {
466 Ok(Self::from_legacy_format(data))
467 }
468 }
469
470 fn from_nested_format(data: &serde_json::Value) -> Self {
471 let section = &data["data"];
472 let sr = §ion["searchResultViewModel"];
473 let breed_obj = §ion["breed"];
474 let contact_obj = section
475 .get("contactInfo")
476 .or_else(|| section.get("ContactInfo"));
477
478 let lpn_id = sr["lpnId"].as_str().unwrap_or_default().to_string();
479 let breed = breed_obj
480 .get("breedName")
481 .and_then(serde_json::Value::as_str)
482 .map(String::from);
483 let date_of_birth = section
484 .get("dateOfBirth")
485 .and_then(serde_json::Value::as_str)
486 .map(String::from);
487 let gender = section
488 .get("gender")
489 .and_then(serde_json::Value::as_str)
490 .map(String::from);
491 let status = sr
492 .get("status")
493 .and_then(serde_json::Value::as_str)
494 .map(String::from);
495 let sire = sr
496 .get("lpnSre")
497 .and_then(serde_json::Value::as_str)
498 .map(String::from);
499 let dam = sr
500 .get("lpnDam")
501 .and_then(serde_json::Value::as_str)
502 .map(String::from);
503 let registration_number = sr
504 .get("regNumber")
505 .and_then(serde_json::Value::as_str)
506 .map(String::from);
507 let total_progeny = section
508 .get("progenyCount")
509 .and_then(serde_json::Value::as_i64);
510 let genotyped = section
511 .get("genotyped")
512 .and_then(serde_json::Value::as_str)
513 .map(String::from);
514
515 let flock_count = section.get("flockCount").and_then(|v| {
516 v.as_i64()
517 .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
518 });
519
520 let traits = sr
522 .as_object()
523 .map(extract_traits_nested)
524 .unwrap_or_default();
525
526 let contact_info = contact_obj.and_then(extract_contact_info);
527
528 Self {
529 lpn_id,
530 breed,
531 breed_group: None,
532 date_of_birth,
533 gender,
534 status,
535 sire,
536 dam,
537 registration_number,
538 total_progeny,
539 flock_count,
540 genotyped,
541 traits,
542 contact_info,
543 }
544 }
545
546 fn from_search_result(data: &serde_json::Value) -> Self {
552 let lpn_id = data
553 .get("lpnId")
554 .and_then(serde_json::Value::as_str)
555 .unwrap_or_default()
556 .to_string();
557
558 let traits = data
559 .as_object()
560 .map(extract_traits_nested)
561 .unwrap_or_default();
562
563 Self {
564 lpn_id,
565 breed: None,
566 breed_group: None,
567 date_of_birth: data
568 .get("dateOfBirth")
569 .and_then(serde_json::Value::as_str)
570 .map(String::from),
571 gender: data
572 .get("gender")
573 .and_then(serde_json::Value::as_str)
574 .map(String::from),
575 status: data
576 .get("status")
577 .and_then(serde_json::Value::as_str)
578 .map(String::from),
579 sire: data
580 .get("lpnSre")
581 .and_then(serde_json::Value::as_str)
582 .map(String::from),
583 dam: data
584 .get("lpnDam")
585 .and_then(serde_json::Value::as_str)
586 .map(String::from),
587 registration_number: data
588 .get("regNumber")
589 .and_then(serde_json::Value::as_str)
590 .map(String::from),
591 total_progeny: None,
592 flock_count: None,
593 genotyped: None,
594 traits,
595 contact_info: None,
596 }
597 }
598
599 fn from_legacy_format(data: &serde_json::Value) -> Self {
600 let lpn_id = data
601 .get("LpnId")
602 .and_then(serde_json::Value::as_str)
603 .unwrap_or_default()
604 .to_string();
605
606 let traits = extract_traits_legacy(data);
607 let contact_info = data.get("ContactInfo").and_then(extract_contact_info);
608
609 Self {
610 lpn_id,
611 breed: data
612 .get("Breed")
613 .and_then(serde_json::Value::as_str)
614 .map(String::from),
615 breed_group: data
616 .get("BreedGroup")
617 .and_then(serde_json::Value::as_str)
618 .map(String::from),
619 date_of_birth: data
620 .get("DateOfBirth")
621 .and_then(serde_json::Value::as_str)
622 .map(String::from),
623 gender: data
624 .get("Gender")
625 .and_then(serde_json::Value::as_str)
626 .map(String::from),
627 status: data
628 .get("Status")
629 .and_then(serde_json::Value::as_str)
630 .map(String::from),
631 sire: data
632 .get("Sire")
633 .and_then(serde_json::Value::as_str)
634 .map(String::from),
635 dam: data
636 .get("Dam")
637 .and_then(serde_json::Value::as_str)
638 .map(String::from),
639 registration_number: data
640 .get("RegistrationNumber")
641 .and_then(serde_json::Value::as_str)
642 .map(String::from),
643 total_progeny: data.get("TotalProgeny").and_then(serde_json::Value::as_i64),
644 flock_count: data.get("FlockCount").and_then(serde_json::Value::as_i64),
645 genotyped: data
646 .get("Genotyped")
647 .and_then(serde_json::Value::as_str)
648 .map(String::from),
649 traits,
650 contact_info,
651 }
652 }
653}
654
655#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct ProgenyAnimal {
662 pub lpn_id: String,
664 #[serde(skip_serializing_if = "Option::is_none")]
666 pub sex: Option<String>,
667 #[serde(skip_serializing_if = "Option::is_none")]
669 pub date_of_birth: Option<String>,
670 pub traits: HashMap<String, f64>,
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct Progeny {
677 pub total_count: i64,
679 pub animals: Vec<ProgenyAnimal>,
681 pub page: u32,
683 pub page_size: u32,
685}
686
687impl Progeny {
688 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
697 pub fn from_api_response(
698 data: &serde_json::Value,
699 page: u32,
700 page_size: u32,
701 ) -> crate::Result<Self> {
702 let records = data
703 .get("records")
704 .or_else(|| data.get("Results"))
705 .and_then(serde_json::Value::as_array);
706
707 let mut animals = Vec::new();
708 if let Some(arr) = records {
709 for item in arr {
710 let lpn_id = item
711 .get("lpnId")
712 .or_else(|| item.get("LpnId"))
713 .and_then(serde_json::Value::as_str)
714 .unwrap_or_default()
715 .to_string();
716
717 let sex = item
718 .get("sex")
719 .or_else(|| item.get("Sex"))
720 .and_then(serde_json::Value::as_str)
721 .map(String::from);
722
723 let date_of_birth = item
724 .get("dob")
725 .or_else(|| item.get("DateOfBirth"))
726 .and_then(serde_json::Value::as_str)
727 .map(String::from);
728
729 let traits = extract_progeny_traits(item);
730
731 animals.push(ProgenyAnimal {
732 lpn_id,
733 sex,
734 date_of_birth,
735 traits,
736 });
737 }
738 }
739
740 let total_count = data
741 .get("recordCount")
742 .or_else(|| data.get("TotalCount"))
743 .and_then(serde_json::Value::as_i64)
744 .unwrap_or(0);
745
746 Ok(Self {
747 total_count,
748 animals,
749 page,
750 page_size,
751 })
752 }
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
761pub struct LineageAnimal {
762 pub lpn_id: String,
764 #[serde(skip_serializing_if = "Option::is_none")]
766 pub farm_name: Option<String>,
767 #[serde(skip_serializing_if = "Option::is_none")]
769 pub us_index: Option<f64>,
770 #[serde(skip_serializing_if = "Option::is_none")]
772 pub src_index: Option<f64>,
773 #[serde(skip_serializing_if = "Option::is_none")]
775 pub date_of_birth: Option<String>,
776 #[serde(skip_serializing_if = "Option::is_none")]
778 pub sex: Option<String>,
779 #[serde(skip_serializing_if = "Option::is_none")]
781 pub status: Option<String>,
782}
783
784#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct Lineage {
790 #[serde(skip_serializing_if = "Option::is_none")]
792 pub subject: Option<LineageAnimal>,
793 #[serde(skip_serializing_if = "Option::is_none")]
795 pub sire: Option<LineageAnimal>,
796 #[serde(skip_serializing_if = "Option::is_none")]
798 pub dam: Option<LineageAnimal>,
799 pub generations: Vec<Vec<LineageAnimal>>,
801}
802
803fn parse_lineage_content(content: &str) -> ParsedLineageContent {
811 let re_farm = Regex::new(r"<div>([^<]+)</div>").ok();
815 let re_us = Regex::new(r"US (?:Hair )?Index: ([\d.]+)").ok();
816 let re_src = Regex::new(r"SRC\$ Index: ([\d.]+)").ok();
817 let re_dob = Regex::new(r"DOB: ([^<]+)").ok();
818 let re_sex = Regex::new(r"Sex: ([^<]+)").ok();
819 let re_status = Regex::new(r"Status: ([^<]+)").ok();
820
821 ParsedLineageContent {
822 farm_name: re_farm
823 .and_then(|r| r.captures(content))
824 .map(|c| c[1].to_string()),
825 us_index: re_us
826 .and_then(|r| r.captures(content))
827 .and_then(|c| c[1].parse().ok()),
828 src_index: re_src
829 .and_then(|r| r.captures(content))
830 .and_then(|c| c[1].parse().ok()),
831 date_of_birth: re_dob
832 .and_then(|r| r.captures(content))
833 .map(|c| c[1].trim().to_string()),
834 sex: re_sex
835 .and_then(|r| r.captures(content))
836 .map(|c| c[1].trim().to_string()),
837 status: re_status
838 .and_then(|r| r.captures(content))
839 .map(|c| c[1].trim().to_string()),
840 }
841}
842
843struct ParsedLineageContent {
844 farm_name: Option<String>,
845 us_index: Option<f64>,
846 src_index: Option<f64>,
847 date_of_birth: Option<String>,
848 sex: Option<String>,
849 status: Option<String>,
850}
851
852fn parse_lineage_node(node: &serde_json::Value) -> Option<LineageAnimal> {
854 let lpn_id = node.get("lpnId")?.as_str()?;
855 let content = node
856 .get("content")
857 .and_then(serde_json::Value::as_str)
858 .unwrap_or_default();
859 let parsed = parse_lineage_content(content);
860
861 Some(LineageAnimal {
862 lpn_id: lpn_id.to_string(),
863 farm_name: parsed.farm_name,
864 us_index: parsed.us_index,
865 src_index: parsed.src_index,
866 date_of_birth: parsed.date_of_birth,
867 sex: parsed.sex,
868 status: parsed.status,
869 })
870}
871
872fn collect_generations(
874 node: &serde_json::Value,
875 generations: &mut Vec<Vec<LineageAnimal>>,
876 depth: usize,
877) {
878 let Some(children) = node.get("children").and_then(serde_json::Value::as_array) else {
879 return;
880 };
881 if children.is_empty() {
882 return;
883 }
884
885 while generations.len() <= depth {
886 generations.push(Vec::new());
887 }
888
889 for child in children {
890 if let Some(animal) = parse_lineage_node(child) {
891 generations[depth].push(animal);
892 }
893 collect_generations(child, generations, depth + 1);
894 }
895}
896
897impl Lineage {
898 pub fn from_api_response(data: &serde_json::Value) -> crate::Result<Self> {
904 let node = if data
905 .get("data")
906 .and_then(serde_json::Value::as_object)
907 .is_some()
908 {
909 &data["data"]
910 } else {
911 data
912 };
913
914 let subject = parse_lineage_node(node);
915
916 let children = node.get("children").and_then(serde_json::Value::as_array);
917
918 let sire = children
919 .and_then(|c| c.first())
920 .and_then(parse_lineage_node);
921
922 let dam = children.and_then(|c| c.get(1)).and_then(parse_lineage_node);
923
924 let mut generations = Vec::new();
925 collect_generations(node, &mut generations, 0);
926
927 Ok(Self {
928 subject,
929 sire,
930 dam,
931 generations,
932 })
933 }
934}
935
936#[derive(Debug, Clone, Serialize, Deserialize)]
942pub struct SearchResults {
943 pub total_count: i64,
945 pub results: Vec<serde_json::Value>,
947 pub page: u32,
949 pub page_size: u32,
951}
952
953impl SearchResults {
954 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
963 pub fn from_api_response(
964 data: &serde_json::Value,
965 page: u32,
966 page_size: u32,
967 ) -> crate::Result<Self> {
968 let total_count = data
969 .get("TotalCount")
970 .or_else(|| data.get("recordCount"))
971 .and_then(serde_json::Value::as_i64)
972 .unwrap_or(0);
973
974 let results = data
975 .get("Results")
976 .or_else(|| data.get("records"))
977 .and_then(serde_json::Value::as_array)
978 .cloned()
979 .unwrap_or_default();
980
981 Ok(Self {
982 total_count,
983 results,
984 page,
985 page_size,
986 })
987 }
988}
989
990#[derive(Debug, Clone, Serialize, Deserialize)]
996pub struct DateLastUpdated {
997 pub data: serde_json::Value,
999}
1000
1001#[derive(Deserialize)]
1007pub(crate) struct RawBreedGroupResponse {
1008 #[serde(default)]
1009 pub data: Option<Vec<RawBreedGroup>>,
1010}
1011
1012#[derive(Deserialize)]
1013#[serde(rename_all = "camelCase")]
1014pub(crate) struct RawBreedGroup {
1015 #[serde(alias = "Id", alias = "id")]
1016 pub breed_group_id: Option<i64>,
1017 #[serde(alias = "Name", alias = "name")]
1018 pub breed_group_name: Option<String>,
1019 #[serde(default)]
1020 pub breeds: Vec<RawBreed>,
1021}
1022
1023#[derive(Deserialize)]
1024#[serde(rename_all = "camelCase")]
1025pub(crate) struct RawBreed {
1026 #[serde(alias = "id")]
1027 pub breed_id: Option<i64>,
1028 #[serde(alias = "name")]
1029 pub breed_name: Option<String>,
1030}
1031
1032#[derive(Debug, Clone, Serialize)]
1038pub struct AnimalProfile {
1039 pub details: AnimalDetails,
1041 pub lineage: Lineage,
1043 pub progeny: Progeny,
1045}
1046
1047#[cfg(test)]
1052mod tests {
1053 use super::*;
1054
1055 #[test]
1056 fn search_criteria_builder_round_trip() {
1057 let criteria = SearchCriteria::new()
1058 .with_breed_id(486)
1059 .with_status("CURRENT")
1060 .with_gender("Female")
1061 .with_proven_only(true);
1062
1063 assert_eq!(criteria.breed_id, Some(486));
1064 assert_eq!(criteria.status.as_deref(), Some("CURRENT"));
1065 assert_eq!(criteria.gender.as_deref(), Some("Female"));
1066 assert_eq!(criteria.proven_only, Some(true));
1067 assert!(criteria.breed_group_id.is_none());
1068 }
1069
1070 #[test]
1071 fn search_criteria_serializes_to_camel_case() {
1072 let criteria = SearchCriteria::new()
1073 .with_breed_group_id(61)
1074 .with_breed_id(486);
1075
1076 let json = serde_json::to_value(&criteria).unwrap();
1077 assert_eq!(json["breedGroupId"], 61);
1078 assert_eq!(json["breedId"], 486);
1079 assert!(json.get("gender").is_none());
1081 }
1082
1083 #[test]
1084 fn animal_details_from_nested_response() {
1085 let json = serde_json::json!({
1086 "success": true,
1087 "data": {
1088 "progenyCount": 6,
1089 "dateOfBirth": "01/15/2020",
1090 "gender": "Female",
1091 "genotyped": "Yes",
1092 "flockCount": "2",
1093 "breed": { "breedName": "Katahdin", "breedId": 640 },
1094 "searchResultViewModel": {
1095 "lpnId": "6####92020###249",
1096 "lpnSre": "SIRE123",
1097 "lpnDam": "DAM456",
1098 "status": "CURRENT",
1099 "regNumber": "REG789",
1100 "bwt": 0.246,
1101 "accbwt": 0.80
1102 },
1103 "contactInfo": {
1104 "farmName": "Test Farm",
1105 "customerName": "John Doe"
1106 }
1107 }
1108 });
1109
1110 let details = AnimalDetails::from_api_response(&json).unwrap();
1111 assert_eq!(details.lpn_id, "6####92020###249");
1112 assert_eq!(details.breed.as_deref(), Some("Katahdin"));
1113 assert_eq!(details.gender.as_deref(), Some("Female"));
1114 assert_eq!(details.total_progeny, Some(6));
1115 assert_eq!(details.flock_count, Some(2));
1116 assert_eq!(details.sire.as_deref(), Some("SIRE123"));
1117
1118 let bwt = details.traits.get("BWT").unwrap();
1119 assert!((bwt.value - 0.246).abs() < f64::EPSILON);
1120 assert_eq!(bwt.accuracy, Some(80));
1121
1122 let contact = details.contact_info.unwrap();
1123 assert_eq!(contact.farm_name.as_deref(), Some("Test Farm"));
1124 assert_eq!(contact.contact_name.as_deref(), Some("John Doe"));
1125 }
1126
1127 #[test]
1128 fn animal_details_from_legacy_response() {
1129 let json = serde_json::json!({
1130 "LpnId": "LEGACY123",
1131 "Breed": "Targhee",
1132 "Gender": "Male",
1133 "Status": "SOLD",
1134 "Traits": {
1135 "BWT": { "Value": 1.5, "Accuracy": 90 }
1136 }
1137 });
1138
1139 let details = AnimalDetails::from_api_response(&json).unwrap();
1140 assert_eq!(details.lpn_id, "LEGACY123");
1141 assert_eq!(details.breed.as_deref(), Some("Targhee"));
1142 let bwt = details.traits.get("BWT").unwrap();
1143 assert!((bwt.value - 1.5).abs() < f64::EPSILON);
1144 assert_eq!(bwt.accuracy, Some(90));
1145 }
1146
1147 #[test]
1148 fn animal_details_from_search_result() {
1149 let json = serde_json::json!({
1150 "lpnId": "6400012006BWR107",
1151 "lpnSre": "SIRE_SR",
1152 "lpnDam": "DAM_SR",
1153 "gender": "Male",
1154 "status": "CURRENT",
1155 "dateOfBirth": "3/15/2022",
1156 "regNumber": "SR_REG",
1157 "bwt": 0.35,
1158 "accbwt": 72,
1159 "wwt": 2.5,
1160 "accwwt": 68,
1161 "pwwt": 4.1,
1162 "accpwwt": 65,
1163 "ywt": 3.8,
1164 "accywt": 55,
1165 "nlb": 0.15,
1166 "accnlb": 40
1167 });
1168
1169 let details = AnimalDetails::from_api_response(&json).unwrap();
1170 assert_eq!(details.lpn_id, "6400012006BWR107");
1171 assert_eq!(details.sire.as_deref(), Some("SIRE_SR"));
1172 assert_eq!(details.dam.as_deref(), Some("DAM_SR"));
1173 assert_eq!(details.gender.as_deref(), Some("Male"));
1174 assert_eq!(details.status.as_deref(), Some("CURRENT"));
1175 assert_eq!(details.date_of_birth.as_deref(), Some("3/15/2022"));
1176 assert_eq!(details.registration_number.as_deref(), Some("SR_REG"));
1177
1178 let bwt = details.traits.get("BWT").unwrap();
1180 assert!((bwt.value - 0.35).abs() < f64::EPSILON);
1181 assert_eq!(bwt.accuracy, Some(72));
1182
1183 let wwt = details.traits.get("WWT").unwrap();
1184 assert!((wwt.value - 2.5).abs() < f64::EPSILON);
1185 assert_eq!(wwt.accuracy, Some(68));
1186
1187 let pwwt = details.traits.get("PWWT").unwrap();
1188 assert!((pwwt.value - 4.1).abs() < f64::EPSILON);
1189
1190 let nlb = details.traits.get("NLB").unwrap();
1191 assert!((nlb.value - 0.15).abs() < f64::EPSILON);
1192 assert_eq!(nlb.accuracy, Some(40));
1193
1194 assert_eq!(details.traits.len(), 5);
1196 }
1197
1198 #[test]
1199 fn progeny_from_api_response() {
1200 let json = serde_json::json!({
1201 "recordCount": 3,
1202 "records": [
1203 { "lpnId": "P1", "sex": "Male", "dob": "03/10/2022" },
1204 { "lpnId": "P2", "sex": "Female", "dob": "04/01/2022" }
1205 ]
1206 });
1207
1208 let progeny = Progeny::from_api_response(&json, 0, 10).unwrap();
1209 assert_eq!(progeny.total_count, 3);
1210 assert_eq!(progeny.animals.len(), 2);
1211 assert_eq!(progeny.animals[0].lpn_id, "P1");
1212 assert_eq!(progeny.animals[1].sex.as_deref(), Some("Female"));
1213 }
1214
1215 #[test]
1216 fn lineage_from_api_response() {
1217 let json = serde_json::json!({
1218 "data": {
1219 "lpnId": "SUBJECT1",
1220 "content": "<div>My Farm</div><div>US Hair Index: 105.2</div><div>DOB: 1/1/2020</div><div>Sex: Female</div>",
1221 "children": [
1222 {
1223 "lpnId": "SIRE1",
1224 "content": "<div>Sire Farm</div><div>DOB: 3/15/2018</div><div>Sex: Male</div>",
1225 "children": []
1226 },
1227 {
1228 "lpnId": "DAM1",
1229 "content": "<div>Dam Farm</div><div>DOB: 6/20/2017</div><div>Sex: Female</div>",
1230 "children": []
1231 }
1232 ]
1233 }
1234 });
1235
1236 let lineage = Lineage::from_api_response(&json).unwrap();
1237 let subject = lineage.subject.unwrap();
1238 assert_eq!(subject.lpn_id, "SUBJECT1");
1239 assert_eq!(subject.farm_name.as_deref(), Some("My Farm"));
1240 assert!((subject.us_index.unwrap() - 105.2).abs() < f64::EPSILON);
1241 assert_eq!(subject.sex.as_deref(), Some("Female"));
1242
1243 let sire = lineage.sire.unwrap();
1244 assert_eq!(sire.lpn_id, "SIRE1");
1245 assert_eq!(sire.sex.as_deref(), Some("Male"));
1246
1247 let dam = lineage.dam.unwrap();
1248 assert_eq!(dam.lpn_id, "DAM1");
1249 }
1250
1251 #[test]
1252 fn search_results_from_api_response() {
1253 let json = serde_json::json!({
1254 "TotalCount": 42,
1255 "Results": [
1256 { "LpnId": "A1" },
1257 { "LpnId": "A2" }
1258 ]
1259 });
1260
1261 let results = SearchResults::from_api_response(&json, 0, 15).unwrap();
1262 assert_eq!(results.total_count, 42);
1263 assert_eq!(results.results.len(), 2);
1264 assert_eq!(results.page, 0);
1265 assert_eq!(results.page_size, 15);
1266 }
1267
1268 #[test]
1269 fn parse_lineage_html_content() {
1270 let content = "<div>Happy Acres</div><div>US Hair Index: 98.5</div><div>SRC$ Index: 102.3</div><div>DOB: 2/13/2024</div><div>Sex: Male</div><div>Status: CURRENT</div>";
1271 let parsed = parse_lineage_content(content);
1272 assert_eq!(parsed.farm_name.as_deref(), Some("Happy Acres"));
1273 assert!((parsed.us_index.unwrap() - 98.5).abs() < f64::EPSILON);
1274 assert!((parsed.src_index.unwrap() - 102.3).abs() < f64::EPSILON);
1275 assert_eq!(parsed.date_of_birth.as_deref(), Some("2/13/2024"));
1276 assert_eq!(parsed.sex.as_deref(), Some("Male"));
1277 assert_eq!(parsed.status.as_deref(), Some("CURRENT"));
1278 }
1279
1280 #[test]
1281 fn trait_range_filter_serializes() {
1282 let filter = TraitRangeFilter {
1283 min: -1.0,
1284 max: 1.0,
1285 };
1286 let json = serde_json::to_value(&filter).unwrap();
1287 assert_eq!(json["min"], -1.0);
1288 assert_eq!(json["max"], 1.0);
1289 }
1290
1291 #[test]
1292 fn search_criteria_with_trait_ranges() {
1293 let mut ranges = std::collections::HashMap::new();
1294 ranges.insert(
1295 "BWT".to_string(),
1296 TraitRangeFilter {
1297 min: -1.0,
1298 max: 1.0,
1299 },
1300 );
1301 let criteria = SearchCriteria::new().with_trait_ranges(ranges);
1302 let json = serde_json::to_value(&criteria).unwrap();
1303 let tr = &json["traitRanges"]["BWT"];
1304 assert_eq!(tr["min"], -1.0);
1305 assert_eq!(tr["max"], 1.0);
1306 }
1307
1308 #[test]
1309 fn extract_contact_info_null_returns_none() {
1310 let json = serde_json::json!({
1311 "data": {
1312 "gender": "Male",
1313 "searchResultViewModel": { "lpnId": "X1" },
1314 "contactInfo": null
1315 }
1316 });
1317 let details = AnimalDetails::from_api_response(&json).unwrap();
1318 assert!(details.contact_info.is_none());
1319 }
1320
1321 #[test]
1322 fn legacy_format_missing_traits_key() {
1323 let json = serde_json::json!({
1324 "LpnId": "LEG_NO_TRAITS",
1325 "Breed": "Suffolk",
1326 "Gender": "Female"
1327 });
1328 let details = AnimalDetails::from_api_response(&json).unwrap();
1329 assert_eq!(details.lpn_id, "LEG_NO_TRAITS");
1330 assert!(details.traits.is_empty());
1331 }
1332
1333 #[test]
1334 fn legacy_format_non_object_trait_value() {
1335 let json = serde_json::json!({
1336 "LpnId": "LEG_BAD_TRAIT",
1337 "Traits": {
1338 "BWT": "not an object",
1339 "WWT": { "Value": 2.0, "Accuracy": 70 }
1340 }
1341 });
1342 let details = AnimalDetails::from_api_response(&json).unwrap();
1343 assert!(!details.traits.contains_key("BWT"));
1345 assert!(details.traits.contains_key("WWT"));
1346 }
1347
1348 #[test]
1349 fn lineage_node_without_children() {
1350 let json = serde_json::json!({
1352 "data": {
1353 "lpnId": "NOCHILDREN",
1354 "content": "<div>Farm</div>"
1355 }
1356 });
1357 let lineage = Lineage::from_api_response(&json).unwrap();
1358 assert!(lineage.sire.is_none());
1359 assert!(lineage.dam.is_none());
1360 }
1361
1362 #[test]
1363 fn breed_group_serializes() {
1364 let bg = BreedGroup {
1365 id: 61,
1366 name: "Range".to_string(),
1367 breeds: vec![Breed {
1368 id: 486,
1369 name: "South African Meat Merino".to_string(),
1370 }],
1371 };
1372
1373 let json = serde_json::to_value(&bg).unwrap();
1374 assert_eq!(json["id"], 61);
1375 assert_eq!(json["breeds"][0]["name"], "South African Meat Merino");
1376 }
1377}