use regex::Regex; use crate::model::{PalletFieldChange, PalletSnapshot}; fn capture(text: &str, pattern: &str, group: usize) -> Option { let regex = Regex::new(pattern).ok()?; let value = regex.captures(text)?.get(group)?.as_str(); clean(value) } fn clean(value: &str) -> Option { let collapsed = value.split_whitespace().collect::>().join(" "); let trimmed = collapsed.trim().trim_matches(|ch: char| matches!(ch, ',' | ';')).trim(); if trimmed.is_empty() { None } else { Some(trimmed.trim_end_matches('.').to_string()) } } fn canonical(value: &str) -> String { value .split_whitespace() .collect::>() .join(" ") .trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':')) .to_ascii_uppercase() } pub fn parse_pallet_snapshot(raw_text: &str, pallet_id: &str) -> PalletSnapshot { let flat = raw_text.split_whitespace().collect::>().join(" "); let mut snapshot = PalletSnapshot { pallet_id: pallet_id.trim().to_string(), ..PalletSnapshot::default() }; snapshot.pick_date = capture( &flat, r"(?i)\bPick\s+Date\s*:\s*([0-9]{1,2}[/-][0-9]{1,2}[/-][0-9]{2,4})", 1, ); snapshot.pack_date = capture( &flat, r"(?i)\bPack\s+Date\s*:\s*([0-9]{1,2}[/-][0-9]{1,2}[/-][0-9]{2,4})", 1, ); snapshot.printed_time = capture( &flat, r"(?i)\bTime\s*:\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)", 1, ); snapshot.total = capture(&flat, r"(?i)\bTotal\s+([0-9]{1,5})\b", 1); if let Ok(regex) = Regex::new( r"(?i)\bGrower\s+Packrun\s+Size\s+Qty\s+([A-Z0-9-]+)\s+([A-Z0-9/._-]+)\s+([0-9]{1,3})\s+([0-9]{1,5})\b", ) { if let Some(captures) = regex.captures(&flat) { snapshot.grower = captures.get(1).and_then(|m| clean(m.as_str())); snapshot.packrun = captures.get(2).and_then(|m| clean(m.as_str())); snapshot.size = captures.get(3).and_then(|m| clean(m.as_str())); snapshot.qty = captures.get(4).and_then(|m| clean(m.as_str())); } } if let Ok(regex) = Regex::new( r"(?i)\bORIGIN\s+VAR\s+BRAND\s+CLASS(?:\s+Pallet\s*:\s*[A-Z0-9-]+)?\s+([A-Z]{2,3})\s+([A-Z][A-Z -]{1,20}?)\s+([A-Z0-9]{2,12})\s+(CLASS\s*\.?\s*[0-9]+\.?)(?:\s|$)", ) { if let Some(captures) = regex.captures(&flat) { snapshot.origin = captures.get(1).and_then(|m| clean(m.as_str())); snapshot.variety = captures.get(2).and_then(|m| clean(m.as_str())); snapshot.brand = captures.get(3).and_then(|m| clean(m.as_str())); snapshot.class = captures.get(4).and_then(|m| clean(m.as_str())); } } if snapshot.variety.is_none() { if let Ok(regex) = Regex::new(r"(?i)^\s*Avocados?\s*-\s*([A-Z][A-Z -]{1,20})\s*$") { snapshot.variety = raw_text.lines().find_map(|line| { regex .captures(line) .and_then(|captures| captures.get(1)) .and_then(|value| clean(value.as_str())) }); } } if let Ok(regex) = Regex::new( r"(?i)\bBASE\s+PACK\s+([A-Z]{2,12})\s+([A-Z0-9-]{2,12})\b", ) { if let Some(captures) = regex.captures(&flat) { snapshot.base = captures.get(1).and_then(|m| clean(m.as_str())); snapshot.pack = captures.get(2).and_then(|m| clean(m.as_str())); } } snapshot } fn comparable_pairs<'a>( previous: &'a PalletSnapshot, current: &'a PalletSnapshot, ) -> [(&'static str, &'a Option, &'a Option); 13] { [ ("origin", &previous.origin, ¤t.origin), ("variety", &previous.variety, ¤t.variety), ("brand", &previous.brand, ¤t.brand), ("class", &previous.class, ¤t.class), ("base", &previous.base, ¤t.base), ("pack", &previous.pack, ¤t.pack), ("grower", &previous.grower, ¤t.grower), ("packrun", &previous.packrun, ¤t.packrun), ("size", &previous.size, ¤t.size), ("qty", &previous.qty, ¤t.qty), ("pickDate", &previous.pick_date, ¤t.pick_date), ("packDate", &previous.pack_date, ¤t.pack_date), ("total", &previous.total, ¤t.total), ] } pub fn comparable_field_count(previous: &PalletSnapshot, current: &PalletSnapshot) -> usize { comparable_pairs(previous, current) .into_iter() .filter(|(_, before, after)| before.is_some() && after.is_some()) .count() } pub fn diff_snapshots( previous: &PalletSnapshot, current: &PalletSnapshot, ) -> Vec { comparable_pairs(previous, current) .into_iter() .filter_map(|(field, before, after)| { let before = before.as_ref()?; let after = after.as_ref()?; if canonical(before) == canonical(after) { return None; } Some(PalletFieldChange { field: field.to_string(), previous: before.clone(), current: after.clone(), }) }) .collect() } #[cfg(test)] mod tests { use super::*; const CARD: &str = r#" Avocados - Hass ORIGIN VAR BRAND CLASS NZ Hass NZAG CLASS 3. BASE PACK CHEP B37 Pick Date: 16/09/2026 Pack Date: 17/09/2026 Time: 14:46:10 Pallet: 40595983 Grower Packrun Size Qty 89930 89930/16/17/26 20 30 Total 30 "#; #[test] fn parses_export_card_fields() { let snapshot = parse_pallet_snapshot(CARD, "40595983"); assert_eq!(snapshot.pallet_id, "40595983"); assert_eq!(snapshot.origin.as_deref(), Some("NZ")); assert_eq!(snapshot.variety.as_deref(), Some("Hass")); assert_eq!(snapshot.brand.as_deref(), Some("NZAG")); assert_eq!(snapshot.class.as_deref(), Some("CLASS 3")); assert_eq!(snapshot.base.as_deref(), Some("CHEP")); assert_eq!(snapshot.pack.as_deref(), Some("B37")); assert_eq!(snapshot.grower.as_deref(), Some("89930")); assert_eq!(snapshot.packrun.as_deref(), Some("89930/16/17/26")); assert_eq!(snapshot.size.as_deref(), Some("20")); assert_eq!(snapshot.qty.as_deref(), Some("30")); assert_eq!(snapshot.total.as_deref(), Some("30")); assert_eq!(snapshot.printed_time.as_deref(), Some("14:46:10")); } #[test] fn ignores_print_time_but_reports_business_field_changes() { let previous = parse_pallet_snapshot(CARD, "40595983"); let changed = CARD .replace("Time: 14:46:10", "Time: 15:07:42") .replace("20 30\n Total 30", "20 28\n Total 28"); let current = parse_pallet_snapshot(&changed, "40595983"); let diff = diff_snapshots(&previous, ¤t); assert_eq!(diff.len(), 2); assert!(diff.iter().any(|item| item.field == "qty" && item.current == "28")); assert!(diff.iter().any(|item| item.field == "total" && item.current == "28")); } #[test] fn missing_extraction_is_not_treated_as_a_changed_value() { let previous = parse_pallet_snapshot(CARD, "40595983"); let current = PalletSnapshot { pallet_id: "40595983".into(), ..PalletSnapshot::default() }; assert_eq!(comparable_field_count(&previous, ¤t), 0); assert!(diff_snapshots(&previous, ¤t).is_empty()); } }