rok-sqlc 0.3.0

Generate SQLx Rust database modules from SQL schema files
Documentation
//! The `rok-sqlc` command line: parses flags into a [`Config`], calls
//! [`generate`](crate::generate), and prints the [`Report`].

use std::ffi::OsString;
use std::path::PathBuf;

use anyhow::Result;
use clap::{Parser, Subcommand};

use crate::config_file::{self, FileConfig};
use crate::report::display_path;
use crate::{Config, Derive, Extractor, Report};

#[derive(Debug, Parser)]
#[command(
    name = "rok-sqlc",
    version,
    about = "Generate SQLx Rust modules from SQL schema files"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Generate Rust database modules from a schema file
    #[command(visible_alias = "g")]
    Generate {
        /// Schema/migration SQL file, or a directory of migrations read in
        /// filename order (`*.down.sql` skipped); repeat to combine inputs
        /// [default: `schema` from rok-sqlc.toml]
        #[arg(short, long, value_name = "PATH")]
        schema: Vec<PathBuf>,

        /// Output directory [default: src/db]
        #[arg(short, long, value_name = "DIR")]
        output: Option<PathBuf>,

        /// Generate this table and regenerate those already generated; skip the rest
        #[arg(short, long, value_name = "TABLE")]
        resource: Option<String>,

        /// List planned writes and refusals without touching the filesystem
        #[arg(short = 'n', long)]
        dry_run: bool,

        /// Write nothing; list missing, changed, and stale files and exit 1 if any
        #[arg(short = 'k', long)]
        check: bool,

        /// Overwrite existing files that were not generated by rok-sqlc
        #[arg(short, long)]
        force: bool,

        /// Annotated query file (generates `queries.rs`) or directory (one
        /// `queries/<stem>.rs` module per `.sql` file); repeatable
        #[arg(short, long, value_name = "PATH")]
        queries: Vec<PathBuf>,

        /// Skip unsupported tables and queries with warnings instead of failing
        #[arg(short, long)]
        lenient: bool,

        /// Read options from this config file instead of discovering
        /// rok-sqlc.toml in the current directory or its parents
        #[arg(short, long, value_name = "FILE")]
        config: Option<PathBuf>,

        /// Options as JSON in the shape of rok-sqlc.toml: inline, `@FILE`, or
        /// `-` for stdin; paths resolve against the current directory
        #[arg(short, long, value_name = "JSON|@FILE|-", allow_hyphen_values = true)]
        json: Option<String>,

        /// Also derive this for the generated types: `serde` adds
        /// `Serialize`/`Deserialize`, `validate` adds schema-derived
        /// `validator::Validate` to inputs and patches (needs `serde`);
        /// repeatable [default: `derive` from rok-sqlc.toml]
        #[arg(short, long, value_name = "NAME", value_enum)]
        derive: Vec<DeriveArg>,

        /// Generate a validated-body extractor for this framework into `http/`
        /// (needs `-d validate`); repeatable [default: `extractors` from
        /// rok-sqlc.toml]
        #[arg(short, long, value_name = "FRAMEWORK", value_enum)]
        extractor: Vec<ExtractorArg>,
    },
}

/// The clap definition of the `rok-sqlc` command line.
pub fn command() -> clap::Command {
    <Cli as clap::CommandFactory>::command()
}

pub fn run() -> Result<()> {
    run_from(std::env::args_os())
}

pub fn run_from<I, T>(args: I) -> Result<()>
where
    I: IntoIterator<Item = T>,
    T: Into<OsString> + Clone,
{
    let cli = match Cli::try_parse_from(args) {
        Ok(cli) => cli,
        Err(err) if is_informational(&err) => {
            // `--help` / `--version` are successful outcomes, not errors.
            err.print()?;
            return Ok(());
        }
        Err(err) => return Err(err.into()),
    };
    match cli.command {
        Command::Generate {
            schema,
            output,
            resource,
            dry_run,
            check,
            force,
            queries,
            lenient,
            config,
            json,
            derive,
            extractor,
        } => {
            let payload = match json {
                Some(arg) => config_file::load_json(&arg)?,
                None => FileConfig::default(),
            };
            let file = match config {
                Some(path) => config_file::load(&path)?,
                None => match config_file::discover(&std::env::current_dir()?) {
                    Some(path) => config_file::load(&path)?,
                    None => FileConfig::default(),
                },
            };
            let flags = FileConfig {
                schema,
                queries,
                output,
                resource,
                conventions: Default::default(),
                derive: (!derive.is_empty())
                    .then(|| derive.into_iter().map(Derive::from).collect()),
                extractors: (!extractor.is_empty())
                    .then(|| extractor.into_iter().map(Extractor::from).collect()),
                relationships: Default::default(),
                modules: Default::default(),
            };
            // Flags override the JSON payload, which overrides the file,
            // which overrides the defaults. `--schema` and `--queries` (each
            // given any number of times) replace the lower sources' whole
            // `schema` / `queries` list; an empty Vec means "not set".
            let merged = flags.over(payload.over(file));
            if merged.schema.is_empty() {
                return Err(missing_schema().into());
            }
            let defaults = Config::default();
            generate(&Config {
                schema: merged.schema,
                queries: merged.queries,
                output: merged.output.unwrap_or(defaults.output),
                resource: merged.resource,
                dry_run,
                check,
                force,
                lenient,
                conventions: merged.conventions.resolve(),
                derive: merged.derive.unwrap_or_default(),
                extractors: merged.extractors.unwrap_or_default(),
                relationships: merged.relationships,
                modules: merged.modules,
            })
        }
    }
}

/// A `--derive` value. It mirrors [`Derive`], which stays free of clap
/// because it is part of the stable library API (ADR 0012).
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum DeriveArg {
    Serde,
    Validate,
}

impl From<DeriveArg> for Derive {
    fn from(arg: DeriveArg) -> Self {
        match arg {
            DeriveArg::Serde => Derive::Serde,
            DeriveArg::Validate => Derive::Validate,
        }
    }
}

/// An `--extractor` value, mirroring [`Extractor`] as [`DeriveArg`]
/// mirrors [`Derive`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum ExtractorArg {
    Axum,
    #[value(name = "actix_web")]
    ActixWeb,
}

impl From<ExtractorArg> for Extractor {
    fn from(arg: ExtractorArg) -> Self {
        match arg {
            ExtractorArg::Axum => Extractor::Axum,
            ExtractorArg::ActixWeb => Extractor::ActixWeb,
        }
    }
}

/// The usage error for a run with neither `--schema` nor a `schema` key in
/// the JSON payload or the config file.
fn missing_schema() -> clap::Error {
    let mut cmd = command();
    cmd.build();
    let generate = cmd
        .find_subcommand_mut("generate")
        .expect("the generate subcommand exists");
    generate.error(
        clap::error::ErrorKind::MissingRequiredArgument,
        format!(
            "no schema given: pass --schema <PATH>, or set `schema` in the --json payload or {}",
            config_file::FILE_NAME
        ),
    )
}

fn is_informational(err: &clap::Error) -> bool {
    use clap::error::ErrorKind;
    matches!(
        err.kind(),
        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
    )
}

fn generate(config: &Config) -> Result<()> {
    match crate::generate(config) {
        Ok(report) => {
            print_report(config, &report);
            Ok(())
        }
        Err(err) => {
            print_report(config, err.report());
            Err(err.into())
        }
    }
}

/// Prints what a run did: skipped or fatal tables and queries on stderr,
/// planned or written files on stdout.
fn print_report(config: &Config, report: &Report) {
    for failure in &report.table_failures {
        if config.lenient {
            eprintln!(
                "warning: {}table `{}` skipped: {}",
                failure.location(),
                failure.table,
                failure.reason
            );
        } else {
            eprintln!("error: {failure}");
        }
    }
    for failure in &report.query_failures {
        if config.lenient {
            eprintln!(
                "warning: {}query `{}` skipped: {}",
                failure.location(),
                failure.query,
                failure.reason
            );
        } else {
            eprintln!("error: {failure}");
        }
    }
    for path in &report.written {
        if config.dry_run {
            println!("would write {}", display_path(path));
        } else {
            println!("generated {}", display_path(path));
        }
    }
    for path in &report.unchanged {
        println!("unchanged {}", display_path(path));
    }
    for path in &report.missing {
        println!("missing {}", display_path(path));
    }
    for path in &report.changed {
        println!("changed {}", display_path(path));
    }
    for path in &report.stale {
        if config.check {
            println!("stale {}", display_path(path));
        } else if config.dry_run {
            println!("would remove {}", display_path(path));
        } else {
            println!("removed {}", display_path(path));
        }
    }
    for refused in &report.refused {
        let (path, reason) = (display_path(&refused.path), &refused.reason);
        if config.dry_run {
            println!("would refuse {path} ({reason})");
        } else {
            eprintln!("error: refused to write {path} ({reason})");
        }
    }
}