Skip to main content

rbt/core/
frontmatter.rs

1//! Staging SQL frontmatter: bronze scan contract and compile-time path checks.
2
3use crate::core::run_scope::OnMissing;
4use anyhow::{bail, Context, Result};
5use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11/// How `rbt compile` treats missing/unresolvable bronze `scan_path` entries.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum BronzeCheckMode {
15    /// Skip filesystem checks (DAG structure only).
16    Off,
17    /// Emit warnings; compile still succeeds (default for `compile`).
18    #[default]
19    Warn,
20    /// Missing or invalid bronze sources fail compile.
21    Fail,
22}
23
24impl BronzeCheckMode {
25    pub fn as_str(self) -> &'static str {
26        match self {
27            Self::Off => "off",
28            Self::Warn => "warn",
29            Self::Fail => "fail",
30        }
31    }
32}
33
34impl fmt::Display for BronzeCheckMode {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(self.as_str())
37    }
38}
39
40/// Supported bronze file formats for staging lake scans.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum SourceFormat {
44    /// Newline-delimited JSON (also accepts alias `ndjson`).
45    #[serde(alias = "ndjson")]
46    Jsonl,
47    /// Single JSON document or JSON array of objects.
48    Json,
49    Parquet,
50    Csv,
51    /// Arrow IPC file (random-access footer).
52    #[serde(alias = "arrow", alias = "arrow_file", alias = "ipc")]
53    ArrowIpc,
54    /// Arrow IPC stream (append-friendly / WAL-style).
55    #[serde(alias = "arrow_stream", alias = "ipc_stream")]
56    ArrowIpcStream,
57    /// Line-oriented application / server logs.
58    Log,
59    /// Line-oriented text (llms.txt, docs dumps, structured line files).
60    Txt,
61    /// TOML tables / array-of-tables as rows.
62    Toml,
63    /// Length-delimited or whole-file protobuf blobs (opaque bronze).
64    ///
65    /// Each file becomes one row: `_source_path` (Utf8) + `payload` (Binary).
66    /// Typed decode of domain messages is a later step (Rust models / schema registry).
67    #[serde(alias = "pb", alias = "proto")]
68    Protobuf,
69}
70
71impl SourceFormat {
72    pub fn as_str(self) -> &'static str {
73        match self {
74            Self::Jsonl => "jsonl",
75            Self::Json => "json",
76            Self::Parquet => "parquet",
77            Self::Csv => "csv",
78            Self::ArrowIpc => "arrow_ipc",
79            Self::ArrowIpcStream => "arrow_ipc_stream",
80            Self::Log => "log",
81            Self::Txt => "txt",
82            Self::Toml => "toml",
83            Self::Protobuf => "protobuf",
84        }
85    }
86
87    /// Prefer DataFusion listing / external table registration when true.
88    pub fn prefers_datafusion_listing(self) -> bool {
89        matches!(self, Self::Parquet | Self::Csv | Self::Json | Self::Jsonl)
90    }
91
92    /// Infer format from a file extension (without the dot).
93    pub fn from_extension(ext: &str) -> Option<Self> {
94        match ext.to_ascii_lowercase().as_str() {
95            "jsonl" | "ndjson" => Some(Self::Jsonl),
96            "json" => Some(Self::Json),
97            "parquet" | "pq" => Some(Self::Parquet),
98            "csv" | "tsv" => Some(Self::Csv),
99            "arrow" | "arrows" | "ipc" | "feather" => Some(Self::ArrowIpc),
100            "arrows_stream" | "ipc_stream" => Some(Self::ArrowIpcStream),
101            "log" => Some(Self::Log),
102            "txt" | "text" | "md" => Some(Self::Txt),
103            "toml" => Some(Self::Toml),
104            "pb" | "protobuf" | "protobin" => Some(Self::Protobuf),
105            _ => None,
106        }
107    }
108
109    /// Parse free-form frontmatter / CLI format strings.
110    pub fn parse(s: &str) -> Result<Self> {
111        let key = s.trim().to_ascii_lowercase().replace('-', "_");
112        match key.as_str() {
113            "jsonl" | "ndjson" | "json_lines" => Ok(Self::Jsonl),
114            "json" => Ok(Self::Json),
115            "parquet" | "pq" => Ok(Self::Parquet),
116            "csv" | "tsv" => Ok(Self::Csv),
117            "arrow_ipc" | "arrow" | "arrow_file" | "ipc" | "feather" => Ok(Self::ArrowIpc),
118            "arrow_ipc_stream" | "arrow_stream" | "ipc_stream" => Ok(Self::ArrowIpcStream),
119            "log" => Ok(Self::Log),
120            "txt" | "text" => Ok(Self::Txt),
121            "toml" => Ok(Self::Toml),
122            "protobuf" | "pb" | "proto" | "protobin" => Ok(Self::Protobuf),
123            other => bail!(
124                "Unknown source_format '{}'. Expected one of: jsonl, json, parquet, csv, arrow_ipc, arrow_ipc_stream, log, txt, toml, protobuf",
125                other
126            ),
127        }
128    }
129}
130
131impl fmt::Display for SourceFormat {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137/// FK-style relationship check: every non-null value in `column` must exist in
138/// `to_model.to_column` (parent must already be materialised / registered).
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140pub struct RelationshipTest {
141    /// Child column on this model.
142    pub column: String,
143    /// Parent model name (same DAG; must be registered for `ref()`).
144    #[serde(alias = "to", alias = "ref")]
145    pub to_model: String,
146    /// Parent column (defaults to same name as `column` when omitted).
147    #[serde(default, alias = "field")]
148    pub to_column: Option<String>,
149}
150
151impl RelationshipTest {
152    pub fn parent_column(&self) -> &str {
153        self.to_column.as_deref().unwrap_or(self.column.as_str())
154    }
155}
156
157/// Declared data-quality tests for a model (run after materialization when present).
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
159pub struct ModelTests {
160    /// Columns that must have zero nulls.
161    #[serde(default)]
162    pub not_null: Option<Vec<String>>,
163    /// Single column unique, or multi-column composite unique when len > 1.
164    #[serde(default)]
165    pub unique: Option<Vec<String>>,
166    /// Map of column → allowed string values.
167    #[serde(default)]
168    pub accepted_values: Option<std::collections::HashMap<String, Vec<String>>>,
169    /// FK-ish checks against already-materialised models (P6 / G6).
170    #[serde(default)]
171    pub relationships: Option<Vec<RelationshipTest>>,
172    /// When true (default), failed tests abort `rbt run` for that model.
173    #[serde(default)]
174    pub fail_on_error: Option<bool>,
175}
176
177impl ModelTests {
178    pub fn is_empty(&self) -> bool {
179        self.not_null.as_ref().map(|v| v.is_empty()).unwrap_or(true)
180            && self.unique.as_ref().map(|v| v.is_empty()).unwrap_or(true)
181            && self
182                .accepted_values
183                .as_ref()
184                .map(|m| m.is_empty())
185                .unwrap_or(true)
186            && self
187                .relationships
188                .as_ref()
189                .map(|r| r.is_empty())
190                .unwrap_or(true)
191    }
192
193    pub fn should_fail_on_error(&self) -> bool {
194        self.fail_on_error.unwrap_or(true)
195    }
196}
197
198/// Per-column documentation for humans and AI agents.
199///
200/// * `description` — short label (1–2 lines)
201/// * `context` — longer intent, units, lineage, caveats (agent-oriented)
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
203pub struct ColumnMeta {
204    #[serde(default)]
205    pub description: Option<String>,
206    #[serde(default)]
207    pub context: Option<String>,
208    /// Optional logical type hint (`utf8`, `int64`, `float64`, `timestamp`, …).
209    #[serde(default)]
210    pub dtype: Option<String>,
211    /// Optional unit (`USD`, `shares`, `ratio`, `ns_epoch`, …).
212    #[serde(default)]
213    pub unit: Option<String>,
214}
215
216/// YAML frontmatter embedded in model SQL files (`---` … `---`).
217///
218/// Used on staging, transforms, and marts. Scan-related fields only apply when
219/// a bronze scan contract (`scan_path`) is present.
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
221pub struct StagingFrontmatter {
222    /// Human-readable model purpose (docs + future catalog).
223    #[serde(default)]
224    pub description: Option<String>,
225    /// Longer model-level context for AI agents (why it exists, consumers, caveats).
226    #[serde(default)]
227    pub context: Option<String>,
228    /// Column-level description + context map (name → meta).
229    #[serde(default)]
230    pub columns: Option<std::collections::BTreeMap<String, ColumnMeta>>,
231
232    /// Explicit bronze format. If omitted, inferred from `scan_path` extension.
233    #[serde(default)]
234    pub source_format: Option<SourceFormat>,
235    /// File or directory to scan (project-relative, absolute, or `$root/...` template).
236    #[serde(default)]
237    pub scan_path: Option<String>,
238    /// Filename / relative-path glob(s) under `scan_path` (OR semantics).
239    ///
240    /// Examples: `crawlplan.parquet`, `**/raw_snoop/crawlplan.parquet`, `*.jsonl`.
241    /// Empty / omitted = all files matching `source_format`.
242    /// Accepts a single string or a YAML list.
243    ///
244    /// **Pushdown note:** any non-empty `path_glob` forces the scan→MemTable bronze path
245    /// (DataFusion directory listing / predicate pushdown is **not** used for that source),
246    /// because listing providers cannot apply rbt's filename globs or hive path injection.
247    #[serde(default, deserialize_with = "deserialize_string_or_vec")]
248    pub path_glob: Option<Vec<String>>,
249    /// Optional hive-style partition keys (path injection + future pruning).
250    #[serde(default)]
251    pub partition_by: Option<Vec<String>>,
252    /// jshift / projection field paths for selective JSON(L) extract.
253    #[serde(default)]
254    pub paths: Option<Vec<String>>,
255    /// Override catalog/schema name for registration (default: first `source()` name).
256    #[serde(default)]
257    pub source_name: Option<String>,
258    /// Override table name for registration (default: first `source()` table).
259    #[serde(default)]
260    pub source_table: Option<String>,
261    /// TOML: key of the array-of-tables to expand into rows (default: auto-detect).
262    #[serde(default)]
263    pub toml_rows_key: Option<String>,
264    /// When true, use scan→MemTable path even for formats that support DF listing.
265    #[serde(default)]
266    pub force_scan: Option<bool>,
267    /// Only scan hive-partitioned files whose path segments match these values.
268    /// Example: `{ timeframe: "1m" }` keeps `.../timeframe=1m/...` and skips `timeframe=1d`.
269    #[serde(default)]
270    pub require_partitions: Option<std::collections::HashMap<String, String>>,
271    /// Inject `_source_path` (Utf8) with the absolute file path for each row.
272    /// Enables "latest chunk wins" dedupe via `ORDER BY _source_path DESC`.
273    #[serde(default)]
274    pub inject_source_path: Option<bool>,
275    /// When scan root is missing or filters match no files: `error` (default) | `empty`.
276    ///
277    /// `empty` registers a zero-row table with a declared schema from `columns.*.dtype`
278    /// (plus `partition_by` keys as Utf8). Required for partial multi-artifact bronze.
279    #[serde(default)]
280    pub on_missing: Option<OnMissing>,
281    /// Silver stage policy hint (docs + future engine): `full_refresh` | `latest_only` |
282    /// `append` | `mirror_bronze`. Does not change SQL by itself — authors implement
283    /// semantics in the model; rbt may use this for materialization defaults later.
284    #[serde(default)]
285    pub stage_mode: Option<String>,
286    /// When true, scan_path is a multi-part parquet directory (`*.parts` / `_rbt_manifest.json`).
287    /// Also auto-detected when the resolved path is a parts directory.
288    #[serde(default)]
289    pub parts: Option<bool>,
290    /// Stamp `_rbt_run_id`, `_rbt_contract_version`, `_rbt_model` (+ optional fingerprint)
291    /// onto each output row at materialize time (P6 lineage).
292    #[serde(default)]
293    pub lineage_stamp: Option<bool>,
294
295    /// Logical grain of the model (e.g. `[symbol, timestamp_ns]`).
296    #[serde(default)]
297    pub grain: Option<Vec<String>>,
298    /// Primary uniqueness contract (usually same as grain for staging facts).
299    #[serde(default)]
300    pub unique_key: Option<Vec<String>>,
301    /// Free-form tags for selection / docs.
302    #[serde(default)]
303    pub tags: Option<Vec<String>>,
304    /// Materialization hint: `table` | `view` | `incremental_append` (engine may ignore for now).
305    #[serde(default)]
306    pub materialization: Option<String>,
307    /// Post-materialization assertions.
308    #[serde(default)]
309    pub tests: Option<ModelTests>,
310    /// Opaque metadata for tools and agents (strings, lists, nested maps OK).
311    #[serde(default)]
312    pub meta: Option<std::collections::BTreeMap<String, serde_yaml::Value>>,
313}
314
315impl StagingFrontmatter {
316    /// Resolve format from explicit field or path extension.
317    pub fn resolve_format(&self) -> Result<SourceFormat> {
318        if let Some(fmt) = self.source_format {
319            return Ok(fmt);
320        }
321        let path = self
322            .scan_path
323            .as_deref()
324            .context("frontmatter missing both source_format and scan_path")?;
325        // Strip globs for extension sniffing: `foo/*.jsonl` → look at last segment
326        let candidate = path.rsplit('/').next().unwrap_or(path);
327        let candidate = candidate.trim_matches(|c| c == '*' || c == '?');
328        if let Some(ext) = Path::new(candidate).extension().and_then(|e| e.to_str()) {
329            if let Some(fmt) = SourceFormat::from_extension(ext) {
330                return Ok(fmt);
331            }
332        }
333        // Directory paths: no extension — require explicit format
334        bail!(
335            "Cannot infer source_format from scan_path '{}'; set source_format explicitly",
336            path
337        );
338    }
339
340    pub fn has_scan_contract(&self) -> bool {
341        self.scan_path
342            .as_ref()
343            .map(|s| !s.trim().is_empty())
344            .unwrap_or(false)
345    }
346
347    pub fn on_missing_policy(&self) -> OnMissing {
348        self.on_missing.unwrap_or(OnMissing::Error)
349    }
350
351    pub fn wants_lineage_stamp(&self) -> bool {
352        self.lineage_stamp.unwrap_or(false)
353    }
354
355    pub fn wants_parts_source(&self) -> bool {
356        self.parts.unwrap_or(false)
357    }
358
359    /// Build Arrow schema for empty bronze frames (`on_missing: empty`).
360    ///
361    /// Fields: declared `columns` with `dtype`, then any `partition_by` keys not already
362    /// present (Utf8), then optional `_source_path`.
363    pub fn empty_frame_schema(&self) -> Result<SchemaRef> {
364        let mut fields: Vec<Field> = Vec::new();
365        let mut seen = std::collections::HashSet::new();
366
367        if let Some(cols) = &self.columns {
368            for (name, meta) in cols {
369                let dtype = meta
370                    .dtype
371                    .as_deref()
372                    .with_context(|| {
373                        format!(
374                            "E_RBT_EMPTY_SCHEMA: column '{name}' needs dtype: for on_missing: empty \
375                             (e.g. utf8, int64, float64, bool, binary, timestamp)"
376                        )
377                    })?;
378                let dt = parse_logical_dtype(dtype).with_context(|| {
379                    format!("E_RBT_EMPTY_SCHEMA: column '{name}' dtype '{dtype}'")
380                })?;
381                fields.push(Field::new(name, dt, true));
382                seen.insert(name.clone());
383            }
384        }
385
386        if let Some(parts) = &self.partition_by {
387            for p in parts {
388                if seen.insert(p.clone()) {
389                    fields.push(Field::new(p, DataType::Utf8, true));
390                }
391            }
392        }
393
394        if self.inject_source_path.unwrap_or(false) && seen.insert("_source_path".into()) {
395            fields.push(Field::new("_source_path", DataType::Utf8, true));
396        }
397
398        if fields.is_empty() {
399            bail!(
400                "E_RBT_EMPTY_SCHEMA: on_missing: empty requires columns with dtype \
401                 and/or partition_by (model scan contract has no schema fields)"
402            );
403        }
404        Ok(Arc::new(Schema::new(fields)))
405    }
406}
407
408/// Parse logical dtype strings used in frontmatter `columns.*.dtype`.
409pub fn parse_logical_dtype(s: &str) -> Result<DataType> {
410    let key = s.trim().to_ascii_lowercase().replace('-', "_");
411    Ok(match key.as_str() {
412        "utf8" | "string" | "str" | "varchar" | "text" => DataType::Utf8,
413        "int64" | "long" | "bigint" | "i64" => DataType::Int64,
414        "int32" | "int" | "i32" => DataType::Int32,
415        "int16" | "smallint" | "i16" => DataType::Int16,
416        "int8" | "tinyint" | "i8" => DataType::Int8,
417        "uint64" | "u64" => DataType::UInt64,
418        "uint32" | "u32" => DataType::UInt32,
419        "float64" | "double" | "f64" => DataType::Float64,
420        "float32" | "float" | "f32" => DataType::Float32,
421        "bool" | "boolean" => DataType::Boolean,
422        "binary" | "bytes" | "blob" => DataType::Binary,
423        "date" | "date32" => DataType::Date32,
424        "timestamp" | "timestamp_us" | "timestamptz" => {
425            DataType::Timestamp(TimeUnit::Microsecond, None)
426        }
427        "timestamp_ms" => DataType::Timestamp(TimeUnit::Millisecond, None),
428        "timestamp_ns" => DataType::Timestamp(TimeUnit::Nanosecond, None),
429        "timestamp_s" => DataType::Timestamp(TimeUnit::Second, None),
430        other => bail!(
431            "unknown dtype '{other}' (expected utf8|int64|int32|float64|bool|binary|date|timestamp…)"
432        ),
433    })
434}
435
436/// Severity of a bronze compile diagnostic.
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum DiagnosticSeverity {
439    Warning,
440    Error,
441}
442
443/// One compile-time finding about bronze frontmatter / scan paths.
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct BronzeDiagnostic {
446    pub model: String,
447    pub severity: DiagnosticSeverity,
448    pub code: &'static str,
449    pub message: String,
450}
451
452impl fmt::Display for BronzeDiagnostic {
453    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454        let level = match self.severity {
455            DiagnosticSeverity::Warning => "warning",
456            DiagnosticSeverity::Error => "error",
457        };
458        write!(
459            f,
460            "{}[{}] model={}: {}",
461            level, self.code, self.model, self.message
462        )
463    }
464}
465
466/// Result of bronze path validation during compile.
467#[derive(Debug, Clone, Default)]
468pub struct BronzeValidationReport {
469    pub diagnostics: Vec<BronzeDiagnostic>,
470}
471
472impl BronzeValidationReport {
473    pub fn warning_count(&self) -> usize {
474        self.diagnostics
475            .iter()
476            .filter(|d| d.severity == DiagnosticSeverity::Warning)
477            .count()
478    }
479
480    pub fn error_count(&self) -> usize {
481        self.diagnostics
482            .iter()
483            .filter(|d| d.severity == DiagnosticSeverity::Error)
484            .count()
485    }
486
487    pub fn has_errors(&self) -> bool {
488        self.error_count() > 0
489    }
490}
491
492/// Resolve `scan_path` against the project root (no named roots). Prefer
493/// [`crate::core::paths::resolve_project_path`] when `roots:` are in play.
494pub fn resolve_scan_path(project_dir: &Path, scan_path: &str) -> PathBuf {
495    crate::core::paths::resolve_project_path(project_dir, scan_path, &Default::default())
496        .unwrap_or_else(|_| project_dir.to_path_buf())
497}
498
499pub use crate::core::paths::is_remote_uri;
500
501/// Deserialize either a single string or a sequence into `Option<Vec<String>>`.
502fn deserialize_string_or_vec<'de, D>(
503    deserializer: D,
504) -> std::result::Result<Option<Vec<String>>, D::Error>
505where
506    D: serde::Deserializer<'de>,
507{
508    use serde::de::{self, SeqAccess, Visitor};
509    use std::fmt;
510
511    struct StringOrVec;
512    impl<'de> Visitor<'de> for StringOrVec {
513        type Value = Option<Vec<String>>;
514
515        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
516            f.write_str("a string or list of strings")
517        }
518
519        fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
520            Ok(None)
521        }
522
523        fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
524            Ok(None)
525        }
526
527        fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
528            Ok(Some(vec![v.to_string()]))
529        }
530
531        fn visit_string<E: de::Error>(self, v: String) -> std::result::Result<Self::Value, E> {
532            Ok(Some(vec![v]))
533        }
534
535        fn visit_seq<A: SeqAccess<'de>>(
536            self,
537            mut seq: A,
538        ) -> std::result::Result<Self::Value, A::Error> {
539            let mut out = Vec::new();
540            while let Some(s) = seq.next_element::<String>()? {
541                out.push(s);
542            }
543            Ok(Some(out))
544        }
545    }
546
547    deserializer.deserialize_any(StringOrVec)
548}
549
550/// Whether a resolved local scan path currently exists (file or directory).
551/// Remote URIs are treated as "exists" for compile (runtime / object-store later).
552pub fn scan_path_exists(project_dir: &Path, scan_path: &str) -> bool {
553    scan_path_exists_with_roots(project_dir, scan_path, &std::collections::HashMap::new())
554}
555
556/// Like [`scan_path_exists`] but expands `$root` templates from project config.
557pub fn scan_path_exists_with_roots(
558    project_dir: &Path,
559    scan_path: &str,
560    roots: &std::collections::HashMap<String, String>,
561) -> bool {
562    if is_remote_uri(scan_path.trim()) {
563        return true;
564    }
565    let Ok(resolved) = crate::core::paths::resolve_project_path(project_dir, scan_path, roots)
566    else {
567        return false;
568    };
569    // Support simple trailing globs: `dir/*.jsonl` → check parent dir
570    let check = strip_simple_glob(&resolved);
571    check.exists()
572}
573
574fn strip_simple_glob(path: &Path) -> PathBuf {
575    let s = path.to_string_lossy();
576    if s.contains('*') || s.contains('?') {
577        if let Some(parent) = path.parent() {
578            return parent.to_path_buf();
579        }
580    }
581    path.to_path_buf()
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn format_from_extension_and_parse() {
590        assert_eq!(
591            SourceFormat::from_extension("jsonl"),
592            Some(SourceFormat::Jsonl)
593        );
594        assert_eq!(
595            SourceFormat::from_extension("toml"),
596            Some(SourceFormat::Toml)
597        );
598        assert_eq!(SourceFormat::from_extension("log"), Some(SourceFormat::Log));
599        assert_eq!(
600            SourceFormat::parse("arrow-ipc").unwrap(),
601            SourceFormat::ArrowIpc
602        );
603        assert_eq!(SourceFormat::parse("ndjson").unwrap(), SourceFormat::Jsonl);
604    }
605
606    #[test]
607    fn resolve_format_from_path() {
608        let fm = StagingFrontmatter {
609            scan_path: Some("lake/bronze/raw.jsonl".into()),
610            ..Default::default()
611        };
612        assert_eq!(fm.resolve_format().unwrap(), SourceFormat::Jsonl);
613    }
614
615    #[test]
616    fn remote_uri_exists_for_compile() {
617        assert!(scan_path_exists(
618            Path::new("/tmp"),
619            "s3://bucket/bronze/x.jsonl"
620        ));
621    }
622}