drizzle-cli 0.4.1

Command-line interface for drizzle-rs migrations
Documentation
//! `drizzle introspect` / `drizzle pull`: reads a live database and writes a
//! Rust schema file (and optionally initial migration metadata).

use crate::commands::overrides::{self, ConnectionOverrides, FilterArgs};
use crate::config::{Config, Dialect, IntrospectCasing};
use crate::error::CliError;
use crate::output;

#[derive(clap::Args, Debug, Clone)]
pub struct IntrospectOptions {
    /// Initialize migration metadata after introspecting
    #[arg(long = "init")]
    pub init_metadata: bool,

    /// Casing for introspected identifiers (camel or preserve)
    #[arg(long)]
    pub casing: Option<IntrospectCasing>,

    /// Override output directory
    #[arg(long)]
    pub out: Option<std::path::PathBuf>,

    /// Override breakpoints setting
    #[arg(long)]
    pub breakpoints: Option<bool>,

    /// Override dialect from config
    #[arg(long)]
    pub dialect: Option<Dialect>,

    #[command(flatten)]
    pub filters: FilterArgs,

    #[command(flatten)]
    pub connection: ConnectionOverrides,
}

/// Runs `drizzle introspect` (alias `drizzle pull`): reads the live schema
/// and writes it as Rust to `<out>/schema.rs`, plus a baseline migration
/// folder `<out>/<tag>/` whose `migration.sql` creates that schema.
///
/// The migrations tracking table is left out, and the table, schema,
/// extension, and role filters are applied. With `--init`, the baseline is
/// also recorded as applied in the database, which is allowed only when the
/// tracking table is empty and the folder holds no other migration. Without
/// it, the baseline's SQL is commented out, as drizzle-kit writes it.
///
/// # Errors
///
/// Returns [`CliError`] if `db_name` does not match the config, there are no
/// credentials ([`CliError::MissingCredentials`]) or no driver for them,
/// connecting or reading the catalog fails, the folder uses the legacy
/// journal layout, files cannot be written, or `--init` is refused.
pub fn run(
    config: &Config,
    db_name: Option<&str>,
    opts: &IntrospectOptions,
) -> Result<(), CliError> {
    let db = config.database(db_name)?;

    // CLI flags override config
    let effective_casing = opts
        .casing
        .unwrap_or_else(|| db.effective_introspect_casing());
    let effective_dialect = overrides::resolve_dialect(db, opts.dialect);
    let effective_out = opts.out.as_deref().unwrap_or_else(|| db.migrations_dir());
    let effective_breakpoints = opts.breakpoints.unwrap_or(db.breakpoints);

    if effective_dialect != Dialect::Postgresql {
        if opts
            .filters
            .schema_filters
            .as_ref()
            .is_some_and(|v| !v.is_empty())
        {
            println!(
                "{}",
                output::warning("Ignoring --schemaFilters: only supported for postgresql")
            );
        }
        if opts
            .filters
            .extensions_filters
            .as_ref()
            .is_some_and(|v| !v.is_empty())
        {
            println!(
                "{}",
                output::warning("Ignoring --extensionsFilters: only supported for postgresql")
            );
        }
    }

    let filters = crate::db::SnapshotFilters {
        tables: overrides::resolve_filter_list(
            opts.filters.tables_filter.as_deref(),
            db.tables_filter.as_ref(),
        ),
        schemas: overrides::resolve_schema_filters(
            effective_dialect,
            opts.filters.schema_filters.as_deref(),
            db.schema_filter.as_ref(),
        ),
        extensions: overrides::resolve_extensions_filter(
            effective_dialect,
            opts.filters.extensions_filters.as_deref(),
            db.extensions_filters.as_deref(),
        ),
        roles: Some(db.effective_entities().roles),
    };

    println!("{}", output::heading("Introspecting database..."));
    println!();

    crate::commands::harness::print_db_header(config, db_name);

    println!(
        "  {}: {}",
        output::label("Dialect"),
        effective_dialect.as_str()
    );
    println!("  {}: {}", output::label("Output"), effective_out.display());

    if opts.init_metadata {
        println!("  {}: enabled", output::label("Init metadata"));
    }
    println!();

    let connection = overrides::resolve_connection(db, effective_dialect, &opts.connection)?;
    let Some(connection) = connection else {
        print_missing_credentials_help(effective_dialect);
        return Err(CliError::MissingCredentials("introspect"));
    };
    println!("  {}: {}", output::label("Driver"), connection.driver);

    // Run introspection
    let result = crate::db::run_introspection(
        &connection,
        effective_out,
        opts.init_metadata,
        effective_breakpoints,
        Some(effective_casing),
        &filters,
        db.migrations_table(),
        db.migrations_schema(),
    )?;

    print_introspection_summary(&result, opts.init_metadata);
    Ok(())
}

/// Print how to configure credentials; the caller reports the failure.
fn print_missing_credentials_help(effective_dialect: Dialect) {
    println!("Add credentials to your drizzle.config.toml:");
    println!();
    println!("  {}", output::muted("[dbCredentials]"));
    match effective_dialect.to_base() {
        drizzle_types::Dialect::SQLite => {
            println!("  {}", output::muted("url = \"./dev.db\""));
        }
        drizzle_types::Dialect::PostgreSQL => {
            println!(
                "  {}",
                output::muted("url = \"postgres://user:pass@localhost:5432/db\"")
            );
        }
        drizzle_types::Dialect::MySQL => {
            println!(
                "  {}",
                output::muted("url = \"mysql://user:pass@localhost:3306/db\"")
            );
        }
    }
    println!();
    println!("Or use an environment variable:");
    println!();
    println!("  {}", output::muted("[dbCredentials]"));
    println!("  {}", output::muted("url = { env = \"DATABASE_URL\" }"));
}

/// Lines reporting what a generated schema holds, plus the codegen warnings
/// (objects the schema could not express, and similar notes). Shared with
/// `drizzle import`.
pub(crate) fn summary_lines(
    table_count: usize,
    index_count: usize,
    view_count: usize,
    warnings: &[String],
) -> Vec<String> {
    let mut lines = vec![format!(
        "  {} {table_count} table(s), {index_count} index(es)",
        output::success("Found"),
    )];
    if view_count > 0 {
        lines.push(format!(
            "  {} {view_count} view(s)",
            output::success("Found")
        ));
    }
    if !warnings.is_empty() {
        lines.push(String::new());
        lines.push(output::warning("Warnings:"));
        for warning in warnings {
            lines.push(format!("  {} {warning}", output::warning("-")));
        }
    }
    lines
}

/// Print the final summary after introspection completes.
fn print_introspection_summary(result: &crate::db::IntrospectResult, init_metadata: bool) {
    println!();
    for line in summary_lines(
        result.table_count,
        result.index_count,
        result.view_count,
        &result.warnings,
    ) {
        println!("{line}");
    }

    println!();
    println!(
        "{} Snapshot saved to {}",
        output::success("Done!"),
        result.snapshot_path.display()
    );

    if init_metadata {
        println!();
        println!(
            "  {} Migration metadata initialized in database.",
            output::label("Note:")
        );
        println!("  The current database state is now the baseline for future migrations.");
    }
}