1use std::borrow::Cow;
6use std::fs::File;
7use std::io::{Read, Seek};
8use std::path::Path;
9
10use crate::encoding::{detect_and_transcode, detect_encoding, skip_bom};
11use crate::error::{Result, SnifferError};
12use crate::field_type::Type;
13use crate::metadata::{Dialect, Header, Metadata, Quote};
14use crate::sample::{DatePreference, SampleSize};
15use crate::tum::potential_dialects::{
16 PotentialDialect, detect_line_terminator, generate_dialects_with_terminator,
17};
18use crate::tum::score::{DialectScore, find_best_dialect, score_all_dialects_with_best_table};
19use crate::tum::table::{Table, parse_table};
20use crate::tum::type_detection::infer_column_types;
21
22const MAX_RECORDS_BYTES: usize = 100 * 1024 * 1024;
24
25#[derive(Debug, Clone)]
40pub struct Sniffer {
41 sample_size: SampleSize,
43 date_preference: DatePreference,
45 forced_delimiter: Option<u8>,
47 forced_quote: Option<Quote>,
49}
50
51impl Default for Sniffer {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl Sniffer {
58 pub const fn new() -> Self {
60 Self {
61 sample_size: SampleSize::Records(100),
62 date_preference: DatePreference::MdyFormat,
63 forced_delimiter: None,
64 forced_quote: None,
65 }
66 }
67
68 pub fn sample_size(&mut self, sample_size: SampleSize) -> &mut Self {
70 self.sample_size = sample_size;
71 self
72 }
73
74 pub fn date_preference(&mut self, date_preference: DatePreference) -> &mut Self {
76 self.date_preference = date_preference;
77 self
78 }
79
80 pub fn delimiter(&mut self, delimiter: u8) -> &mut Self {
82 self.forced_delimiter = Some(delimiter);
83 self
84 }
85
86 pub fn quote(&mut self, quote: Quote) -> &mut Self {
88 self.forced_quote = Some(quote);
89 self
90 }
91
92 pub fn sniff_path<P: AsRef<Path>>(&mut self, path: P) -> Result<Metadata> {
94 let file = File::open(path.as_ref())?;
95 let mut reader = std::io::BufReader::new(file);
96 self.sniff_reader(&mut reader)
97 }
98
99 pub fn sniff_reader<R: Read + Seek>(&mut self, reader: R) -> Result<Metadata> {
101 let data = self.read_sample(reader)?;
102
103 if data.is_empty() {
104 return Err(SnifferError::EmptyData);
105 }
106
107 self.sniff_bytes(&data)
108 }
109
110 pub fn sniff_bytes(&self, data: &[u8]) -> Result<Metadata> {
112 if data.is_empty() {
113 return Err(SnifferError::EmptyData);
114 }
115
116 let (transcoded_data, was_transcoded) = detect_and_transcode(data);
118 let data = &transcoded_data[..];
119
120 let encoding_info = detect_encoding(data);
122 let is_utf8 = !was_transcoded || encoding_info.is_utf8;
123
124 let data = skip_bom(data);
126
127 let (comment_preamble_rows, data) = skip_preamble(data);
129
130 let line_terminator = detect_line_terminator(data);
132
133 let dialects = self.forced_delimiter.map_or_else(
135 || generate_dialects_with_terminator(line_terminator),
136 |delim| {
137 let quotes = if let Some(q) = self.forced_quote {
139 vec![q]
140 } else {
141 vec![Quote::Some(b'"'), Quote::Some(b'\''), Quote::None]
142 };
143
144 quotes
145 .into_iter()
146 .map(|q| PotentialDialect::new(delim, q, line_terminator))
147 .collect()
148 },
149 );
150 let max_rows = match self.sample_size {
152 SampleSize::Records(n) => n,
153 SampleSize::Bytes(_) | SampleSize::All => 0, };
155
156 let (scores, best_table) = score_all_dialects_with_best_table(data, &dialects, max_rows);
158
159 let best = find_best_dialect(&scores)
161 .ok_or_else(|| SnifferError::NoDialectDetected("No valid dialect found".to_string()))?;
162
163 let table_for_preamble = match best_table {
172 Some((dialect, table)) if dialect == best.dialect => table,
173 _ => parse_table(data, &best.dialect, max_rows),
174 };
175 let structural_preamble = detect_structural_preamble(&table_for_preamble);
176
177 let total_preamble_rows = comment_preamble_rows + structural_preamble;
179
180 self.build_metadata(
184 best,
185 is_utf8,
186 structural_preamble,
187 total_preamble_rows,
188 &table_for_preamble,
189 data,
190 )
191 }
192
193 fn read_sample<R: Read + Seek>(&self, mut reader: R) -> Result<Vec<u8>> {
195 fn fill<R: Read>(reader: &mut R, buf: &mut [u8]) -> std::io::Result<usize> {
200 let mut filled = 0;
201 while filled < buf.len() {
202 match reader.read(&mut buf[filled..]) {
203 Ok(0) => break,
204 Ok(n) => filled += n,
205 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
206 Err(e) => return Err(e),
207 }
208 }
209 Ok(filled)
210 }
211
212 match self.sample_size {
213 SampleSize::Bytes(n) => {
214 let mut buffer = vec![0u8; n];
215 let bytes_read = fill(&mut reader, &mut buffer)?;
216 buffer.truncate(bytes_read);
217 Ok(buffer)
218 }
219 SampleSize::All => {
220 const MAX_BYTES: u64 = 1024 * 1024 * 1024; let mut buffer = Vec::new();
222 (&mut reader).take(MAX_BYTES).read_to_end(&mut buffer)?;
223 if buffer.len() as u64 == MAX_BYTES {
224 let mut probe = [0u8; 1];
225 if reader.read(&mut probe)? > 0 {
226 eprintln!(
227 "warning: input exceeds 1 GB; sniffing on truncated sample — results may be inaccurate"
228 );
229 }
230 }
231 Ok(buffer)
232 }
233 SampleSize::Records(n) => {
234 let estimated_size = n.saturating_mul(1024).clamp(8192, MAX_RECORDS_BYTES);
237 let mut buffer = vec![0u8; estimated_size];
238 let bytes_read = fill(&mut reader, &mut buffer)?;
239 buffer.truncate(bytes_read);
240
241 if bytes_read == estimated_size {
243 let newlines = bytecount::count(&buffer, b'\n');
245 if newlines < n {
246 let remaining = MAX_RECORDS_BYTES.saturating_sub(buffer.len());
250 let additional = (n - newlines).saturating_mul(2048).min(remaining);
251 let mut more = vec![0u8; additional];
252 let more_read = fill(&mut reader, &mut more)?;
253 more.truncate(more_read);
254 buffer.extend(more);
255 }
256 }
257
258 if buffer.len() >= MAX_RECORDS_BYTES {
259 let mut probe = [0u8; 1];
260 if reader.read(&mut probe)? > 0 {
261 eprintln!(
262 "warning: Records sample capped at 100 MB; \
263 sniff result may be approximate for very large inputs"
264 );
265 }
266 }
267
268 Ok(buffer)
269 }
270 }
271 }
272
273 fn build_metadata(
281 &self,
282 score: &DialectScore,
283 is_utf8: bool,
284 structural_preamble: usize,
285 total_preamble_rows: usize,
286 table: &Table,
287 data: &[u8],
288 ) -> Result<Metadata> {
289 if table.is_empty() {
290 return Err(SnifferError::EmptyData);
291 }
292
293 let effective_table: Cow<'_, Table> =
297 if structural_preamble > 0 && table.rows.len() > structural_preamble {
298 let mut et = Table::new();
299 et.rows = table.rows[structural_preamble..].to_vec();
300 et.field_counts = table.field_counts[structural_preamble..].to_vec();
301 et.update_modal_field_count();
302 Cow::Owned(et)
303 } else {
304 Cow::Borrowed(table)
305 };
306
307 let header = detect_header(&effective_table, total_preamble_rows);
309
310 let fields = if header.has_header_row && !effective_table.rows.is_empty() {
312 effective_table.rows[0].clone()
313 } else {
314 (0..score.num_fields)
316 .map(|i| format!("field_{}", i + 1))
317 .collect()
318 };
319
320 let data_table = if header.has_header_row && effective_table.rows.len() > 1 {
322 let mut dt = crate::tum::table::Table::new();
323 dt.rows = effective_table.rows[1..].to_vec();
324 dt.field_counts = effective_table.field_counts[1..].to_vec();
325 dt.update_modal_field_count();
326 dt
327 } else {
328 effective_table.into_owned()
329 };
330
331 let types = infer_column_types(&data_table);
333
334 let dialect = Dialect {
336 delimiter: score.dialect.delimiter,
337 header,
338 quote: score.dialect.quote,
339 flexible: !score.is_uniform,
340 is_utf8,
341 };
342
343 let avg_record_len = calculate_avg_record_len(data, table.num_rows());
345
346 Ok(Metadata {
347 dialect,
348 avg_record_len,
349 num_fields: score.num_fields,
350 fields,
351 types,
352 })
353 }
354}
355
356fn detect_header(table: &crate::tum::table::Table, preamble_rows: usize) -> Header {
360 if table.rows.is_empty() {
361 return Header::new(false, preamble_rows);
362 }
363
364 if table.rows.len() < 2 {
365 return Header::new(false, preamble_rows);
367 }
368
369 let first_row = &table.rows[0];
370 let second_row = &table.rows[1];
371
372 let mut header_score = 0.0;
378 let mut checks = 0;
379
380 let (first_text_count, first_numeric_count) =
382 first_row.iter().fold((0, 0), |(text, num), s| {
383 let t = crate::tum::type_detection::detect_cell_type(s);
384 (
385 text + usize::from(t == Type::Text),
386 num + usize::from(t.is_numeric()),
387 )
388 });
389
390 let second_text_count = second_row
392 .iter()
393 .filter(|s| crate::tum::type_detection::detect_cell_type(s) == Type::Text)
394 .count();
395
396 if first_text_count > second_text_count {
397 header_score += 1.0;
398 }
399 checks += 1;
400
401 if first_text_count > first_numeric_count {
403 header_score += 0.5;
404 }
405 checks += 1;
406
407 let unique_count = {
409 let mut seen = std::collections::HashSet::new();
410 first_row.iter().filter(|s| seen.insert(s.as_str())).count()
411 };
412 if unique_count == first_row.len() {
413 header_score += 0.5;
414 }
415 checks += 1;
416
417 let avg_first_len: f64 = first_row
419 .iter()
420 .map(std::string::String::len)
421 .sum::<usize>() as f64
422 / first_row.len().max(1) as f64;
423 let avg_second_len: f64 = second_row
424 .iter()
425 .map(std::string::String::len)
426 .sum::<usize>() as f64
427 / second_row.len().max(1) as f64;
428
429 if avg_first_len <= avg_second_len {
430 header_score += 0.3;
431 }
432 checks += 1;
433
434 let has_header = (header_score / checks as f64) > 0.4;
436
437 Header::new(has_header, preamble_rows)
438}
439
440fn calculate_avg_record_len(data: &[u8], num_rows: usize) -> usize {
447 if num_rows == 0 || data.is_empty() {
448 return 0;
449 }
450
451 let mut rows_seen = 0;
454 let mut byte_offset = 0;
455
456 for (i, &byte) in data.iter().enumerate() {
457 if byte == b'\n' {
458 rows_seen += 1;
459 if rows_seen >= num_rows {
460 byte_offset = i + 1; break;
462 }
463 }
464 }
465
466 if byte_offset == 0 {
469 byte_offset = data.len();
470 }
471
472 byte_offset / num_rows
473}
474
475fn skip_preamble(data: &[u8]) -> (usize, &[u8]) {
486 let mut preamble_rows = 0;
487 let mut offset = 0;
488
489 let mut pending_blank_rows = 0;
492 let mut pending_blank_offset = offset;
493
494 while offset < data.len() {
495 let mut line_end = offset;
497 while line_end < data.len() && data[line_end] != b'\n' && data[line_end] != b'\r' {
498 line_end += 1;
499 }
500
501 let mut next_offset = line_end;
503 if next_offset < data.len() && data[next_offset] == b'\r' {
504 next_offset += 1;
505 }
506 if next_offset < data.len() && data[next_offset] == b'\n' {
507 next_offset += 1;
508 }
509
510 let mut line_start = offset;
512 while line_start < line_end && (data[line_start] == b' ' || data[line_start] == b'\t') {
513 line_start += 1;
514 }
515
516 if line_start == line_end {
517 if next_offset == offset {
519 break; }
521 pending_blank_rows += 1;
522 offset = next_offset;
523 } else if data[line_start] == b'#' {
524 preamble_rows += pending_blank_rows + 1;
526 pending_blank_rows = 0;
527 offset = next_offset;
528 pending_blank_offset = offset;
529 } else {
530 break;
532 }
533 }
534
535 if pending_blank_rows > 0 {
536 offset = pending_blank_offset;
537 }
538
539 (preamble_rows, &data[offset..])
540}
541
542fn detect_structural_preamble(table: &crate::tum::table::Table) -> usize {
547 let n = table.field_counts.len();
548 if n < 3 {
549 return 0;
550 }
551
552 let modal_count = table.modal_field_count();
553
554 let mut matching_suffix = vec![0usize; n];
557 let mut count = 0;
558 for i in (0..n).rev() {
559 if table.field_counts[i] == modal_count {
560 count += 1;
561 }
562 matching_suffix[i] = count;
563 }
564
565 for (i, &field_count) in table.field_counts.iter().enumerate() {
567 if field_count == modal_count {
568 let remaining_len = n - i;
569 let matching = matching_suffix[i];
570 let consistency = matching as f64 / remaining_len as f64;
571
572 if consistency >= 0.8 {
573 return i;
574 }
575 }
576 }
577
578 0
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn test_sniffer_builder() {
587 let mut sniffer = Sniffer::new();
588 sniffer
589 .sample_size(SampleSize::Records(50))
590 .date_preference(DatePreference::DmyFormat)
591 .delimiter(b',');
592
593 assert_eq!(sniffer.sample_size, SampleSize::Records(50));
594 assert_eq!(sniffer.date_preference, DatePreference::DmyFormat);
595 assert_eq!(sniffer.forced_delimiter, Some(b','));
596 }
597
598 #[test]
599 fn test_sniff_bytes() {
600 let data = b"name,age,city\nAlice,30,NYC\nBob,25,LA\n";
601 let sniffer = Sniffer::new();
602
603 let metadata = sniffer.sniff_bytes(data).unwrap();
604
605 assert_eq!(metadata.dialect.delimiter, b',');
606 assert!(metadata.dialect.header.has_header_row);
607 assert_eq!(metadata.num_fields, 3);
608 assert_eq!(metadata.fields, vec!["name", "age", "city"]);
609 }
610
611 #[test]
612 fn test_sniff_tsv() {
613 let data = b"name\tage\tcity\nAlice\t30\tNYC\nBob\t25\tLA\n";
614 let sniffer = Sniffer::new();
615
616 let metadata = sniffer.sniff_bytes(data).unwrap();
617
618 assert_eq!(metadata.dialect.delimiter, b'\t');
619 assert!(metadata.dialect.header.has_header_row);
620 }
621
622 #[test]
623 fn test_sniff_semicolon() {
624 let data = b"name;age;city\nAlice;30;NYC\nBob;25;LA\n";
625 let sniffer = Sniffer::new();
626
627 let metadata = sniffer.sniff_bytes(data).unwrap();
628
629 assert_eq!(metadata.dialect.delimiter, b';');
630 }
631
632 #[test]
633 fn test_sniff_no_header() {
634 let data = b"1,2,3\n4,5,6\n7,8,9\n";
635 let sniffer = Sniffer::new();
636
637 let metadata = sniffer.sniff_bytes(data).unwrap();
638
639 assert_eq!(metadata.dialect.delimiter, b',');
640 assert!(!metadata.dialect.header.has_header_row);
642 }
643
644 #[test]
645 fn test_sniff_with_quotes() {
646 let data = b"\"name\",\"value\"\n\"hello, world\",123\n\"test\",456\n";
647 let sniffer = Sniffer::new();
648
649 let metadata = sniffer.sniff_bytes(data).unwrap();
650
651 assert_eq!(metadata.dialect.delimiter, b',');
652 assert_eq!(metadata.dialect.quote, Quote::Some(b'"'));
653 }
654
655 #[test]
656 fn test_sniff_empty() {
657 let data = b"";
658 let sniffer = Sniffer::new();
659
660 let result = sniffer.sniff_bytes(data);
661 assert!(result.is_err());
662 }
663
664 #[test]
665 fn test_skip_preamble() {
666 let data = b"# This is a comment\n# Another comment\nname,age\nAlice,30\n";
668 let (preamble_rows, remaining) = skip_preamble(data);
669 assert_eq!(preamble_rows, 2);
670 assert_eq!(remaining, b"name,age\nAlice,30\n");
671
672 let data = b"name,age\nAlice,30\n";
674 let (preamble_rows, remaining) = skip_preamble(data);
675 assert_eq!(preamble_rows, 0);
676 assert_eq!(remaining, b"name,age\nAlice,30\n");
677
678 let data = b" # Indented comment\nname,age\n";
680 let (preamble_rows, remaining) = skip_preamble(data);
681 assert_eq!(preamble_rows, 1);
682 assert_eq!(remaining, b"name,age\n");
683 }
684
685 #[test]
686 fn test_skip_preamble_blank_lines_within_comment_block() {
687 let data = b"#c\n\n#d\ndata\n";
689 let (preamble_rows, remaining) = skip_preamble(data);
690 assert_eq!(preamble_rows, 3);
691 assert_eq!(remaining, b"data\n");
692
693 let data = b"#c\n\n\ndata\n";
695 let (preamble_rows, remaining) = skip_preamble(data);
696 assert_eq!(preamble_rows, 1);
697 assert_eq!(remaining, b"\n\ndata\n");
698
699 let data = b"#a\n\n#b\n\n\n#c\n\ndata\n";
701 let (preamble_rows, remaining) = skip_preamble(data);
702 assert_eq!(preamble_rows, 6);
703 assert_eq!(remaining, b"\ndata\n");
704
705 let data = b"\n\nname,age\n";
707 let (preamble_rows, remaining) = skip_preamble(data);
708 assert_eq!(preamble_rows, 0);
709 assert_eq!(remaining, b"\n\nname,age\n");
710
711 let data = b"#c\r\n \r\n#d\r\ndata\r\n";
713 let (preamble_rows, remaining) = skip_preamble(data);
714 assert_eq!(preamble_rows, 3);
715 assert_eq!(remaining, b"data\r\n");
716
717 let data = b"#c\n\n";
719 let (preamble_rows, remaining) = skip_preamble(data);
720 assert_eq!(preamble_rows, 1);
721 assert_eq!(remaining, b"\n");
722 }
723
724 #[test]
725 fn test_sniff_with_preamble() {
726 let data = b"# LimeSurvey export\n# Generated 2024-01-01\nname,age,city\nAlice,30,NYC\nBob,25,LA\n";
727 let sniffer = Sniffer::new();
728
729 let metadata = sniffer.sniff_bytes(data).unwrap();
730
731 assert_eq!(metadata.dialect.delimiter, b',');
732 assert!(metadata.dialect.header.has_header_row);
733 assert_eq!(metadata.num_fields, 3);
734 }
735
736 #[test]
737 fn test_comment_preamble_propagated() {
738 let data = b"# Comment 1\n# Comment 2\nname,age\nAlice,30\nBob,25\n";
739 let metadata = Sniffer::new().sniff_bytes(data).unwrap();
740 assert_eq!(metadata.dialect.header.num_preamble_rows, 2);
741 assert!(metadata.dialect.header.has_header_row);
742 assert_eq!(metadata.fields, vec!["name", "age"]);
743 }
744
745 #[test]
746 fn test_structural_preamble_detection() {
747 let data = b"TITLE\nSUB,TITLE\nA,B,C,D,E\n1,2,3,4,5\n2,3,4,5,6\n3,4,5,6,7\n";
749 let metadata = Sniffer::new().sniff_bytes(data).unwrap();
750 assert_eq!(metadata.dialect.header.num_preamble_rows, 2);
751 assert!(metadata.dialect.header.has_header_row);
752 assert_eq!(metadata.fields, vec!["A", "B", "C", "D", "E"]);
753 }
754
755 #[test]
756 fn test_mixed_preamble_detection() {
757 let data =
760 b"# File header\nMETADATA\nname,age,city\nAlice,30,NYC\nBob,25,LA\nCharlie,35,CHI\n";
761 let metadata = Sniffer::new().sniff_bytes(data).unwrap();
762 assert_eq!(metadata.dialect.header.num_preamble_rows, 2);
764 assert!(metadata.dialect.header.has_header_row);
765 assert_eq!(metadata.fields, vec!["name", "age", "city"]);
766 }
767
768 #[test]
769 fn test_no_preamble() {
770 let data = b"a,b,c\n1,2,3\n4,5,6\n";
771 let metadata = Sniffer::new().sniff_bytes(data).unwrap();
772 assert_eq!(metadata.dialect.header.num_preamble_rows, 0);
773 }
774
775 #[test]
776 fn test_detect_structural_preamble_function() {
777 use crate::tum::table::Table;
778
779 let mut table = Table::new();
781 table.rows = vec![
782 vec!["TITLE".to_string()],
783 vec!["".to_string(), "".to_string()],
784 vec!["A".to_string(), "B".to_string(), "C".to_string()],
785 vec!["1".to_string(), "2".to_string(), "3".to_string()],
786 vec!["4".to_string(), "5".to_string(), "6".to_string()],
787 ];
788 table.field_counts = vec![1, 2, 3, 3, 3];
789 table.update_modal_field_count();
790 assert_eq!(detect_structural_preamble(&table), 2);
791
792 let mut table = Table::new();
794 table.rows = vec![
795 vec!["A".to_string(), "B".to_string(), "C".to_string()],
796 vec!["1".to_string(), "2".to_string(), "3".to_string()],
797 ];
798 table.field_counts = vec![3, 3];
799 table.update_modal_field_count();
800 assert_eq!(detect_structural_preamble(&table), 0);
801
802 let mut table = Table::new();
804 table.rows = vec![vec!["A".to_string()]];
805 table.field_counts = vec![1];
806 table.update_modal_field_count();
807 assert_eq!(detect_structural_preamble(&table), 0);
808 }
809
810 #[test]
811 fn test_avg_record_len_calculated_from_data() {
812 let short_data = b"a,b\n1,2\n3,4\n";
814 let sniffer = Sniffer::new();
815 let metadata = sniffer.sniff_bytes(short_data).unwrap();
816
817 assert_eq!(metadata.avg_record_len, 4);
820 }
821
822 #[test]
823 fn test_avg_record_len_with_quoted_fields() {
824 let quoted_data = b"\"hello\",\"world\"\n\"foo\",\"bar\"\n";
825 let sniffer = Sniffer::new();
826 let metadata = sniffer.sniff_bytes(quoted_data).unwrap();
827
828 assert_eq!(metadata.avg_record_len, 14);
830 }
831
832 #[test]
833 fn test_records_mode_cap_boundary_ok() {
834 let row = b"col1,col2,col3\n1,2,3\n"; let total = MAX_RECORDS_BYTES + row.len();
841 let data: Vec<u8> = row.iter().copied().cycle().take(total).collect();
842 assert!(
844 data.len() > MAX_RECORDS_BYTES,
845 "test data must exceed MAX_RECORDS_BYTES to exercise probe-read path"
846 );
847 let cursor = std::io::Cursor::new(data);
848 let mut sniffer = Sniffer::new();
849 sniffer.sample_size(SampleSize::Records(200_000));
851 let result = sniffer.sniff_reader(cursor);
852 assert!(
853 result.is_ok(),
854 "sniff should succeed at cap boundary: {result:?}"
855 );
856 }
860}