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
11pub const DEFAULT_MEMTABLE_MAX_ROWS: usize = 50_000;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum RefStrategy {
21 #[default]
23 #[serde(alias = "parquet_reread", alias = "lake", alias = "file")]
24 Parquet,
25 #[serde(alias = "mem_table", alias = "memory", alias = "arc")]
27 Memtable,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum RefBackend {
33 MemTable,
35 LakeFile,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct MaterializeConfig {
44 #[serde(default)]
46 pub ref_strategy: RefStrategy,
47 #[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
65pub const DEFAULT_PROTOBUF_MAX_PAYLOAD_BYTES: u64 = 1024 * 1024 * 1024;
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ScanConfig {
71 #[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 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#[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#[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 #[serde(default)]
120 pub materialize: MaterializeConfig,
121 #[serde(default)]
123 pub scan: ScanConfig,
124 #[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 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 pub fn resolve_path(&self, project_dir: &Path, configured: &str) -> Result<PathBuf> {
204 resolve_project_path(project_dir, configured, &self.roots)
205 }
206
207 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 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 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 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 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 #[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 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 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}