Skip to main content

rbt/core/
project.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use walkdir::WalkDir;
7
8use super::dag::{Materialization, ModelDag, ModelLayer, OutputFormat};
9use super::paths::{resolve_configured_path, resolve_project_path};
10
11/// Default MemTable row cutoff when `ref_strategy: memtable` and max rows omitted.
12pub const DEFAULT_MEMTABLE_MAX_ROWS: usize = 50_000;
13
14/// How completed models are exposed to downstream `{{ ref() }}` in the same run.
15///
16/// Default is lake-as-truth **parquet / file re-read** (no long-lived MemTable).
17/// Opt into MemTable via `materialize.ref_strategy: memtable` in `rbt_project.yml`.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum RefStrategy {
21    /// Always re-read the written lake file for `ref()` (default).
22    #[default]
23    #[serde(alias = "parquet_reread", alias = "lake", alias = "file")]
24    Parquet,
25    /// Keep an in-memory `MemTable` when `row_count < memtable_max_rows`; else re-read file.
26    #[serde(alias = "mem_table", alias = "memory", alias = "arc")]
27    Memtable,
28}
29
30/// Chosen backend after applying strategy + row cutoff.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum RefBackend {
33    /// DataFusion `MemTable` holding Arrow batches (Arc-shared).
34    MemTable,
35    /// Re-read model output from the lake path (`register_parquet` / json / csv).
36    LakeFile,
37}
38
39/// Optional materialization / `ref()` registration policy (`materialize:` in yml).
40///
41/// All fields are optional; omitting the whole block keeps lake-as-truth Parquet re-read.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct MaterializeConfig {
44    /// `parquet` (default) | `memtable`
45    #[serde(default)]
46    pub ref_strategy: RefStrategy,
47    /// Used only when `ref_strategy: memtable`. Defaults to [`DEFAULT_MEMTABLE_MAX_ROWS`].
48    #[serde(default = "default_memtable_max_rows")]
49    pub memtable_max_rows: usize,
50}
51
52fn default_memtable_max_rows() -> usize {
53    DEFAULT_MEMTABLE_MAX_ROWS
54}
55
56impl Default for MaterializeConfig {
57    fn default() -> Self {
58        Self {
59            ref_strategy: RefStrategy::Parquet,
60            memtable_max_rows: DEFAULT_MEMTABLE_MAX_ROWS,
61        }
62    }
63}
64
65/// Default max size for a single opaque protobuf bronze file (1 GiB).
66pub const DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES: u64 = 1024 * 1024 * 1024;
67
68/// Optional scan / bronze ingest limits (`scan:` in yml).
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ScanConfig {
71    /// Max bytes for one `source_format: protobuf` file. Default: 1 GiB.
72    ///
73    /// Override to raise/lower the safety cap for opaque `payload` columns.
74    #[serde(default = "default_protobuf_max_payload_bytes")]
75    pub protobuf_max_payload_bytes: u64,
76}
77
78fn default_protobuf_max_payload_bytes() -> u64 {
79    DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES
80}
81
82impl Default for ScanConfig {
83    fn default() -> Self {
84        Self {
85            protobuf_max_payload_bytes: DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES,
86        }
87    }
88}
89
90impl MaterializeConfig {
91    /// Decide MemTable vs lake file for a model that produced `row_count` rows.
92    pub fn choose_ref_backend(&self, row_count: usize) -> RefBackend {
93        match self.ref_strategy {
94            RefStrategy::Parquet => RefBackend::LakeFile,
95            RefStrategy::Memtable if row_count < self.memtable_max_rows => RefBackend::MemTable,
96            RefStrategy::Memtable => RefBackend::LakeFile,
97        }
98    }
99}
100
101/// Layer-specific target storage & path configuration.
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub struct LayerConfig {
104    pub path: PathBuf,
105    pub target_path: PathBuf,
106    pub default_format: Option<String>,
107}
108
109/// Project-wide `rbt_project.yml` configuration schema.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct RbtProjectConfig {
112    pub name: String,
113    pub version: String,
114    pub models_dir: PathBuf,
115    pub target_path: PathBuf,
116    #[serde(default)]
117    pub layers: HashMap<String, LayerConfig>,
118    /// Optional; defaults to lake-as-truth Parquet re-read for `ref()`.
119    #[serde(default)]
120    pub materialize: MaterializeConfig,
121    /// Optional bronze scan limits (e.g. protobuf payload cap).
122    #[serde(default)]
123    pub scan: ScanConfig,
124    /// Named absolute (or relative) roots for multi-root lakes.
125    ///
126    /// Referenced in paths as `$name` or `${name}` (e.g. `$nonprod_lake/lz/runs`).
127    #[serde(default)]
128    pub roots: HashMap<String, String>,
129}
130
131impl Default for RbtProjectConfig {
132    fn default() -> Self {
133        let mut layers = HashMap::new();
134        layers.insert(
135            "staging".to_string(),
136            LayerConfig {
137                path: PathBuf::from("models/staging"),
138                target_path: PathBuf::from("lake/silver"),
139                default_format: Some("parquet".to_string()),
140            },
141        );
142        layers.insert(
143            "transforms".to_string(),
144            LayerConfig {
145                path: PathBuf::from("models/transforms"),
146                target_path: PathBuf::from("lake/gold"),
147                default_format: Some("parquet".to_string()),
148            },
149        );
150        layers.insert(
151            "marts".to_string(),
152            LayerConfig {
153                path: PathBuf::from("models/marts"),
154                target_path: PathBuf::from("lake/gold"),
155                default_format: Some("parquet_and_iceberg".to_string()),
156            },
157        );
158
159        Self {
160            name: "rbt_project".to_string(),
161            version: "1.0.0".to_string(),
162            models_dir: PathBuf::from("models"),
163            target_path: PathBuf::from("lake/gold"),
164            layers,
165            materialize: MaterializeConfig::default(),
166            scan: ScanConfig::default(),
167            roots: HashMap::new(),
168        }
169    }
170}
171
172impl RbtProjectConfig {
173    /// Loads `rbt_project.yml` from project directory or returns default configuration.
174    pub fn load(project_dir: &Path) -> Result<Self> {
175        let project_file = project_dir.join("rbt_project.yml");
176        if project_file.exists() {
177            let content = fs::read_to_string(&project_file).with_context(|| {
178                format!(
179                    "E_RBT_PROJECT_LOAD: cannot read project file {}",
180                    project_file.display()
181                )
182            })?;
183            let mut config: RbtProjectConfig = serde_yaml::from_str(&content).with_context(|| {
184                format!(
185                    "E_RBT_PROJECT_LOAD: failed to parse {}. \
186                     Check required keys (name, version, models_dir, target_path) and \
187                     optional materialize:/scan:/roots:/layers blocks.",
188                    project_file.display()
189                )
190            })?;
191
192            let defaults = Self::default();
193            for (key, val) in defaults.layers {
194                config.layers.entry(key).or_insert(val);
195            }
196            Ok(config)
197        } else {
198            Ok(Self::default())
199        }
200    }
201
202    /// Resolve a configured path (absolute, relative, or `$root/...`) against the project.
203    pub fn resolve_path(&self, project_dir: &Path, configured: &str) -> Result<PathBuf> {
204        resolve_project_path(project_dir, configured, &self.roots)
205    }
206
207    /// Layer output directory (file parent for flat parquet, or table root parent).
208    pub fn resolve_layer_target_dir(
209        &self,
210        project_dir: &Path,
211        layer: ModelLayer,
212    ) -> Result<PathBuf> {
213        let layer_key = match layer {
214            ModelLayer::Staging => "staging",
215            ModelLayer::Transform => "transforms",
216            ModelLayer::Mart => "marts",
217        };
218        if let Some(layer_cfg) = self.layers.get(layer_key) {
219            resolve_configured_path(project_dir, &layer_cfg.target_path, &self.roots)
220        } else {
221            resolve_configured_path(project_dir, &self.target_path, &self.roots)
222        }
223    }
224
225    /// Resolves destination output file path for a model based on its layer configuration.
226    ///
227    /// Supports absolute `target_path` and `$root` templates — never nests an absolute
228    /// lake path under `project_dir`.
229    pub fn resolve_model_target_path(
230        &self,
231        project_dir: &Path,
232        model_name: &str,
233        layer: ModelLayer,
234        ext: &str,
235    ) -> Result<PathBuf> {
236        let dir = self
237            .resolve_layer_target_dir(project_dir, layer)
238            .with_context(|| {
239                format!(
240                    "E_RBT_MODEL_TARGET: cannot resolve output directory for model '{model_name}' \
241                 (layer={layer:?}). Check `layers.*.target_path`, top-level `target_path`, and \
242                 `roots:` in rbt_project.yml."
243                )
244            })?;
245        Ok(dir.join(format!("{model_name}.{ext}")))
246    }
247
248    /// Directory target for Iceberg-style table roots (no file extension).
249    pub fn resolve_model_target_dir(
250        &self,
251        project_dir: &Path,
252        model_name: &str,
253        layer: ModelLayer,
254    ) -> Result<PathBuf> {
255        let dir = self
256            .resolve_layer_target_dir(project_dir, layer)
257            .with_context(|| {
258                format!(
259                    "E_RBT_MODEL_TARGET: cannot resolve table directory for model '{model_name}' \
260                 (layer={layer:?}). Check layer target_path and roots:."
261                )
262            })?;
263        Ok(dir.join(model_name))
264    }
265
266    /// Discovers all `.sql` models under `models/` directory, resolves layer target paths, and constructs `ModelDag`.
267    pub fn build_dag(
268        &self,
269        project_dir: &Path,
270        cli_format_override: Option<OutputFormat>,
271    ) -> Result<ModelDag> {
272        let models_dir = project_dir.join(&self.models_dir);
273        let mut dag = ModelDag::new();
274
275        if !models_dir.exists() {
276            let default_fmt = cli_format_override.unwrap_or(OutputFormat::Parquet);
277            dag.add_model_with_format(
278                "stg_users",
279                "SELECT 1 AS id, 'Alice' AS name, 'admin' AS role",
280                Materialization::Table,
281                default_fmt,
282                None,
283                "",
284            )?;
285            dag.build_graph()?;
286            return Ok(dag);
287        }
288
289        let mut model_count = 0;
290        for entry in WalkDir::new(&models_dir).into_iter().filter_map(|e| e.ok()) {
291            let path = entry.path();
292            if path.is_file() && path.extension().is_some_and(|ext| ext == "sql") {
293                let stem = path.file_stem().and_then(|s| s.to_str()).with_context(|| {
294                    format!(
295                        "E_RBT_MODEL_NAME: invalid file stem for model path {}",
296                        path.display()
297                    )
298                })?;
299                let raw_sql = fs::read_to_string(path).with_context(|| {
300                    format!(
301                        "E_RBT_MODEL_IO: failed reading model SQL {}",
302                        path.display()
303                    )
304                })?;
305
306                let layer = ModelLayer::from_name(stem);
307                let format = cli_format_override.clone().unwrap_or_else(|| {
308                    let layer_key = match layer {
309                        ModelLayer::Staging => "staging",
310                        ModelLayer::Transform => "transforms",
311                        ModelLayer::Mart => "marts",
312                    };
313                    if let Some(l_cfg) = self.layers.get(layer_key) {
314                        match l_cfg.default_format.as_deref() {
315                            Some("parquet") => OutputFormat::Parquet,
316                            Some("jsonl") => OutputFormat::Jsonl,
317                            Some("csv") => OutputFormat::Csv,
318                            Some("iceberg") => OutputFormat::Iceberg,
319                            Some("parquet_and_iceberg") => OutputFormat::ParquetAndIceberg,
320                            _ => OutputFormat::Parquet,
321                        }
322                    } else {
323                        OutputFormat::Parquet
324                    }
325                });
326
327                let target_file_path = match format {
328                    OutputFormat::Iceberg => self
329                        .resolve_model_target_dir(project_dir, stem, layer)
330                        .with_context(|| {
331                            format!(
332                                "E_RBT_MODEL_TARGET: model '{stem}' (Iceberg) — \
333                                 failed resolving layer target. \
334                                 layer={layer:?}; project={}",
335                                project_dir.display()
336                            )
337                        })?,
338                    OutputFormat::Parquet
339                    | OutputFormat::ParquetAndIceberg
340                    | OutputFormat::ZeroCopyClone => self
341                        .resolve_model_target_path(project_dir, stem, layer, "parquet")
342                        .with_context(|| {
343                            format!(
344                                "E_RBT_MODEL_TARGET: model '{stem}' (parquet) — \
345                                 failed resolving layer target. \
346                                 layer={layer:?}; project={}",
347                                project_dir.display()
348                            )
349                        })?,
350                    OutputFormat::Jsonl => self
351                        .resolve_model_target_path(project_dir, stem, layer, "jsonl")
352                        .with_context(|| {
353                            format!(
354                                "E_RBT_MODEL_TARGET: model '{stem}' (jsonl) — \
355                                 failed resolving layer target"
356                            )
357                        })?,
358                    OutputFormat::Csv => self
359                        .resolve_model_target_path(project_dir, stem, layer, "csv")
360                        .with_context(|| {
361                            format!(
362                                "E_RBT_MODEL_TARGET: model '{stem}' (csv) — \
363                                 failed resolving layer target"
364                            )
365                        })?,
366                };
367
368                dag.add_model_with_format(
369                    stem,
370                    &raw_sql,
371                    Materialization::Table,
372                    format,
373                    Some(target_file_path.to_string_lossy().to_string()),
374                    "",
375                )?;
376                model_count += 1;
377            }
378        }
379
380        if model_count == 0 {
381            let default_fmt = cli_format_override.unwrap_or(OutputFormat::Parquet);
382            dag.add_model_with_format(
383                "stg_users",
384                "SELECT 1 AS id, 'Alice' AS name, 'admin' AS role",
385                Materialization::Table,
386                default_fmt,
387                None,
388                "",
389            )?;
390        }
391
392        dag.build_graph()?;
393        Ok(dag)
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn test_layer_target_path_resolution() -> Result<()> {
403        let config = RbtProjectConfig::default();
404        let project_dir = Path::new("/tmp/test_project");
405
406        let stg_path = config.resolve_model_target_path(
407            project_dir,
408            "stg_trades",
409            ModelLayer::Staging,
410            "parquet",
411        )?;
412        assert_eq!(stg_path, project_dir.join("lake/silver/stg_trades.parquet"));
413
414        let tf_path = config.resolve_model_target_path(
415            project_dir,
416            "tf_1m_bars",
417            ModelLayer::Transform,
418            "parquet",
419        )?;
420        assert_eq!(tf_path, project_dir.join("lake/gold/tf_1m_bars.parquet"));
421
422        let mart_path = config.resolve_model_target_path(
423            project_dir,
424            "fact_1d_bars",
425            ModelLayer::Mart,
426            "parquet",
427        )?;
428        assert_eq!(
429            mart_path,
430            project_dir.join("lake/gold/fact_1d_bars.parquet")
431        );
432
433        Ok(())
434    }
435
436    #[test]
437    fn materialize_defaults_to_parquet_reread() {
438        let cfg = MaterializeConfig::default();
439        assert_eq!(cfg.ref_strategy, RefStrategy::Parquet);
440        assert_eq!(cfg.memtable_max_rows, DEFAULT_MEMTABLE_MAX_ROWS);
441        assert_eq!(cfg.choose_ref_backend(0), RefBackend::LakeFile);
442        assert_eq!(cfg.choose_ref_backend(1_000_000), RefBackend::LakeFile);
443    }
444
445    #[test]
446    fn materialize_memtable_respects_cutoff() {
447        let cfg = MaterializeConfig {
448            ref_strategy: RefStrategy::Memtable,
449            memtable_max_rows: 50_000,
450        };
451        assert_eq!(cfg.choose_ref_backend(49_999), RefBackend::MemTable);
452        assert_eq!(cfg.choose_ref_backend(50_000), RefBackend::LakeFile);
453        assert_eq!(cfg.choose_ref_backend(50_001), RefBackend::LakeFile);
454    }
455
456    #[test]
457    fn parse_materialize_block_from_yaml() -> Result<()> {
458        let yml = r#"
459name: t
460version: "1"
461models_dir: models
462target_path: lake/gold
463materialize:
464  ref_strategy: memtable
465  memtable_max_rows: 10000
466"#;
467        let cfg: RbtProjectConfig = serde_yaml::from_str(yml)?;
468        assert_eq!(cfg.materialize.ref_strategy, RefStrategy::Memtable);
469        assert_eq!(cfg.materialize.memtable_max_rows, 10_000);
470        assert_eq!(
471            cfg.materialize.choose_ref_backend(9_999),
472            RefBackend::MemTable
473        );
474        assert_eq!(
475            cfg.materialize.choose_ref_backend(10_000),
476            RefBackend::LakeFile
477        );
478        Ok(())
479    }
480
481    #[test]
482    fn parse_project_without_materialize_uses_defaults() -> Result<()> {
483        let yml = r#"
484name: t
485version: "1"
486models_dir: models
487target_path: lake/gold
488"#;
489        let cfg: RbtProjectConfig = serde_yaml::from_str(yml)?;
490        assert_eq!(cfg.materialize, MaterializeConfig::default());
491        Ok(())
492    }
493
494    #[test]
495    fn parse_memtable_without_max_rows_defaults_cutoff() -> Result<()> {
496        let yml = r#"
497name: t
498version: "1"
499models_dir: models
500target_path: lake/gold
501materialize:
502  ref_strategy: memtable
503"#;
504        let cfg: RbtProjectConfig = serde_yaml::from_str(yml)?;
505        assert_eq!(cfg.materialize.ref_strategy, RefStrategy::Memtable);
506        assert_eq!(cfg.materialize.memtable_max_rows, DEFAULT_MEMTABLE_MAX_ROWS);
507        Ok(())
508    }
509
510    #[test]
511    fn absolute_layer_target_not_nested_under_project() -> Result<()> {
512        let yml = r#"
513name: multi_root_demo
514version: "1"
515models_dir: models
516target_path: /mnt/datalake/acme/nonprod/lake_us/lake/gold
517layers:
518  staging:
519    path: models/staging
520    target_path: /mnt/datalake/acme/nonprod/lake_us/lake/silver/stage
521    default_format: parquet
522"#;
523        let cfg: RbtProjectConfig = serde_yaml::from_str(yml)?;
524        let project = Path::new("/home/dev/rbt_projects/demo");
525        let stg =
526            cfg.resolve_model_target_path(project, "stg_events", ModelLayer::Staging, "parquet")?;
527        assert_eq!(
528            stg,
529            PathBuf::from(
530                "/mnt/datalake/acme/nonprod/lake_us/lake/silver/stage/stg_events.parquet"
531            )
532        );
533        assert!(!stg.starts_with(project));
534        Ok(())
535    }
536
537    #[test]
538    fn multi_root_template_in_layer_target() -> Result<()> {
539        let yml = r#"
540name: multi_root_demo
541version: "1"
542models_dir: models
543target_path: $nonprod_lake/gold
544roots:
545  nonprod_lake: /mnt/datalake/acme/nonprod/lake_us/lake
546layers:
547  staging:
548    path: models/staging
549    target_path: $nonprod_lake/silver/stage
550    default_format: parquet
551"#;
552        let cfg: RbtProjectConfig = serde_yaml::from_str(yml)?;
553        let project = Path::new("/home/dev/proj");
554        let dir = cfg.resolve_layer_target_dir(project, ModelLayer::Staging)?;
555        assert_eq!(
556            dir,
557            PathBuf::from("/mnt/datalake/acme/nonprod/lake_us/lake/silver/stage")
558        );
559        Ok(())
560    }
561
562    #[test]
563    fn bad_root_in_layer_target_is_error() {
564        let yml = r#"
565name: t
566version: "1"
567models_dir: models
568target_path: lake/gold
569layers:
570  staging:
571    path: models/staging
572    target_path: $missing_root/silver
573    default_format: parquet
574"#;
575        let cfg: RbtProjectConfig = serde_yaml::from_str(yml).unwrap();
576        let err = cfg
577            .resolve_layer_target_dir(Path::new("/proj"), ModelLayer::Staging)
578            .unwrap_err()
579            .to_string();
580        assert!(err.contains("E_RBT_ROOT_UNKNOWN") || err.contains("E_RBT_LAYER_PATH"));
581    }
582
583    #[test]
584    fn scan_config_defaults_protobuf_cap() {
585        let cfg = ScanConfig::default();
586        assert_eq!(
587            cfg.protobuf_max_payload_bytes,
588            DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES
589        );
590        assert_eq!(DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES, 1024 * 1024 * 1024);
591    }
592
593    #[test]
594    fn scan_config_override_from_yml() -> Result<()> {
595        let yml = r#"
596name: t
597version: "1"
598models_dir: models
599target_path: lake/gold
600scan:
601  protobuf_max_payload_bytes: 4096
602"#;
603        let cfg: RbtProjectConfig = serde_yaml::from_str(yml)?;
604        assert_eq!(cfg.scan.protobuf_max_payload_bytes, 4096);
605        // omit scan: → default 1 GiB
606        let yml2 = r#"
607name: t
608version: "1"
609models_dir: models
610target_path: lake/gold
611"#;
612        let cfg2: RbtProjectConfig = serde_yaml::from_str(yml2)?;
613        assert_eq!(
614            cfg2.scan.protobuf_max_payload_bytes,
615            DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES
616        );
617        Ok(())
618    }
619
620    /// Workspace examples stay loadable / 0.3.7-shaped (roots + defaults).
621    #[test]
622    fn load_workspace_example_projects() -> Result<()> {
623        let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
624        let repo = manifest.join("../..");
625        for (rel, name, expect_root) in [
626            ("examples/smoke_fixture", "smoke_fixture", "lake"),
627            ("examples/full_e2e_rbt_example", "market_bars", "lake"),
628        ] {
629            let dir = repo.join(rel);
630            if !dir.join("rbt_project.yml").is_file() {
631                // crates.io source package may omit large e2e bronze; skip if missing
632                continue;
633            }
634            let cfg = RbtProjectConfig::load(&dir)?;
635            assert_eq!(cfg.name, name, "example {rel}");
636            assert_eq!(
637                cfg.roots.get("lake").map(String::as_str),
638                Some(expect_root),
639                "example {rel} should declare roots.lake"
640            );
641            assert_eq!(cfg.materialize.ref_strategy, RefStrategy::Parquet);
642            assert_eq!(
643                cfg.scan.protobuf_max_payload_bytes,
644                DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES
645            );
646            let silver = cfg.resolve_layer_target_dir(&dir, ModelLayer::Staging)?;
647            assert!(
648                silver.ends_with("lake/silver") || silver.ends_with("lake\\silver"),
649                "staging target for {rel}: {}",
650                silver.display()
651            );
652            // DAG builds for smoke always; e2e only if models present
653            if dir.join("models").is_dir() {
654                let dag = cfg.build_dag(&dir, None)?;
655                assert!(
656                    dag.graph.node_count() >= 3,
657                    "example {rel} expected ≥3 models, got {}",
658                    dag.graph.node_count()
659                );
660            }
661        }
662        Ok(())
663    }
664}