1use crate::core::frontmatter::{resolve_scan_path, SourceFormat, StagingFrontmatter};
6use anyhow::{anyhow, bail, Context, Result};
7use arrow::array::{ArrayRef, Int64Builder, StringBuilder};
8use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
9use arrow::record_batch::RecordBatch;
10use std::fs::File;
11use std::io::{BufRead, BufReader};
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15#[derive(Debug, Clone)]
17pub struct ScanRequest {
18 pub project_dir: PathBuf,
19 pub scan_path: String,
20 pub format: SourceFormat,
21 pub paths: Vec<String>,
23 pub toml_rows_key: Option<String>,
25 pub partition_by: Vec<String>,
27 pub require_partitions: std::collections::HashMap<String, String>,
29 pub inject_source_path: bool,
31}
32
33impl ScanRequest {
34 pub fn from_frontmatter(
35 project_dir: impl AsRef<Path>,
36 fm: &StagingFrontmatter,
37 ) -> Result<Self> {
38 let scan_path = fm
39 .scan_path
40 .as_ref()
41 .context("frontmatter.scan_path is required for bronze scan")?
42 .clone();
43 let format = fm.resolve_format()?;
44 Ok(Self {
45 project_dir: project_dir.as_ref().to_path_buf(),
46 scan_path,
47 format,
48 paths: fm.paths.clone().unwrap_or_default(),
49 toml_rows_key: fm.toml_rows_key.clone(),
50 partition_by: fm.partition_by.clone().unwrap_or_default(),
51 require_partitions: fm.require_partitions.clone().unwrap_or_default(),
52 inject_source_path: fm.inject_source_path.unwrap_or(false),
53 })
54 }
55
56 pub fn resolved_path(&self) -> PathBuf {
57 resolve_scan_path(&self.project_dir, &self.scan_path)
58 }
59}
60
61pub struct LakeScanner {
63 pub paths: Vec<String>,
64}
65
66impl LakeScanner {
67 pub fn new(paths: Vec<String>) -> Self {
68 Self { paths }
69 }
70
71 pub fn from_request(req: &ScanRequest) -> Self {
72 Self {
73 paths: req.paths.clone(),
74 }
75 }
76
77 pub async fn scan(&self, req: &ScanRequest) -> Result<Vec<RecordBatch>> {
79 let root = req.resolved_path();
80 if !root.exists() {
81 bail!(
82 "Bronze scan_path does not exist: {} (resolved from '{}')",
83 root.display(),
84 req.scan_path
85 );
86 }
87
88 let mut files = Vec::new();
89 collect_files_for_format(&root, req.format, &mut files)?;
90 if !req.require_partitions.is_empty() {
91 files.retain(|f| path_matches_require_partitions(f, &root, &req.require_partitions));
92 }
93 if files.is_empty() {
94 bail!(
95 "No {} files found under {} (after partition filters)",
96 req.format.as_str(),
97 root.display()
98 );
99 }
100
101 tracing::info!(
102 "Bronze scan: {} file(s) under {} (format={})",
103 files.len(),
104 root.display(),
105 req.format
106 );
107
108 let mut batches = Vec::new();
109 for file_path in files {
110 let file_batches = self
111 .read_file(&file_path, req)
112 .with_context(|| format!("Failed reading bronze file {}", file_path.display()))?;
113 for batch in file_batches {
114 let mut batch = if req.partition_by.is_empty() {
115 batch
116 } else {
117 inject_hive_partitions(batch, &file_path, &root, &req.partition_by)?
118 };
119 if req.inject_source_path {
120 batch = inject_source_path_column(batch, &file_path)?;
121 }
122 batches.push(batch);
123 }
124 }
125 Ok(batches)
126 }
127
128 pub async fn scan_path(
131 &self,
132 path: impl AsRef<Path>,
133 schema: SchemaRef,
134 ) -> Result<Vec<RecordBatch>> {
135 let mut files = Vec::new();
136 collect_files(path.as_ref(), &mut files)?;
137 let mut batches = Vec::new();
138
139 for file_path in files {
140 let ext = file_path
141 .extension()
142 .and_then(|e| e.to_str())
143 .unwrap_or("")
144 .to_ascii_lowercase();
145 match ext.as_str() {
146 "parquet" => batches.extend(read_parquet(&file_path, Some(schema.clone()))?),
147 "json" | "jsonl" | "ndjson" => {
148 let bytes = std::fs::read(&file_path)?;
149 let extractor = crate::json::JShiftExtractor::new(self.paths.clone());
150 batches.push(extractor.extract_jsonl(&bytes, schema.clone())?);
151 }
152 "csv" | "tsv" => batches.extend(read_csv(&file_path, Some(schema.clone()))?),
153 "log" | "txt" | "text" | "md" => {
154 batches.push(read_line_oriented(&file_path)?);
155 }
156 "toml" => batches.push(read_toml(&file_path, None)?),
157 "arrow" | "arrows" | "ipc" | "feather" => {
158 batches.extend(read_arrow_ipc_auto(&file_path)?);
159 }
160 _ => {
161 tracing::debug!("Skipping unsupported file: {:?}", file_path);
162 }
163 }
164 }
165 Ok(batches)
166 }
167
168 fn read_file(&self, file_path: &Path, req: &ScanRequest) -> Result<Vec<RecordBatch>> {
169 match req.format {
170 SourceFormat::Parquet => read_parquet(file_path, None),
171 SourceFormat::Csv => read_csv(file_path, None),
172 SourceFormat::Jsonl | SourceFormat::Json => {
173 if !self.paths.is_empty() {
174 let schema = utf8_schema_from_paths(&self.paths);
175 let bytes = std::fs::read(file_path)?;
176 let extractor = crate::json::JShiftExtractor::new(self.paths.clone());
177 if req.format == SourceFormat::Json {
179 let expanded = expand_json_document_to_jsonl(&bytes)?;
180 Ok(vec![extractor.extract_jsonl(&expanded, schema)?])
181 } else {
182 Ok(vec![extractor.extract_jsonl(&bytes, schema)?])
183 }
184 } else {
185 read_json_arrow(file_path, req.format)
187 }
188 }
189 SourceFormat::ArrowIpc | SourceFormat::ArrowIpcStream => read_arrow_ipc_auto(file_path),
191 SourceFormat::Log | SourceFormat::Txt => Ok(vec![read_line_oriented(file_path)?]),
192 SourceFormat::Toml => Ok(vec![read_toml(file_path, req.toml_rows_key.as_deref())?]),
193 }
194 }
195}
196
197fn utf8_schema_from_paths(paths: &[String]) -> SchemaRef {
198 Arc::new(Schema::new(
199 paths
200 .iter()
201 .map(|p| Field::new(p.as_str(), DataType::Utf8, true))
202 .collect::<Vec<_>>(),
203 ))
204}
205
206fn expand_json_document_to_jsonl(bytes: &[u8]) -> Result<Vec<u8>> {
207 let v: serde_json::Value =
208 serde_json::from_slice(bytes).map_err(|e| anyhow!("Invalid JSON document: {}", e))?;
209 match v {
210 serde_json::Value::Array(items) => {
211 let mut out = Vec::new();
212 for item in items {
213 out.extend(serde_json::to_vec(&item)?);
214 out.push(b'\n');
215 }
216 Ok(out)
217 }
218 other => {
219 let mut out = serde_json::to_vec(&other)?;
220 out.push(b'\n');
221 Ok(out)
222 }
223 }
224}
225
226fn collect_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
227 if path.is_file() {
228 files.push(path.to_path_buf());
229 } else if path.is_dir() {
230 for entry in std::fs::read_dir(path)? {
231 let entry = entry?;
232 collect_files(&entry.path(), files)?;
233 }
234 }
235 Ok(())
236}
237
238fn extension_matches(format: SourceFormat, ext: &str) -> bool {
239 let ext = ext.to_ascii_lowercase();
240 match format {
241 SourceFormat::Jsonl => matches!(ext.as_str(), "jsonl" | "ndjson"),
242 SourceFormat::Json => ext == "json",
243 SourceFormat::Parquet => matches!(ext.as_str(), "parquet" | "pq"),
244 SourceFormat::Csv => matches!(ext.as_str(), "csv" | "tsv"),
245 SourceFormat::ArrowIpc => matches!(ext.as_str(), "arrow" | "arrows" | "ipc" | "feather"),
246 SourceFormat::ArrowIpcStream => {
247 matches!(
248 ext.as_str(),
249 "arrow" | "arrows" | "ipc" | "arrows_stream" | "ipc_stream"
250 )
251 }
252 SourceFormat::Log => ext == "log",
253 SourceFormat::Txt => matches!(ext.as_str(), "txt" | "text" | "md"),
254 SourceFormat::Toml => ext == "toml",
255 }
256}
257
258fn collect_files_for_format(
259 path: &Path,
260 format: SourceFormat,
261 files: &mut Vec<PathBuf>,
262) -> Result<()> {
263 if path.is_file() {
264 files.push(path.to_path_buf());
265 return Ok(());
266 }
267 if path.is_dir() {
268 let mut all = Vec::new();
269 collect_files(path, &mut all)?;
270 for f in all {
271 let ext = f.extension().and_then(|e| e.to_str()).unwrap_or("");
272 if extension_matches(format, ext) {
273 files.push(f);
274 }
275 }
276 return Ok(());
279 }
280 bail!(
281 "scan path is neither file nor directory: {}",
282 path.display()
283 );
284}
285
286fn read_parquet(path: &Path, projection: Option<SchemaRef>) -> Result<Vec<RecordBatch>> {
287 let file = File::open(path)?;
288 let builder = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)?;
289 let reader = if let Some(schema) = projection {
290 let schema_descr = builder.metadata().file_metadata().schema_descr_ptr();
291 let arrow_schema = builder.schema();
292 let mut indices = Vec::new();
293 for field in schema.fields() {
294 if let Some((idx, _)) =
295 parquet::arrow::parquet_column(&schema_descr, arrow_schema, field.name())
296 {
297 indices.push(idx);
298 }
299 }
300 let mask = parquet::arrow::ProjectionMask::leaves(&schema_descr, indices);
301 builder.with_projection(mask).build()?
302 } else {
303 builder.build()?
304 };
305 let mut batches = Vec::new();
306 for batch in reader {
307 batches.push(batch?);
308 }
309 Ok(batches)
310}
311
312fn read_csv(path: &Path, schema: Option<SchemaRef>) -> Result<Vec<RecordBatch>> {
313 let file = File::open(path)?;
314 let mut batches = Vec::new();
315 if let Some(schema) = schema {
316 let reader = arrow::csv::ReaderBuilder::new(schema)
317 .with_header(true)
318 .build(file)?;
319 for batch in reader {
320 batches.push(batch?);
321 }
322 } else {
323 let format = arrow::csv::reader::Format::default().with_header(true);
325 let (inferred, _) = format.infer_schema(File::open(path)?, Some(1024))?;
326 let schema = Arc::new(inferred);
327 let file = File::open(path)?;
328 let reader = arrow::csv::ReaderBuilder::new(schema)
329 .with_header(true)
330 .build(file)?;
331 for batch in reader {
332 batches.push(batch?);
333 }
334 }
335 Ok(batches)
336}
337
338fn read_json_arrow(path: &Path, format: SourceFormat) -> Result<Vec<RecordBatch>> {
339 let data = std::fs::read(path)?;
340 let data = if format == SourceFormat::Json {
341 expand_json_document_to_jsonl(&data)?
342 } else {
343 data
344 };
345 read_json_infer_from_bytes(&data)
346}
347
348fn read_json_infer_from_bytes(data: &[u8]) -> Result<Vec<RecordBatch>> {
349 use arrow::json::reader::infer_json_schema_from_seekable;
350 let mut cursor = std::io::Cursor::new(data);
351 let (schema, _n) = infer_json_schema_from_seekable(&mut cursor, Some(1024))?;
352 cursor.set_position(0);
353 let schema = Arc::new(schema);
354 let reader = arrow::json::ReaderBuilder::new(schema).build(cursor)?;
355 let mut batches = Vec::new();
356 for batch in reader {
357 batches.push(batch?);
358 }
359 Ok(batches)
360}
361
362fn read_arrow_ipc_file(path: &Path) -> Result<Vec<RecordBatch>> {
363 let file = File::open(path)?;
364 let reader = arrow::ipc::reader::FileReader::try_new(file, None)?;
365 let mut batches = Vec::new();
366 for batch in reader {
367 batches.push(batch?);
368 }
369 Ok(batches)
370}
371
372fn read_arrow_ipc_stream(path: &Path) -> Result<Vec<RecordBatch>> {
373 let file = File::open(path)?;
374 let reader = arrow::ipc::reader::StreamReader::try_new(file, None)?;
375 let mut batches = Vec::new();
376 for batch in reader {
377 batches.push(batch?);
378 }
379 Ok(batches)
380}
381
382fn read_arrow_ipc_auto(path: &Path) -> Result<Vec<RecordBatch>> {
384 match read_arrow_ipc_file(path) {
385 Ok(batches) => Ok(batches),
386 Err(file_err) => read_arrow_ipc_stream(path).with_context(|| {
387 format!(
388 "Arrow IPC file and stream readers both failed for {} (file error: {file_err})",
389 path.display()
390 )
391 }),
392 }
393}
394
395pub fn parse_hive_partitions(
397 file: &Path,
398 root: &Path,
399) -> std::collections::HashMap<String, String> {
400 let mut out = std::collections::HashMap::new();
401 let rel = file.strip_prefix(root).unwrap_or(file);
402 for comp in rel.components() {
403 if let std::path::Component::Normal(os) = comp {
404 let s = os.to_string_lossy();
405 if let Some((k, v)) = s.split_once('=') {
406 if !k.is_empty() {
407 out.insert(k.to_string(), v.to_string());
408 }
409 }
410 }
411 }
412 out
413}
414
415fn path_matches_require_partitions(
416 file: &Path,
417 root: &Path,
418 require: &std::collections::HashMap<String, String>,
419) -> bool {
420 if require.is_empty() {
421 return true;
422 }
423 let parts = parse_hive_partitions(file, root);
424 require
425 .iter()
426 .all(|(k, v)| parts.get(k).map(|pv| pv == v).unwrap_or(false))
427}
428
429fn inject_hive_partitions(
431 batch: RecordBatch,
432 file: &Path,
433 root: &Path,
434 partition_by: &[String],
435) -> Result<RecordBatch> {
436 if partition_by.is_empty() {
437 return Ok(batch);
438 }
439 let parts = parse_hive_partitions(file, root);
440 let n = batch.num_rows();
441 let mut fields: Vec<arrow::datatypes::Field> = batch
442 .schema()
443 .fields()
444 .iter()
445 .map(|f| f.as_ref().clone())
446 .collect();
447 let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
448
449 for key in partition_by {
450 if batch.schema().index_of(key).is_ok() {
451 continue;
453 }
454 let val = parts.get(key).map(|s| s.as_str());
455 let mut b = StringBuilder::with_capacity(n, n * 8);
456 for _ in 0..n {
457 match val {
458 Some(v) => b.append_value(v),
459 None => b.append_null(),
460 }
461 }
462 fields.push(Field::new(key.as_str(), DataType::Utf8, true));
463 columns.push(Arc::new(b.finish()) as ArrayRef);
464 }
465
466 let schema = Arc::new(Schema::new(fields));
467 Ok(RecordBatch::try_new(schema, columns)?)
468}
469
470fn inject_source_path_column(batch: RecordBatch, file: &Path) -> Result<RecordBatch> {
472 if batch.schema().index_of("_source_path").is_ok() {
473 return Ok(batch);
474 }
475 let n = batch.num_rows();
476 let path_str = file.to_string_lossy();
477 let mut b = StringBuilder::with_capacity(n, n * path_str.len().max(16));
478 for _ in 0..n {
479 b.append_value(path_str.as_ref());
480 }
481 let mut fields: Vec<Field> = batch
482 .schema()
483 .fields()
484 .iter()
485 .map(|f| f.as_ref().clone())
486 .collect();
487 fields.push(Field::new("_source_path", DataType::Utf8, false));
488 let mut columns = batch.columns().to_vec();
489 columns.push(Arc::new(b.finish()) as ArrayRef);
490 Ok(RecordBatch::try_new(
491 Arc::new(Schema::new(fields)),
492 columns,
493 )?)
494}
495
496fn read_line_oriented(path: &Path) -> Result<RecordBatch> {
499 let file = File::open(path)?;
500 let reader = BufReader::new(file);
501 let mut line_nos = Int64Builder::new();
502 let mut contents = StringBuilder::new();
503 let mut n: i64 = 0;
504 for line in reader.lines() {
505 let line = line?;
506 n += 1;
507 line_nos.append_value(n);
508 contents.append_value(line);
509 }
510 let schema = Arc::new(Schema::new(vec![
511 Field::new("line_no", DataType::Int64, false),
512 Field::new("content", DataType::Utf8, false),
513 ]));
514 Ok(RecordBatch::try_new(
515 schema,
516 vec![
517 Arc::new(line_nos.finish()) as ArrayRef,
518 Arc::new(contents.finish()) as ArrayRef,
519 ],
520 )?)
521}
522
523fn read_toml(path: &Path, rows_key: Option<&str>) -> Result<RecordBatch> {
524 let text = std::fs::read_to_string(path)?;
525 let value: toml::Value = text
526 .parse()
527 .with_context(|| format!("Invalid TOML: {}", path.display()))?;
528
529 let rows: Vec<toml::map::Map<String, toml::Value>> = match &value {
530 toml::Value::Table(table) => {
531 if let Some(key) = rows_key {
532 match table.get(key) {
533 Some(toml::Value::Array(arr)) => array_of_tables(arr)?,
534 other => bail!(
535 "toml_rows_key '{}' is not an array of tables (got {:?})",
536 key,
537 other.map(|v| v.type_str())
538 ),
539 }
540 } else if let Some((_k, toml::Value::Array(arr))) = table
541 .iter()
542 .find(|(_, v)| matches!(v, toml::Value::Array(a) if a.iter().all(|x| x.is_table())))
543 {
544 array_of_tables(arr)?
545 } else {
546 vec![table.clone()]
548 }
549 }
550 toml::Value::Array(arr) => array_of_tables(arr)?,
551 other => bail!("Unsupported top-level TOML type: {}", other.type_str()),
552 };
553
554 if rows.is_empty() {
555 let schema = Arc::new(Schema::new(vec![Field::new("empty", DataType::Utf8, true)]));
556 return Ok(RecordBatch::try_new(
557 schema,
558 vec![Arc::new(StringBuilder::new().finish()) as ArrayRef],
559 )?);
560 }
561
562 let mut col_names: Vec<String> = Vec::new();
564 for row in &rows {
565 for k in row.keys() {
566 if !col_names.iter().any(|c| c == k) {
567 col_names.push(k.clone());
568 }
569 }
570 }
571
572 let mut builders: Vec<StringBuilder> = col_names
573 .iter()
574 .map(|_| StringBuilder::with_capacity(rows.len(), rows.len() * 16))
575 .collect();
576
577 for row in &rows {
578 for (i, col) in col_names.iter().enumerate() {
579 match row.get(col) {
580 Some(v) => builders[i].append_value(toml_value_to_string(v)),
581 None => builders[i].append_null(),
582 }
583 }
584 }
585
586 let fields: Vec<Field> = col_names
587 .iter()
588 .map(|c| Field::new(c.as_str(), DataType::Utf8, true))
589 .collect();
590 let schema = Arc::new(Schema::new(fields));
591 let arrays: Vec<ArrayRef> = builders
592 .into_iter()
593 .map(|mut b| Arc::new(b.finish()) as ArrayRef)
594 .collect();
595 Ok(RecordBatch::try_new(schema, arrays)?)
596}
597
598fn array_of_tables(arr: &[toml::Value]) -> Result<Vec<toml::map::Map<String, toml::Value>>> {
599 let mut rows = Vec::with_capacity(arr.len());
600 for (i, item) in arr.iter().enumerate() {
601 match item {
602 toml::Value::Table(t) => rows.push(t.clone()),
603 other => bail!(
604 "TOML array element {} is not a table (got {})",
605 i,
606 other.type_str()
607 ),
608 }
609 }
610 Ok(rows)
611}
612
613fn toml_value_to_string(v: &toml::Value) -> String {
614 match v {
615 toml::Value::String(s) => s.clone(),
616 toml::Value::Integer(i) => i.to_string(),
617 toml::Value::Float(f) => f.to_string(),
618 toml::Value::Boolean(b) => b.to_string(),
619 toml::Value::Datetime(d) => d.to_string(),
620 other => other.to_string(),
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627 use arrow::array::{Array, Int64Array, StringArray};
628 use arrow::datatypes::{DataType, Field, Schema};
629 use std::sync::Arc;
630
631 #[tokio::test]
632 async fn test_lake_scanner_multi_format() -> Result<()> {
633 let temp_dir = tempfile::tempdir()?;
634 let dir_path = temp_dir.path();
635
636 let schema = Arc::new(Schema::new(vec![
637 Field::new("id", DataType::Int64, true),
638 Field::new("name", DataType::Utf8, true),
639 ]));
640
641 std::fs::write(
642 dir_path.join("file1.jsonl"),
643 b"{\"id\": 1, \"name\": \"Alice\"}\n{\"id\": 2, \"name\": \"Bob\"}\n",
644 )?;
645 std::fs::write(dir_path.join("file2.csv"), b"id,name\n3,Charlie\n4,Dave\n")?;
646
647 let batch = RecordBatch::try_new(
648 schema.clone(),
649 vec![
650 Arc::new(Int64Array::from(vec![5, 6])),
651 Arc::new(StringArray::from(vec!["Eve", "Frank"])),
652 ],
653 )?;
654 let file = File::create(dir_path.join("file3.parquet"))?;
655 let mut writer =
656 parquet::arrow::arrow_writer::ArrowWriter::try_new(file, schema.clone(), None)?;
657 writer.write(&batch)?;
658 writer.close()?;
659
660 std::fs::write(dir_path.join("app.log"), "info boot\nwarn disk\n")?;
661 std::fs::write(
662 dir_path.join("meta.toml"),
663 r#"
664[[records]]
665id = "1"
666name = "toml_a"
667[[records]]
668id = "2"
669name = "toml_b"
670"#,
671 )?;
672
673 let scanner = LakeScanner::new(vec!["id".to_string(), "name".to_string()]);
674 let batches = scanner.scan_path(dir_path, schema.clone()).await?;
675 let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
676 assert_eq!(total_rows, 10);
678 Ok(())
679 }
680
681 #[tokio::test]
682 async fn test_scan_request_jsonl() -> Result<()> {
683 let temp = tempfile::tempdir()?;
684 let path = temp.path().join("trades.jsonl");
685 std::fs::write(
686 &path,
687 r#"{"ticker":"NVDA","price":1.0}
688{"ticker":"AAPL","price":2.0}
689"#,
690 )?;
691
692 let fm = StagingFrontmatter {
693 source_format: Some(SourceFormat::Jsonl),
694 scan_path: Some(path.file_name().unwrap().to_string_lossy().into()),
695 ..Default::default()
696 };
697 let req = ScanRequest::from_frontmatter(temp.path(), &fm)?;
698 let scanner = LakeScanner::from_request(&req);
699 let batches = scanner.scan(&req).await?;
700 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
701 assert_eq!(rows, 2);
702 Ok(())
703 }
704
705 #[tokio::test]
706 async fn test_hive_partition_inject_and_filter() -> Result<()> {
707 let temp = tempfile::tempdir()?;
708 let root = temp.path();
709 let dir = root.join("symbol=NVDA").join("timeframe=1m");
710 std::fs::create_dir_all(&dir)?;
711 let schema = Arc::new(Schema::new(vec![Field::new(
713 "close",
714 DataType::Float64,
715 true,
716 )]));
717 let batch = RecordBatch::try_new(
718 schema,
719 vec![Arc::new(arrow::array::Float64Array::from(vec![1.0, 2.0])) as ArrayRef],
720 )?;
721 let path = dir.join("chunk.arrow");
722 {
723 let file = File::create(&path)?;
724 let mut writer = arrow::ipc::writer::StreamWriter::try_new(file, &batch.schema())?;
725 writer.write(&batch)?;
726 writer.finish()?;
727 }
728 let other = root.join("symbol=AAPL").join("timeframe=1d");
730 std::fs::create_dir_all(&other)?;
731 {
732 let file = File::create(other.join("chunk.arrow"))?;
733 let mut writer = arrow::ipc::writer::StreamWriter::try_new(file, &batch.schema())?;
734 writer.write(&batch)?;
735 writer.finish()?;
736 }
737
738 let mut require = std::collections::HashMap::new();
739 require.insert("timeframe".into(), "1m".into());
740 let fm = StagingFrontmatter {
741 source_format: Some(SourceFormat::ArrowIpcStream),
742 scan_path: Some(".".into()),
743 partition_by: Some(vec!["symbol".into(), "timeframe".into()]),
744 require_partitions: Some(require),
745 ..Default::default()
746 };
747 let req = ScanRequest::from_frontmatter(root, &fm)?;
748 let scanner = LakeScanner::from_request(&req);
749 let batches = scanner.scan(&req).await?;
750 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
751 assert_eq!(rows, 2);
752 let schema = batches[0].schema();
753 assert!(schema.index_of("timeframe").is_ok());
754 assert!(schema.index_of("symbol").is_ok());
755 Ok(())
756 }
757
758 #[test]
759 fn test_line_oriented_and_toml() -> Result<()> {
760 let temp = tempfile::tempdir()?;
761 let txt = temp.path().join("llms.txt");
762 std::fs::write(&txt, "# Title\n\nSome doc line\n")?;
763 let batch = read_line_oriented(&txt)?;
764 assert_eq!(batch.num_rows(), 3);
765 assert_eq!(batch.schema().field(0).name(), "line_no");
766 assert_eq!(batch.schema().field(1).name(), "content");
767
768 let toml_path = temp.path().join("cfg.toml");
769 std::fs::write(
770 &toml_path,
771 r#"
772[[items]]
773k = "a"
774[[items]]
775k = "b"
776"#,
777 )?;
778 let tbatch = read_toml(&toml_path, Some("items"))?;
779 assert_eq!(tbatch.num_rows(), 2);
780 let col = tbatch
781 .column(0)
782 .as_any()
783 .downcast_ref::<StringArray>()
784 .unwrap();
785 assert!(col.value(0) == "a" || col.value(1) == "a");
786 Ok(())
787 }
788}