1use std::fs::File;
38use std::io::{BufRead, BufReader};
39use std::path::Path;
40
41use crate::error::{IoError, Result};
42
43#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum ColumnType {
56 Integer,
58 Float,
60 Boolean,
62 Text,
64}
65
66#[derive(Debug, Clone, PartialEq)]
70pub enum TypedValue {
71 Integer(i64),
73 Float(f64),
75 Boolean(bool),
77 Text(String),
79 Null,
81}
82
83pub type TypedRow = Vec<TypedValue>;
87
88fn parse_csv_row_quoted(line: &str, delimiter: u8) -> Vec<String> {
95 let sep = delimiter as char;
96 let mut fields: Vec<String> = Vec::new();
97 let mut current = String::new();
98 let mut in_quotes = false;
99 let mut chars = line.chars().peekable();
100
101 while let Some(ch) = chars.next() {
102 if ch == '"' {
103 if in_quotes {
104 if chars.peek() == Some(&'"') {
105 chars.next();
107 current.push('"');
108 } else {
109 in_quotes = false;
110 }
111 } else {
112 in_quotes = true;
113 }
114 } else if ch == sep && !in_quotes {
115 fields.push(current.trim().to_string());
116 current.clear();
117 } else {
118 current.push(ch);
119 }
120 }
121 fields.push(current.trim().to_string());
122 fields
123}
124
125fn coerce_cell(cell: &str, col_type: &ColumnType) -> TypedValue {
127 let trimmed = cell.trim();
128 if trimmed.is_empty()
129 || trimmed.eq_ignore_ascii_case("null")
130 || trimmed.eq_ignore_ascii_case("na")
131 || trimmed.eq_ignore_ascii_case("n/a")
132 || trimmed.eq_ignore_ascii_case("nan")
133 {
134 return TypedValue::Null;
135 }
136 match col_type {
137 ColumnType::Integer => trimmed
138 .parse::<i64>()
139 .map(TypedValue::Integer)
140 .unwrap_or_else(|_| TypedValue::Text(trimmed.to_string())),
141 ColumnType::Float => trimmed
142 .parse::<f64>()
143 .map(TypedValue::Float)
144 .unwrap_or_else(|_| TypedValue::Text(trimmed.to_string())),
145 ColumnType::Boolean => match trimmed.to_lowercase().as_str() {
146 "true" | "yes" | "1" => TypedValue::Boolean(true),
147 "false" | "no" | "0" => TypedValue::Boolean(false),
148 _ => TypedValue::Text(trimmed.to_string()),
149 },
150 ColumnType::Text => TypedValue::Text(trimmed.to_string()),
151 }
152}
153
154pub struct CsvStreamReader {
170 inner: BufReader<File>,
171 delimiter: u8,
172 headers: Option<Vec<String>>,
173 finished: bool,
174 rows_yielded: u64,
175}
176
177impl CsvStreamReader {
178 pub fn new<P: AsRef<Path>>(path: P, delimiter: u8, has_header: bool) -> Result<Self> {
191 let path = path.as_ref();
192 let file = File::open(path)
193 .map_err(|e| IoError::FileNotFound(format!("{}: {e}", path.display())))?;
194 let mut inner = BufReader::new(file);
195
196 let headers = if has_header {
197 let mut line = String::new();
198 loop {
199 line.clear();
200 let n = inner
201 .read_line(&mut line)
202 .map_err(|e| IoError::FileError(format!("header read error: {e}")))?;
203 if n == 0 {
204 break None;
206 }
207 let trimmed = line.trim();
208 if !trimmed.is_empty() {
209 let hdrs = parse_csv_row_quoted(trimmed, delimiter);
210 break Some(hdrs);
211 }
212 }
213 } else {
214 None
215 };
216
217 Ok(Self {
218 inner,
219 delimiter,
220 headers,
221 finished: false,
222 rows_yielded: 0,
223 })
224 }
225
226 pub fn headers(&self) -> Option<&[String]> {
228 self.headers.as_deref()
229 }
230
231 pub fn rows_yielded(&self) -> u64 {
233 self.rows_yielded
234 }
235
236 pub fn read_chunk(&mut self, n_rows: usize) -> Result<Vec<Vec<String>>> {
241 let mut batch = Vec::with_capacity(n_rows);
242 for _ in 0..n_rows {
243 match self.next_row_inner()? {
244 Some(row) => batch.push(row),
245 None => break,
246 }
247 }
248 Ok(batch)
249 }
250
251 fn next_row_inner(&mut self) -> Result<Option<Vec<String>>> {
253 if self.finished {
254 return Ok(None);
255 }
256 let mut line = String::new();
257 loop {
258 line.clear();
259 let n = self.inner.read_line(&mut line).map_err(|e| {
260 IoError::FileError(format!("read error at row {}: {e}", self.rows_yielded + 1))
261 })?;
262 if n == 0 {
263 self.finished = true;
264 return Ok(None);
265 }
266 let trimmed = line.trim();
267 if trimmed.is_empty() {
268 continue;
269 }
270 self.rows_yielded += 1;
271 return Ok(Some(parse_csv_row_quoted(trimmed, self.delimiter)));
272 }
273 }
274}
275
276impl Iterator for CsvStreamReader {
277 type Item = Result<Vec<String>>;
278
279 fn next(&mut self) -> Option<Self::Item> {
280 match self.next_row_inner() {
281 Ok(Some(row)) => Some(Ok(row)),
282 Ok(None) => None,
283 Err(e) => Some(Err(e)),
284 }
285 }
286}
287
288pub fn infer_schema<P: AsRef<Path>>(
307 path: P,
308 delimiter: u8,
309 n_rows: usize,
310) -> Result<Vec<ColumnType>> {
311 let path = path.as_ref();
312 let mut reader = CsvStreamReader::new(path, delimiter, true)?;
313
314 let sample = reader.read_chunk(n_rows)?;
316
317 if sample.is_empty() {
318 return Ok(Vec::new());
319 }
320
321 let n_cols = sample.iter().map(|r| r.len()).max().unwrap_or(0);
322 if n_cols == 0 {
323 return Ok(Vec::new());
324 }
325
326 #[derive(Clone)]
329 struct ColFlags {
330 can_int: bool,
331 can_float: bool,
332 can_bool: bool,
333 seen_non_null: bool,
334 }
335
336 let mut flags = vec![
337 ColFlags {
338 can_int: true,
339 can_float: true,
340 can_bool: true,
341 seen_non_null: false,
342 };
343 n_cols
344 ];
345
346 let null_sentinels: &[&str] = &["", "null", "na", "n/a", "nan"];
347
348 for row in &sample {
349 for (col_idx, cell) in row.iter().enumerate() {
350 if col_idx >= n_cols {
351 break;
352 }
353 let trimmed = cell.trim();
354 let is_null = null_sentinels
355 .iter()
356 .any(|s| trimmed.eq_ignore_ascii_case(s));
357 if is_null {
358 continue;
359 }
360
361 let f = &mut flags[col_idx];
362 f.seen_non_null = true;
363
364 if f.can_int && trimmed.parse::<i64>().is_err() {
366 f.can_int = false;
367 }
368 if f.can_float && trimmed.parse::<f64>().is_err() {
370 f.can_float = false;
371 }
372 if f.can_bool {
374 let lower = trimmed.to_lowercase();
375 match lower.as_str() {
376 "true" | "false" | "yes" | "no" | "1" | "0" => {}
377 _ => f.can_bool = false,
378 }
379 }
380 }
381 }
382
383 let schema = flags
384 .into_iter()
385 .map(|f| {
386 if !f.seen_non_null {
387 return ColumnType::Text;
388 }
389 if f.can_int {
390 ColumnType::Integer
391 } else if f.can_float {
392 ColumnType::Float
393 } else if f.can_bool {
394 ColumnType::Boolean
395 } else {
396 ColumnType::Text
397 }
398 })
399 .collect();
400
401 Ok(schema)
402}
403
404pub fn read_typed_row(row: &[String], schema: &[ColumnType]) -> Result<TypedRow> {
416 let len = schema.len().max(row.len());
417 let mut typed = Vec::with_capacity(len);
418 for col_idx in 0..len {
419 let cell = row.get(col_idx).map(String::as_str).unwrap_or("");
420 let col_type = schema.get(col_idx).unwrap_or(&ColumnType::Text);
421 typed.push(coerce_cell(cell, col_type));
422 }
423 Ok(typed)
424}
425
426#[cfg(test)]
429mod tests {
430 use super::*;
431 use std::io::Write;
432
433 fn write_temp_csv(name: &str, content: &str) -> std::path::PathBuf {
434 let dir = std::env::temp_dir().join("scirs2_streaming_csv_tests");
435 std::fs::create_dir_all(&dir).expect("mkdir");
436 let path = dir.join(name);
437 let mut f = File::create(&path).expect("create");
438 f.write_all(content.as_bytes()).expect("write");
439 path
440 }
441
442 #[test]
445 fn test_iterator_with_header() {
446 let path = write_temp_csv(
447 "iter_header.csv",
448 "name,age,score\nAlice,30,9.5\nBob,25,8.1\n",
449 );
450 let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
451 assert_eq!(
452 r.headers(),
453 Some(vec!["name".to_string(), "age".to_string(), "score".to_string()].as_slice())
454 );
455
456 let rows: Vec<_> = r.by_ref().map(|x| x.expect("row ok")).collect();
457 assert_eq!(rows.len(), 2);
458 assert_eq!(rows[0], vec!["Alice", "30", "9.5"]);
459 assert_eq!(rows[1], vec!["Bob", "25", "8.1"]);
460 }
461
462 #[test]
463 fn test_iterator_no_header() {
464 let path = write_temp_csv("iter_no_header.csv", "1,2,3\n4,5,6\n");
465 let mut r = CsvStreamReader::new(&path, b',', false).expect("open");
466 assert!(r.headers().is_none());
467 let rows: Vec<_> = r.by_ref().map(|x| x.expect("ok")).collect();
468 assert_eq!(rows.len(), 2);
469 assert_eq!(rows[0], vec!["1", "2", "3"]);
470 }
471
472 #[test]
475 fn test_read_chunk_basic() {
476 let content = "a,b\n1,2\n3,4\n5,6\n7,8\n9,10\n";
477 let path = write_temp_csv("chunk_basic.csv", content);
478 let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
479
480 let chunk1 = r.read_chunk(2).expect("chunk1");
481 assert_eq!(chunk1.len(), 2);
482 assert_eq!(chunk1[0], vec!["1", "2"]);
483
484 let chunk2 = r.read_chunk(2).expect("chunk2");
485 assert_eq!(chunk2.len(), 2);
486 assert_eq!(chunk2[0], vec!["5", "6"]);
487
488 let chunk3 = r.read_chunk(10).expect("chunk3"); assert_eq!(chunk3.len(), 1);
490 assert_eq!(chunk3[0], vec!["9", "10"]);
491
492 let empty = r.read_chunk(5).expect("empty");
493 assert!(empty.is_empty());
494 }
495
496 #[test]
497 fn test_read_chunk_larger_than_file() {
498 let path = write_temp_csv("chunk_large.csv", "x\n1\n2\n");
499 let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
500 let all = r.read_chunk(9999).expect("all");
501 assert_eq!(all.len(), 2);
502 }
503
504 #[test]
507 fn test_rows_yielded_tracks_count() {
508 let path = write_temp_csv("rows_yielded.csv", "h\n1\n2\n3\n");
509 let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
510 assert_eq!(r.rows_yielded(), 0);
511 r.next().expect("some").expect("ok");
512 assert_eq!(r.rows_yielded(), 1);
513 r.read_chunk(5).expect("rest");
514 assert_eq!(r.rows_yielded(), 3);
515 }
516
517 #[test]
520 fn test_quoted_fields_with_commas() {
521 let content = "name,address\nAlice,\"New York, NY\"\nBob,\"Los Angeles, CA\"\n";
522 let path = write_temp_csv("quoted.csv", content);
523 let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
524 let row1 = r.next().expect("some").expect("ok");
525 assert_eq!(row1[1], "New York, NY");
526 }
527
528 #[test]
529 fn test_escaped_double_quote_inside_field() {
530 let content = "id,note\n1,\"He said \"\"hello\"\"\"\n";
531 let path = write_temp_csv("escaped_quote.csv", content);
532 let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
533 let row = r.next().expect("some").expect("ok");
534 assert_eq!(row[1], "He said \"hello\"");
535 }
536
537 #[test]
540 fn test_tab_delimiter() {
541 let path = write_temp_csv("tab.csv", "a\tb\tc\n10\t20\t30\n");
542 let mut r = CsvStreamReader::new(&path, b'\t', true).expect("open");
543 assert_eq!(
544 r.headers(),
545 Some(vec!["a".to_string(), "b".to_string(), "c".to_string()].as_slice())
546 );
547 let row = r.next().expect("some").expect("ok");
548 assert_eq!(row, vec!["10", "20", "30"]);
549 }
550
551 #[test]
554 fn test_blank_lines_skipped() {
555 let path = write_temp_csv("blanks.csv", "x\n1\n\n\n2\n");
556 let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
557 let rows: Vec<_> = r.by_ref().map(|x| x.expect("ok")).collect();
558 assert_eq!(rows.len(), 2);
559 }
560
561 #[test]
564 fn test_infer_schema_mixed_types() {
565 let content = "id,value,active,label\n1,3.14,true,hello\n2,2.71,false,world\n";
566 let path = write_temp_csv("schema_mixed.csv", content);
567 let schema = infer_schema(&path, b',', 50).expect("infer");
568 assert_eq!(schema.len(), 4);
569 assert_eq!(schema[0], ColumnType::Integer);
570 assert_eq!(schema[1], ColumnType::Float);
571 assert_eq!(schema[2], ColumnType::Boolean);
572 assert_eq!(schema[3], ColumnType::Text);
573 }
574
575 #[test]
576 fn test_infer_schema_all_integer() {
577 let path = write_temp_csv("schema_int.csv", "n\n1\n2\n3\n");
578 let schema = infer_schema(&path, b',', 10).expect("infer");
579 assert_eq!(schema[0], ColumnType::Integer);
580 }
581
582 #[test]
583 fn test_infer_schema_float_beats_integer_when_mixed() {
584 let path = write_temp_csv("schema_float.csv", "n\n1\n2.5\n3\n");
585 let schema = infer_schema(&path, b',', 10).expect("infer");
586 assert_eq!(schema[0], ColumnType::Float);
587 }
588
589 #[test]
590 fn test_infer_schema_with_nulls() {
591 let path = write_temp_csv("schema_null.csv", "a,b\n1,\n2,NA\n");
593 let schema = infer_schema(&path, b',', 10).expect("infer");
594 assert_eq!(schema[0], ColumnType::Integer);
595 assert_eq!(schema[1], ColumnType::Text);
596 }
597
598 #[test]
601 fn test_read_typed_row_all_types() {
602 let schema = vec![
603 ColumnType::Integer,
604 ColumnType::Float,
605 ColumnType::Boolean,
606 ColumnType::Text,
607 ];
608 let raw: Vec<String> = vec!["42", "2.5", "true", "hello"]
609 .into_iter()
610 .map(String::from)
611 .collect();
612 let typed = read_typed_row(&raw, &schema).expect("parse");
613 assert!(matches!(typed[0], TypedValue::Integer(42)));
614 assert!(matches!(typed[1], TypedValue::Float(f) if (f - 2.5).abs() < 1e-10));
615 assert!(matches!(typed[2], TypedValue::Boolean(true)));
616 assert!(matches!(typed[3], TypedValue::Text(ref s) if s == "hello"));
617 }
618
619 #[test]
620 fn test_read_typed_row_null_cells() {
621 let schema = vec![ColumnType::Integer, ColumnType::Float];
622 let raw: Vec<String> = vec!["", "NA"].into_iter().map(String::from).collect();
623 let typed = read_typed_row(&raw, &schema).expect("parse");
624 assert!(matches!(typed[0], TypedValue::Null));
625 assert!(matches!(typed[1], TypedValue::Null));
626 }
627
628 #[test]
629 fn test_read_typed_row_short_row_padded_with_null() {
630 let schema = vec![ColumnType::Integer, ColumnType::Float, ColumnType::Boolean];
631 let raw: Vec<String> = vec!["1"].into_iter().map(String::from).collect();
632 let typed = read_typed_row(&raw, &schema).expect("parse");
633 assert_eq!(typed.len(), 3);
634 assert!(matches!(typed[0], TypedValue::Integer(1)));
635 assert!(matches!(typed[1], TypedValue::Null));
636 assert!(matches!(typed[2], TypedValue::Null));
637 }
638
639 #[test]
640 fn test_read_typed_row_extra_columns_text() {
641 let schema = vec![ColumnType::Integer];
642 let raw: Vec<String> = vec!["1", "extra"].into_iter().map(String::from).collect();
643 let typed = read_typed_row(&raw, &schema).expect("parse");
644 assert_eq!(typed.len(), 2);
645 assert!(matches!(typed[1], TypedValue::Text(_)));
646 }
647
648 #[test]
651 fn test_large_file_lazy_iteration() {
652 let n = 10_000_usize;
654 let mut content = String::from("i,v\n");
655 for i in 0..n {
656 content.push_str(&format!("{},{}\n", i, i as f64 * 1.1));
657 }
658 let path = write_temp_csv("large.csv", &content);
659 let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
660 let mut count = 0usize;
661 for item in &mut r {
662 let row = item.expect("row ok");
663 let _ = row[0].parse::<usize>().expect("int");
664 count += 1;
665 }
666 assert_eq!(count, n);
667 }
668}