limnifs_write/file_categorizer/
csv_text.rs1use std::path::Path;
17
18use super::{Categorization, FileCategorizer};
19use limnifs_core::codec::CODEC_FSST_BROTLI;
20
21const FSST_ENABLED: bool = true;
26
27const MIN_FSST_SIZE: usize = 4 * 1024;
30
31pub struct CsvTextCategorizer;
32
33impl FileCategorizer for CsvTextCategorizer {
34 fn name(&self) -> &'static str {
35 "csv-text"
36 }
37
38 fn categories(&self) -> &'static [&'static str] {
39 &["csv-text/composite"]
40 }
41
42 fn categorize(&self, path: &Path, data: &[u8]) -> Option<Categorization> {
43 if !FSST_ENABLED {
44 return None;
45 }
46 if data.len() < MIN_FSST_SIZE {
47 return None;
48 }
49 if !looks_like_csv_text(path, data) {
50 return None;
51 }
52 Some(Categorization {
53 codec_id: CODEC_FSST_BROTLI,
54 codec_params: Vec::new(),
55 category: "csv-text/composite",
56 })
57 }
58}
59
60#[must_use]
69fn looks_like_csv_text(path: &Path, data: &[u8]) -> bool {
70 let ext = match path.extension().and_then(|e| e.to_str()) {
71 Some(e) => e.to_ascii_lowercase(),
72 None => return false,
73 };
74 if !matches!(ext.as_str(), "csv" | "tsv" | "json" | "jsonl" | "ndjson") {
75 return false;
76 }
77 let sample = if data.len() > 4096 {
78 &data[..4096]
79 } else {
80 data
81 };
82 let printables = sample
83 .iter()
84 .filter(|&&b| (0x20..=0x7E).contains(&b) || b == b'\n' || b == b'\r' || b == b'\t')
85 .count();
86 if (printables as f32 / sample.len() as f32) < 0.95 {
87 return false;
88 }
89 let punct_count = sample
90 .iter()
91 .filter(|&&b| matches!(b, b',' | b'"' | b'{' | b'}' | b'[' | b']'))
92 .count();
93 (punct_count as f32 / sample.len() as f32) > 0.01
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use std::path::PathBuf;
100
101 #[test]
102 fn csv_extension_routes_when_enabled() {
103 let c = CsvTextCategorizer;
104 let csv = "a,b,c\n".repeat(2000);
105 let cat = c
106 .categorize(&PathBuf::from("/x.csv"), csv.as_bytes())
107 .expect("csv extension claims");
108 assert_eq!(cat.codec_id, limnifs_core::codec::CODEC_FSST_BROTLI);
109 }
110
111 #[test]
112 fn csv_extension_detected_when_enabled() {
113 let csv = "a,b,c\n".repeat(2000);
116 assert!(looks_like_csv_text(
117 &PathBuf::from("/x.csv"),
118 csv.as_bytes()
119 ));
120 assert!(looks_like_csv_text(
121 &PathBuf::from("/x.json"),
122 csv.as_bytes()
123 ));
124 }
125
126 #[test]
127 fn binary_data_not_misdetected() {
128 let mut state: u64 = 0xDEAD_BEEF_CAFE_BABE;
131 let mut binary = Vec::with_capacity(8192);
132 for _ in 0..8192 {
133 state ^= state << 13;
134 state ^= state >> 7;
135 state ^= state << 17;
136 binary.push(u8::try_from(state & 0xFF).unwrap());
137 }
138 assert!(!looks_like_csv_text(&PathBuf::from("/x.csv"), &binary));
139 }
140
141 #[test]
142 fn empty_extension_does_not_trigger_fsst() {
143 let csv = "alpha,beta,gamma\n".repeat(500);
147 assert!(!looks_like_csv_text(
148 &PathBuf::from("/noext"),
149 csv.as_bytes()
150 ));
151 }
152}