use alphanumeric::IsNumeric;
use fuzzy_datetime::{iso_fuzzy_to_date_string, iso_fuzzy_to_datetime_string};
use crate::OptionSet;
pub const DETECT_SAMPLE_SIZE: usize = 20;
const LENGTH_COMPARISON_SAMPLE: usize = 3;
const LENGTH_CONFIDENCE_RATIO: f64 = 0.7;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DetectedRows {
pub header_index: Option<usize>,
pub data_index: usize,
}
fn row_width(row: &[String]) -> usize {
row.iter().filter(|c| !c.trim().is_empty()).count()
}
fn avg_cell_length(row: &[String]) -> f64 {
let populated: Vec<&String> = row.iter().filter(|c| !c.trim().is_empty()).collect();
if populated.is_empty() {
return 0.0;
}
let total: usize = populated.iter().map(|c| c.trim().chars().count()).sum();
total as f64 / populated.len() as f64
}
fn looks_like_year(cell: &str) -> bool {
cell.len() == 4
&& cell.chars().all(|c| c.is_ascii_digit())
&& cell.parse::<u32>().is_ok_and(|n| (1900..=2100).contains(&n))
}
fn is_boolean_like(cell: &str) -> bool {
matches!(cell.to_lowercase().as_str(), "true" | "false" | "yes" | "no")
}
fn is_date_like(cell: &str) -> bool {
if cell.chars().all(|c| c.is_ascii_digit()) {
return false;
}
iso_fuzzy_to_date_string(cell).is_some() || iso_fuzzy_to_datetime_string(cell).is_some()
}
fn is_data_signal(cell: &str) -> bool {
let trimmed = cell.trim();
if trimmed.is_empty() || looks_like_year(trimmed) {
return false;
}
is_boolean_like(trimmed) || trimmed.is_numeric() || is_date_like(trimmed)
}
fn looks_like_data(row: &[String]) -> bool {
let populated: Vec<&String> = row.iter().filter(|c| !c.trim().is_empty()).collect();
if populated.is_empty() {
return false;
}
let signal_count = populated.iter().filter(|c| is_data_signal(c)).count();
signal_count * 2 >= populated.len()
}
fn looks_like_labels(candidate: &[String], comparison_rows: &[&Vec<String>]) -> bool {
if comparison_rows.is_empty() {
return false;
}
let candidate_len = avg_cell_length(candidate);
let data_len: f64 =
comparison_rows.iter().map(|r| avg_cell_length(r)).sum::<f64>() / comparison_rows.len() as f64;
data_len > 0.0 && candidate_len < data_len * LENGTH_CONFIDENCE_RATIO
}
pub(crate) fn detect_header_and_data_rows(sample_rows: &[Vec<String>]) -> DetectedRows {
const FALLBACK: DetectedRows = DetectedRows { header_index: Some(0), data_index: 1 };
if sample_rows.is_empty() {
return FALLBACK;
}
let widths: Vec<usize> = sample_rows.iter().map(|r| row_width(r)).collect();
let Some(target_width) = widths.iter().copied().filter(|&w| w >= 2).max() else {
return FALLBACK;
};
let Some(header_index) = widths.iter().position(|&w| w == target_width) else {
return FALLBACK;
};
if looks_like_data(&sample_rows[header_index]) {
return FALLBACK;
}
let min_data_width = target_width.div_ceil(2).max(2);
let matching_after: Vec<usize> = widths
.iter()
.enumerate()
.skip(header_index + 1)
.filter(|&(_, &w)| w >= min_data_width)
.map(|(i, _)| i)
.collect();
let comparison_max_width = matching_after
.iter()
.take(LENGTH_COMPARISON_SAMPLE)
.map(|&i| widths[i])
.max()
.unwrap_or(0);
let width_gap_confirms_header = target_width > comparison_max_width;
let any_type_signal = sample_rows.iter().any(|r| r.iter().any(|c| is_data_signal(c)));
if !any_type_signal && !width_gap_confirms_header {
let comparison_rows: Vec<&Vec<String>> = matching_after
.iter()
.take(LENGTH_COMPARISON_SAMPLE)
.map(|&i| &sample_rows[i])
.collect();
if !looks_like_labels(&sample_rows[header_index], &comparison_rows) {
return DetectedRows { header_index: None, data_index: header_index };
}
}
let data_index = matching_after.first().copied().unwrap_or(header_index + 1);
DetectedRows { header_index: Some(header_index), data_index }
}
pub(crate) fn resolve_header_and_data_rows<F: FnOnce() -> Vec<Vec<String>>>(
opts: &OptionSet,
sample: F,
) -> DetectedRows {
if opts.detect_header && !opts.omit_header && opts.header_row.is_none() && opts.data_row_index.is_none() {
return detect_header_and_data_rows(&sample());
}
let header_row_index = opts.header_row_index();
let default_start = if opts.omit_header { header_row_index } else { header_row_index + 1 };
let data_index = match opts.data_row_index {
Some(requested) if requested >= header_row_index => requested,
_ => default_start,
};
let header_index = if opts.omit_header { None } else { Some(header_row_index) };
DetectedRows { header_index, data_index }
}
#[cfg(test)]
mod tests {
use super::*;
fn row(cells: &[&str]) -> Vec<String> {
cells.iter().map(|s| s.to_string()).collect()
}
fn found(header_index: usize, data_index: usize) -> DetectedRows {
DetectedRows { header_index: Some(header_index), data_index }
}
#[test]
fn test_detects_header_with_notes_row_between_header_and_data() {
let sample = vec![
row(&["Sales 2025"]),
row(&["region", "team size", "revenue"]),
row(&["long explanation about the data"]),
row(&["west", "12", "923456"]),
row(&["east", "7", "817285"]),
];
assert_eq!(detect_header_and_data_rows(&sample), found(1, 3));
}
#[test]
fn test_detects_header_with_no_gap() {
let sample = vec![
row(&["sku", "qty"]),
row(&["SKU001", "10"]),
row(&["SKU002", "20"]),
];
assert_eq!(detect_header_and_data_rows(&sample), found(0, 1));
}
#[test]
fn test_falls_back_when_sample_is_empty() {
assert_eq!(detect_header_and_data_rows(&[]), found(0, 1));
}
#[test]
fn test_falls_back_when_no_row_has_two_or_more_cells() {
let sample = vec![row(&["a"]), row(&["b"]), row(&["c"])];
assert_eq!(detect_header_and_data_rows(&sample), found(0, 1));
}
#[test]
fn test_falls_back_when_first_wide_row_looks_numeric() {
let sample = vec![row(&["100", "200"]), row(&["300", "400"])];
assert_eq!(detect_header_and_data_rows(&sample), found(0, 1));
}
#[test]
fn test_year_column_headers_are_not_mistaken_for_data() {
let sample = vec![
row(&["region", "2020", "2021", "2022"]),
row(&["west", "12000", "13500", "14200"]),
row(&["east", "9800", "10100", "11000"]),
];
assert_eq!(detect_header_and_data_rows(&sample), found(0, 1));
}
#[test]
fn test_boolean_and_date_cells_are_a_stronger_data_signal_than_plain_numbers() {
let sample = vec![
row(&["name", "active", "joined"]),
row(&["Alice", "true", "2024-01-15"]),
row(&["Bob", "false", "2023-06-02"]),
];
assert_eq!(detect_header_and_data_rows(&sample), found(0, 1));
}
#[test]
fn test_large_numbers_are_not_misread_as_dates() {
let sample = vec![
row(&["Payroll Report"]),
row(&["employee", "salary"]),
row(&["Alice", "54000"]),
row(&["Bob", "923456"]),
];
assert_eq!(detect_header_and_data_rows(&sample), found(1, 2));
}
#[test]
fn test_purely_textual_header_is_still_detected_via_length() {
let sample = vec![
row(&["Product Descriptions - EN to FR Migration"]),
row(&["key", "english", "french"]),
row(&["Translator notes: verify context before translating"]),
row(&["welcome_msg", "Welcome to our store", "Bienvenue dans notre magasin"]),
row(&["goodbye_msg", "Thank you for visiting", "Merci de votre visite"]),
];
assert_eq!(detect_header_and_data_rows(&sample), found(1, 3));
}
#[test]
fn test_header_is_the_fully_populated_row_not_the_most_common_width() {
let sample = vec![
row(&["Authors"]),
row(&["first name", "last name", "country code", "bio", "website", "linkedin profile"]),
row(&["Explanatory notes"]),
row(&["Jane", "Blogs", "ca", "Lorem ipsum", "", ""]),
row(&["Joe", "Doe", "au", "Lorem ipsum", "https://www.joedoe.com", ""]),
row(&["Giovanna", "Rossi", "it", "Lorem ipsum", "", "https://www.linkedin.com/in/giovanna-rossi/"]),
];
assert_eq!(detect_header_and_data_rows(&sample), found(1, 3));
}
#[test]
fn test_headerless_purely_textual_file_reports_no_header_found() {
let sample = vec![
row(&["welcome_msg", "Welcome to our store", "Bienvenue dans notre magasin"]),
row(&["goodbye_msg", "Thank you for visiting", "Merci de votre visite"]),
];
let result = detect_header_and_data_rows(&sample);
assert_eq!(result.header_index, None);
assert_eq!(result.data_index, 0);
}
}