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
}