// SPDX-License-Identifier: MIT // Copyright (c) 2026 OpenViST use std::fs::File; use std::io::{self, Read, Write}; const BYTE_LIMIT: u64 = 65_536; const ROW_LIMIT: usize = 1_000; #[derive(Debug, PartialEq, Eq)] struct Summary { records: usize, open: usize, closed: usize, } fn parse_tasks(bytes: &[u8]) -> Result { if bytes.len() > BYTE_LIMIT as usize { return Err("too large"); } let text = std::str::from_utf8(bytes).map_err(|_| "invalid UTF-8")?; if text.starts_with('\u{feff}') || text.contains('\r') { return Err("unsupported encoding or line ending"); } // Remove at most one final LF. A second final LF is an empty record. let body = text.strip_suffix('\n').unwrap_or(text); let mut lines = body.split('\n'); if lines.next() != Some("status\ttitle") { return Err("invalid header"); } let mut summary = Summary { records: 0, open: 0, closed: 0, }; for line in lines { if summary.records == ROW_LIMIT { return Err("too many records"); } let (status, title) = line.split_once('\t').ok_or("missing tab")?; if title.is_empty() || title.chars().count() > 100 || title.chars().any(char::is_control) { return Err("invalid title"); } match status { "open" => summary.open += 1, "closed" => summary.closed += 1, _ => return Err("invalid status"), } summary.records += 1; } if summary.records == 0 { return Err("no records"); } Ok(summary) } fn format_summary(summary: &Summary) -> String { format!( "records={}\nopen={}\nclosed={}\n", summary.records, summary.open, summary.closed ) } fn run() -> Result { let mut args = std::env::args_os().skip(1); let path = args.next().ok_or(())?; if args.next().is_some() { return Err(()); } let file = File::open(path).map_err(|_| ())?; if !file.metadata().map_err(|_| ())?.is_file() { return Err(()); } let mut bytes = Vec::new(); file.take(BYTE_LIMIT + 1) .read_to_end(&mut bytes) .map_err(|_| ())?; let summary = parse_tasks(&bytes).map_err(|_| ())?; Ok(format_summary(&summary)) } fn main() { match run() { Ok(report) => { if io::stdout().lock().write_all(report.as_bytes()).is_err() { std::process::exit(1); } } Err(()) => { let _ = io::stderr().lock().write_all(b"error: invalid input\n"); std::process::exit(2); } } } #[cfg(test)] mod tests { use super::*; #[test] fn counts_valid_rows() { assert_eq!( parse_tasks(b"status\ttitle\nopen\tOne\nclosed\tTwo\nopen\tThree\n"), Ok(Summary { records: 3, open: 2, closed: 1 }) ); } #[test] fn final_newline_is_optional() { assert_eq!( parse_tasks(b"status\ttitle\nopen\tOne"), parse_tasks(b"status\ttitle\nopen\tOne\n") ); } #[test] fn counts_duplicate_rows() { assert_eq!( parse_tasks(b"status\ttitle\nopen\tOne\nopen\tOne\n") .unwrap() .records, 2 ); } #[test] fn rejects_bad_status() { assert!(parse_tasks(b"status\ttitle\nOPEN\tOne\n").is_err()); } #[test] fn rejects_missing_header() { assert!(parse_tasks(b"open\tOne\n").is_err()); } #[test] fn rejects_empty_input_and_header_only() { for value in [b"".as_slice(), b"status\ttitle", b"status\ttitle\n"] { assert!(parse_tasks(value).is_err()); } } #[test] fn rejects_extra_columns() { assert!(parse_tasks(b"status\ttitle\nopen\tOne\tTwo\n").is_err()); } #[test] fn rejects_empty_title() { assert!(parse_tasks(b"status\ttitle\nopen\t\n").is_err()); } #[test] fn title_boundary_counts_unicode_scalars() { assert!( parse_tasks(format!("status\ttitle\nopen\t{}\n", "é".repeat(100)).as_bytes()).is_ok() ); assert!( parse_tasks(format!("status\ttitle\nopen\t{}\n", "é".repeat(101)).as_bytes()).is_err() ); } #[test] fn rejects_control_characters() { assert!(parse_tasks(b"status\ttitle\nopen\tOne\x00\n").is_err()); } #[test] fn rejects_invalid_utf8() { assert!(parse_tasks(b"status\ttitle\nopen\t\xff\n").is_err()); } #[test] fn rejects_bom_and_crlf() { assert!(parse_tasks("\u{feff}status\ttitle\nopen\tOne\n".as_bytes()).is_err()); assert!(parse_tasks(b"status\ttitle\r\nopen\tOne\r\n").is_err()); } #[test] fn rejects_blank_records() { assert!(parse_tasks(b"status\ttitle\nopen\tOne\n\n").is_err()); } #[test] fn row_limit_boundary() { assert!( parse_tasks(format!("status\ttitle\n{}", "open\tx\n".repeat(1000)).as_bytes()).is_ok() ); assert!( parse_tasks(format!("status\ttitle\n{}", "open\tx\n".repeat(1001)).as_bytes()).is_err() ); } #[test] fn byte_limit_boundary() { let mut data = String::from("status\ttitle\n"); for _ in 0..618 { data.push_str(&format!("open\t{}\n", "x".repeat(100))); } data.push_str(&format!("open\t{}", "x".repeat(10))); assert_eq!(data.len(), 65536); assert!(parse_tasks(data.as_bytes()).is_ok()); data.push('x'); assert!(parse_tasks(data.as_bytes()).is_err()); } #[test] fn formats_fixed_order() { assert_eq!( format_summary(&Summary { records: 3, open: 2, closed: 1 }), "records=3\nopen=2\nclosed=1\n" ); } }