udb 0.3.7

Universal Data Broker — a Rust gRPC broker over multiple databases (Postgres, MySQL, SQLite, MongoDB, ClickHouse, Cassandra, MSSQL, Redis, Qdrant, S3, Neo4j, …) with per-tenant RLS, 2PC, sagas, and CDC.
Documentation
//! `udb init` command runner.

use std::fs;
use std::path::PathBuf;

use udb::init::{
    FindingSeverity, InitOptions, InitPlanError, InitProfile, ProtoStrategy, apply_init_plan,
    backend_catalog, build_init_plan, default_backend_ids_for_features, framework_catalog,
    native_service_catalog, revert_last_init,
};
use udb::tui;

use super::init_prompt::{InitPromptCatalog, run_init_prompts};
use super::output_json;

#[derive(Debug)]
pub(crate) enum InitRunError {
    ConfigRead {
        path: String,
        source: std::io::Error,
    },
    ConfigParse {
        path: String,
        source: serde_yaml::Error,
    },
    Plan(InitPlanError),
    Apply(std::io::Error),
    Prompt(super::init_prompt::InitPromptError),
    Tui(tui::TuiError),
    InvalidArgs(String),
}

impl std::fmt::Display for InitRunError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ConfigRead { path, source } => {
                write!(f, "failed to read init config {path}: {source}")
            }
            Self::ConfigParse { path, source } => {
                write!(f, "failed to parse init config {path}: {source}")
            }
            Self::Plan(source) => write!(f, "{source}"),
            Self::Apply(source) => write!(f, "failed to apply init plan: {source}"),
            Self::Prompt(source) => write!(f, "{source}"),
            Self::Tui(source) => write!(f, "{source}"),
            Self::InvalidArgs(message) => f.write_str(message),
        }
    }
}

impl std::error::Error for InitRunError {}

pub(crate) fn run(args: &super::InitArgs) -> Result<i32, InitRunError> {
    let cwd = std::env::current_dir().map_err(InitRunError::Apply)?;
    if args.revert {
        let receipt = revert_last_init(&cwd).map_err(InitRunError::Apply)?;
        output_json(&receipt, "init revert receipt");
        return Ok(0);
    }

    let mut options = if let Some(config) = args.config.as_deref() {
        read_config(config)?
    } else {
        InitOptions::default()
    };

    let mut prompt_confirmed = None;
    if args.no_tui && !args.confirmed && !args.json_plan && !args.dry_run {
        let selection = run_init_prompts(default_prompt_catalog()).map_err(InitRunError::Prompt)?;
        if let Some(profile) = parse_profile(Some(&selection.profile)) {
            options.profile = profile;
        }
        options.framework = selection.framework;
        options.backends = selection.backends;
        options.native_services = selection.native_services;
        options.features = selection.features;
        prompt_confirmed = Some(selection.confirm_apply);
    }

    if let Some(profile) = args.profile.as_deref() {
        options.profile = parse_profile(Some(profile)).ok_or_else(|| {
            InitRunError::InvalidArgs(format!(
                "unknown init profile `{profile}`; expected local, dev, ci, or prod"
            ))
        })?;
    }
    if args.framework.is_some() {
        options.framework = args.framework.clone();
    }
    if !args.backends.is_empty() {
        options.backends = args.backends.clone();
    }
    if !args.native_services.is_empty() {
        options.native_services = args.native_services.clone();
    }
    if !args.features.is_empty() {
        options.features = args.features.clone();
    }
    if let Some(strategy) = args.proto_strategy.as_deref() {
        options.proto_strategy = Some(parse_proto_strategy(Some(strategy)).ok_or_else(|| {
            InitRunError::InvalidArgs(format!(
                "unknown proto strategy `{strategy}`; expected starter, existing, or skip"
            ))
        })?);
    }
    let confirmed = args.confirmed || prompt_confirmed.unwrap_or(false);
    options.dry_run = args.dry_run || args.json_plan || (!confirmed && !args.tui);

    let result = build_init_plan(&cwd, options).map_err(InitRunError::Plan)?;
    let confirmed = if args.tui && !args.no_tui {
        run_tui_preview(&result, args.force).map_err(InitRunError::Tui)?
    } else {
        confirmed
    };
    let has_errors = result
        .findings
        .iter()
        .any(|finding| finding.severity == FindingSeverity::Error);

    if args.json_plan || args.dry_run || !confirmed {
        output_json(&result, "init plan");
        if !confirmed && !args.json_plan && !args.dry_run {
            eprintln!("init: dry-run only; pass --yes to apply the generated plan");
        }
        return Ok(0);
    }
    if has_errors && !args.force {
        output_json(&result, "init plan");
        eprintln!("init: plan has error findings; fix them or pass --force to apply anyway");
        return Ok(1);
    }

    let receipt =
        apply_init_plan(PathBuf::from(&cwd), &result, false).map_err(InitRunError::Apply)?;
    output_json(&receipt, "init apply receipt");
    Ok(0)
}

fn run_tui_preview(result: &udb::init::InitPlanResult, force: bool) -> tui::TuiResult<bool> {
    tui::run_init_tui(InitPreviewDriver {
        snapshot: tui_snapshot(result),
        has_errors: result
            .findings
            .iter()
            .any(|finding| finding.severity == FindingSeverity::Error),
        force,
        apply: false,
        done: false,
    })
}

struct InitPreviewDriver {
    snapshot: tui::InitSnapshot,
    has_errors: bool,
    force: bool,
    apply: bool,
    done: bool,
}

impl tui::InitTuiDriver for InitPreviewDriver {
    type Output = bool;

    fn snapshot(&self) -> tui::InitSnapshot {
        self.snapshot.clone()
    }

    fn handle_event(&mut self, event: tui::InitEvent) -> tui::TuiResult<()> {
        match event {
            tui::InitEvent::Apply | tui::InitEvent::Input(tui::InitInput::Char('a')) => {
                if self.has_errors && !self.force {
                    self.snapshot.status_line =
                        "Plan has errors; quit and fix them or rerun with --force".to_string();
                } else {
                    self.apply = true;
                    self.done = true;
                }
            }
            tui::InitEvent::Cancel
            | tui::InitEvent::Input(tui::InitInput::Esc | tui::InitInput::CtrlC)
            | tui::InitEvent::Input(tui::InitInput::Char('q')) => {
                self.apply = false;
                self.done = true;
            }
            tui::InitEvent::Next => {
                self.snapshot.status_line = "Press a to apply or q to quit".to_string();
            }
            _ => {}
        }
        Ok(())
    }

    fn should_quit(&self) -> bool {
        self.done
    }

    fn finish(self) -> tui::TuiResult<Self::Output> {
        Ok(self.apply)
    }
}

fn tui_snapshot(result: &udb::init::InitPlanResult) -> tui::InitSnapshot {
    let has_errors = result
        .findings
        .iter()
        .any(|finding| finding.severity == FindingSeverity::Error);
    tui::InitSnapshot {
        title: "UDB init".to_string(),
        cwd: Some(result.plan.workspace.root.clone()),
        mode: "plan preview".to_string(),
        warnings: result
            .findings
            .iter()
            .map(|finding| format!("{:?}: {}", finding.severity, finding.message))
            .collect(),
        steps: vec![
            tui::InitStep {
                id: "scan".to_string(),
                label: "Workspace scan".to_string(),
                status: tui::InitStepStatus::Complete,
                validation_count: 0,
            },
            tui::InitStep {
                id: "plan".to_string(),
                label: "Plan preview".to_string(),
                status: if has_errors {
                    tui::InitStepStatus::Error
                } else {
                    tui::InitStepStatus::Active
                },
                validation_count: result.findings.len(),
            },
            tui::InitStep {
                id: "sdk".to_string(),
                label: "SDK/native bootstrap preflight".to_string(),
                status: if result
                    .plan
                    .verification_steps
                    .iter()
                    .any(|step| step.contains("udb sdk init"))
                {
                    tui::InitStepStatus::Warning
                } else {
                    tui::InitStepStatus::Complete
                },
                validation_count: result
                    .plan
                    .verification_steps
                    .iter()
                    .filter(|step| step.contains("udb sdk init"))
                    .count(),
            },
            tui::InitStep {
                id: "apply".to_string(),
                label: "Apply".to_string(),
                status: tui::InitStepStatus::Pending,
                validation_count: 0,
            },
        ],
        facts: vec![
            format!("root: {:?}", result.plan.workspace.root_kind),
            format!("framework: {:?}", result.plan.selections.framework),
            format!("proto: {:?}", result.plan.proto_strategy),
            format!("dbops: {:?}", result.plan.dbops_policy),
        ],
        selections: vec![
            format!("backends: {}", result.plan.selections.backends.join(", ")),
            format!(
                "native services: {}",
                result.plan.selections.native_services.join(", ")
            ),
            format!("features: {}", result.plan.selections.features.join(", ")),
        ],
        plan_summary: result
            .plan
            .mutations
            .iter()
            .map(|mutation| format!("{}: {}", mutation.path.display(), mutation.summary))
            .chain(
                result
                    .plan
                    .generator_steps
                    .iter()
                    .map(|step| format!("generator: {step}")),
            )
            .chain(
                result
                    .plan
                    .verification_steps
                    .iter()
                    .map(|step| format!("verify: {step}")),
            )
            .collect(),
        apply_log: Vec::new(),
        action_bar: vec![tui::InitAction::Apply, tui::InitAction::Cancel],
        status_line: if has_errors {
            "Plan has errors; apply is blocked unless --force is supplied".to_string()
        } else {
            "Press a to apply or q to quit".to_string()
        },
    }
}

fn default_prompt_catalog() -> InitPromptCatalog {
    let frameworks = framework_catalog()
        .into_iter()
        .map(|framework| framework.id)
        .collect::<Vec<_>>();
    let backends = backend_catalog()
        .into_iter()
        .map(|backend| backend.id)
        .collect::<Vec<_>>();
    let native_services = native_service_catalog()
        .into_iter()
        .map(|service| service.id)
        .collect::<Vec<_>>();
    InitPromptCatalog {
        frameworks,
        backends,
        native_services,
        features: vec![
            "auth".to_string(),
            "vector".to_string(),
            "storage".to_string(),
            "dbops".to_string(),
        ],
        default_backends: default_backend_ids_for_features(&[]),
        default_native_services: native_service_catalog()
            .into_iter()
            .filter(|service| service.default_enabled)
            .map(|service| service.id)
            .collect(),
        ..InitPromptCatalog::default()
    }
}

fn read_config(path: &str) -> Result<InitOptions, InitRunError> {
    let raw = fs::read_to_string(path).map_err(|source| InitRunError::ConfigRead {
        path: path.to_string(),
        source,
    })?;
    serde_yaml::from_str(&raw).map_err(|source| InitRunError::ConfigParse {
        path: path.to_string(),
        source,
    })
}

fn parse_profile(value: Option<&str>) -> Option<InitProfile> {
    match value?.trim().to_ascii_lowercase().as_str() {
        "local" => Some(InitProfile::Local),
        "dev" => Some(InitProfile::Dev),
        "ci" => Some(InitProfile::Ci),
        "prod" | "production" => Some(InitProfile::Prod),
        _ => None,
    }
}

fn parse_proto_strategy(value: Option<&str>) -> Option<ProtoStrategy> {
    match value?.trim().to_ascii_lowercase().as_str() {
        "starter" | "create" | "create-starter" => Some(ProtoStrategy::CreateStarter),
        "existing" | "use-existing" => Some(ProtoStrategy::UseExisting),
        "skip" | "none" => Some(ProtoStrategy::Skip),
        _ => None,
    }
}