use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use rbt::{
model_has_test_contract, BronzeCheckMode, OutputFormat, RbtProjectConfig, SelectMode,
TransformationEngine,
};
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Instant;
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(
name = "rbt",
version,
about = "Rust lake build tool: medallion SQL DAGs on Parquet/Iceberg-style tables"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)]
enum CliFormat {
Parquet,
Jsonl,
Csv,
Iceberg,
ParquetAndIceberg,
ZeroCopyClone,
}
impl From<CliFormat> for OutputFormat {
fn from(fmt: CliFormat) -> Self {
match fmt {
CliFormat::Parquet => OutputFormat::Parquet,
CliFormat::Jsonl => OutputFormat::Jsonl,
CliFormat::Csv => OutputFormat::Csv,
CliFormat::Iceberg => OutputFormat::Iceberg,
CliFormat::ParquetAndIceberg => OutputFormat::ParquetAndIceberg,
CliFormat::ZeroCopyClone => OutputFormat::ZeroCopyClone,
}
}
}
#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)]
enum CliBronzeCheck {
Off,
Warn,
Fail,
}
impl From<CliBronzeCheck> for BronzeCheckMode {
fn from(v: CliBronzeCheck) -> Self {
match v {
CliBronzeCheck::Off => BronzeCheckMode::Off,
CliBronzeCheck::Warn => BronzeCheckMode::Warn,
CliBronzeCheck::Fail => BronzeCheckMode::Fail,
}
}
}
impl FromStr for CliBronzeCheck {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"off" => Ok(Self::Off),
"warn" | "warning" => Ok(Self::Warn),
"fail" | "error" => Ok(Self::Fail),
other => Err(format!(
"invalid bronze-check '{}'; expected off|warn|fail",
other
)),
}
}
}
#[derive(Subcommand)]
enum Commands {
Compile {
#[arg(short, long, default_value = ".")]
project_dir: PathBuf,
#[arg(short = 's', long)]
select: Option<String>,
#[arg(long, value_enum, default_value_t = CliBronzeCheck::Warn)]
bronze_check: CliBronzeCheck,
},
Run {
#[arg(short, long, default_value = ".")]
project_dir: PathBuf,
#[arg(short = 's', long)]
select: Option<String>,
#[arg(short, long, default_value = "./target/output")]
output_dir: PathBuf,
#[arg(short, long, value_enum, default_value_t = CliFormat::Parquet)]
format: CliFormat,
#[arg(long, value_enum, default_value_t = CliBronzeCheck::Fail)]
bronze_check: CliBronzeCheck,
},
Test {
#[arg(short, long, default_value = ".")]
project_dir: PathBuf,
#[arg(short = 's', long)]
select: Option<String>,
#[arg(short, long)]
model: Option<String>,
#[arg(short, long, default_value = "./target/output")]
output_dir: PathBuf,
#[arg(short, long, value_enum, default_value_t = CliFormat::Parquet)]
format: CliFormat,
#[arg(long, value_enum, default_value_t = CliBronzeCheck::Fail)]
bronze_check: CliBronzeCheck,
},
Bench {
#[arg(short, long, default_value_t = 1000000)]
num_rows: usize,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
match cli.command {
Commands::Compile {
project_dir,
select,
bronze_check,
} => {
println!(
"[rbt] Compiling project {:?} (select={:?}, bronze_check={})...",
project_dir,
select,
BronzeCheckMode::from(bronze_check.clone())
);
let config = RbtProjectConfig::load(&project_dir)?;
let full = config.build_dag(&project_dir, None)?;
let dag = full.apply_select(select.as_deref(), SelectMode::Exact)?;
let tiers = dag.execution_tiers()?;
println!(
"[rbt] DAG built with {} model(s) in {} tier(s):",
dag.node_map.len(),
tiers.len()
);
for (i, tier) in tiers.iter().enumerate() {
let names: Vec<&str> = tier.iter().map(|m| m.name.as_str()).collect();
println!(" Tier {}: {:?}", i, names);
}
let report = dag.validate_bronze_sources(&project_dir, bronze_check.into())?;
for d in &report.diagnostics {
eprintln!("{d}");
}
if report.has_errors() {
bail!(
"[rbt] compile failed: {} bronze error(s), {} warning(s)",
report.error_count(),
report.warning_count()
);
}
if report.warning_count() > 0 {
println!(
"[rbt] compile succeeded with {} bronze warning(s)",
report.warning_count()
);
} else {
println!("[rbt] compile succeeded (bronze sources ok)");
}
}
Commands::Run {
project_dir,
select,
output_dir,
format,
bronze_check,
} => {
println!(
"[rbt] Executing pipeline from {:?} (select: {:?}, format: {:?}, output: {:?}, bronze_check={:?})...",
project_dir, select, format, output_dir, bronze_check
);
let start = Instant::now();
let config = RbtProjectConfig::load(&project_dir)?;
let full = config.build_dag(&project_dir, Some(format.into()))?;
let dag = full
.apply_select(select.as_deref(), SelectMode::Execute)
.context("invalid --select")?;
let report = dag.validate_bronze_sources(&project_dir, bronze_check.into())?;
for d in &report.diagnostics {
eprintln!("{d}");
}
if report.has_errors() {
bail!(
"[rbt] run aborted: {} bronze error(s) — fix scan_path/frontmatter or use --bronze-check=warn",
report.error_count()
);
}
println!(
"[rbt] Running {} model(s): {:?}",
dag.node_map.len(),
dag.topological_sequence()?
.iter()
.map(|m| m.name.as_str())
.collect::<Vec<_>>()
);
let engine = TransformationEngine::new();
let summary = engine.execute_dag(&dag, &project_dir, &output_dir).await?;
let duration = start.elapsed();
println!(
"[rbt] Completed {} models ({} rows, {} bronze sources) in {:.2?}",
summary.models_executed,
summary.total_rows_produced,
summary.bronze_sources_registered,
duration
);
}
Commands::Test {
project_dir,
select,
model,
output_dir,
format,
bronze_check,
} => {
let select = match (select, model) {
(Some(s), Some(m)) => Some(format!("{},{}", s, m)),
(Some(s), None) => Some(s),
(None, Some(m)) => Some(m),
(None, None) => None,
};
println!(
"[rbt] Testing project {:?} (select={:?})...",
project_dir, select
);
let config = RbtProjectConfig::load(&project_dir)?;
let full = config.build_dag(&project_dir, Some(format.into()))?;
let select_spec = if let Some(s) = select {
s
} else {
let names = full.models_with_test_contract()?;
if names.is_empty() {
println!("[rbt] No models with frontmatter tests/grain/unique_key found.");
return Ok(());
}
names.join(",")
};
let dag = full
.apply_select(Some(&select_spec), SelectMode::Execute)
.context("invalid --select / --model")?;
let report = dag.validate_bronze_sources(&project_dir, bronze_check.into())?;
for d in &report.diagnostics {
eprintln!("{d}");
}
if report.has_errors() {
bail!(
"[rbt] test aborted: {} bronze error(s)",
report.error_count()
);
}
println!(
"[rbt] Executing {} model(s) then validating frontmatter tests...",
dag.node_map.len()
);
let engine = TransformationEngine::new();
let summary = engine.execute_dag(&dag, &project_dir, &output_dir).await?;
let mut tested = 0usize;
let mut without = 0usize;
for node in dag.topological_sequence()? {
if model_has_test_contract(&node) {
tested += 1;
println!(
" PASS {} (frontmatter tests executed during materialize)",
node.name
);
} else {
without += 1;
println!(" SKIP {} (no tests/grain/unique_key)", node.name);
}
}
println!(
"[rbt] Tests finished: {} models with tests, {} skipped, {} rows produced across run",
tested, without, summary.total_rows_produced
);
}
Commands::Bench { num_rows } => {
println!(
"[rbt BENCHMARK] Generating & transforming {} rows in-memory...",
num_rows
);
let start = Instant::now();
let engine = TransformationEngine::new();
let query = format!(
"SELECT id, id * 2 AS val, CONCAT('user_', CAST(id AS VARCHAR)) AS name FROM (SELECT \"range()\".value AS id FROM range(0, {}))",
num_rows
);
let df = engine.ctx.sql(&query).await?;
let batches = df.collect().await?;
let duration = start.elapsed();
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
let rows_per_sec = (total_rows as f64) / duration.as_secs_f64();
println!(
"[rbt BENCHMARK RESULT] Processed {} rows in {:.2?} ({:.0} rows/sec)",
total_rows, duration, rows_per_sec
);
}
}
Ok(())
}