puerto 0.10.1

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::{pascal_to_snake, to_pascal_case};
use crate::generators::render::render;

#[derive(Serialize)]
struct BootstrapUseCase {
    action: String,
    uc_pascal: String,
    repo_expr: String,
}

#[derive(Serialize)]
struct BootstrapEntity {
    pascal: String,
    snake: String,
    uc_import_lines: Vec<String>,
    repo_trait_import: String,
    repo_impl_import: String,
    api_import: String,
    needs_repo_var: bool,
    repo_var_line: String,
    use_cases: Vec<BootstrapUseCase>,
    /// The full `EntityApi { … }` initializer. Built here rather than joined in the template so an
    /// entity with no use cases emits `EntityApi {}`, not a dangling separator.
    api_init: String,
    api_var: String,
}

fn build_context(entities: &[crate::puerto_toml::Entity]) -> tera::Context {
    let has_db = entities.iter().any(|e| e.db);

    let api_args = if entities.is_empty() {
        "()".to_string()
    } else if entities.len() == 1 {
        format!("{}_api", pascal_to_snake(&entities[0].name))
    } else {
        let apis = entities
            .iter()
            .map(|e| format!("{}_api", pascal_to_snake(&e.name)))
            .collect::<Vec<_>>()
            .join(", ");
        format!("({apis})")
    };

    let ctx_entities: Vec<BootstrapEntity> = entities
        .iter()
        .map(|entity| {
            let pascal = &entity.name;
            let snake = pascal_to_snake(pascal);
            let uc_count = entity.use_cases.len();

            let repo_init = if entity.db {
                format!("Pg{pascal}Repository::new(pool.clone())")
            } else {
                format!("InMemory{pascal}Repository::new()")
            };

            let repo_crate_path = if entity.db {
                format!("infrastructure::{snake}::repository::Pg{pascal}Repository")
            } else {
                format!("infrastructure::{snake}::repository::InMemory{pascal}Repository")
            };

            let uc_import_lines = entity
                .use_cases
                .iter()
                .map(|uc| {
                    let uc_pascal = to_pascal_case(uc);
                    format!(
                        "use business::application::{snake}::{uc}::{uc_pascal}UseCaseImpl;"
                    )
                })
                .collect();

            let (needs_repo_var, repo_var_line) = if uc_count <= 1 {
                (false, String::new())
            } else {
                let line = format!(
                    "    let {snake}_repo: Arc<dyn {pascal}RepositoryTrait> = Arc::new({repo_init});"
                );
                (true, line)
            };

            let use_cases = entity
                .use_cases
                .iter()
                .enumerate()
                .map(|(i, uc)| {
                    let uc_pascal = to_pascal_case(uc);
                    let repo_expr = if uc_count == 1 {
                        format!(
                            "Arc::new({repo_init}) as Arc<dyn {pascal}RepositoryTrait>"
                        )
                    } else if i < uc_count - 1 {
                        format!("Arc::clone(&{snake}_repo)")
                    } else {
                        format!("{snake}_repo")
                    };
                    BootstrapUseCase {
                        action: uc.clone(),
                        uc_pascal,
                        repo_expr,
                    }
                })
                .collect();

            BootstrapEntity {
                repo_trait_import: format!(
                    "use business::domain::{snake}::repository::{pascal}RepositoryTrait;"
                ),
                repo_impl_import: format!("use {repo_crate_path};"),
                api_import: format!("use crate::api::{snake}::routes::{pascal}Api;"),
                api_init: if entity.use_cases.is_empty() {
                    format!("{pascal}Api {{}}")
                } else {
                    format!("{pascal}Api {{ {} }}", entity.use_cases.join(", "))
                },
                api_var: format!("{snake}_api"),
                pascal: pascal.clone(),
                snake,
                uc_import_lines,
                needs_repo_var,
                repo_var_line,
                use_cases,
            }
        })
        .collect();

    let mut ctx = tera::Context::new();
    ctx.insert("entities", &ctx_entities);
    ctx.insert("has_db", &has_db);
    ctx.insert("api_args", &api_args);
    ctx
}

pub fn generate_bootstrap_content(entities: &[crate::puerto_toml::Entity]) -> String {
    let ctx = build_context(entities);
    render("bootstrap/bootstrap.tera", &ctx).expect("bootstrap/bootstrap.tera render failed")
}

pub fn generate_tags_content(entities: &[crate::puerto_toml::Entity]) -> String {
    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).expect("bootstrap/tags.tera render failed")
}

/// `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 drifts = crate::generators::consistency::missing_entity_files(base, &config);
        if !drifts.is_empty() {
            let lines = crate::generators::consistency::drift_lines(&drifts);
            return Err(format!(
                "refusing to write a bootstrap.rs that will not compile:\n  {}\n\nPass \
                 --allow-missing to write it anyway.",
                lines.join("\n  ")
            )
            .into());
        }
    }
    regenerate_bootstrap(base)?;
    println!("✓ bootstrap.rs regenerated.");
    Ok(())
}

/// Read puerto.toml and overwrite `presentation/src/generated/bootstrap.rs`
/// and `presentation/src/api/tags.rs`.
///
/// 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),
    ) {
        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(&config.entity))?;

    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))?;

    Ok(())
}