use super::Line;
use crate::Table;
#[cfg(test)]
use super::Cell;
const MIN_TABLE_ROWS: usize = 2;
const MIN_TABLE_COLUMNS: usize = 2;
const COLUMN_X_TOLERANCE: f32 = 8.0;
pub(super) fn try_table(lines: &[Line]) -> Option<(Table, usize)> {
let column_count = lines.first()?.cells.len();
if column_count < MIN_TABLE_COLUMNS {
return None;
}
let header_cells = &lines[0].cells;
let mut run_end = 0;
for line in lines {
if line.cells.len() != column_count {
break;
}
let aligned = line
.cells
.iter()
.zip(header_cells)
.all(|(cell, header_cell)| (cell.x - header_cell.x).abs() <= COLUMN_X_TOLERANCE);
if !aligned {
break;
}
run_end += 1;
}
if run_end < MIN_TABLE_ROWS {
return None;
}
let headers = header_cells.iter().map(|cell| cell.text.clone()).collect();
let rows = lines[1..run_end]
.iter()
.map(|line| line.cells.iter().map(|cell| cell.text.clone()).collect())
.collect();
Some((Table { headers, rows }, run_end))
}
#[cfg(test)]
mod tests {
use super::*;
fn line(cells: &[(&str, f32)]) -> Line {
Line {
text: cells.iter().map(|(t, _)| *t).collect::<Vec<_>>().join(" "),
y: 0.0,
font_size: 10.0,
cells: cells
.iter()
.map(|(t, x)| Cell {
text: t.to_string(),
x: *x,
x_end: *x + 20.0,
})
.collect(),
}
}
#[test]
fn aligned_two_column_rows_become_a_table() {
let lines = vec![
line(&[("Metric", 0.0), ("Value", 60.0)]),
line(&[("Commits", 0.0), ("282", 60.0)]),
line(&[("Outside", 0.0), ("81", 60.0)]),
];
let (table, consumed) = try_table(&lines).unwrap();
assert_eq!(table.headers, vec!["Metric", "Value"]);
assert_eq!(
table.rows,
vec![vec!["Commits", "282"], vec!["Outside", "81"]]
);
assert_eq!(consumed, 3);
}
#[test]
fn misaligned_columns_are_not_a_table() {
let lines = vec![
line(&[("Metric", 0.0), ("Value", 60.0)]),
line(&[("Commits", 0.0), ("282", 90.0)]),
];
assert!(try_table(&lines).is_none());
}
#[test]
fn single_row_is_not_a_table() {
let lines = vec![line(&[("Metric", 0.0), ("Value", 60.0)])];
assert!(try_table(&lines).is_none());
}
#[test]
fn ragged_row_truncates_the_table_to_its_uniform_prefix() {
let lines = vec![
line(&[("Metric", 0.0), ("Value", 60.0)]),
line(&[("Commits", 0.0), ("282", 60.0)]),
line(&[("Outside", 0.0), ("81", 60.0)]),
line(&[("Subtotal across both", 0.0)]), line(&[("Notes", 0.0), ("later", 60.0)]),
];
let (table, consumed) = try_table(&lines).unwrap();
assert_eq!(consumed, 3, "table must stop at the last uniform row");
assert_eq!(table.headers, vec!["Metric", "Value"]);
assert_eq!(
table.rows,
vec![vec!["Commits", "282"], vec!["Outside", "81"]],
"only the uniform prefix rows belong to the table"
);
assert!(consumed < lines.len(), "remainder must be left for the caller");
}
#[test]
fn a_misaligned_row_partway_down_cuts_the_table_there() {
let lines = vec![
line(&[("Metric", 0.0), ("Value", 60.0)]),
line(&[("Commits", 0.0), ("282", 60.0)]),
line(&[("Outside", 0.0), ("81", 90.0)]), line(&[("Reviews", 0.0), ("7", 60.0)]),
];
let (table, consumed) = try_table(&lines).unwrap();
assert_eq!(consumed, 2);
assert_eq!(table.headers, vec!["Metric", "Value"]);
assert_eq!(table.rows, vec![vec!["Commits", "282"]]);
}
#[test]
fn a_ragged_row_too_early_leaves_no_valid_prefix() {
let lines = vec![
line(&[("Metric", 0.0), ("Value", 60.0)]),
line(&[("Only one cell here", 0.0)]),
line(&[("Commits", 0.0), ("282", 60.0)]),
];
assert!(try_table(&lines).is_none());
}
#[test]
fn too_few_columns_is_not_a_table() {
let lines = vec![
line(&[("Just one", 0.0)]),
line(&[("Another", 0.0)]),
line(&[("And another", 0.0)]),
];
assert!(try_table(&lines).is_none());
}
}