Skip to main content

rbt/scan/
mod.rs

1//! `rbt::scan`: Multi-format bronze lake scanners producing Arrow `RecordBatch`es.
2//!
3//! Formats: JSONL (jshift), JSON, Parquet, CSV, Arrow IPC (file/stream), log, txt, TOML.
4
5use crate::core::frontmatter::{SourceFormat, StagingFrontmatter};
6use crate::core::paths::{resolve_project_path, validate_glob_patterns, PathGlobSet};
7use crate::core::project::{ScanConfig, DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES};
8use anyhow::{anyhow, bail, Context, Result};
9use arrow::array::{ArrayRef, BinaryBuilder, Int64Builder, StringBuilder};
10use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
11use arrow::record_batch::RecordBatch;
12use std::collections::HashMap;
13use std::fs::File;
14use std::io::{BufRead, BufReader, Read};
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18/// Scan request derived from staging frontmatter + project root.
19#[derive(Debug, Clone)]
20pub struct ScanRequest {
21    pub project_dir: PathBuf,
22    pub scan_path: String,
23    pub format: SourceFormat,
24    /// jshift / projection paths (JSONL selective extract).
25    pub paths: Vec<String>,
26    /// TOML array-of-tables key (optional).
27    pub toml_rows_key: Option<String>,
28    /// Hive-style partition keys to inject as Utf8 columns from the file path.
29    pub partition_by: Vec<String>,
30    /// Keep only files whose hive path matches these partition values.
31    pub require_partitions: std::collections::HashMap<String, String>,
32    /// Filename / relative globs under scan_path (OR). Empty = all format matches.
33    pub path_glob: Vec<String>,
34    /// Inject `_source_path` column (absolute path of the bronze file).
35    pub inject_source_path: bool,
36    /// Named roots for `$name` expansion in `scan_path` (from project config).
37    pub roots: HashMap<String, String>,
38    /// Max bytes for one opaque protobuf file (from `scan.protobuf_max_payload_bytes`).
39    pub protobuf_max_payload_bytes: u64,
40    /// When true, missing roots / zero files after filters return empty list (no error).
41    pub allow_empty: bool,
42}
43
44impl ScanRequest {
45    pub fn from_frontmatter(
46        project_dir: impl AsRef<Path>,
47        fm: &StagingFrontmatter,
48    ) -> Result<Self> {
49        Self::from_frontmatter_with_roots(project_dir, fm, HashMap::new())
50    }
51
52    pub fn from_frontmatter_with_roots(
53        project_dir: impl AsRef<Path>,
54        fm: &StagingFrontmatter,
55        roots: HashMap<String, String>,
56    ) -> Result<Self> {
57        Self::from_frontmatter_with_config(project_dir, fm, roots, &ScanConfig::default())
58    }
59
60    pub fn from_frontmatter_with_config(
61        project_dir: impl AsRef<Path>,
62        fm: &StagingFrontmatter,
63        roots: HashMap<String, String>,
64        scan_cfg: &ScanConfig,
65    ) -> Result<Self> {
66        let scan_path = fm
67            .scan_path
68            .as_ref()
69            .context(
70                "E_RBT_BRONZE_SCAN_PATH_MISSING: frontmatter.scan_path is required for bronze scan",
71            )?
72            .clone();
73        let format = fm.resolve_format().with_context(|| {
74            format!("E_RBT_BRONZE_FORMAT: cannot resolve source_format for scan_path '{scan_path}'")
75        })?;
76        let path_glob = fm.path_glob.clone().unwrap_or_default();
77        validate_glob_patterns(&path_glob).with_context(|| {
78            format!("E_RBT_PATH_GLOB_INVALID: bad path_glob on scan_path '{scan_path}'")
79        })?;
80        Ok(Self {
81            project_dir: project_dir.as_ref().to_path_buf(),
82            scan_path,
83            format,
84            paths: fm.paths.clone().unwrap_or_default(),
85            toml_rows_key: fm.toml_rows_key.clone(),
86            partition_by: fm.partition_by.clone().unwrap_or_default(),
87            require_partitions: fm.require_partitions.clone().unwrap_or_default(),
88            path_glob,
89            inject_source_path: fm.inject_source_path.unwrap_or(false),
90            roots,
91            protobuf_max_payload_bytes: scan_cfg.protobuf_max_payload_bytes,
92            allow_empty: matches!(
93                fm.on_missing_policy(),
94                crate::core::run_scope::OnMissing::Empty
95            ),
96        })
97    }
98
99    pub fn resolved_path(&self) -> Result<PathBuf> {
100        resolve_project_path(&self.project_dir, &self.scan_path, &self.roots)
101    }
102}
103
104/// Multi-format lake scanner.
105pub struct LakeScanner {
106    pub paths: Vec<String>,
107}
108
109impl LakeScanner {
110    pub fn new(paths: Vec<String>) -> Self {
111        Self { paths }
112    }
113
114    pub fn from_request(req: &ScanRequest) -> Self {
115        Self {
116            paths: req.paths.clone(),
117        }
118    }
119
120    /// Resolve scan root and list bronze files after partition/glob filters.
121    pub fn list_files(&self, req: &ScanRequest) -> Result<(PathBuf, Vec<PathBuf>)> {
122        let root = req.resolved_path().with_context(|| {
123            format!(
124                "E_RBT_BRONZE_PATH: failed resolving scan_path '{}' \
125                 (project_dir={}). Check absolute paths and `roots:` templates.",
126                req.scan_path,
127                req.project_dir.display()
128            )
129        })?;
130        if !root.exists() {
131            if req.allow_empty {
132                return Ok((root, Vec::new()));
133            }
134            bail!(
135                "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND: bronze scan_path does not exist: {} \
136                 (resolved from '{}'). Hint: verify the lake path and `$root` expansion, \
137                 or set on_missing: empty for optional artifact families.",
138                root.display(),
139                req.scan_path
140            );
141        }
142
143        let mut files = Vec::new();
144        collect_files_for_format(&root, req.format, &mut files)?;
145        if !req.require_partitions.is_empty() {
146            files.retain(|f| path_matches_require_partitions(f, &root, &req.require_partitions));
147        }
148        if !req.path_glob.is_empty() {
149            let glob_set = PathGlobSet::compile(&req.path_glob)?;
150            files.retain(|f| glob_set.matches(f, &root));
151        }
152        if files.is_empty() {
153            if req.allow_empty {
154                return Ok((root, Vec::new()));
155            }
156            bail!(
157                "E_RBT_BRONZE_SCAN_EMPTY: no {} files under {} after filters \
158                 (require_partitions={:?}, path_glob={:?}). \
159                 Hint: path_glob disables DataFusion listing pushdown and uses the \
160                 scan→MemTable/spill path; check filename patterns and hive partitions, \
161                 or set on_missing: empty for optional artifact families.",
162                req.format.as_str(),
163                root.display(),
164                req.require_partitions,
165                req.path_glob
166            );
167        }
168        Ok((root, files))
169    }
170
171    /// Scan using a full [`ScanRequest`] (format-aware). Loads all batches into memory.
172    ///
173    /// When `allow_empty` and no files match, returns an empty `Vec` (caller builds empty schema).
174    pub async fn scan(&self, req: &ScanRequest) -> Result<Vec<RecordBatch>> {
175        let (root, files) = self.list_files(req)?;
176        if files.is_empty() {
177            tracing::info!(
178                "Bronze scan: 0 file(s) under {} (format={}, allow_empty={})",
179                root.display(),
180                req.format,
181                req.allow_empty
182            );
183            return Ok(Vec::new());
184        }
185        tracing::info!(
186            "Bronze scan: {} file(s) under {} (format={}, globs={:?})",
187            files.len(),
188            root.display(),
189            req.format,
190            req.path_glob
191        );
192
193        let mut batches = Vec::new();
194        for file_path in files {
195            for batch in self.read_enriched(&file_path, &root, req)? {
196                batches.push(batch);
197            }
198        }
199        Ok(batches)
200    }
201
202    /// Stream bronze files into a single Parquet file (atomic publish).
203    ///
204    /// Peak memory ≈ one source file's batches + Parquet encoder — not the full hive tree.
205    /// Used for Arrow IPC multi-file bronze when `scan.spill_arrow_ipc` is true.
206    pub fn scan_spill_to_parquet(
207        &self,
208        req: &ScanRequest,
209        dest: &Path,
210        opts: &crate::materializer::MaterializeWriteOptions,
211    ) -> Result<crate::materializer::StreamWriteStats> {
212        use crate::materializer::{atomic_publish, partial_path_for};
213        use parquet::arrow::ArrowWriter;
214        use parquet::basic::Compression;
215        use parquet::file::properties::WriterProperties;
216        use std::fs::{self, File};
217        use std::io::BufWriter;
218
219        let (root, files) = self.list_files(req)?;
220        tracing::info!(
221            "Bronze spill→parquet: {} file(s) under {} → {} (format={})",
222            files.len(),
223            root.display(),
224            dest.display(),
225            req.format
226        );
227
228        let partial = partial_path_for(dest);
229        if partial.exists() {
230            let _ = fs::remove_file(&partial);
231        }
232        if let Some(parent) = partial.parent() {
233            fs::create_dir_all(parent)?;
234        }
235
236        let props = WriterProperties::builder()
237            .set_max_row_group_row_count(Some(opts.max_row_group_rows.max(1)))
238            .set_compression(Compression::SNAPPY)
239            .build();
240
241        let mut writer: Option<ArrowWriter<BufWriter<File>>> = None;
242        let mut schema: Option<SchemaRef> = None;
243        let mut rows = 0usize;
244        let mut batches_n = 0usize;
245
246        let mut write_loop = || -> Result<()> {
247            for file_path in &files {
248                let file_batches = self.read_enriched(file_path, &root, req).with_context(|| {
249                    format!(
250                        "E_RBT_BRONZE_SPILL: read {} for spill",
251                        file_path.display()
252                    )
253                })?;
254                for batch in file_batches {
255                    if batch.num_rows() == 0 && batch.num_columns() == 0 {
256                        continue;
257                    }
258                    if writer.is_none() {
259                        schema = Some(batch.schema());
260                        let file = File::create(&partial).with_context(|| {
261                            format!(
262                                "E_RBT_BRONZE_SPILL: create partial {}",
263                                partial.display()
264                            )
265                        })?;
266                        let buf = BufWriter::with_capacity(8 * 1024 * 1024, file);
267                        writer = Some(
268                            ArrowWriter::try_new(buf, batch.schema(), Some(props.clone()))
269                                .with_context(|| {
270                                    format!(
271                                        "E_RBT_BRONZE_SPILL: ArrowWriter for {}",
272                                        partial.display()
273                                    )
274                                })?,
275                        );
276                    }
277                    let w = writer.as_mut().unwrap();
278                    w.write(&batch).with_context(|| {
279                        format!(
280                            "E_RBT_BRONZE_SPILL: write batch from {}",
281                            file_path.display()
282                        )
283                    })?;
284                    rows += batch.num_rows();
285                    batches_n += 1;
286                    if w.in_progress_size() >= opts.max_row_group_bytes {
287                        w.flush()?;
288                    }
289                    // batch dropped
290                }
291            }
292            Ok(())
293        };
294
295        if let Err(e) = write_loop() {
296            if let Some(w) = writer.take() {
297                let _ = w.close();
298            }
299            let _ = fs::remove_file(&partial);
300            return Err(e);
301        }
302
303        if let Some(w) = writer.take() {
304            w.close().with_context(|| {
305                format!("E_RBT_BRONZE_SPILL: close writer {}", partial.display())
306            })?;
307            atomic_publish(&partial, dest)?;
308        } else {
309            // Zero rows but known schema is rare; fail if we never saw a batch.
310            let _ = schema;
311            bail!(
312                "E_RBT_BRONZE_SPILL: no batches produced for spill to {} \
313                 ({} candidate files)",
314                dest.display(),
315                files.len()
316            );
317        }
318
319        let bytes_written = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
320        tracing::info!(
321            "Bronze spill complete: {} rows, {} batches, {} bytes → {}",
322            rows,
323            batches_n,
324            bytes_written,
325            dest.display()
326        );
327        Ok(crate::materializer::StreamWriteStats {
328            rows,
329            batches: batches_n,
330            path: dest.to_path_buf(),
331            bytes_written,
332            validation: crate::testing::ValidationResult {
333                total_rows: rows,
334                passed_assertions: 0,
335                failed_assertions: 0,
336                errors: Vec::new(),
337            },
338        })
339    }
340
341    fn read_enriched(
342        &self,
343        file_path: &Path,
344        root: &Path,
345        req: &ScanRequest,
346    ) -> Result<Vec<RecordBatch>> {
347        let file_batches = self
348            .read_file(file_path, req)
349            .with_context(|| format!("Failed reading bronze file {}", file_path.display()))?;
350        let mut out = Vec::with_capacity(file_batches.len());
351        for batch in file_batches {
352            let mut batch = if req.partition_by.is_empty() {
353                batch
354            } else {
355                inject_hive_partitions(batch, file_path, root, &req.partition_by)?
356            };
357            if req.inject_source_path {
358                batch = inject_source_path_column(batch, file_path)?;
359            }
360            out.push(batch);
361        }
362        Ok(out)
363    }
364
365    /// Legacy helper: recurse path and read by extension with an explicit schema
366    /// (used by unit tests and jshift-projected JSONL).
367    pub async fn scan_path(
368        &self,
369        path: impl AsRef<Path>,
370        schema: SchemaRef,
371    ) -> Result<Vec<RecordBatch>> {
372        let mut files = Vec::new();
373        collect_files(path.as_ref(), &mut files)?;
374        let mut batches = Vec::new();
375
376        for file_path in files {
377            let ext = file_path
378                .extension()
379                .and_then(|e| e.to_str())
380                .unwrap_or("")
381                .to_ascii_lowercase();
382            match ext.as_str() {
383                "parquet" => batches.extend(read_parquet(&file_path, Some(schema.clone()))?),
384                "json" | "jsonl" | "ndjson" => {
385                    let bytes = std::fs::read(&file_path)?;
386                    let extractor = crate::json::JShiftExtractor::new(self.paths.clone());
387                    batches.push(extractor.extract_jsonl(&bytes, schema.clone())?);
388                }
389                "csv" | "tsv" => batches.extend(read_csv(&file_path, Some(schema.clone()))?),
390                "log" | "txt" | "text" | "md" => {
391                    batches.push(read_line_oriented(&file_path)?);
392                }
393                "toml" => batches.push(read_toml(&file_path, None)?),
394                "arrow" | "arrows" | "ipc" | "feather" => {
395                    batches.extend(read_arrow_ipc_auto(&file_path)?);
396                }
397                _ => {
398                    tracing::debug!("Skipping unsupported file: {:?}", file_path);
399                }
400            }
401        }
402        Ok(batches)
403    }
404
405    fn read_file(&self, file_path: &Path, req: &ScanRequest) -> Result<Vec<RecordBatch>> {
406        match req.format {
407            SourceFormat::Parquet => read_parquet(file_path, None),
408            SourceFormat::Protobuf => Ok(vec![read_protobuf_opaque(
409                file_path,
410                req.protobuf_max_payload_bytes,
411            )?]),
412            SourceFormat::Csv => read_csv(file_path, None),
413            SourceFormat::Jsonl | SourceFormat::Json => {
414                if !self.paths.is_empty() {
415                    let schema = utf8_schema_from_paths(&self.paths);
416                    let bytes = std::fs::read(file_path)?;
417                    let extractor = crate::json::JShiftExtractor::new(self.paths.clone());
418                    // JSONL extractor works line-wise; for single JSON array, expand first
419                    if req.format == SourceFormat::Json {
420                        let expanded = expand_json_document_to_jsonl(&bytes)?;
421                        Ok(vec![extractor.extract_jsonl(&expanded, schema)?])
422                    } else {
423                        Ok(vec![extractor.extract_jsonl(&bytes, schema)?])
424                    }
425                } else {
426                    // Schema-free path: use Arrow JSON reader (line-delimited)
427                    read_json_arrow(file_path, req.format)
428                }
429            }
430            // Real lakes often write stream IPC with a `.arrow` extension.
431            SourceFormat::ArrowIpc | SourceFormat::ArrowIpcStream => read_arrow_ipc_auto(file_path),
432            SourceFormat::Log | SourceFormat::Txt => Ok(vec![read_line_oriented(file_path)?]),
433            SourceFormat::Toml => Ok(vec![read_toml(file_path, req.toml_rows_key.as_deref())?]),
434        }
435    }
436}
437
438fn utf8_schema_from_paths(paths: &[String]) -> SchemaRef {
439    Arc::new(Schema::new(
440        paths
441            .iter()
442            .map(|p| Field::new(p.as_str(), DataType::Utf8, true))
443            .collect::<Vec<_>>(),
444    ))
445}
446
447fn expand_json_document_to_jsonl(bytes: &[u8]) -> Result<Vec<u8>> {
448    let v: serde_json::Value =
449        serde_json::from_slice(bytes).map_err(|e| anyhow!("Invalid JSON document: {}", e))?;
450    match v {
451        serde_json::Value::Array(items) => {
452            let mut out = Vec::new();
453            for item in items {
454                out.extend(serde_json::to_vec(&item)?);
455                out.push(b'\n');
456            }
457            Ok(out)
458        }
459        other => {
460            let mut out = serde_json::to_vec(&other)?;
461            out.push(b'\n');
462            Ok(out)
463        }
464    }
465}
466
467fn collect_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
468    if path.is_file() {
469        files.push(path.to_path_buf());
470    } else if path.is_dir() {
471        for entry in std::fs::read_dir(path)? {
472            let entry = entry?;
473            collect_files(&entry.path(), files)?;
474        }
475    }
476    Ok(())
477}
478
479fn extension_matches(format: SourceFormat, ext: &str) -> bool {
480    let ext = ext.to_ascii_lowercase();
481    match format {
482        SourceFormat::Jsonl => matches!(ext.as_str(), "jsonl" | "ndjson"),
483        SourceFormat::Json => ext == "json",
484        SourceFormat::Parquet => matches!(ext.as_str(), "parquet" | "pq"),
485        SourceFormat::Csv => matches!(ext.as_str(), "csv" | "tsv"),
486        SourceFormat::ArrowIpc => matches!(ext.as_str(), "arrow" | "arrows" | "ipc" | "feather"),
487        SourceFormat::ArrowIpcStream => {
488            matches!(
489                ext.as_str(),
490                "arrow" | "arrows" | "ipc" | "arrows_stream" | "ipc_stream"
491            )
492        }
493        SourceFormat::Log => ext == "log",
494        SourceFormat::Txt => matches!(ext.as_str(), "txt" | "text" | "md"),
495        SourceFormat::Toml => ext == "toml",
496        SourceFormat::Protobuf => matches!(ext.as_str(), "pb" | "protobuf" | "protobin"),
497    }
498}
499
500/// Opaque protobuf bronze: one row per file with path + raw bytes.
501///
502/// Typed message decode is intentionally deferred (schema registry / Rust models).
503/// `max_bytes` defaults to 1 GiB via project `scan.protobuf_max_payload_bytes`.
504fn read_protobuf_opaque(path: &Path, max_bytes: u64) -> Result<RecordBatch> {
505    let meta = std::fs::metadata(path).with_context(|| {
506        format!(
507            "E_RBT_PROTOBUF_IO: cannot stat protobuf file {}",
508            path.display()
509        )
510    })?;
511    let len = meta.len();
512    if len > max_bytes {
513        bail!(
514            "E_RBT_PROTOBUF_TOO_LARGE: file {} is {len} bytes, exceeds \
515             scan.protobuf_max_payload_bytes={max_bytes} (default {} = 1 GiB). \
516             Raise `scan.protobuf_max_payload_bytes` in rbt_project.yml only if intentional.",
517            path.display(),
518            DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES
519        );
520    }
521
522    let mut file = File::open(path)
523        .with_context(|| format!("E_RBT_PROTOBUF_IO: open protobuf file {}", path.display()))?;
524    let mut payload = Vec::with_capacity(len as usize);
525    file.read_to_end(&mut payload)
526        .with_context(|| format!("E_RBT_PROTOBUF_IO: read protobuf file {}", path.display()))?;
527
528    let schema = Arc::new(Schema::new(vec![
529        Field::new("_source_path", DataType::Utf8, false),
530        Field::new("payload", DataType::Binary, false),
531        Field::new("payload_len", DataType::Int64, false),
532    ]));
533
534    let mut path_b = StringBuilder::new();
535    let mut bin_b = BinaryBuilder::new();
536    let mut len_b = Int64Builder::new();
537    path_b.append_value(path.to_string_lossy().as_ref());
538    bin_b.append_value(&payload);
539    len_b.append_value(payload.len() as i64);
540
541    Ok(RecordBatch::try_new(
542        schema,
543        vec![
544            Arc::new(path_b.finish()),
545            Arc::new(bin_b.finish()),
546            Arc::new(len_b.finish()),
547        ],
548    )?)
549}
550
551fn collect_files_for_format(
552    path: &Path,
553    format: SourceFormat,
554    files: &mut Vec<PathBuf>,
555) -> Result<()> {
556    if path.is_file() {
557        files.push(path.to_path_buf());
558        return Ok(());
559    }
560    if path.is_dir() {
561        let mut all = Vec::new();
562        collect_files(path, &mut all)?;
563        for f in all {
564            let ext = f.extension().and_then(|e| e.to_str()).unwrap_or("");
565            if extension_matches(format, ext) {
566                files.push(f);
567            }
568        }
569        // If directory has files but none matched extension, and format is Log/Txt,
570        // still allow extensionless? Skip for now.
571        return Ok(());
572    }
573    bail!(
574        "scan path is neither file nor directory: {}",
575        path.display()
576    );
577}
578
579fn read_parquet(path: &Path, projection: Option<SchemaRef>) -> Result<Vec<RecordBatch>> {
580    let file = File::open(path)?;
581    let builder = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)?;
582    let reader = if let Some(schema) = projection {
583        let schema_descr = builder.metadata().file_metadata().schema_descr_ptr();
584        let arrow_schema = builder.schema();
585        let mut indices = Vec::new();
586        for field in schema.fields() {
587            if let Some((idx, _)) =
588                parquet::arrow::parquet_column(&schema_descr, arrow_schema, field.name())
589            {
590                indices.push(idx);
591            }
592        }
593        let mask = parquet::arrow::ProjectionMask::leaves(&schema_descr, indices);
594        builder.with_projection(mask).build()?
595    } else {
596        builder.build()?
597    };
598    let mut batches = Vec::new();
599    for batch in reader {
600        batches.push(batch?);
601    }
602    Ok(batches)
603}
604
605fn read_csv(path: &Path, schema: Option<SchemaRef>) -> Result<Vec<RecordBatch>> {
606    let file = File::open(path)?;
607    let mut batches = Vec::new();
608    if let Some(schema) = schema {
609        let reader = arrow::csv::ReaderBuilder::new(schema)
610            .with_header(true)
611            .build(file)?;
612        for batch in reader {
613            batches.push(batch?);
614        }
615    } else {
616        // Infer schema from first rows
617        let format = arrow::csv::reader::Format::default().with_header(true);
618        let (inferred, _) = format.infer_schema(File::open(path)?, Some(1024))?;
619        let schema = Arc::new(inferred);
620        let file = File::open(path)?;
621        let reader = arrow::csv::ReaderBuilder::new(schema)
622            .with_header(true)
623            .build(file)?;
624        for batch in reader {
625            batches.push(batch?);
626        }
627    }
628    Ok(batches)
629}
630
631fn read_json_arrow(path: &Path, format: SourceFormat) -> Result<Vec<RecordBatch>> {
632    let data = std::fs::read(path)?;
633    let data = if format == SourceFormat::Json {
634        expand_json_document_to_jsonl(&data)?
635    } else {
636        data
637    };
638    read_json_infer_from_bytes(&data)
639}
640
641fn read_json_infer_from_bytes(data: &[u8]) -> Result<Vec<RecordBatch>> {
642    use arrow::json::reader::infer_json_schema_from_seekable;
643    let mut cursor = std::io::Cursor::new(data);
644    let (schema, _n) = infer_json_schema_from_seekable(&mut cursor, Some(1024))?;
645    cursor.set_position(0);
646    let schema = Arc::new(schema);
647    let reader = arrow::json::ReaderBuilder::new(schema).build(cursor)?;
648    let mut batches = Vec::new();
649    for batch in reader {
650        batches.push(batch?);
651    }
652    Ok(batches)
653}
654
655fn read_arrow_ipc_file(path: &Path) -> Result<Vec<RecordBatch>> {
656    let file = File::open(path)?;
657    let reader = arrow::ipc::reader::FileReader::try_new(file, None)?;
658    let mut batches = Vec::new();
659    for batch in reader {
660        batches.push(batch?);
661    }
662    Ok(batches)
663}
664
665fn read_arrow_ipc_stream(path: &Path) -> Result<Vec<RecordBatch>> {
666    let file = File::open(path)?;
667    let reader = arrow::ipc::reader::StreamReader::try_new(file, None)?;
668    let mut batches = Vec::new();
669    for batch in reader {
670        batches.push(batch?);
671    }
672    Ok(batches)
673}
674
675/// Prefer random-access IPC file; fall back to stream (common for `.arrow` lake dumps).
676fn read_arrow_ipc_auto(path: &Path) -> Result<Vec<RecordBatch>> {
677    match read_arrow_ipc_file(path) {
678        Ok(batches) => Ok(batches),
679        Err(file_err) => read_arrow_ipc_stream(path).with_context(|| {
680            format!(
681                "Arrow IPC file and stream readers both failed for {} (file error: {file_err})",
682                path.display()
683            )
684        }),
685    }
686}
687
688/// Parse `key=value` segments from `file` relative to `root`.
689pub fn parse_hive_partitions(
690    file: &Path,
691    root: &Path,
692) -> std::collections::HashMap<String, String> {
693    let mut out = std::collections::HashMap::new();
694    let rel = file.strip_prefix(root).unwrap_or(file);
695    for comp in rel.components() {
696        if let std::path::Component::Normal(os) = comp {
697            let s = os.to_string_lossy();
698            if let Some((k, v)) = s.split_once('=') {
699                if !k.is_empty() {
700                    out.insert(k.to_string(), v.to_string());
701                }
702            }
703        }
704    }
705    out
706}
707
708fn path_matches_require_partitions(
709    file: &Path,
710    root: &Path,
711    require: &std::collections::HashMap<String, String>,
712) -> bool {
713    if require.is_empty() {
714        return true;
715    }
716    let parts = parse_hive_partitions(file, root);
717    require
718        .iter()
719        .all(|(k, v)| parts.get(k).map(|pv| pv == v).unwrap_or(false))
720}
721
722/// Append hive partition columns (Utf8) for keys in `partition_by` that are not already present.
723fn inject_hive_partitions(
724    batch: RecordBatch,
725    file: &Path,
726    root: &Path,
727    partition_by: &[String],
728) -> Result<RecordBatch> {
729    if partition_by.is_empty() {
730        return Ok(batch);
731    }
732    let parts = parse_hive_partitions(file, root);
733    let n = batch.num_rows();
734    let mut fields: Vec<arrow::datatypes::Field> = batch
735        .schema()
736        .fields()
737        .iter()
738        .map(|f| f.as_ref().clone())
739        .collect();
740    let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
741
742    for key in partition_by {
743        if batch.schema().index_of(key).is_ok() {
744            // Column already in payload (e.g. symbol); keep file data, do not overwrite.
745            continue;
746        }
747        let val = parts.get(key).map(|s| s.as_str());
748        let mut b = StringBuilder::with_capacity(n, n * 8);
749        for _ in 0..n {
750            match val {
751                Some(v) => b.append_value(v),
752                None => b.append_null(),
753            }
754        }
755        fields.push(Field::new(key.as_str(), DataType::Utf8, true));
756        columns.push(Arc::new(b.finish()) as ArrayRef);
757    }
758
759    let schema = Arc::new(Schema::new(fields));
760    Ok(RecordBatch::try_new(schema, columns)?)
761}
762
763/// Append `_source_path` Utf8 with the absolute bronze file path (for latest-wins dedupe).
764fn inject_source_path_column(batch: RecordBatch, file: &Path) -> Result<RecordBatch> {
765    if batch.schema().index_of("_source_path").is_ok() {
766        return Ok(batch);
767    }
768    let n = batch.num_rows();
769    let path_str = file.to_string_lossy();
770    let mut b = StringBuilder::with_capacity(n, n * path_str.len().max(16));
771    for _ in 0..n {
772        b.append_value(path_str.as_ref());
773    }
774    let mut fields: Vec<Field> = batch
775        .schema()
776        .fields()
777        .iter()
778        .map(|f| f.as_ref().clone())
779        .collect();
780    fields.push(Field::new("_source_path", DataType::Utf8, false));
781    let mut columns = batch.columns().to_vec();
782    columns.push(Arc::new(b.finish()) as ArrayRef);
783    Ok(RecordBatch::try_new(
784        Arc::new(Schema::new(fields)),
785        columns,
786    )?)
787}
788
789/// Line-oriented bronze: `.log`, `.txt`, llms.txt-style docs.
790/// Schema: `line_no` Int64, `content` Utf8.
791fn read_line_oriented(path: &Path) -> Result<RecordBatch> {
792    let file = File::open(path)?;
793    let reader = BufReader::new(file);
794    let mut line_nos = Int64Builder::new();
795    let mut contents = StringBuilder::new();
796    let mut n: i64 = 0;
797    for line in reader.lines() {
798        let line = line?;
799        n += 1;
800        line_nos.append_value(n);
801        contents.append_value(line);
802    }
803    let schema = Arc::new(Schema::new(vec![
804        Field::new("line_no", DataType::Int64, false),
805        Field::new("content", DataType::Utf8, false),
806    ]));
807    Ok(RecordBatch::try_new(
808        schema,
809        vec![
810            Arc::new(line_nos.finish()) as ArrayRef,
811            Arc::new(contents.finish()) as ArrayRef,
812        ],
813    )?)
814}
815
816fn read_toml(path: &Path, rows_key: Option<&str>) -> Result<RecordBatch> {
817    let text = std::fs::read_to_string(path)?;
818    let value: toml::Value = text
819        .parse()
820        .with_context(|| format!("Invalid TOML: {}", path.display()))?;
821
822    let rows: Vec<toml::map::Map<String, toml::Value>> = match &value {
823        toml::Value::Table(table) => {
824            if let Some(key) = rows_key {
825                match table.get(key) {
826                    Some(toml::Value::Array(arr)) => array_of_tables(arr)?,
827                    other => bail!(
828                        "toml_rows_key '{}' is not an array of tables (got {:?})",
829                        key,
830                        other.map(|v| v.type_str())
831                    ),
832                }
833            } else if let Some((_k, toml::Value::Array(arr))) = table
834                .iter()
835                .find(|(_, v)| matches!(v, toml::Value::Array(a) if a.iter().all(|x| x.is_table())))
836            {
837                array_of_tables(arr)?
838            } else {
839                // Single-row: top-level scalars / nested stringified
840                vec![table.clone()]
841            }
842        }
843        toml::Value::Array(arr) => array_of_tables(arr)?,
844        other => bail!("Unsupported top-level TOML type: {}", other.type_str()),
845    };
846
847    if rows.is_empty() {
848        let schema = Arc::new(Schema::new(vec![Field::new("empty", DataType::Utf8, true)]));
849        return Ok(RecordBatch::try_new(
850            schema,
851            vec![Arc::new(StringBuilder::new().finish()) as ArrayRef],
852        )?);
853    }
854
855    // Column union
856    let mut col_names: Vec<String> = Vec::new();
857    for row in &rows {
858        for k in row.keys() {
859            if !col_names.iter().any(|c| c == k) {
860                col_names.push(k.clone());
861            }
862        }
863    }
864
865    let mut builders: Vec<StringBuilder> = col_names
866        .iter()
867        .map(|_| StringBuilder::with_capacity(rows.len(), rows.len() * 16))
868        .collect();
869
870    for row in &rows {
871        for (i, col) in col_names.iter().enumerate() {
872            match row.get(col) {
873                Some(v) => builders[i].append_value(toml_value_to_string(v)),
874                None => builders[i].append_null(),
875            }
876        }
877    }
878
879    let fields: Vec<Field> = col_names
880        .iter()
881        .map(|c| Field::new(c.as_str(), DataType::Utf8, true))
882        .collect();
883    let schema = Arc::new(Schema::new(fields));
884    let arrays: Vec<ArrayRef> = builders
885        .into_iter()
886        .map(|mut b| Arc::new(b.finish()) as ArrayRef)
887        .collect();
888    Ok(RecordBatch::try_new(schema, arrays)?)
889}
890
891fn array_of_tables(arr: &[toml::Value]) -> Result<Vec<toml::map::Map<String, toml::Value>>> {
892    let mut rows = Vec::with_capacity(arr.len());
893    for (i, item) in arr.iter().enumerate() {
894        match item {
895            toml::Value::Table(t) => rows.push(t.clone()),
896            other => bail!(
897                "TOML array element {} is not a table (got {})",
898                i,
899                other.type_str()
900            ),
901        }
902    }
903    Ok(rows)
904}
905
906fn toml_value_to_string(v: &toml::Value) -> String {
907    match v {
908        toml::Value::String(s) => s.clone(),
909        toml::Value::Integer(i) => i.to_string(),
910        toml::Value::Float(f) => f.to_string(),
911        toml::Value::Boolean(b) => b.to_string(),
912        toml::Value::Datetime(d) => d.to_string(),
913        other => other.to_string(),
914    }
915}
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920    use arrow::array::{Array, Int64Array, StringArray};
921    use arrow::datatypes::{DataType, Field, Schema};
922    use std::sync::Arc;
923
924    #[tokio::test]
925    async fn test_lake_scanner_multi_format() -> Result<()> {
926        let temp_dir = tempfile::tempdir()?;
927        let dir_path = temp_dir.path();
928
929        let schema = Arc::new(Schema::new(vec![
930            Field::new("id", DataType::Int64, true),
931            Field::new("name", DataType::Utf8, true),
932        ]));
933
934        std::fs::write(
935            dir_path.join("file1.jsonl"),
936            b"{\"id\": 1, \"name\": \"Alice\"}\n{\"id\": 2, \"name\": \"Bob\"}\n",
937        )?;
938        std::fs::write(dir_path.join("file2.csv"), b"id,name\n3,Charlie\n4,Dave\n")?;
939
940        let batch = RecordBatch::try_new(
941            schema.clone(),
942            vec![
943                Arc::new(Int64Array::from(vec![5, 6])),
944                Arc::new(StringArray::from(vec!["Eve", "Frank"])),
945            ],
946        )?;
947        let file = File::create(dir_path.join("file3.parquet"))?;
948        let mut writer =
949            parquet::arrow::arrow_writer::ArrowWriter::try_new(file, schema.clone(), None)?;
950        writer.write(&batch)?;
951        writer.close()?;
952
953        std::fs::write(dir_path.join("app.log"), "info boot\nwarn disk\n")?;
954        std::fs::write(
955            dir_path.join("meta.toml"),
956            r#"
957[[records]]
958id = "1"
959name = "toml_a"
960[[records]]
961id = "2"
962name = "toml_b"
963"#,
964        )?;
965
966        let scanner = LakeScanner::new(vec!["id".to_string(), "name".to_string()]);
967        let batches = scanner.scan_path(dir_path, schema.clone()).await?;
968        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
969        // 2 jsonl + 2 csv + 2 parquet + 2 log + 2 toml = 10
970        assert_eq!(total_rows, 10);
971        Ok(())
972    }
973
974    #[tokio::test]
975    async fn test_scan_request_jsonl() -> Result<()> {
976        let temp = tempfile::tempdir()?;
977        let path = temp.path().join("trades.jsonl");
978        std::fs::write(
979            &path,
980            r#"{"ticker":"NVDA","price":1.0}
981{"ticker":"AAPL","price":2.0}
982"#,
983        )?;
984
985        let fm = StagingFrontmatter {
986            source_format: Some(SourceFormat::Jsonl),
987            scan_path: Some(path.file_name().unwrap().to_string_lossy().into()),
988            ..Default::default()
989        };
990        let req = ScanRequest::from_frontmatter(temp.path(), &fm)?;
991        let scanner = LakeScanner::from_request(&req);
992        let batches = scanner.scan(&req).await?;
993        let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
994        assert_eq!(rows, 2);
995        Ok(())
996    }
997
998    #[tokio::test]
999    async fn test_hive_partition_inject_and_filter() -> Result<()> {
1000        let temp = tempfile::tempdir()?;
1001        let root = temp.path();
1002        let dir = root.join("symbol=NVDA").join("timeframe=1m");
1003        std::fs::create_dir_all(&dir)?;
1004        // minimal IPC stream with one utf8 column
1005        let schema = Arc::new(Schema::new(vec![Field::new(
1006            "close",
1007            DataType::Float64,
1008            true,
1009        )]));
1010        let batch = RecordBatch::try_new(
1011            schema,
1012            vec![Arc::new(arrow::array::Float64Array::from(vec![1.0, 2.0])) as ArrayRef],
1013        )?;
1014        let path = dir.join("chunk.arrow");
1015        {
1016            let file = File::create(&path)?;
1017            let mut writer = arrow::ipc::writer::StreamWriter::try_new(file, &batch.schema())?;
1018            writer.write(&batch)?;
1019            writer.finish()?;
1020        }
1021        // noise partition that should be filtered out
1022        let other = root.join("symbol=AAPL").join("timeframe=1d");
1023        std::fs::create_dir_all(&other)?;
1024        {
1025            let file = File::create(other.join("chunk.arrow"))?;
1026            let mut writer = arrow::ipc::writer::StreamWriter::try_new(file, &batch.schema())?;
1027            writer.write(&batch)?;
1028            writer.finish()?;
1029        }
1030
1031        let mut require = std::collections::HashMap::new();
1032        require.insert("timeframe".into(), "1m".into());
1033        let fm = StagingFrontmatter {
1034            source_format: Some(SourceFormat::ArrowIpcStream),
1035            scan_path: Some(".".into()),
1036            partition_by: Some(vec!["symbol".into(), "timeframe".into()]),
1037            require_partitions: Some(require),
1038            ..Default::default()
1039        };
1040        let req = ScanRequest::from_frontmatter(root, &fm)?;
1041        let scanner = LakeScanner::from_request(&req);
1042        let batches = scanner.scan(&req).await?;
1043        let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1044        assert_eq!(rows, 2);
1045        let schema = batches[0].schema();
1046        assert!(schema.index_of("timeframe").is_ok());
1047        assert!(schema.index_of("symbol").is_ok());
1048        Ok(())
1049    }
1050
1051    #[test]
1052    fn test_line_oriented_and_toml() -> Result<()> {
1053        let temp = tempfile::tempdir()?;
1054        let txt = temp.path().join("llms.txt");
1055        std::fs::write(&txt, "# Title\n\nSome doc line\n")?;
1056        let batch = read_line_oriented(&txt)?;
1057        assert_eq!(batch.num_rows(), 3);
1058        assert_eq!(batch.schema().field(0).name(), "line_no");
1059        assert_eq!(batch.schema().field(1).name(), "content");
1060
1061        let toml_path = temp.path().join("cfg.toml");
1062        std::fs::write(
1063            &toml_path,
1064            r#"
1065[[items]]
1066k = "a"
1067[[items]]
1068k = "b"
1069"#,
1070        )?;
1071        let tbatch = read_toml(&toml_path, Some("items"))?;
1072        assert_eq!(tbatch.num_rows(), 2);
1073        let col = tbatch
1074            .column(0)
1075            .as_any()
1076            .downcast_ref::<StringArray>()
1077            .unwrap();
1078        assert!(col.value(0) == "a" || col.value(1) == "a");
1079        Ok(())
1080    }
1081
1082    #[tokio::test]
1083    async fn test_path_glob_filters_artifacts() -> Result<()> {
1084        let temp = tempfile::tempdir()?;
1085        let root = temp.path();
1086        // Multi-artifact hive (same layout pattern as multi-domain landing zones)
1087        let d1 = root
1088            .join("domain=x.com")
1089            .join("report_date=2026-07-29")
1090            .join("run_id=r1")
1091            .join("raw_snoop");
1092        std::fs::create_dir_all(&d1)?;
1093        // two different parquet artifact types
1094        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1095        for (name, ids) in [
1096            ("crawlplan.parquet", vec![1i64]),
1097            ("other.parquet", vec![9i64]),
1098        ] {
1099            let batch =
1100                RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(ids))])?;
1101            let f = File::create(d1.join(name))?;
1102            let mut w =
1103                parquet::arrow::arrow_writer::ArrowWriter::try_new(f, schema.clone(), None)?;
1104            w.write(&batch)?;
1105            w.close()?;
1106        }
1107
1108        let fm = StagingFrontmatter {
1109            source_format: Some(SourceFormat::Parquet),
1110            scan_path: Some(".".into()),
1111            path_glob: Some(vec!["**/crawlplan.parquet".into()]),
1112            partition_by: Some(vec!["domain".into()]),
1113            inject_source_path: Some(true),
1114            ..Default::default()
1115        };
1116        let req = ScanRequest::from_frontmatter(root, &fm)?;
1117        let scanner = LakeScanner::from_request(&req);
1118        let batches = scanner.scan(&req).await?;
1119        let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1120        assert_eq!(rows, 1, "only crawlplan.parquet rows");
1121        assert!(batches[0].schema().index_of("domain").is_ok());
1122        Ok(())
1123    }
1124
1125    #[tokio::test]
1126    async fn test_protobuf_opaque_scan() -> Result<()> {
1127        let temp = tempfile::tempdir()?;
1128        let pb = temp.path().join("msg.pb");
1129        std::fs::write(&pb, b"\x08\x96\x01")?; // arbitrary bytes
1130        let fm = StagingFrontmatter {
1131            source_format: Some(SourceFormat::Protobuf),
1132            scan_path: Some(pb.file_name().unwrap().to_string_lossy().into()),
1133            ..Default::default()
1134        };
1135        let req = ScanRequest::from_frontmatter(temp.path(), &fm)?;
1136        assert_eq!(
1137            req.protobuf_max_payload_bytes,
1138            DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES
1139        );
1140        let scanner = LakeScanner::from_request(&req);
1141        let batches = scanner.scan(&req).await?;
1142        assert_eq!(batches.len(), 1);
1143        assert_eq!(batches[0].num_rows(), 1);
1144        assert_eq!(batches[0].schema().field(0).name(), "_source_path");
1145        assert_eq!(batches[0].schema().field(1).name(), "payload");
1146        Ok(())
1147    }
1148
1149    #[tokio::test]
1150    async fn test_protobuf_respects_max_payload_bytes() -> Result<()> {
1151        let temp = tempfile::tempdir()?;
1152        let pb = temp.path().join("big.pb");
1153        std::fs::write(&pb, vec![0u8; 64])?;
1154        let fm = StagingFrontmatter {
1155            source_format: Some(SourceFormat::Protobuf),
1156            scan_path: Some("big.pb".into()),
1157            ..Default::default()
1158        };
1159        let scan_cfg = ScanConfig {
1160            protobuf_max_payload_bytes: 16,
1161            ..Default::default()
1162        };
1163        let req = ScanRequest::from_frontmatter_with_config(
1164            temp.path(),
1165            &fm,
1166            HashMap::new(),
1167            &scan_cfg,
1168        )?;
1169        let scanner = LakeScanner::from_request(&req);
1170        let err = scanner.scan(&req).await.unwrap_err();
1171        // anyhow chains: outer "Failed reading bronze file …" + inner E_RBT_PROTOBUF_TOO_LARGE
1172        let full = format!("{err:#}");
1173        assert!(
1174            full.contains("E_RBT_PROTOBUF_TOO_LARGE"),
1175            "expected size-cap error in chain, got: {full}"
1176        );
1177        assert!(full.contains("protobuf_max_payload_bytes"));
1178        Ok(())
1179    }
1180
1181    #[tokio::test]
1182    async fn test_path_glob_single_star_is_not_recursive() -> Result<()> {
1183        let temp = tempfile::tempdir()?;
1184        let root = temp.path();
1185        let deep = root.join("a").join("b").join("raw");
1186        std::fs::create_dir_all(&deep)?;
1187        let shallow = root.join("raw");
1188        std::fs::create_dir_all(&shallow)?;
1189        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1190        for (dir, id) in [(&deep, 1i64), (&shallow, 2i64)] {
1191            let batch =
1192                RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![id]))])?;
1193            let f = File::create(dir.join("crawlplan.parquet"))?;
1194            let mut w =
1195                parquet::arrow::arrow_writer::ArrowWriter::try_new(f, schema.clone(), None)?;
1196            w.write(&batch)?;
1197            w.close()?;
1198        }
1199        let fm = StagingFrontmatter {
1200            source_format: Some(SourceFormat::Parquet),
1201            scan_path: Some(".".into()),
1202            // one segment only — should hit raw/crawlplan, not a/b/raw/crawlplan
1203            path_glob: Some(vec!["*/crawlplan.parquet".into()]),
1204            ..Default::default()
1205        };
1206        let req = ScanRequest::from_frontmatter(root, &fm)?;
1207        let scanner = LakeScanner::from_request(&req);
1208        let batches = scanner.scan(&req).await?;
1209        let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1210        assert_eq!(rows, 1, "single-star glob must not match deep hive path");
1211        Ok(())
1212    }
1213
1214    #[tokio::test]
1215    async fn test_absolute_scan_path() -> Result<()> {
1216        let temp = tempfile::tempdir()?;
1217        let abs = temp.path().join("abs.jsonl");
1218        std::fs::write(&abs, b"{\"a\":1}\n{\"a\":2}\n")?;
1219        let fm = StagingFrontmatter {
1220            source_format: Some(SourceFormat::Jsonl),
1221            scan_path: Some(abs.to_string_lossy().into()),
1222            ..Default::default()
1223        };
1224        // project_dir is different from parent of abs
1225        let proj = tempfile::tempdir()?;
1226        let req = ScanRequest::from_frontmatter(proj.path(), &fm)?;
1227        let scanner = LakeScanner::from_request(&req);
1228        let batches = scanner.scan(&req).await?;
1229        assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 2);
1230        Ok(())
1231    }
1232}