//! `plan-run` CLI: read a JSON plan from a file (or stdin if `-`), execute //! it against the chosen backend, and print the resulting binding relation //! as JSON on stdout. //! //! Backends: //! //! - `memory` (default): build tables straight from the plan's `facts` //! block, no `Storage` trait involved. Pure in-memory path. //! - `memory-storage`: load the same facts through `storage::MemoryStorage` //! via the `Storage` trait, then materialize tables back out with //! `scan_as_table` before executing. //! - `lmdb`, `redb`, `fjall`, `sqlite`: file-backed `Storage` adapters. //! Each invocation creates a fresh tempdir for the store and drops it on //! exit; the runner is one-shot, so persistent paths aren't needed. //! - `geomerge`: CRDT-backed adapter. Constructed in-memory; alpha-status //! upstream. use std::collections::HashMap; use std::io::{self, Read}; use std::process::ExitCode; use plan_runner::{JsonValue, Plan, build_tables, build_tables_via_storage, execute, parse_plan}; use storage::MemoryStorage; use storage::adapters::fjall::FjallStorage; use storage::adapters::geomerge::{ColumnKind, GeomergeStorage}; use storage::adapters::lmdb::LmdbStorage; use storage::adapters::redb::RedbStorage; use storage::adapters::sqlite::SqliteStorage; use storage::table::Table; use storage::value::Value; use tempfile::TempDir; #[derive(Debug, Clone, Copy)] enum Backend { Memory, MemoryStorage, Lmdb, Redb, Fjall, Sqlite, Geomerge, } impl Backend { fn parse(s: &str) -> Option { match s { "memory" => Some(Self::Memory), "memory-storage" => Some(Self::MemoryStorage), "lmdb" => Some(Self::Lmdb), "redb" => Some(Self::Redb), "fjall" => Some(Self::Fjall), "sqlite" => Some(Self::Sqlite), "geomerge" => Some(Self::Geomerge), _ => None, } } } const BACKENDS_HELP: &str = "memory|memory-storage|lmdb|redb|fjall|sqlite|geomerge"; fn main() -> ExitCode { let mut backend = Backend::Memory; let mut input_path: Option = None; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { "--backend" => { let Some(value) = args.next() else { eprintln!("--backend requires a value ({BACKENDS_HELP})"); return ExitCode::from(2); }; let Some(parsed) = Backend::parse(&value) else { eprintln!("unknown backend {value:?} (try {BACKENDS_HELP})"); return ExitCode::from(2); }; backend = parsed; } other if input_path.is_none() => input_path = Some(other.to_string()), other => { eprintln!("unexpected argument: {other}"); return ExitCode::from(2); } } } let Some(path) = input_path else { eprintln!("usage: plan-run [--backend {BACKENDS_HELP}] "); return ExitCode::from(2); }; let input = match read_input(&path) { Ok(s) => s, Err(err) => { eprintln!("failed to read {path}: {err}"); return ExitCode::from(1); } }; let plan = match parse_plan(&input) { Ok(p) => p, Err(err) => { eprintln!("parse error: {err}"); return ExitCode::from(1); } }; let tables = match build_tables_for(&plan, backend) { Ok(t) => t, Err(err) => { eprintln!("{err}"); return ExitCode::from(1); } }; let relation = match execute(&tables, &plan.query) { Ok(r) => r, Err(err) => { eprintln!("execute error: {err}"); return ExitCode::from(1); } }; let payload = serde_json::json!({ "columns": relation.columns, "rows": relation .rows .iter() .map(|row| row.iter().map(value_to_json).collect::>()) .collect::>(), }); println!("{payload}"); ExitCode::SUCCESS } /// Build the input tables for `plan` using `backend`. Path-based adapters /// allocate a fresh tempdir; it drops at the end of this function, which is /// safe because `build_tables_via_storage` fully materializes the tables /// into owned `Vec` before returning. fn build_tables_for(plan: &Plan, backend: Backend) -> Result, String> { match backend { Backend::Memory => build_tables(plan).map_err(|e| format!("build error: {e}")), Backend::MemoryStorage => { let mut storage = MemoryStorage::default(); build_tables_via_storage(plan, &mut storage) .map_err(|e| format!("build error (memory-storage): {e}")) } Backend::Lmdb => { let dir = TempDir::new().map_err(|e| format!("tempdir: {e}"))?; let mut storage = LmdbStorage::open(dir.path()) .map_err(|e| format!("failed to open lmdb backend: {e}"))?; build_tables_via_storage(plan, &mut storage) .map_err(|e| format!("build error (lmdb): {e}")) } Backend::Redb => { let dir = TempDir::new().map_err(|e| format!("tempdir: {e}"))?; let mut storage = RedbStorage::open(dir.path().join("data.redb")) .map_err(|e| format!("failed to open redb backend: {e}"))?; build_tables_via_storage(plan, &mut storage) .map_err(|e| format!("build error (redb): {e}")) } Backend::Fjall => { let dir = TempDir::new().map_err(|e| format!("tempdir: {e}"))?; let mut storage = FjallStorage::open(dir.path()) .map_err(|e| format!("failed to open fjall backend: {e}"))?; build_tables_via_storage(plan, &mut storage) .map_err(|e| format!("build error (fjall): {e}")) } Backend::Sqlite => { let dir = TempDir::new().map_err(|e| format!("tempdir: {e}"))?; let mut storage = SqliteStorage::open(dir.path().join("data.sqlite")) .map_err(|e| format!("failed to open sqlite backend: {e}"))?; build_tables_via_storage(plan, &mut storage) .map_err(|e| format!("build error (sqlite): {e}")) } Backend::Geomerge => { let relations = plan .schema .iter() .map(|(name, &arity)| (name.clone(), infer_column_kinds(plan, name, arity))); let mut storage = GeomergeStorage::with_relations(relations) .map_err(|e| format!("failed to open geomerge backend: {e}"))?; build_tables_via_storage(plan, &mut storage) .map_err(|e| format!("build error (geomerge): {e}")) } } } /// Best-effort column type inference for `geomerge`'s synthesized theory. /// The runner IR carries only arity, so we peek at the first fact row of /// the relation. Columns without a sample default to `String`, which /// matches every checked-in fixture (entity identities are encoded as /// strings by the exporter). fn infer_column_kinds(plan: &Plan, name: &str, arity: usize) -> Vec { let mut kinds = vec![ColumnKind::String; arity]; let Some(rows) = plan.facts.get(name) else { return kinds; }; let Some(first) = rows.first() else { return kinds; }; for (i, cell) in first.iter().take(arity).enumerate() { kinds[i] = match cell { JsonValue::Int(_) => ColumnKind::Int, JsonValue::Str(_) => ColumnKind::String, }; } kinds } fn read_input(path: &str) -> io::Result { if path == "-" { let mut buf = String::new(); io::stdin().read_to_string(&mut buf)?; Ok(buf) } else { std::fs::read_to_string(path) } } fn value_to_json(value: &Value) -> serde_json::Value { match value { Value::Int(n) => serde_json::Value::Number((*n).into()), Value::Str(s) => serde_json::Value::String(s.clone()), Value::Id(id) => serde_json::Value::String(id.to_string()), } }