1use chrono::{DateTime, Utc};
2use std::path::PathBuf;
3
4use crate::config::{NegativeStrategy, SamplerConfig, Selector, TripletRecipe};
5use crate::data::{DataRecord, QualityScore, SectionRole};
6use crate::errors::SamplerError;
7use crate::source::{DataSource, IndexablePager, IndexableSource, SourceCursor, SourceSnapshot};
8use crate::types::SourceId;
9use crate::utils::{file_times, make_section, normalize_inline_whitespace};
10
11const CSV_RECIPE_ANCHOR_POSITIVE_WRONG_ARTICLE: &str = "csv_anchor_positive_wrong_article";
12const CSV_RECIPE_ANCHOR_ANCHOR_WRONG_ARTICLE: &str = "csv_anchor_anchor_wrong_article";
13pub const CSV_RECIPE_TEXT_SIMCSE_WRONG_ARTICLE: &str = "csv_text_simcse_wrong_article";
15
16#[derive(Clone, Debug)]
33pub struct CsvSourceConfig {
34 pub source_id: SourceId,
36 pub path: PathBuf,
38 pub anchor_column: Option<String>,
42 pub positive_column: Option<String>,
47 pub text_column: Option<String>,
51 pub trust: f32,
53}
54
55impl CsvSourceConfig {
56 pub fn new(source_id: impl Into<SourceId>, path: impl Into<PathBuf>) -> Self {
58 Self {
59 source_id: source_id.into(),
60 path: path.into(),
61 anchor_column: None,
62 positive_column: None,
63 text_column: None,
64 trust: 0.85,
65 }
66 }
67
68 pub fn with_anchor_column(mut self, column: impl Into<String>) -> Self {
70 self.anchor_column = Some(column.into());
71 self
72 }
73
74 pub fn with_positive_column(mut self, column: impl Into<String>) -> Self {
76 self.positive_column = Some(column.into());
77 self
78 }
79
80 pub fn with_text_column(mut self, column: impl Into<String>) -> Self {
82 self.text_column = Some(column.into());
83 self
84 }
85
86 pub fn with_trust(mut self, trust: f32) -> Self {
88 self.trust = trust;
89 self
90 }
91
92 fn is_role_mode(&self) -> bool {
93 self.anchor_column.is_some()
94 }
95
96 fn validate(&self) -> Result<(), SamplerError> {
97 if self.anchor_column.is_some() && self.text_column.is_some() {
98 return Err(SamplerError::Configuration(
99 "CsvSourceConfig: `anchor_column` and `text_column` are mutually exclusive"
100 .to_string(),
101 ));
102 }
103 if self.anchor_column.is_none() && self.text_column.is_none() {
104 return Err(SamplerError::Configuration(
105 "CsvSourceConfig: one of `anchor_column` or `text_column` must be set".to_string(),
106 ));
107 }
108 if self.positive_column.is_some() && self.anchor_column.is_none() {
109 return Err(SamplerError::Configuration(
110 "CsvSourceConfig: `positive_column` requires `anchor_column` to be set".to_string(),
111 ));
112 }
113 Ok(())
114 }
115}
116
117#[derive(Debug)]
144pub struct CsvSource {
145 config: CsvSourceConfig,
146 records: Vec<DataRecord>,
147}
148
149impl CsvSource {
150 pub fn new(config: CsvSourceConfig) -> Result<Self, SamplerError> {
156 config.validate()?;
157 let records = Self::load_records(&config)?;
158 Ok(Self { config, records })
159 }
160
161 fn load_records(config: &CsvSourceConfig) -> Result<Vec<DataRecord>, SamplerError> {
162 let (created_at, updated_at) = file_times(&config.path);
163
164 let mut reader = csv::ReaderBuilder::new()
165 .has_headers(true)
166 .flexible(false)
167 .trim(csv::Trim::All)
168 .from_path(&config.path)
169 .map_err(|err| SamplerError::SourceUnavailable {
170 source_id: config.source_id.clone(),
171 reason: format!("failed to open CSV file '{}': {err}", config.path.display()),
172 })?;
173
174 let headers = reader
176 .headers()
177 .map_err(|err| SamplerError::SourceUnavailable {
178 source_id: config.source_id.clone(),
179 reason: format!(
180 "failed to read CSV headers in '{}': {err}",
181 config.path.display()
182 ),
183 })?
184 .clone();
185
186 let anchor_idx = if let Some(col) = &config.anchor_column {
189 Some(column_index(&headers, col).ok_or_else(|| {
190 SamplerError::Configuration(format!(
191 "anchor_column '{}' not found in CSV headers of '{}'",
192 col,
193 config.path.display()
194 ))
195 })?)
196 } else {
197 None
198 };
199
200 let positive_idx = if let Some(col) = &config.positive_column {
201 Some(column_index(&headers, col).ok_or_else(|| {
202 SamplerError::Configuration(format!(
203 "positive_column '{}' not found in CSV headers of '{}'",
204 col,
205 config.path.display()
206 ))
207 })?)
208 } else {
209 None
210 };
211
212 let text_idx = if let Some(col) = &config.text_column {
213 Some(column_index(&headers, col).ok_or_else(|| {
214 SamplerError::Configuration(format!(
215 "text_column '{}' not found in CSV headers of '{}'",
216 col,
217 config.path.display()
218 ))
219 })?)
220 } else {
221 None
222 };
223
224 let mut records = Vec::new();
225
226 let cols = ColumnIndices {
227 anchor: anchor_idx,
228 positive: positive_idx,
229 text: text_idx,
230 };
231
232 for (row_idx, result) in reader.records().enumerate() {
233 let row = result.map_err(|err| SamplerError::SourceUnavailable {
234 source_id: config.source_id.clone(),
235 reason: format!(
236 "failed to read row {} in '{}': {err}",
237 row_idx,
238 config.path.display()
239 ),
240 })?;
241
242 if let Some(record) = build_record(config, &row, row_idx, &cols, created_at, updated_at)
243 {
244 records.push(record);
245 }
246 }
247
248 Ok(records)
249 }
250}
251
252fn column_index(headers: &csv::StringRecord, name: &str) -> Option<usize> {
254 headers.iter().position(|h| h.eq_ignore_ascii_case(name))
255}
256
257struct ColumnIndices {
259 anchor: Option<usize>,
260 positive: Option<usize>,
261 text: Option<usize>,
262}
263
264fn build_record(
268 config: &CsvSourceConfig,
269 row: &csv::StringRecord,
270 row_idx: usize,
271 cols: &ColumnIndices,
272 created_at: DateTime<Utc>,
273 updated_at: DateTime<Utc>,
274) -> Option<DataRecord> {
275 let id = format!("{}::row_{}", config.source_id, row_idx);
276
277 let sections = if config.is_role_mode() {
278 let anchor_raw = cols.anchor.and_then(|i| row.get(i)).unwrap_or("");
280 let anchor_text = normalize_inline_whitespace(anchor_raw);
281 if anchor_text.is_empty() {
282 return None;
283 }
284
285 let positive_text = if let Some(pidx) = cols.positive {
286 let raw = row.get(pidx).unwrap_or("");
287 let normalized = normalize_inline_whitespace(raw);
288 if normalized.is_empty() {
289 return None;
290 }
291 normalized
292 } else {
293 anchor_text.clone()
295 };
296
297 let anchor_heading = config.anchor_column.as_deref();
298 let positive_heading = config
299 .positive_column
300 .as_deref()
301 .or(config.anchor_column.as_deref());
302
303 vec![
304 make_section(SectionRole::Anchor, anchor_heading, &anchor_text),
305 make_section(SectionRole::Context, positive_heading, &positive_text),
306 ]
307 } else {
308 let raw = cols.text.and_then(|i| row.get(i)).unwrap_or("");
310 let text = normalize_inline_whitespace(raw);
311 if text.is_empty() {
312 return None;
313 }
314
315 let heading = config.text_column.as_deref();
316 vec![
317 make_section(SectionRole::Anchor, heading, &text),
318 make_section(SectionRole::Context, heading, &text),
319 ]
320 };
321
322 Some(DataRecord {
323 id,
324 source: config.source_id.clone(),
325 created_at,
326 updated_at,
327 quality: QualityScore {
328 trust: config.trust,
329 },
330 taxonomy: vec![config.source_id.clone()],
331 sections,
332 meta_prefix: None,
333 label: None,
334 })
335}
336
337impl IndexableSource for CsvSource {
338 fn id(&self) -> &str {
339 &self.config.source_id
340 }
341
342 fn len_hint(&self) -> Option<usize> {
343 Some(self.records.len())
344 }
345
346 fn record_at(&self, idx: usize) -> Result<Option<DataRecord>, SamplerError> {
347 Ok(self.records.get(idx).cloned())
348 }
349}
350
351impl DataSource for CsvSource {
352 fn id(&self) -> &str {
353 &self.config.source_id
354 }
355
356 fn refresh(
357 &self,
358 _config: &SamplerConfig,
359 cursor: Option<&SourceCursor>,
360 limit: Option<usize>,
361 ) -> Result<SourceSnapshot, SamplerError> {
362 IndexablePager::new(&self.config.source_id).refresh(self, cursor, limit)
363 }
364
365 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
366 Ok(self.records.len() as u128)
367 }
368
369 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
370 if !self.config.is_role_mode() {
371 return vec![TripletRecipe {
375 name: CSV_RECIPE_TEXT_SIMCSE_WRONG_ARTICLE.into(),
376 anchor: Selector::Role(SectionRole::Anchor),
377 positive_selector: Selector::Role(SectionRole::Context),
378 negative_selector: Selector::Role(SectionRole::Context),
379 negative_strategy: NegativeStrategy::WrongArticle,
380 weight: 1.0,
381 instruction: None,
382 allow_same_anchor_positive: true,
383 }];
384 }
385
386 vec![
387 TripletRecipe {
389 name: CSV_RECIPE_ANCHOR_POSITIVE_WRONG_ARTICLE.into(),
390 anchor: Selector::Role(SectionRole::Anchor),
391 positive_selector: Selector::Role(SectionRole::Context),
392 negative_selector: Selector::Role(SectionRole::Context),
393 negative_strategy: NegativeStrategy::WrongArticle,
394 weight: 0.75,
395 instruction: None,
396 allow_same_anchor_positive: false,
397 },
398 TripletRecipe {
400 name: CSV_RECIPE_ANCHOR_ANCHOR_WRONG_ARTICLE.into(),
401 anchor: Selector::Role(SectionRole::Anchor),
402 positive_selector: Selector::Role(SectionRole::Context),
403 negative_selector: Selector::Role(SectionRole::Anchor),
404 negative_strategy: NegativeStrategy::WrongArticle,
405 weight: 0.25,
406 instruction: None,
407 allow_same_anchor_positive: false,
408 },
409 ]
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::config::SamplerConfig;
417 use crate::source::DataSource;
418 use std::io::Write;
419 use tempfile::NamedTempFile;
420
421 fn write_csv(content: &str) -> NamedTempFile {
422 let mut f = NamedTempFile::new().unwrap();
423 write!(f, "{content}").unwrap();
424 f
425 }
426
427 fn sampler_config() -> SamplerConfig {
428 SamplerConfig {
429 seed: 42,
430 ..SamplerConfig::default()
431 }
432 }
433
434 #[test]
437 fn rejects_anchor_and_text_columns_together() {
438 let f = write_csv("anchor,text\nhello,world\n");
439 let err = CsvSource::new(
440 CsvSourceConfig::new("src", f.path())
441 .with_anchor_column("anchor")
442 .with_text_column("text"),
443 )
444 .unwrap_err();
445 assert!(
446 matches!(err, SamplerError::Configuration(_)),
447 "expected Configuration error, got {err:?}"
448 );
449 }
450
451 #[test]
452 fn rejects_missing_column_spec() {
453 let f = write_csv("anchor,text\nhello,world\n");
454 let err = CsvSource::new(CsvSourceConfig::new("src", f.path())).unwrap_err();
455 assert!(matches!(err, SamplerError::Configuration(_)));
456 }
457
458 #[test]
459 fn rejects_positive_without_anchor() {
460 let f = write_csv("anchor,text\nhello,world\n");
461 let err =
462 CsvSource::new(CsvSourceConfig::new("src", f.path()).with_positive_column("text"))
463 .unwrap_err();
464 assert!(matches!(err, SamplerError::Configuration(_)));
465 }
466
467 #[test]
468 fn rejects_missing_anchor_column_in_file() {
469 let f = write_csv("question,answer\nhello,world\n");
470 let err =
471 CsvSource::new(CsvSourceConfig::new("src", f.path()).with_anchor_column("missing_col"))
472 .unwrap_err();
473 assert!(matches!(err, SamplerError::Configuration(_)));
474 }
475
476 #[test]
477 fn rejects_missing_text_column_in_file() {
478 let f = write_csv("question,answer\nhello,world\n");
479 let err =
480 CsvSource::new(CsvSourceConfig::new("src", f.path()).with_text_column("missing_col"))
481 .unwrap_err();
482 assert!(matches!(err, SamplerError::Configuration(_)));
483 }
484
485 #[test]
486 fn rejects_missing_positive_column_in_file() {
487 let f = write_csv("question,answer\nhello,world\n");
488 let err = CsvSource::new(
489 CsvSourceConfig::new("src", f.path())
490 .with_anchor_column("question")
491 .with_positive_column("missing_col"),
492 )
493 .unwrap_err();
494 assert!(matches!(err, SamplerError::Configuration(_)));
495 }
496
497 #[test]
500 fn role_mode_anchor_and_positive() {
501 let f = write_csv("question,answer\nWhat is Rust?,A systems language.\n");
502 let source = CsvSource::new(
503 CsvSourceConfig::new("qna", f.path())
504 .with_anchor_column("question")
505 .with_positive_column("answer"),
506 )
507 .unwrap();
508
509 let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
510 assert_eq!(snapshot.records.len(), 1);
511 let record = &snapshot.records[0];
512 assert_eq!(record.source, "qna");
513 assert_eq!(record.sections.len(), 2);
514 assert_eq!(record.sections[0].role, SectionRole::Anchor);
515 assert_eq!(record.sections[0].text, "What is Rust?");
516 assert_eq!(record.sections[1].role, SectionRole::Context);
517 assert_eq!(record.sections[1].text, "A systems language.");
518 }
519
520 #[test]
521 fn role_mode_anchor_only_duplicates_to_context() {
522 let f = write_csv("sentence\nHello world\n");
523 let source = CsvSource::new(
524 CsvSourceConfig::new("anchors", f.path()).with_anchor_column("sentence"),
525 )
526 .unwrap();
527
528 let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
529 assert_eq!(snapshot.records.len(), 1);
530 let record = &snapshot.records[0];
531 assert_eq!(record.sections.len(), 2);
532 assert_eq!(record.sections[0].role, SectionRole::Anchor);
533 assert_eq!(record.sections[1].role, SectionRole::Context);
534 assert_eq!(record.sections[0].text, record.sections[1].text);
536 }
537
538 #[test]
539 fn role_mode_skips_rows_with_empty_anchor() {
540 let f = write_csv(
541 "question,answer\n\
542 What is Rust?,A systems language.\n\
543 ,Missing anchor\n\
544 What is Go?,A concurrent language.\n",
545 );
546 let source = CsvSource::new(
547 CsvSourceConfig::new("qna", f.path())
548 .with_anchor_column("question")
549 .with_positive_column("answer"),
550 )
551 .unwrap();
552 let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
553 assert_eq!(snapshot.records.len(), 2);
554 }
555
556 #[test]
557 fn role_mode_skips_rows_with_empty_positive() {
558 let f = write_csv(
559 "question,answer\n\
560 What is Rust?,A systems language.\n\
561 What is Go?,\n",
562 );
563 let source = CsvSource::new(
564 CsvSourceConfig::new("qna", f.path())
565 .with_anchor_column("question")
566 .with_positive_column("answer"),
567 )
568 .unwrap();
569 let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
570 assert_eq!(snapshot.records.len(), 1);
571 }
572
573 #[test]
576 fn text_mode_produces_identical_anchor_and_context() {
577 let f = write_csv("text\nThe quick brown fox\n");
578 let source =
579 CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
580 .unwrap();
581
582 let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
583 assert_eq!(snapshot.records.len(), 1);
584 let record = &snapshot.records[0];
585 assert_eq!(record.sections.len(), 2);
586 assert_eq!(record.sections[0].role, SectionRole::Anchor);
587 assert_eq!(record.sections[1].role, SectionRole::Context);
588 assert_eq!(record.sections[0].text, record.sections[1].text);
589 }
590
591 #[test]
592 fn text_mode_skips_empty_rows() {
593 let f = write_csv("text\nHello\n\nWorld\n");
594 let source =
595 CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
596 .unwrap();
597 let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
598 assert_eq!(snapshot.records.len(), 2);
599 }
600
601 #[test]
604 fn applies_trust_score() {
605 let f = write_csv("text\nHello world\n");
606 let source = CsvSource::new(
607 CsvSourceConfig::new("corpus", f.path())
608 .with_text_column("text")
609 .with_trust(0.7),
610 )
611 .unwrap();
612 let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
613 assert_eq!(snapshot.records[0].quality.trust, 0.7);
614 }
615
616 #[test]
619 fn text_mode_default_recipes_is_simcse() {
620 let f = write_csv("text\nHello\n");
621 let source =
622 CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
623 .unwrap();
624 let recipes = source.default_triplet_recipes();
625 assert_eq!(recipes.len(), 1);
626 assert_eq!(recipes[0].name, CSV_RECIPE_TEXT_SIMCSE_WRONG_ARTICLE);
627 assert!(
628 recipes[0].allow_same_anchor_positive,
629 "SimCSE recipe must allow same anchor/positive"
630 );
631 }
632
633 #[test]
634 fn role_mode_default_recipes_returns_two_recipes() {
635 let f = write_csv("question,answer\nQ,A\n");
636 let source = CsvSource::new(
637 CsvSourceConfig::new("qna", f.path())
638 .with_anchor_column("question")
639 .with_positive_column("answer"),
640 )
641 .unwrap();
642 let recipes = source.default_triplet_recipes();
643 assert_eq!(recipes.len(), 2);
644 let names: Vec<&str> = recipes.iter().map(|r| r.name.as_ref()).collect();
645 assert!(names.contains(&CSV_RECIPE_ANCHOR_POSITIVE_WRONG_ARTICLE));
646 assert!(names.contains(&CSV_RECIPE_ANCHOR_ANCHOR_WRONG_ARTICLE));
647 assert!(
648 recipes.iter().all(|r| !r.allow_same_anchor_positive),
649 "role-mode recipes must not allow same anchor/positive"
650 );
651 }
652
653 #[test]
656 fn len_hint_matches_loaded_record_count() {
657 let f = write_csv("text\nAlpha\nBeta\nGamma\n");
658 let source =
659 CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
660 .unwrap();
661 assert_eq!(source.len_hint(), Some(3));
662 }
663
664 #[test]
665 fn record_at_returns_correct_record() {
666 let f = write_csv("question,answer\nFirst?,Yes.\nSecond?,No.\n");
667 let source = CsvSource::new(
668 CsvSourceConfig::new("qna", f.path())
669 .with_anchor_column("question")
670 .with_positive_column("answer"),
671 )
672 .unwrap();
673 let r0 = source.record_at(0).unwrap().unwrap();
674 let r1 = source.record_at(1).unwrap().unwrap();
675 assert_eq!(r0.sections[0].text, "First?");
676 assert_eq!(r1.sections[0].text, "Second?");
677 assert!(source.record_at(99).unwrap().is_none());
678 }
679
680 #[test]
683 fn reported_record_count_matches_loaded_records() {
684 let f = write_csv("text\nAlpha\nBeta\n");
685 let source =
686 CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
687 .unwrap();
688 let count = source.reported_record_count(&sampler_config()).unwrap();
689 assert_eq!(count, 2);
690 }
691
692 #[test]
695 fn record_ids_are_stable_across_refreshes() {
696 let f = write_csv("text\nAlpha\nBeta\n");
697 let source =
698 CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
699 .unwrap();
700 let ids_a: Vec<_> = source
701 .refresh(&sampler_config(), None, None)
702 .unwrap()
703 .records
704 .iter()
705 .map(|r| r.id.clone())
706 .collect();
707 let ids_b: Vec<_> = source
708 .refresh(&sampler_config(), None, None)
709 .unwrap()
710 .records
711 .iter()
712 .map(|r| r.id.clone())
713 .collect();
714 let mut sorted_a = ids_a.clone();
716 let mut sorted_b = ids_b.clone();
717 sorted_a.sort();
718 sorted_b.sort();
719 assert_eq!(sorted_a, sorted_b);
720 }
721
722 #[test]
725 fn source_id_is_propagated_to_records() {
726 let f = write_csv("text\nHello\n");
727 let source =
728 CsvSource::new(CsvSourceConfig::new("my_source", f.path()).with_text_column("text"))
729 .unwrap();
730 assert_eq!(DataSource::id(&source), "my_source");
731 let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
732 assert_eq!(snapshot.records[0].source, "my_source");
733 }
734
735 #[test]
738 fn column_lookup_is_case_insensitive() {
739 let f = write_csv("Question,Answer\nWhat is Rust?,A systems language.\n");
740 let source = CsvSource::new(
742 CsvSourceConfig::new("qna", f.path())
743 .with_anchor_column("question")
744 .with_positive_column("answer"),
745 )
746 .unwrap();
747 let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
748 assert_eq!(snapshot.records.len(), 1);
749 assert_eq!(snapshot.records[0].sections[0].text, "What is Rust?");
750 }
751
752 #[test]
755 fn refresh_with_limit_returns_at_most_limit_records() {
756 let f = write_csv("text\nA\nB\nC\nD\nE\n");
757 let source =
758 CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
759 .unwrap();
760 let snapshot = source.refresh(&sampler_config(), None, Some(3)).unwrap();
761 assert!(
762 snapshot.records.len() <= 3,
763 "expected at most 3 records, got {}",
764 snapshot.records.len()
765 );
766 }
767
768 #[test]
771 fn rejects_positive_and_text_column_without_anchor() {
772 let f = write_csv("text,answer\nhello,world\n");
775 let err = CsvSource::new(
776 CsvSourceConfig::new("src", f.path())
777 .with_text_column("text")
778 .with_positive_column("answer"),
779 )
780 .unwrap_err();
781 assert!(
782 matches!(err, SamplerError::Configuration(_)),
783 "expected Configuration error, got {err:?}"
784 );
785 }
786
787 #[test]
790 fn returns_source_unavailable_for_nonexistent_file() {
791 let err = CsvSource::new(
793 CsvSourceConfig::new("src", "/nonexistent/does-not-exist.csv").with_text_column("text"),
794 )
795 .unwrap_err();
796 assert!(
797 matches!(err, SamplerError::SourceUnavailable { .. }),
798 "expected SourceUnavailable, got {err:?}"
799 );
800 }
801
802 #[test]
805 fn returns_source_unavailable_for_malformed_row() {
806 let f = write_csv("question,answer\nWhat is Rust?,Good language.,extra_column\n");
809 let err = CsvSource::new(
810 CsvSourceConfig::new("src", f.path())
811 .with_anchor_column("question")
812 .with_positive_column("answer"),
813 )
814 .unwrap_err();
815 assert!(
816 matches!(err, SamplerError::SourceUnavailable { .. }),
817 "expected SourceUnavailable for malformed row, got {err:?}"
818 );
819 }
820
821 #[test]
824 fn text_mode_skips_whitespace_only_cells() {
825 let f = write_csv("text\nHello\n \nWorld\n");
831 let source =
832 CsvSource::new(CsvSourceConfig::new("corpus", f.path()).with_text_column("text"))
833 .unwrap();
834 let snapshot = source.refresh(&sampler_config(), None, None).unwrap();
835 assert_eq!(
836 snapshot.records.len(),
837 2,
838 "whitespace-only cell should be skipped"
839 );
840 }
841
842 #[test]
845 fn indexable_source_id_matches_config() {
846 let f = write_csv("text\nHello\n");
849 let source =
850 CsvSource::new(CsvSourceConfig::new("explicit_id", f.path()).with_text_column("text"))
851 .unwrap();
852 assert_eq!(IndexableSource::id(&source), "explicit_id");
853 }
854}