puerto 0.18.0

Puerto — Rust full-stack DDD framework CLI. Scaffolds Clean Architecture projects with auto-wired DI, SQLx support, and zero manual wiring. Formerly puerto-framework.
use std::{fs, path::Path};

use serde::Serialize;

use crate::generators::naming::{crud_use_case, is_crud_entity, pascal_to_snake, to_pascal_case};
use crate::generators::render::{RenderError, render};

#[derive(Serialize)]
struct BootstrapUseCase {
    action: String,
    uc_pascal: String,
    /// Unit-of-work entities only: which ports the use case is built with.
    repository: bool,
    uow: bool,
}

#[derive(Serialize)]
struct BootstrapEntity {
    pascal: String,
    snake: String,
    db: bool,
    /// Its writes go through a `Pg{Entity}UnitOfWorkFactory` (ADR-0018 § 2).
    uow: bool,
    use_cases: Vec<BootstrapUseCase>,
}

/// Whether an entity's writes go through a unit of work: a CRUD entity of an `events` project.
pub fn uses_unit_of_work(entity: &crate::puerto_toml::Entity, events: bool) -> bool {
    events && entity.db && is_crud_entity(&pascal_to_snake(&entity.name), &entity.use_cases)
}

fn build_context(
    entities: &[crate::puerto_toml::Entity],
    web: bool,
    events: bool,
) -> tera::Context {
    let ctx_entities: Vec<BootstrapEntity> = entities
        .iter()
        .map(|entity| {
            let snake = pascal_to_snake(&entity.name);
            // `create` only writes; `get` and `list` only read; the rest (update, delete and
            // every custom use case) read, then write.
            let create = crud_use_case("create", &snake);
            let reads = [crud_use_case("get", &snake), crud_use_case("list", &snake)];
            BootstrapEntity {
                pascal: entity.name.clone(),
                db: entity.db,
                uow: uses_unit_of_work(entity, events),
                use_cases: entity
                    .use_cases
                    .iter()
                    .map(|uc| BootstrapUseCase {
                        action: uc.clone(),
                        uc_pascal: to_pascal_case(uc),
                        repository: *uc != create,
                        uow: !reads.contains(uc),
                    })
                    .collect(),
                snake,
            }
        })
        .collect();

    let mut ctx = tera::Context::new();
    ctx.insert("entities", &ctx_entities);
    ctx.insert("has_db", &entities.iter().any(|e| e.db));
    ctx.insert("web", &web);
    ctx.insert("apis", &api_expression(entities));
    ctx
}

/// What `OpenApiService` serves: `()` with no entity, the one `{snake}_api`, or a tuple of them.
fn api_expression(entities: &[crate::puerto_toml::Entity]) -> String {
    let apis: Vec<String> = entities
        .iter()
        .map(|e| format!("{}_api", pascal_to_snake(&e.name)))
        .collect();
    match apis.as_slice() {
        [single] => single.clone(),
        _ => format!("({})", apis.join(", ")),
    }
}

/// `web`: the project has a frontend, so `bootstrap.rs` also carries the test that exports the
/// OpenAPI spec to `docs/api/openapi.json` (ADR-0013).
#[cfg(test)]
pub fn generate_bootstrap_content(
    entities: &[crate::puerto_toml::Entity],
    web: bool,
) -> Result<String, RenderError> {
    generate_bootstrap_content_with_events(entities, web, false)
}

/// `events`: the project records domain events, so its CRUD entities are wired with a unit of
/// work.
pub fn generate_bootstrap_content_with_events(
    entities: &[crate::puerto_toml::Entity],
    web: bool,
    events: bool,
) -> Result<String, RenderError> {
    let ctx = build_context(entities, web, events);
    render("bootstrap/bootstrap.tera", &ctx)
}

pub fn generate_tags_content(
    entities: &[crate::puerto_toml::Entity],
) -> Result<String, RenderError> {
    let tags: Vec<&str> = entities.iter().map(|e| e.name.as_str()).collect();
    let mut ctx = tera::Context::new();
    ctx.insert("tags_entities", &tags);
    render("bootstrap/tags.tera", &ctx)
}

/// `puerto generate bootstrap` — refuses when the manifest describes code that is not on disk.
///
/// The write itself is unconditional in `regenerate_bootstrap()` (scaffold calls it right after
/// writing the very files in question). The refusal belongs to the standalone command, which is
/// the one a user runs against a manifest they hand-edited.
pub fn run_bootstrap_command(
    base: &Path,
    allow_missing: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    if !allow_missing {
        let config = crate::puerto_toml::read(base)?;
        let mut lines = crate::generators::consistency::drift_lines(
            &crate::generators::consistency::missing_entity_files(base, &config),
        );
        lines.extend(crate::generators::consistency::missing_consumer_files(
            base, &config,
        ));
        lines.extend(crate::generators::consistency::missing_mail_files(
            base, &config,
        ));
        if !lines.is_empty() {
            return Err(format!(
                "refusing to write generated wiring that will not compile:\n  {}\n\nPass \
                 --allow-missing to write it anyway.",
                lines.join("\n  ")
            )
            .into());
        }
    }
    regenerate_bootstrap(base)?;
    println!("✓ bootstrap.rs regenerated.");
    if crate::puerto_toml::read(base).is_ok_and(|c| c.project.web) {
        println!("✓ web/src/presentation/core/generated/ regenerated.");
    }
    Ok(())
}

/// Read puerto.toml and overwrite `presentation/src/generated/bootstrap.rs`,
/// `presentation/src/api/tags.rs` and, in a web project, `web/src/presentation/core/generated/*`.
///
/// Warns — rather than refuses — on manifest/filesystem drift: this runs inside `scaffold`, where
/// the entity being generated is fine and it is some *other* declared entity that is missing.
pub fn regenerate_bootstrap(base: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let config = crate::puerto_toml::read(base)?;

    for line in crate::generators::consistency::drift_lines(
        &crate::generators::consistency::missing_entity_files(base, &config),
    )
    .into_iter()
    .chain(crate::generators::consistency::missing_consumer_files(
        base, &config,
    ))
    .chain(crate::generators::consistency::missing_mail_files(
        base, &config,
    )) {
        eprintln!("⚠ {line}");
    }

    let bootstrap_path = base.join("presentation/src/generated/bootstrap.rs");
    if let Some(parent) = bootstrap_path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(
        bootstrap_path,
        generate_bootstrap_content_with_events(
            &config.entity,
            config.project.web,
            config.project.has_events(),
        )?,
    )?;

    let tags_path = base.join("presentation/src/api/tags.rs");
    if let Some(parent) = tags_path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(tags_path, generate_tags_content(&config.entity)?)?;

    crate::generators::events::regenerate_events(base, &config)?;
    crate::generators::worker::regenerate_registry(base, &config)?;
    crate::generators::web::regenerate_web(base, &config)?;

    Ok(())
}