use std::collections::{HashMap, HashSet}; use std::io::BufRead; use std::iter::Iterator; const HEADER: &str = " "; const FOOTER: &str = "
"; #[derive(Debug, PartialEq, Eq, Hash)] struct Entry { col: String, instance: Option, } impl From<&str> for Entry { fn from(value: &str) -> Entry { match value.split_once(':') { None => Entry { col: String::from(value), instance: None, }, Some((col, instance)) => Entry { col: String::from(col.trim()), instance: Some(String::from(instance.trim())), }, } } } #[derive(Debug, PartialEq, Eq)] struct RowInput { label: String, entries: Vec, } struct Reader>> { input: std::iter::Enumerate, row: Option, } impl>> Reader { fn new(input: Input) -> Self { Self { input: input.enumerate(), row: None, } } } impl>> Iterator for Reader { type Item = Result; fn next(&mut self) -> Option { loop { match self .input .next() .map(|(n, r)| (n, r.map(|line| String::from(line.trim_end())))) { None => return Ok(std::mem::take(&mut self.row)).transpose(), Some((_, Err(e))) => return Some(Err(e)), Some((_, Ok(line))) if line.is_empty() && self.row.is_some() => { return Ok(std::mem::take(&mut self.row)).transpose() } Some((_, Ok(line))) if line.is_empty() => {} Some((n, Ok(line))) if line.starts_with(' ') => match &mut self.row { None => { return Some(Err(std::io::Error::other(format!( "{}: Entry with no header", n + 1 )))) } Some(ref mut row) => row.entries.push(Entry::from(line.trim())), }, Some((_, Ok(line))) => { let prev = std::mem::take(&mut self.row); self.row = Some(RowInput { label: line, entries: vec![], }); if prev.is_some() { return Ok(prev).transpose(); } } } } } } fn read_rows(input: impl std::io::Read) -> impl Iterator> { Reader::new(std::io::BufReader::new(input).lines()) } fn column_counts(rows: &[RowInput]) -> Vec<(usize, String)> { let mut counts: Vec<_> = rows .iter() .flat_map(|r| { r.entries .iter() .map(|e| &e.col) .collect::>() .into_iter() }) .fold(HashMap::new(), |mut cs, col| { cs.entry(String::from(col)) .and_modify(|n| *n += 1) .or_insert(1); cs }) .into_iter() .map(|(col, n)| (n, col)) .collect(); counts.sort(); counts } fn column_order(rows: &[RowInput]) -> Vec { column_counts(rows) .into_iter() .map(|(_, col)| col) .collect() } fn render_instance(entry: &Entry) -> String { match &entry.instance { None => String::from("✓ "), Some(instance) => String::from(instance) + " ", } } fn render_cell(col: &str, row: &RowInput) -> String { // TODO: Escape HTML special characters let entries: Vec<&Entry> = row.entries.iter().filter(|e| e.col == col).collect(); let class = if entries.is_empty() { "" } else { "yes" }; let all_empty = entries.iter().all(|e| e.instance.is_none()); let contents = if entries.is_empty() || (all_empty && entries.len() == 1) { String::new() } else if all_empty { format!("{}", entries.len()) } else { entries .iter() .map(|i| render_instance(i)) .collect::() }; format!("{}", contents.trim()) } fn render_row(columns: &[String], row: &RowInput) -> String { // This is O(n^2) & doesn't need to be // TODO: Escape HTML special characters format!( "{}{}\n", row.label, &columns .iter() .map(|col| render_cell(col, row)) .collect::() ) } fn render_column_headers(columns: &[String]) -> String { // TODO: Escape HTML special characters String::from("") + &columns .iter() .map(|c| format!("{c}")) .collect::() + "\n" } /// # Errors /// /// Will return `Err` if /// * there's an i/o error while reading `input` /// * the log has invalid syntax: /// * an indented line with no preceding non-indented line pub fn tablify(input: impl std::io::Read) -> Result { let rows = read_rows(input).collect::, _>>()?; let columns = column_order(&rows); Ok(String::from(HEADER) + &render_column_headers(&columns) + &rows .into_iter() .map(|r| render_row(&columns, &r)) .collect::() + FOOTER) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_entry() { assert_eq!( Entry::from("foo"), Entry { col: String::from("foo"), instance: None } ); assert_eq!( Entry::from("foo:bar"), Entry { col: String::from("foo"), instance: Some(String::from("bar")) } ); assert_eq!( Entry::from("foo: bar"), Entry { col: String::from("foo"), instance: Some(String::from("bar")) } ); } #[test] fn test_read_rows() { assert_eq!( read_rows(&b"foo"[..]).flatten().collect::>(), vec![RowInput { label: String::from("foo"), entries: vec![] }] ); assert_eq!( read_rows(&b"bar"[..]).flatten().collect::>(), vec![RowInput { label: String::from("bar"), entries: vec![] }] ); assert_eq!( read_rows(&b"foo\nbar\n"[..]).flatten().collect::>(), vec![ RowInput { label: String::from("foo"), entries: vec![] }, RowInput { label: String::from("bar"), entries: vec![] } ] ); assert_eq!( read_rows(&b"foo\n bar\n"[..]).flatten().collect::>(), vec![RowInput { label: String::from("foo"), entries: vec![Entry::from("bar")] }] ); assert_eq!( read_rows(&b"foo\n bar\n baz\n"[..]) .flatten() .collect::>(), vec![RowInput { label: String::from("foo"), entries: vec![Entry::from("bar"), Entry::from("baz")] }] ); assert_eq!( read_rows(&b"foo\n\nbar\n"[..]) .flatten() .collect::>(), vec![ RowInput { label: String::from("foo"), entries: vec![] }, RowInput { label: String::from("bar"), entries: vec![] } ] ); assert_eq!( read_rows(&b"foo\n \nbar\n"[..]) .flatten() .collect::>(), vec![ RowInput { label: String::from("foo"), entries: vec![] }, RowInput { label: String::from("bar"), entries: vec![] } ] ); assert_eq!( read_rows(&b"foo \n bar \n"[..]) .flatten() .collect::>(), vec![RowInput { label: String::from("foo"), entries: vec![Entry::from("bar")] }] ); let bad = read_rows(&b" foo"[..]).next().unwrap(); assert!(bad.is_err()); assert!(format!("{bad:?}").contains("1: Entry with no header")); let bad2 = read_rows(&b"foo\n\n bar"[..]).nth(1).unwrap(); assert!(bad2.is_err()); assert!(format!("{bad2:?}").contains("3: Entry with no header")); } #[test] fn test_column_counts() { assert_eq!( column_counts( &read_rows(&b"foo\n bar\n baz\n"[..]) .collect::, _>>() .unwrap() ), vec![(1, String::from("bar")), (1, String::from("baz"))] ); assert_eq!( column_counts( &read_rows(&b"foo\n bar\n baz\nquux\n baz"[..]) .collect::, _>>() .unwrap() ), vec![(1, String::from("bar")), (2, String::from("baz"))] ); assert_eq!( column_counts( &read_rows(&b"foo\n bar\n bar\n baz\n bar\nquux\n baz"[..]) .collect::, _>>() .unwrap() ), vec![(1, String::from("bar")), (2, String::from("baz"))] ); assert_eq!( column_counts( &read_rows(&b"foo\n bar: 1\n bar: 2\n baz\n bar\nquux\n baz"[..]) .collect::, _>>() .unwrap() ), vec![(1, String::from("bar")), (2, String::from("baz"))] ); } #[test] fn test_render_cell() { assert_eq!( render_cell( "foo", &RowInput { label: String::from("nope"), entries: vec![] } ), String::from("") ); assert_eq!( render_cell( "foo", &RowInput { label: String::from("nope"), entries: vec![Entry::from("bar")] } ), String::from("") ); assert_eq!( render_cell( "foo", &RowInput { label: String::from("nope"), entries: vec![Entry::from("foo")] } ), String::from("") ); assert_eq!( render_cell( "foo", &RowInput { label: String::from("nope"), entries: vec![Entry::from("foo"), Entry::from("foo")] } ), String::from("2") ); assert_eq!( render_cell( "foo", &RowInput { label: String::from("nope"), entries: vec![Entry::from("foo: 5"), Entry::from("foo: 10")] } ), String::from("5 10") ); assert_eq!( render_cell( "foo", &RowInput { label: String::from("nope"), entries: vec![Entry::from("foo: 5"), Entry::from("foo")] } ), String::from("5 ✓") ); } }