keelson-gen 0.1.0

keelson's code generator: introspect a live schema, emit readable model .rs files against keelson-models.
Documentation
//! Emission: resolved models → formatted `.rs` file contents.
//!
//! TokenStreams are built with `quote`, parsed into a `syn::File` and
//! formatted with `prettyplease` — one formatter, bundled, so the same
//! schema and config produce byte-identical output on every machine
//! (rustfmt versions never enter the picture). Identifiers are
//! path-qualified throughout instead of managing `use` lists: Rust needs no
//! import management, and a generated file with zero imports can never trip
//! an unused-import lint. The two `as _` trait imports a preload body needs
//! are function-local and always used.

mod factory;
mod model;

use proc_macro2::TokenStream;
use quote::quote;

use crate::config::{Config, Dialect};
use crate::error::{GenError, Result};
use crate::resolve::Model;

/// The fixed first lines of every generated file. `@generated` is the marker
/// review tools key on; no timestamps, no versions — determinism includes
/// the header.
const HEADER: &str = "// @generated by keelson-gen. DO NOT EDIT.\n\
                      // Regenerate from the schema instead; hand-written code (hooks included)\n\
                      // belongs outside this directory.\n";

/// What the dialect contributes to emission: the crate its statements come
/// from, and whether that dialect has `RETURNING`.
///
/// The model machinery is dialect-generic, so for PostgreSQL and SQLite this
/// is only the crate name. MySQL has no `RETURNING` on any statement, and
/// that single fact is what makes its `Table` body — and the mutation
/// surface `table()` hands out — a different shape; see
/// [`model`](super::model) and `keelson-models/tests/spec_mysql.rs`.
pub(crate) struct Dial {
    pub(crate) krate: TokenStream,
    /// Whether `INSERT`/`UPDATE`/`DELETE` can carry `RETURNING`.
    pub(crate) returning: bool,
}

impl Dial {
    pub(crate) fn new(dialect: Dialect) -> Result<Dial> {
        Ok(match dialect {
            Dialect::Psql => Dial {
                krate: quote!(keelson_psql),
                returning: true,
            },
            Dialect::Sqlite => Dial {
                krate: quote!(keelson_sqlite),
                returning: true,
            },
            Dialect::Mysql => Dial {
                krate: quote!(keelson_mysql),
                returning: false,
            },
        })
    }
}

/// Render every generated file: `mod.rs`, one file per model in table order,
/// and — when `[output] factories` is on — `factories.rs` last.
pub(crate) fn render(models: &[Model], config: &Config) -> Result<Vec<(String, String)>> {
    let dial = Dial::new(config.dialect)?;
    let mut files = Vec::with_capacity(models.len() + 2);
    files.push(("mod.rs".to_owned(), render_mod_rs(models, config)));
    for m in models {
        let tokens = model::model_file(m, models, config, &dial)?;
        files.push((format!("{}.rs", m.table), render_file(tokens)?));
    }
    if config.output.factories {
        let tokens = factory::factories_file(models, config)?;
        files.push(("factories.rs".to_owned(), render_file(tokens)?));
    }
    Ok(files)
}

fn render_mod_rs(models: &[Model], config: &Config) -> String {
    let mut out = String::from(HEADER);
    out.push_str("\n//! The generated models, one module per table.\n\n");
    for m in models {
        let module = crate::names::ident(&m.table);
        out.push_str(&format!("pub mod {module};\n"));
    }
    if config.output.factories {
        out.push_str("\n/// The generated test-data factories, one module per writable table.\npub mod factories;\n");
    }
    out
}

fn render_file(tokens: TokenStream) -> Result<String> {
    let file: syn::File = syn::parse2(tokens)
        .map_err(|e| GenError::Config(format!("internal: generated tokens do not parse: {e}")))?;
    Ok(format!("{HEADER}\n{}", prettyplease::unparse(&file)))
}