Skip to main content

nsip/
models.rs

1//! Data models for the NSIP Search API.
2//!
3//! These types map to the JSON responses from `nsipsearch.nsip.org/api`.
4//! The API uses a mix of `camelCase` and `PascalCase` field names depending
5//! on the endpoint; serde aliases handle both conventions transparently.
6
7use std::collections::HashMap;
8
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11
12// ---------------------------------------------------------------------------
13// Search criteria (request body for POST search)
14// ---------------------------------------------------------------------------
15
16/// Criteria for the animal search endpoint.
17///
18/// Uses a builder pattern so callers can set only the fields they care about.
19///
20/// # Examples
21///
22/// ```rust
23/// use nsip::SearchCriteria;
24///
25/// let criteria = SearchCriteria::new()
26///     .with_breed_id(486)
27///     .with_status("CURRENT")
28///     .with_gender("Female");
29/// ```
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct SearchCriteria {
33    /// Breed group identifier.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub breed_group_id: Option<i64>,
36
37    /// Breed identifier.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub breed_id: Option<i64>,
40
41    /// Only return animals born after this date (`YYYY-MM-DD`).
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub born_after: Option<String>,
44
45    /// Only return animals born before this date (`YYYY-MM-DD`).
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub born_before: Option<String>,
48
49    /// Gender filter: `"Male"`, `"Female"`, or `"Both"`.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub gender: Option<String>,
52
53    /// Only return proven animals.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub proven_only: Option<bool>,
56
57    /// Animal status: `"CURRENT"`, `"SOLD"`, `"DEAD"`, etc.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub status: Option<String>,
60
61    /// Flock identifier.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub flock_id: Option<String>,
64
65    /// Per-trait min/max ranges, e.g. `{"BWT": {"min": -1.0, "max": 1.0}}`.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub trait_ranges: Option<HashMap<String, TraitRangeFilter>>,
68}
69
70/// Min/max bounds for a single trait filter.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct TraitRangeFilter {
73    /// Minimum value (inclusive).
74    pub min: f64,
75    /// Maximum value (inclusive).
76    pub max: f64,
77}
78
79impl SearchCriteria {
80    /// Creates an empty search criteria.
81    #[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    /// Sets the breed group identifier.
97    #[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    /// Sets the breed identifier.
104    #[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    /// Sets the born-after date filter (`YYYY-MM-DD`).
111    #[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    /// Sets the born-before date filter (`YYYY-MM-DD`).
118    #[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    /// Sets the gender filter (`"Male"`, `"Female"`, or `"Both"`).
125    #[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    /// Restricts results to proven animals only.
132    #[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    /// Sets the animal status filter.
139    #[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    /// Sets the flock identifier filter.
146    #[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    /// Sets the trait range filters.
153    #[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// ---------------------------------------------------------------------------
161// Breed groups
162// ---------------------------------------------------------------------------
163
164/// A single breed within a breed group.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct Breed {
167    /// Breed identifier.
168    pub id: i64,
169    /// Human-readable breed name.
170    pub name: String,
171}
172
173/// A breed group containing one or more breeds.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct BreedGroup {
176    /// Breed group identifier.
177    pub id: i64,
178    /// Human-readable group name.
179    pub name: String,
180    /// Breeds belonging to this group.
181    pub breeds: Vec<Breed>,
182}
183
184// ---------------------------------------------------------------------------
185// Trait information
186// ---------------------------------------------------------------------------
187
188/// A single EBV trait with value and optional accuracy.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Trait {
191    /// Trait abbreviation (e.g. `"BWT"`, `"WWT"`).
192    pub name: String,
193    /// Estimated breeding value.
194    pub value: f64,
195    /// Accuracy percentage (0–100), if available.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub accuracy: Option<i32>,
198    /// Unit of measurement, if applicable.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub units: Option<String>,
201}
202
203/// Min/max range for a trait within a breed (returned by `getTraitRangesByBreed`).
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct TraitRange {
206    /// Trait abbreviation.
207    pub trait_name: String,
208    /// Minimum observed value.
209    pub min_value: f64,
210    /// Maximum observed value.
211    pub max_value: f64,
212    /// Unit of measurement.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub unit: Option<String>,
215}
216
217// ---------------------------------------------------------------------------
218// Contact info
219// ---------------------------------------------------------------------------
220
221/// Contact information for an animal's owner / flock.
222#[derive(Debug, Clone, Serialize, Deserialize, Default)]
223pub struct ContactInfo {
224    /// Farm or ranch name.
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub farm_name: Option<String>,
227    /// Contact person's name.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub contact_name: Option<String>,
230    /// Phone number.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub phone: Option<String>,
233    /// Email address.
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub email: Option<String>,
236    /// Street address.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub address: Option<String>,
239    /// City.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub city: Option<String>,
242    /// State / province.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub state: Option<String>,
245    /// ZIP / postal code.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub zip_code: Option<String>,
248}
249
250// ---------------------------------------------------------------------------
251// Animal details
252// ---------------------------------------------------------------------------
253
254/// Full detail record for a single animal.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct AnimalDetails {
257    /// LPN identifier.
258    pub lpn_id: String,
259    /// Breed name.
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub breed: Option<String>,
262    /// Breed group name.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub breed_group: Option<String>,
265    /// Date of birth (string as returned by the API).
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub date_of_birth: Option<String>,
268    /// Gender: `"Male"` or `"Female"`.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub gender: Option<String>,
271    /// Status: `"CURRENT"`, `"SOLD"`, `"DEAD"`, etc.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub status: Option<String>,
274    /// Sire LPN identifier.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub sire: Option<String>,
277    /// Dam LPN identifier.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub dam: Option<String>,
280    /// Registration number.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub registration_number: Option<String>,
283    /// Total number of progeny.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub total_progeny: Option<i64>,
286    /// Number of flocks this animal appears in.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub flock_count: Option<i64>,
289    /// Genotyped status.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub genotyped: Option<String>,
292    /// EBV traits keyed by abbreviation.
293    pub traits: HashMap<String, Trait>,
294    /// Owner / flock contact information.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub contact_info: Option<ContactInfo>,
297}
298
299/// Mapping from API `searchResultViewModel` trait field names to canonical
300/// abbreviations and their corresponding accuracy field names.
301const 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/// Convert a raw accuracy value to an integer percentage.
318///
319/// Values <= 1.0 are treated as fractions and multiplied by 100.
320#[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
329/// Extract EBV traits from the nested `searchResultViewModel` object.
330fn 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
355/// Extract contact information from a JSON value, checking both camelCase and `PascalCase` keys.
356fn 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/// Extract traits from the legacy `PascalCase` format.
405#[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
435/// Extract trait values from a progeny record.
436fn 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    /// Parse an `AnimalDetails` from the raw JSON value returned by the API.
447    ///
448    /// Supports both the nested format (`{ "success": true, "data": { ... } }`)
449    /// and a legacy flat `PascalCase` format.
450    ///
451    /// # Errors
452    ///
453    /// Returns `crate::Error::Parse` if the response cannot be interpreted.
454    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            // Search result row: camelCase fields with inline trait values
464            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 = &section["searchResultViewModel"];
473        let breed_obj = &section["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        // Extract traits from searchResultViewModel
521        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    /// Parse from a search result row (camelCase fields with inline trait values).
547    ///
548    /// Search results from `getPageOfSearchResults` use camelCase keys like
549    /// `lpnId`, `bwt`, `accbwt` — the same schema as `searchResultViewModel`
550    /// but at the top level without the `data` wrapper.
551    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// ---------------------------------------------------------------------------
656// Progeny
657// ---------------------------------------------------------------------------
658
659/// A single offspring in a progeny response.
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct ProgenyAnimal {
662    /// LPN identifier.
663    pub lpn_id: String,
664    /// Sex of the animal.
665    #[serde(skip_serializing_if = "Option::is_none")]
666    pub sex: Option<String>,
667    /// Date of birth.
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub date_of_birth: Option<String>,
670    /// Trait values keyed by abbreviation.
671    pub traits: HashMap<String, f64>,
672}
673
674/// Paginated progeny result.
675#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct Progeny {
677    /// Total number of offspring.
678    pub total_count: i64,
679    /// Offspring on this page.
680    pub animals: Vec<ProgenyAnimal>,
681    /// Current page number (0-indexed).
682    pub page: u32,
683    /// Page size.
684    pub page_size: u32,
685}
686
687impl Progeny {
688    /// Parse a `Progeny` from the raw JSON value returned by the API.
689    ///
690    /// The progeny endpoint uses `records` / `recordCount` (lowercase) rather
691    /// than the `Results` / `TotalCount` convention used elsewhere.
692    ///
693    /// # Errors
694    ///
695    /// Returns `crate::Error::Parse` if the response cannot be interpreted.
696    #[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// ---------------------------------------------------------------------------
756// Lineage
757// ---------------------------------------------------------------------------
758
759/// A single animal node in the pedigree tree.
760#[derive(Debug, Clone, Serialize, Deserialize)]
761pub struct LineageAnimal {
762    /// LPN identifier.
763    pub lpn_id: String,
764    /// Farm name (parsed from HTML content).
765    #[serde(skip_serializing_if = "Option::is_none")]
766    pub farm_name: Option<String>,
767    /// US (Hair) Index value.
768    #[serde(skip_serializing_if = "Option::is_none")]
769    pub us_index: Option<f64>,
770    /// SRC$ Index value.
771    #[serde(skip_serializing_if = "Option::is_none")]
772    pub src_index: Option<f64>,
773    /// Date of birth.
774    #[serde(skip_serializing_if = "Option::is_none")]
775    pub date_of_birth: Option<String>,
776    /// Sex.
777    #[serde(skip_serializing_if = "Option::is_none")]
778    pub sex: Option<String>,
779    /// Status.
780    #[serde(skip_serializing_if = "Option::is_none")]
781    pub status: Option<String>,
782}
783
784/// Pedigree / lineage information for an animal.
785///
786/// The API returns a recursive tree where each node has `lpnId`, `content`
787/// (HTML), and `children: [sire, dam]`.
788#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct Lineage {
790    /// The subject animal.
791    #[serde(skip_serializing_if = "Option::is_none")]
792    pub subject: Option<LineageAnimal>,
793    /// Sire (father).
794    #[serde(skip_serializing_if = "Option::is_none")]
795    pub sire: Option<LineageAnimal>,
796    /// Dam (mother).
797    #[serde(skip_serializing_if = "Option::is_none")]
798    pub dam: Option<LineageAnimal>,
799    /// Ancestor generations beyond parents; index 0 = grandparents.
800    pub generations: Vec<Vec<LineageAnimal>>,
801}
802
803/// Parse structured fields out of the HTML `content` string returned by the
804/// lineage endpoint.
805///
806/// Example content:
807/// ```text
808/// <div>Farm Name</div><div>US Hair Index: 102.03</div><div>DOB: 2/13/2024</div>
809/// ```
810fn parse_lineage_content(content: &str) -> ParsedLineageContent {
811    // These are cheap to construct; `Regex::new` is the expensive part, but
812    // the patterns are simple enough that the cost is negligible here.
813    // For hot-path usage consider `std::sync::LazyLock`.
814    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
852/// Parse a single lineage tree node into a [`LineageAnimal`].
853fn 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
872/// Recursively collect ancestor generations from the lineage tree.
873fn 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    /// Parse a `Lineage` from the raw JSON value returned by the API.
899    ///
900    /// # Errors
901    ///
902    /// Returns `crate::Error::Parse` if the response cannot be interpreted.
903    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// ---------------------------------------------------------------------------
937// Search results
938// ---------------------------------------------------------------------------
939
940/// Paginated search results.
941#[derive(Debug, Clone, Serialize, Deserialize)]
942pub struct SearchResults {
943    /// Total number of matching animals.
944    pub total_count: i64,
945    /// Result rows for the current page (raw JSON objects).
946    pub results: Vec<serde_json::Value>,
947    /// Current page number (0-indexed).
948    pub page: u32,
949    /// Page size.
950    pub page_size: u32,
951}
952
953impl SearchResults {
954    /// Parse `SearchResults` from the raw JSON value returned by the API.
955    ///
956    /// Supports both `PascalCase` (`TotalCount`, `Results`) and `camelCase`
957    /// (`recordCount`, `records`) field names.
958    ///
959    /// # Errors
960    ///
961    /// Returns `crate::Error::Parse` if the response cannot be interpreted.
962    #[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// ---------------------------------------------------------------------------
991// Date last updated
992// ---------------------------------------------------------------------------
993
994/// Response from the `getDateLastUpdated` endpoint.
995#[derive(Debug, Clone, Serialize, Deserialize)]
996pub struct DateLastUpdated {
997    /// The raw response value.
998    pub data: serde_json::Value,
999}
1000
1001// ---------------------------------------------------------------------------
1002// Breed group API response (raw JSON)
1003// ---------------------------------------------------------------------------
1004
1005/// Intermediate struct for deserializing the raw breed-groups API response.
1006#[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// ---------------------------------------------------------------------------
1033// Animal profile (combined details + lineage + progeny)
1034// ---------------------------------------------------------------------------
1035
1036/// Combined profile returned by [`crate::NsipClient::search_by_lpn`].
1037#[derive(Debug, Clone, Serialize)]
1038pub struct AnimalProfile {
1039    /// Detailed information about the animal.
1040    pub details: AnimalDetails,
1041    /// Pedigree / lineage tree.
1042    pub lineage: Lineage,
1043    /// Progeny (offspring) list.
1044    pub progeny: Progeny,
1045}
1046
1047// ---------------------------------------------------------------------------
1048// Unit tests
1049// ---------------------------------------------------------------------------
1050
1051#[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        // None fields should be absent
1080        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        // Verify traits parsed from camelCase inline fields
1179        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        // Should have 5 traits total
1195        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        // BWT should be skipped, WWT should parse
1344        assert!(!details.traits.contains_key("BWT"));
1345        assert!(details.traits.contains_key("WWT"));
1346    }
1347
1348    #[test]
1349    fn lineage_node_without_children() {
1350        // Lineage response where a node has no "children" key at all
1351        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}