Tolerate irregular sales report columns

Reviewed-by: sigit-code[bot] <279100073+sigit-code[bot]@users.noreply.github.com>

Seto Elkahfi committed Sep 12, 2026 at 19:16 UTC bc5ad266aba053f1f68d0af5ab748799aca1d81f
1 file changed +64 -22
crates/cli/src/main.rs
+64 -22
@@ -657,35 +657,58 @@ fn sales_report_as_json(gzip: &[u8]) -> Result<Vec<serde_json::Value>> {
657 let mut reader = csv::ReaderBuilder::new()
658 .delimiter(b'\t')
659 .has_headers(true)
660 - .flexible(false)
660 + .flexible(true)
661 .from_reader(decoder);
662
663 - let headers = reader
663 + let raw_headers = reader
664 .headers()
665 .context("reading the TSV header from Apple's sales report")?
666 .iter()
667 .map(str::to_owned)
668 .collect::<Vec<_>>();
669 - if headers.is_empty() || headers.iter().any(|header| header.is_empty()) {
670 - anyhow::bail!("Apple's sales report has an empty TSV header")
671 - }
672 - let unique_headers = headers.iter().collect::<HashSet<_>>();
673 - if unique_headers.len() != headers.len() {
674 - anyhow::bail!("Apple's sales report has duplicate TSV headers")
669 + if raw_headers.is_empty() {
670 + anyhow::bail!("Apple's sales report has no TSV headers")
671 }
672
673 + // Apple may add blank or repeated columns. JSON objects require unique,
674 + // non-empty keys, so retain valid headers and assign stable fallbacks to
675 + // the rest instead of discarding an otherwise usable report.
676 + let mut used_headers = HashSet::new();
677 + let headers = raw_headers
678 + .into_iter()
679 + .enumerate()
680 + .map(|(index, header)| {
681 + let base = if header.is_empty() {
682 + format!("column_{}", index + 1)
683 + } else {
684 + header
685 + };
686 + let mut name = base.clone();
687 + let mut suffix = 2;
688 + while !used_headers.insert(name.clone()) {
689 + name = format!("{base}_{suffix}");
690 + suffix += 1;
691 + }
692 + name
693 + })
694 + .collect::<Vec<_>>();
695 +
696 let mut rows = Vec::new();
697 for (index, record) in reader.records().enumerate() {
698 let record = record.with_context(|| format!("parsing TSV row {}", index + 2))?;
680 - if record.len() != headers.len() {
681 - anyhow::bail!(
682 - "TSV row {} has {} columns; expected {}",
683 - index + 2,
684 - record.len(),
685 - headers.len()
686 - )
699 + let mut row_headers = headers.clone();
700 + let mut used_row_headers = row_headers.iter().cloned().collect::<HashSet<_>>();
701 + for column in row_headers.len()..record.len() {
702 + let base = format!("extra_column_{}", column + 1);
703 + let mut name = base.clone();
704 + let mut suffix = 2;
705 + while !used_row_headers.insert(name.clone()) {
706 + name = format!("{base}_{suffix}");
707 + suffix += 1;
708 + }
709 + row_headers.push(name);
710 }
688 - let object = headers
711 + let object = row_headers
712 .iter()
713 .zip(record.iter())
714 .map(|(header, value)| (header.clone(), Value::String(value.to_owned())))
@@ -1465,9 +1488,20 @@ mod tests {
1488 }
1489
1490 #[test]
1468 - fn sales_report_rejects_mismatched_rows() {
1469 - let error = sales_report_as_json(&gzip("Country\tUnits\nUS\n")).unwrap_err();
1470 - assert!(error.to_string().contains("parsing TSV row 2"));
1491 + fn sales_report_accepts_rows_with_missing_or_extra_fields() {
1492 + let rows = sales_report_as_json(&gzip("Country\tUnits\nUS\nCA\t3\textra\n")).unwrap();
1493 +
1494 + assert_eq!(
1495 + rows,
1496 + vec![
1497 + serde_json::json!({ "Country": "US" }),
1498 + serde_json::json!({
1499 + "Country": "CA",
1500 + "Units": "3",
1501 + "extra_column_3": "extra",
1502 + }),
1503 + ]
1504 + );
1505 }
1506
1507 #[test]
@@ -1479,8 +1513,16 @@ mod tests {
1513 }
1514
1515 #[test]
1482 - fn sales_report_rejects_duplicate_headers() {
1483 - let error = sales_report_as_json(&gzip("Country\tCountry\nUS\tCA\n")).unwrap_err();
1484 - assert!(error.to_string().contains("duplicate TSV headers"));
1516 + fn sales_report_disambiguates_blank_and_duplicate_headers() {
1517 + let rows = sales_report_as_json(&gzip("Country\t\tCountry\nUS\t3\tCA\n")).unwrap();
1518 +
1519 + assert_eq!(
1520 + rows,
1521 + vec![serde_json::json!({
1522 + "Country": "US",
1523 + "column_2": "3",
1524 + "Country_2": "CA",
1525 + })]
1526 + );
1527 }
1528 }