#![allow(clippy::print_stdout, clippy::print_stderr)]
use std::collections::BTreeMap;
use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use rust_dotenv::dotenv::DotEnv;
use surrealkit::config::{DbCfg, DbOverrides, connect};
use surrealkit::core::exec_surql;
use surrealkit::module::Module;
use surrealkit::project::{ProjectConfig, Target};
use surrealkit::rollout::{self, RolloutExecutionOpts, RolloutPlanOpts};
use surrealkit::setup::run_setup;
use surrealkit::sync::{self, SyncOpts};
use surrealkit::tester::{TestOpts, run_test};
use surrealkit::typegen::{TypegenOpts, run_typegen};
use surrealkit::variables::{TemplateVars, build_vars, parse_var_flag};
use crate::templates::InitOpts;
mod templates;
#[derive(Parser, Debug)]
#[command(version, about = "SurrealKit CLI")]
pub struct Cli {
#[arg(short, long, global = true)]
verbose: bool,
#[arg(short = 's', long, global = true, value_name = "NAME")]
schema: Vec<String>,
#[arg(short = 't', long, global = true, value_name = "NAME")]
target: Vec<String>,
#[arg(long, global = true)]
all: bool,
#[arg(long, global = true)]
keep_going: bool,
#[arg(long, global = true)]
no_deps: bool,
#[arg(long, global = true)]
host: Option<String>,
#[arg(long, global = true)]
db: Option<String>,
#[arg(long, global = true)]
ns: Option<String>,
#[arg(long, global = true)]
user: Option<String>,
#[arg(long, global = true)]
pass: Option<String>,
#[arg(long, global = true)]
auth_level: Option<String>,
#[arg(long, global = true)]
folder: Option<String>,
#[arg(long, global = true, value_name = "SECS")]
connect_timeout_secs: Option<u64>,
#[arg(long, global = true, value_name = "SECS")]
query_timeout_secs: Option<u64>,
#[arg(long = "var", global = true, value_name = "KEY=VALUE")]
var: Vec<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Init {
#[arg(long)]
template: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long = "feature", value_name = "ID")]
feature: Vec<String>,
#[arg(long)]
minimal: bool,
#[arg(short = 'y', long)]
yes: bool,
#[arg(long)]
force: bool,
},
Setup,
Sync {
#[arg(long)]
watch: bool,
#[arg(long, default_value_t = 1000)]
debounce_ms: u64,
#[arg(long)]
dry_run: bool,
#[arg(long, default_value_t = true)]
fail_fast: bool,
#[arg(long)]
no_prune: bool,
#[arg(long)]
allow_shared_prune: bool,
#[arg(long)]
allow_empty_prune: bool,
#[arg(long)]
allow_all_statements: bool,
},
Rollout {
#[command(subcommand)]
command: RolloutCommands,
},
Seed {
#[arg(long)]
force: bool,
},
Status,
Apply {
path: PathBuf,
},
Test {
#[arg(long)]
suite: Option<String>,
#[arg(long)]
case: Option<String>,
#[arg(long)]
tag: Vec<String>,
#[arg(long)]
fail_fast: bool,
#[arg(long, default_value_t = 1)]
parallel: usize,
#[arg(long)]
json_out: Option<PathBuf>,
#[arg(long)]
no_setup: bool,
#[arg(long)]
no_sync: bool,
#[arg(long)]
no_seed: bool,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
timeout_ms: Option<u64>,
#[arg(long)]
keep_db: bool,
},
Typegen {
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
stdout: bool,
#[arg(long)]
compact: bool,
},
}
#[derive(Subcommand, Debug)]
enum RolloutCommands {
Baseline,
Plan {
#[arg(long)]
name: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
allow_modified: bool,
},
Start {
#[arg(value_name = "ROLLOUT_ID")]
rollout: String,
},
Complete {
#[arg(value_name = "ROLLOUT_ID")]
rollout: String,
},
Rollback {
#[arg(value_name = "ROLLOUT_ID")]
rollout: String,
},
Status {
#[arg(value_name = "ROLLOUT_ID")]
rollout: Option<String>,
},
Lint {
#[arg(value_name = "ROLLOUT_ID")]
rollout: String,
},
Repair {
#[arg(value_name = "ROLLOUT_ID")]
rollout: String,
},
}
#[derive(Debug)]
struct Selection {
targets: Vec<Target>,
modules: Vec<Module>,
}
impl Selection {
fn resolve(
project: &ProjectConfig,
base: &DbCfg,
schemas: &[String],
targets: &[String],
all: bool,
no_deps: bool,
) -> Result<Self> {
let declared: Vec<String> = project.schema.keys().cloned().collect();
let declared_list = if declared.is_empty() {
"(none)".to_string()
} else {
declared.join(", ")
};
let wanted: Vec<String> = if !schemas.is_empty() {
for name in schemas {
if !project.schema.contains_key(name) && name != Module::DEFAULT_NAME {
bail!("unknown schema module {name:?}; declared modules are: {declared_list}");
}
}
schemas.to_vec()
} else if all || !declared.is_empty() {
declared
} else {
vec![Module::DEFAULT_NAME.to_string()]
};
let ordered = if no_deps {
let mut only = wanted;
only.sort();
only
} else {
project.module_order(&wanted)?
};
let modules = ordered
.into_iter()
.map(Module::new)
.collect::<Result<Vec<_>>>()
.context("resolving selected schema modules")?;
let resolved = if !targets.is_empty() {
targets
.iter()
.map(|name| {
let tc = project.target.get(name).ok_or_else(|| {
anyhow::anyhow!(
"unknown target {name:?}; declared targets are: {}",
if project.target.is_empty() {
"(none)".to_string()
} else {
project.target.keys().cloned().collect::<Vec<_>>().join(", ")
}
)
})?;
Target::resolve(name, tc, base)
})
.collect::<Result<Vec<_>>>()?
} else if all && !project.target.is_empty() {
project
.target
.iter()
.map(|(n, tc)| Target::resolve(n, tc, base))
.collect::<Result<Vec<_>>>()?
} else if let Some((n, tc)) = project.target.iter().find(|(_, t)| t.primary).or_else(|| {
(project.target.len() == 1).then(|| project.target.iter().next()).flatten()
}) {
vec![Target::resolve(n, tc, base)?]
} else {
vec![Target::implicit(base.clone())]
};
Ok(Self {
targets: resolved,
modules,
})
}
fn targets(&self) -> &[Target] {
&self.targets
}
fn modules_for(&self, target: &Target) -> Vec<Module> {
self.modules.iter().filter(|m| target.allows(m.name())).cloned().collect()
}
fn pairs(&self) -> usize {
self.targets.iter().map(|t| self.modules_for(t).len()).sum()
}
fn is_fan_out(&self) -> bool {
self.pairs() > 1
}
fn single_module(&self) -> Result<&Module> {
match self.modules.as_slice() {
[one] => Ok(one),
other => bail!(
"this command operates on one schema module at a time ({} selected); \
pass --schema <NAME>",
other.len()
),
}
}
fn single_target(&self) -> Result<&Target> {
match self.targets.as_slice() {
[one] => Ok(one),
other => bail!(
"this command operates on one database target at a time ({} selected); \
pass --target <NAME>",
other.len()
),
}
}
}
fn warn_unused_target_selection(command: &str, used: bool) {
if used {
log::warn!(
"{command} reads only the filesystem and never connects, so --target/--all has \
no effect here and was ignored"
);
}
}
struct PairResult {
module: String,
target: String,
error: Option<String>,
}
fn report_pairs(results: &[PairResult]) {
let mw = results.iter().map(|r| r.module.len()).max().unwrap_or(6).max("schema".len());
let tw = results.iter().map(|r| r.target.len()).max().unwrap_or(6).max("target".len());
println!();
println!(" {:<mw$} {:<tw$} status", "schema", "target", mw = mw, tw = tw);
println!(" {} {} ------", "-".repeat(mw), "-".repeat(tw));
for r in results {
let status = if r.error.is_some() {
"FAILED"
} else {
"ok"
};
println!(" {:<mw$} {:<tw$} {status}", r.module, r.target, mw = mw, tw = tw);
}
let failed = results.iter().filter(|r| r.error.is_some()).count();
println!();
if failed == 0 {
println!("{} ok", results.len());
} else {
println!("{} ok, {failed} failed", results.len() - failed);
}
}
struct CliLogger {
level: log::LevelFilter,
}
impl log::Log for CliLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.level && metadata.target().starts_with("surrealkit")
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
match record.level() {
log::Level::Error | log::Level::Warn => {
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "{}", record.args());
}
_ => {
let mut out = std::io::stdout().lock();
let _ = writeln!(out, "{}", record.args());
let _ = out.flush();
}
}
}
fn flush(&self) {
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
}
}
fn init_logging(verbose: bool) {
let level = if verbose {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
let logger = Box::leak(Box::new(CliLogger {
level,
}));
let _ = log::set_logger(logger).map(|()| log::set_max_level(level));
}
fn load_env() -> Option<DotEnv> {
let has_env =
std::path::Path::new(".env").exists() || std::path::Path::new(".env.local").exists();
if has_env {
Some(DotEnv::new(""))
} else {
None
}
}
#[tokio::main]
async fn main() -> Result<()> {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let args = Cli::parse();
init_logging(args.verbose);
let env = load_env();
let overrides = DbOverrides {
host: args.host,
ns: args.ns,
db: args.db,
user: args.user,
pass: args.pass,
auth_level: args.auth_level,
folder: args.folder,
connect_timeout_secs: args.connect_timeout_secs,
query_timeout_secs: args.query_timeout_secs,
};
let raw_vars: Vec<(String, String)> =
args.var.iter().map(|s| parse_var_flag(s)).collect::<anyhow::Result<_>>()?;
let template_vars = TemplateVars {
vars: build_vars(&raw_vars, None)?,
};
let cfg = DbCfg::from_env(env.as_ref(), &overrides)?;
let folder = cfg.folder().to_owned();
let project = ProjectConfig::load(None)?;
let selection =
Selection::resolve(&project, &cfg, &args.schema, &args.target, args.all, args.no_deps)?;
let target_selection_used = !args.target.is_empty() || args.all;
match args.command {
Commands::Init {
template,
from,
feature,
minimal,
yes,
force,
} => templates::run_init(
&folder,
InitOpts {
template,
from,
feature,
minimal,
yes,
force,
},
)?,
Commands::Setup => {
let db = connect(&cfg).await?;
run_setup(&db, &folder).await?;
}
Commands::Sync {
watch,
debounce_ms,
dry_run,
fail_fast,
no_prune,
allow_shared_prune,
allow_empty_prune,
allow_all_statements,
} => {
let typegen_cfg = surrealkit::variables::load_typegen_config(None)?;
if selection.pairs() == 0 {
bail!(
"refusing filesystem sync: the selected targets accept none of the selected \
schema modules (applicable_pair_count=0)"
);
}
if watch && selection.pairs() > 1 {
bail!(
"--watch needs a single schema module and target ({} selected); \
watching a whole matrix on a timer is rarely what you want",
selection.pairs()
);
}
let mut filesystem_sources = BTreeMap::new();
for target in selection.targets() {
for module in selection.modules_for(target) {
if let std::collections::btree_map::Entry::Vacant(entry) =
filesystem_sources.entry(module.name().to_string())
{
let layout = project.layout_for(&folder, &module);
let schema_dir = layout.schema_dir();
let files = sync::collect_filesystem_schema_files(
layout.folder(),
&schema_dir,
&module,
allow_empty_prune,
)?;
entry.insert((layout, files));
}
}
}
let mut results: Vec<PairResult> = Vec::new();
'targets: for target in selection.targets() {
let modules = selection.modules_for(target);
if modules.is_empty() {
continue;
}
let db = connect(target.cfg()).await?;
for module in modules {
if selection.is_fan_out() {
println!("→ {} → {}", module.name(), target.name());
}
let (layout, files) =
filesystem_sources.get(module.name()).with_context(|| {
format!(
"missing preflight sources for schema module {:?}",
module.name()
)
})?;
let opts = SyncOpts {
watch,
debounce_ms,
dry_run,
fail_fast,
prune: !no_prune,
allow_shared_prune,
allow_empty_prune,
allow_all_statements,
vars: template_vars.clone(),
folder: folder.clone(),
module: module.clone(),
typegen_ts_out: typegen_cfg.typescript.clone(),
typegen_ts_format: typegen_cfg.format.clone(),
};
let outcome =
sync::run_sync_with_filesystem_sources(&db, opts, layout, files).await;
let failed = outcome.is_err();
if let Err(err) = &outcome {
eprintln!("error: {} → {}: {err:#}", module.name(), target.name());
}
results.push(PairResult {
module: module.name().to_string(),
target: target.name().to_string(),
error: outcome.err().map(|e| format!("{e:#}")),
});
if failed {
if args.keep_going {
continue 'targets;
}
break 'targets;
}
}
}
if selection.is_fan_out() {
report_pairs(&results);
}
if results.iter().any(|r| r.error.is_some()) {
std::process::exit(1);
}
}
Commands::Rollout {
command,
} => match command {
RolloutCommands::Baseline => {
let db = connect(selection.single_target()?.cfg()).await?;
rollout::run_baseline(&db, &folder, selection.single_module()?).await?;
}
RolloutCommands::Plan {
name,
dry_run,
allow_modified,
} => {
warn_unused_target_selection("rollout plan", target_selection_used);
rollout::run_plan(
&folder,
RolloutPlanOpts {
name,
dry_run,
allow_modified,
},
)
.await?;
}
RolloutCommands::Start {
rollout,
} => {
let target = selection.single_target()?;
let db = connect(target.cfg()).await?;
rollout::run_start(
&db,
&folder,
RolloutExecutionOpts {
selector: Some(rollout),
query_timeout: target.cfg().query_timeout,
},
&template_vars,
)
.await?;
}
RolloutCommands::Complete {
rollout,
} => {
let target = selection.single_target()?;
let db = connect(target.cfg()).await?;
rollout::run_complete(
&db,
&folder,
RolloutExecutionOpts {
selector: Some(rollout),
query_timeout: target.cfg().query_timeout,
},
&template_vars,
)
.await?;
}
RolloutCommands::Rollback {
rollout,
} => {
let target = selection.single_target()?;
let db = connect(target.cfg()).await?;
rollout::run_rollback(
&db,
&folder,
RolloutExecutionOpts {
selector: Some(rollout),
query_timeout: target.cfg().query_timeout,
},
&template_vars,
)
.await?;
}
RolloutCommands::Status {
rollout,
} => {
let fan_out = selection.targets().len() > 1;
for target in selection.targets() {
if fan_out {
log::info!("=== target {} ===", target.name());
}
let db = connect(target.cfg()).await?;
rollout::run_status(&db, &folder, rollout.clone()).await?;
}
}
RolloutCommands::Lint {
rollout,
} => {
warn_unused_target_selection("rollout lint", target_selection_used);
rollout::run_lint(&folder, RolloutExecutionOpts::new(Some(rollout))).await?;
}
RolloutCommands::Repair {
rollout,
} => {
let target = selection.single_target()?;
let db = connect(target.cfg()).await?;
rollout::run_repair(
&db,
&folder,
RolloutExecutionOpts {
selector: Some(rollout),
query_timeout: target.cfg().query_timeout,
},
)
.await?;
}
},
Commands::Seed {
force,
} => {
let db = connect(&cfg).await?;
surrealkit::Seed::from_dir(folder.clone())
.vars(template_vars)
.force(force)
.run(&db)
.await?;
}
Commands::Status => {
let db = connect(&cfg).await?;
rollout::run_status(&db, &folder, None).await?;
}
Commands::Apply {
path,
} => {
let db = connect(&cfg).await?;
let sql = std::fs::read_to_string(&path)?;
let sql = template_vars.apply(&sql)?;
exec_surql(&db, &sql).await?;
}
Commands::Test {
suite,
case,
tag,
fail_fast,
parallel,
json_out,
no_setup,
no_sync,
no_seed,
base_url,
timeout_ms,
keep_db,
} => {
run_test(
env.as_ref(),
TestOpts {
suite,
case,
tags: tag,
fail_fast,
parallel,
json_out,
no_setup,
no_sync,
no_seed,
base_url,
timeout_ms,
keep_db,
},
template_vars,
&overrides,
)
.await?;
}
Commands::Typegen {
out,
stdout,
compact,
} => {
let db = connect(&cfg).await?;
let typegen_cfg = surrealkit::variables::load_typegen_config(None)?;
run_typegen(
&db,
&folder,
cfg.ns(),
cfg.db(),
TypegenOpts {
out,
stdout,
pretty: !compact,
ts_out: typegen_cfg.typescript,
ts_format: typegen_cfg.format,
},
)
.await?;
}
}
use std::io::Write;
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
std::process::exit(0);
}
#[cfg(test)]
mod selection_tests {
use surrealkit::config::DbOverrides;
use super::*;
fn base() -> DbCfg {
DbCfg::from_env(None, &DbOverrides::default()).expect("base cfg")
}
fn project(raw: &str) -> ProjectConfig {
ProjectConfig::parse(raw).expect("parse config")
}
fn resolve(
raw: &str,
schemas: &[&str],
targets: &[&str],
all: bool,
no_deps: bool,
) -> Selection {
let schemas: Vec<String> = schemas.iter().map(|s| s.to_string()).collect();
let targets: Vec<String> = targets.iter().map(|s| s.to_string()).collect();
Selection::resolve(&project(raw), &base(), &schemas, &targets, all, no_deps)
.expect("resolve selection")
}
#[test]
fn no_config_and_no_flags_is_one_default_pair() {
let sel = resolve("", &[], &[], false, false);
assert_eq!(sel.pairs(), 1);
assert!(!sel.is_fan_out());
assert!(sel.modules_for(&sel.targets()[0])[0].is_default());
assert_eq!(sel.targets()[0].name(), "default");
}
#[test]
fn declared_modules_are_all_selected_by_default() {
let sel = resolve("[schema.core]\n[schema.billing]\n", &[], &[], false, false);
assert_eq!(sel.pairs(), 2, "both modules against the ambient target");
}
#[test]
fn selecting_a_module_pulls_in_its_dependencies_in_order() {
let sel = resolve(
"[schema.core]\n[schema.billing]\ndepends_on = [\"core\"]\n",
&["billing"],
&[],
false,
false,
);
let names: Vec<String> =
sel.modules_for(&sel.targets()[0]).iter().map(|m| m.name().to_string()).collect();
assert_eq!(names, vec!["core", "billing"], "dependency must be applied first");
}
#[test]
fn no_deps_selects_only_what_was_asked_for() {
let sel = resolve(
"[schema.core]\n[schema.billing]\ndepends_on = [\"core\"]\n",
&["billing"],
&[],
false,
true,
);
assert_eq!(sel.modules_for(&sel.targets()[0]).len(), 1);
assert_eq!(sel.modules_for(&sel.targets()[0])[0].name(), "billing");
}
#[test]
fn all_expands_to_the_full_matrix() {
let sel = resolve(
"[schema.core]\n[schema.billing]\n[target.acme]\n[target.globex]\n",
&[],
&[],
true,
false,
);
assert_eq!(sel.pairs(), 4, "2 modules x 2 targets");
assert!(sel.is_fan_out());
}
#[test]
fn a_targets_schema_list_filters_the_matrix() {
let sel = resolve(
"[schema.core]\n[schema.billing]\n\
[target.acme]\n[target.warehouse]\nschemas = [\"core\"]\n",
&[],
&[],
true,
false,
);
assert_eq!(sel.pairs(), 3);
}
#[test]
fn a_single_declared_target_is_used_without_being_marked_primary() {
let sel = resolve("[target.only]\nns = \"x\"\n", &[], &[], false, false);
assert_eq!(sel.targets().len(), 1);
assert_eq!(sel.targets()[0].name(), "only");
assert_eq!(sel.targets()[0].cfg().ns(), "x");
}
#[test]
fn primary_is_chosen_when_several_targets_exist() {
let sel = resolve("[target.a]\n[target.b]\nprimary = true\n", &[], &[], false, false);
assert_eq!(sel.targets().len(), 1);
assert_eq!(sel.targets()[0].name(), "b");
}
#[test]
fn unknown_module_is_rejected_and_lists_the_declared_ones() {
let err = Selection::resolve(
&project("[schema.core]\n"),
&base(),
&["ghost".to_string()],
&[],
false,
false,
)
.unwrap_err()
.to_string();
assert!(err.contains("ghost"), "got: {err}");
assert!(err.contains("core"), "should list declared modules: {err}");
}
#[test]
fn unknown_target_is_rejected_and_lists_the_declared_ones() {
let err = Selection::resolve(
&project("[target.acme]\n"),
&base(),
&[],
&["ghost".to_string()],
false,
false,
)
.unwrap_err()
.to_string();
assert!(err.contains("ghost") && err.contains("acme"), "got: {err}");
}
#[test]
fn single_module_errors_when_several_are_selected() {
let sel = resolve("[schema.a]\n[schema.b]\n", &[], &[], false, false);
assert!(sel.single_module().is_err(), "commands that cannot fan out must refuse");
}
#[test]
fn single_target_errors_when_several_are_selected() {
let sel = resolve("[target.a]\n[target.b]\n", &[], &[], true, false);
assert!(sel.single_target().is_err(), "rollout commands must not fan out across targets");
}
#[test]
fn no_subcommand_shadows_a_global_arg_id() {
use clap::CommandFactory;
let root = Cli::command();
let globals: Vec<String> = root
.get_arguments()
.filter(|a| a.is_global_set())
.map(|a| a.get_id().to_string())
.collect();
assert!(!globals.is_empty(), "expected at least one global arg to guard");
fn walk(cmd: &clap::Command, globals: &[String], path: &str, bad: &mut Vec<String>) {
for sub in cmd.get_subcommands() {
let here = format!("{path} {}", sub.get_name());
for arg in sub.get_arguments() {
let id = arg.get_id().to_string();
if globals.contains(&id) {
bad.push(format!("`{}` redefines global arg id `{id}`", here.trim()));
}
}
walk(sub, globals, &here, bad);
}
}
let mut bad = Vec::new();
walk(&root, &globals, "", &mut bad);
assert!(bad.is_empty(), "argument id shadowing:\n {}", bad.join("\n "));
Cli::command().debug_assert();
}
}