Skip to main content

csv_nose/
sniffer.rs

1//! Main Sniffer builder and sniff methods.
2//!
3//! This module provides the qsv-sniffer compatible API.
4
5use 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
22/// Maximum buffer size for `SampleSize::Records` mode (100 MB).
23const MAX_RECORDS_BYTES: usize = 100 * 1024 * 1024;
24
25/// CSV dialect sniffer using the Table Uniformity Method.
26///
27/// # Example
28///
29/// ```no_run
30/// use csv_nose::{Sniffer, SampleSize};
31///
32/// let mut sniffer = Sniffer::new();
33/// sniffer.sample_size(SampleSize::Records(100));
34///
35/// let metadata = sniffer.sniff_path("data.csv").unwrap();
36/// println!("Delimiter: {}", metadata.dialect.delimiter as char);
37/// println!("Has header: {}", metadata.dialect.header.has_header_row);
38/// ```
39#[derive(Debug, Clone)]
40pub struct Sniffer {
41    /// Sample size for sniffing.
42    sample_size: SampleSize,
43    /// Date format preference for ambiguous dates.
44    date_preference: DatePreference,
45    /// Optional forced delimiter.
46    forced_delimiter: Option<u8>,
47    /// Optional forced quote character.
48    forced_quote: Option<Quote>,
49}
50
51impl Default for Sniffer {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl Sniffer {
58    /// Create a new Sniffer with default settings.
59    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    /// Set the sample size for sniffing.
69    pub fn sample_size(&mut self, sample_size: SampleSize) -> &mut Self {
70        self.sample_size = sample_size;
71        self
72    }
73
74    /// Set the date preference for ambiguous date parsing.
75    pub fn date_preference(&mut self, date_preference: DatePreference) -> &mut Self {
76        self.date_preference = date_preference;
77        self
78    }
79
80    /// Force a specific delimiter (skip delimiter detection).
81    pub fn delimiter(&mut self, delimiter: u8) -> &mut Self {
82        self.forced_delimiter = Some(delimiter);
83        self
84    }
85
86    /// Force a specific quote character.
87    pub fn quote(&mut self, quote: Quote) -> &mut Self {
88        self.forced_quote = Some(quote);
89        self
90    }
91
92    /// Sniff a CSV file at the given path.
93    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    /// Sniff CSV data from a reader.
100    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    /// Sniff CSV data from bytes.
111    pub fn sniff_bytes(&self, data: &[u8]) -> Result<Metadata> {
112        if data.is_empty() {
113            return Err(SnifferError::EmptyData);
114        }
115
116        // Detect encoding and transcode to UTF-8 if necessary
117        let (transcoded_data, was_transcoded) = detect_and_transcode(data);
118        let data = &transcoded_data[..];
119
120        // Detect encoding info (for metadata)
121        let encoding_info = detect_encoding(data);
122        let is_utf8 = !was_transcoded || encoding_info.is_utf8;
123
124        // Skip BOM
125        let data = skip_bom(data);
126
127        // Skip comment/preamble lines (lines starting with #)
128        let (comment_preamble_rows, data) = skip_preamble(data);
129
130        // Detect line terminator first to reduce search space
131        let line_terminator = detect_line_terminator(data);
132
133        // Generate potential dialects
134        let dialects = self.forced_delimiter.map_or_else(
135            || generate_dialects_with_terminator(line_terminator),
136            |delim| {
137                // If delimiter is forced, only test that delimiter with different quotes
138                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        // Determine max rows for scoring
151        let max_rows = match self.sample_size {
152            SampleSize::Records(n) => n,
153            SampleSize::Bytes(_) | SampleSize::All => 0, // Already limited by read_sample
154        };
155
156        // Score all dialects and get the best table (avoids re-parsing)
157        let (scores, best_table) = score_all_dialects_with_best_table(data, &dialects, max_rows);
158
159        // Find the best dialect
160        let best = find_best_dialect(&scores)
161            .ok_or_else(|| SnifferError::NoDialectDetected("No valid dialect found".to_string()))?;
162
163        // Detect structural preamble using the already-parsed table.
164        //
165        // `best_table` was parsed with the top-gamma dialect, but
166        // `find_best_dialect` may deliberately select a different dialect when
167        // scores are close (delimiter/quote tiebreakers). Reuse the cached table
168        // only when it was parsed with the dialect we actually selected;
169        // otherwise re-parse with the selected dialect so preamble detection,
170        // field names, and type inference all run on the correct parse.
171        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        // Total preamble = comment rows + structural rows
178        let total_preamble_rows = comment_preamble_rows + structural_preamble;
179
180        // Build metadata from the best dialect, reusing the already-parsed table
181        // Pass structural_preamble for table row indexing (since comment rows are already skipped from data)
182        // Pass total_preamble_rows for Header metadata (to report true preamble count in original file)
183        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    /// Read a sample of data from the reader based on `sample_size` settings.
194    fn read_sample<R: Read + Seek>(&self, mut reader: R) -> Result<Vec<u8>> {
195        // `Read::read` may return fewer bytes than requested even when more data
196        // is available (pipes, BufReader chunk boundaries). Loop until the buffer
197        // is full or EOF is reached so the sample is not silently truncated.
198        // Returns the number of bytes actually read.
199        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; // 1 GB hard cap
221                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                // For records, we read enough to capture n records
235                // Estimate ~1KB per record as a starting point, with a minimum
236                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 we filled the estimate, we may not have enough records yet.
242                if bytes_read == estimated_size {
243                    // Count newlines to see if we have enough records
244                    let newlines = bytecount::count(&buffer, b'\n');
245                    if newlines < n {
246                        // Read more data, but never let the total exceed the
247                        // MAX_RECORDS_BYTES cap (cap against remaining capacity,
248                        // not just per-read).
249                        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    /// Build Metadata from the best scoring dialect.
274    ///
275    /// # Arguments
276    /// * `structural_preamble` - Number of structural preamble rows in the table (for row indexing)
277    /// * `total_preamble_rows` - Total preamble rows including comments (for Header metadata)
278    /// * `table` - Pre-parsed table to avoid redundant parsing
279    /// * `data` - Raw data bytes for accurate avg_record_len calculation
280    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        // Create a view of the table without structural preamble
294        // (comment preamble rows are already stripped from data)
295        // Use Cow to avoid cloning in the common no-preamble case
296        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        // Detect header on the effective table (pass total_preamble_rows for Header metadata)
308        let header = detect_header(&effective_table, total_preamble_rows);
309
310        // Get field names from the effective table (first row after structural preamble)
311        let fields = if header.has_header_row && !effective_table.rows.is_empty() {
312            effective_table.rows[0].clone()
313        } else {
314            // Generate field names
315            (0..score.num_fields)
316                .map(|i| format!("field_{}", i + 1))
317                .collect()
318        };
319
320        // Skip header row for type inference if present
321        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        // Infer types for each column
332        let types = infer_column_types(&data_table);
333
334        // Build dialect
335        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        // Calculate average record length from the raw data
344        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
356/// Detect if the first row (after preamble) is likely a header row.
357///
358/// Optimized: Computes type counts in a single pass without allocating Vecs.
359fn 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        // Can't determine header with only one row
366        return Header::new(false, preamble_rows);
367    }
368
369    let first_row = &table.rows[0];
370    let second_row = &table.rows[1];
371
372    // Heuristics for header detection:
373    // 1. First row has different types than subsequent rows
374    // 2. First row values look like labels (text when data is numeric)
375    // 3. First row has no duplicates (header columns should be unique)
376
377    let mut header_score = 0.0;
378    let mut checks = 0;
379
380    // Check 1 & 2: Count types in a single pass for first row
381    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    // Count types in a single pass for second row
391    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    // Check 2: First row has more text than numeric
402    if first_text_count > first_numeric_count {
403        header_score += 0.5;
404    }
405    checks += 1;
406
407    // Check 3: No duplicates in first row
408    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    // Check 4: First row values are shorter (headers tend to be concise)
418    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    // Threshold for header detection
435    let has_header = (header_score / checks as f64) > 0.4;
436
437    Header::new(has_header, preamble_rows)
438}
439
440/// Calculate average record length from raw data.
441///
442/// Uses the byte length of the first `num_rows` rows for accurate results
443/// that include quote characters and actual line terminators.
444/// This handles the case where `data` contains more bytes than `num_rows` rows
445/// (e.g., when `SampleSize::Records(n)` reads more data than needed).
446fn calculate_avg_record_len(data: &[u8], num_rows: usize) -> usize {
447    if num_rows == 0 || data.is_empty() {
448        return 0;
449    }
450
451    // Find the byte offset where the num_rows-th row ends
452    // by counting newlines (handling both \n and \r\n)
453    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; // Include the newline
461                break;
462            }
463        }
464    }
465
466    // If we didn't find enough newlines, use the entire data length
467    // (this handles files without trailing newlines or small files)
468    if byte_offset == 0 {
469        byte_offset = data.len();
470    }
471
472    byte_offset / num_rows
473}
474
475/// Skip preamble/comment lines at the start of data.
476///
477/// Detects lines starting with '#' at the beginning of the file and returns
478/// the number of preamble rows and a slice starting after the preamble.
479///
480/// Blank lines interspersed within the leading comment block are tolerated:
481/// they are buffered and only counted as preamble once a further comment line
482/// is seen. A trailing run of blank lines that is followed by data (rather than
483/// by another comment) is left in place, so `#c\n\n\ndata` reports one preamble
484/// row, not three.
485fn skip_preamble(data: &[u8]) -> (usize, &[u8]) {
486    let mut preamble_rows = 0;
487    let mut offset = 0;
488
489    // Blank lines seen since the last comment line; only committed to the
490    // preamble if another comment line follows.
491    let mut pending_blank_rows = 0;
492    let mut pending_blank_offset = offset;
493
494    while offset < data.len() {
495        // Find end of line (exclusive of the terminator).
496        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        // Include the line terminator in the consumed span.
502        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        // Skip leading whitespace to classify the line.
511        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            // Blank (or whitespace-only) line: defer judgement.
518            if next_offset == offset {
519                break; // no progress possible
520            }
521            pending_blank_rows += 1;
522            offset = next_offset;
523        } else if data[line_start] == b'#' {
524            // Comment line: any deferred blanks are part of the comment block.
525            preamble_rows += pending_blank_rows + 1;
526            pending_blank_rows = 0;
527            offset = next_offset;
528            pending_blank_offset = offset;
529        } else {
530            // Data line: deferred blanks belong to the data, not the preamble.
531            break;
532        }
533    }
534
535    if pending_blank_rows > 0 {
536        offset = pending_blank_offset;
537    }
538
539    (preamble_rows, &data[offset..])
540}
541
542/// Detect structural preamble rows using field count consistency analysis.
543///
544/// Identifies rows at the start that don't match the predominant field count
545/// pattern (metadata rows, empty rows, title rows with different structure).
546fn 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    // Pre-compute suffix counts: for each position i, how many rows from i to end match modal_count
555    // This converts O(n²) scanning to O(n) preprocessing + O(1) lookups
556    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    // Find first row where remaining data is 80%+ consistent with modal field count
566    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        // All numeric data - should not detect header
641        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        // Test with comment lines
667        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        // Test without comment lines
673        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        // Test with whitespace before #
679        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        // A blank line between comments is part of the comment block.
688        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        // Trailing blanks followed by data belong to the data, not the preamble.
694        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        // Several blank runs interleaved with comments, then a trailing run.
700        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        // Leading blanks with no comment at all: nothing is preamble.
706        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        // Whitespace-only lines count as blank, and CRLF is handled.
712        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        // A comment block that runs to EOF with trailing blanks.
718        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        // TITLE row has 1 field, SUBTITLE has 2 fields, data has 5 fields
748        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        // Both comment preamble and structural preamble
758        // METADATA has 1 field, data has 3 fields
759        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        // 1 comment + 1 structural = 2 total
763        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        // Table with 2 preamble rows (different field counts)
780        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        // Table with no preamble (uniform field counts)
793        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        // Table too small to determine preamble
803        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        // Test that avg_record_len uses raw bytes, not parsed content
813        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        // Each row: "a,b\n" = 4 bytes, "1,2\n" = 4 bytes, "3,4\n" = 4 bytes
818        // Average: 12 / 3 = 4 bytes
819        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        // Raw: 16 + 12 = 28 bytes for 2 rows = 14 bytes avg
829        assert_eq!(metadata.avg_record_len, 14);
830    }
831
832    #[test]
833    fn test_records_mode_cap_boundary_ok() {
834        // Verify that a reader with more than MAX_RECORDS_BYTES of valid CSV is handled
835        // gracefully and returns Ok. Uses a cycling pattern to avoid a large literal in
836        // test source; the runtime still materializes ~100 MB via collect().
837        // We supply MAX_RECORDS_BYTES + one extra row so the probe-read finds data and
838        // the truncation warning fires, but the sniff still succeeds.
839        let row = b"col1,col2,col3\n1,2,3\n"; // 21 bytes, valid CSV pair
840        let total = MAX_RECORDS_BYTES + row.len();
841        let data: Vec<u8> = row.iter().copied().cycle().take(total).collect();
842        // Confirm the test data actually exceeds the cap so the probe path is exercised.
843        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        // Records(200_000): estimated_size = 200_000 * 1024 > MAX_RECORDS_BYTES (100 MB), clamped to MAX_RECORDS_BYTES.
850        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        // Note: the eprintln! truncation warning cannot be captured in a standard Rust
857        // unit test without process-level stderr redirection. The probe-read path is
858        // exercised by virtue of data.len() > MAX_RECORDS_BYTES (asserted above).
859    }
860}