puerto 0.10.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 crate::generators::naming::write_file;
use crate::generators::render::render;

const DB_MAKEFILE_TARGETS: &str = r#"
.PHONY: db-up db-down docker-compose/up docker-compose/down reset-db \
        test/infrastructure \
        sqlx/online sqlx/offline sqlx/migrate sqlx/prepare sqlx/check \
        generate/migration

DOCKER         := docker
DOCKER_COMPOSE := docker compose

db-up: docker-compose/up ## Start database containers

db-down: docker-compose/down ## Stop database containers

docker-compose/up: ## Start all containers
	@echo "${CYAN}Starting containers...${NC}"
	$(DOCKER_COMPOSE) up -d

docker-compose/down: ## Stop all containers
	@echo "${CYAN}Stopping containers...${NC}"
	$(DOCKER_COMPOSE) down

reset-db: ## Wipe database and re-run migrations (DESTRUCTIVE)
	@printf "${RED}This destroys ALL database data. Type DESTROY to confirm: ${NC}"; \
	read CONFIRM; \
	if [ "$$CONFIRM" != "DESTROY" ]; then \
		echo "${GREEN}Cancelled${NC}"; \
		exit 1; \
	fi; \
	echo "${CYAN}Stopping containers and removing volumes...${NC}"; \
	$(DOCKER_COMPOSE) down -v; \
	echo "${CYAN}Restarting containers...${NC}"; \
	$(DOCKER_COMPOSE) up -d; \
	echo "${GREEN}Running migrations...${NC}"; \
	sqlx migrate run --source infrastructure/migrations

test/infrastructure: docker-compose/up ## Run infrastructure tests (requires live DB)
	@echo "${YELLOW}Running infrastructure tests...${NC}"
	$(CARGO) nextest run -p infrastructure

sqlx/online: ## Switch SQLx to ONLINE mode (check against live DB)
	@printf '[env]\nSQLX_OFFLINE = "false"\n' > .cargo/config.toml
	@echo "${GREEN}SQLx ONLINE mode${NC}"

sqlx/offline: ## Switch SQLx to OFFLINE mode (use saved cache)
	@printf '[env]\nSQLX_OFFLINE = "true"\n' > .cargo/config.toml
	@echo "${GREEN}SQLx OFFLINE mode${NC}"

sqlx/migrate: docker-compose/up ## Run pending database migrations
	@echo "${GREEN}Running migrations...${NC}"
	sqlx migrate run --source infrastructure/migrations

sqlx/prepare: docker-compose/up ## Regenerate SQLx offline cache (requires live DB)
	@echo "${GREEN}Preparing SQLx cache...${NC}"
	SQLX_OFFLINE=false $(CARGO) sqlx prepare --workspace

sqlx/check: docker-compose/up ## Verify SQLx cache matches current queries
	@echo "${GREEN}Checking SQLx cache...${NC}"
	SQLX_OFFLINE=false $(CARGO) sqlx prepare --workspace --check

generate/migration: ## Create a new SQLx migration — make generate/migration NAME=add_users
	@if [ -z "$$NAME" ]; then \
		echo "${RED}Error: provide name — make generate/migration NAME=add_users_table${NC}"; \
		exit 1; \
	fi
	@echo "${GREEN}Creating migration '$$NAME'...${NC}"
	puerto generate migration $$NAME
"#;

fn docker_compose_content(project_name: &str, db_name: &str) -> String {
    format!(
        "services:\n  postgres:\n    image: postgres:16\n    container_name: {project_name}-postgres\n    ports:\n      - \"${{DB_HOST_PORT:-5432}}:5432\"\n    environment:\n      POSTGRES_USER: devuser\n      POSTGRES_PASSWORD: password\n      POSTGRES_DB: {db_name}\n    volumes:\n      - postgres-data:/var/lib/postgresql/data\n    restart: unless-stopped\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U devuser -d {db_name}\"]\n      interval: 5s\n      timeout: 5s\n      retries: 5\n\nvolumes:\n  postgres-data:\n"
    )
}

fn add_env_to_gitignore(base: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let path = base.join(".gitignore");
    if path.exists() {
        let mut src = fs::read_to_string(&path)?;
        if !src.contains(".env") {
            if !src.ends_with('\n') {
                src.push('\n');
            }
            src.push_str(".env\n");
            fs::write(&path, src)?;
        }
    } else {
        // `.sqlx/` is deliberately *not* ignored: `--db` projects compile with SQLX_OFFLINE=true,
        // so the query cache written by `make sqlx/prepare` must be committed for a clone (or CI)
        // to build without a live database.
        write_file(&path, "/target\n.env\n")?;
    }
    Ok(())
}

fn add_db_makefile_targets(base: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let path = base.join("Makefile");
    if !path.exists() {
        return Ok(());
    }
    let src = fs::read_to_string(&path)?;
    if src.contains("docker-compose/up") {
        return Ok(()); // idempotent
    }
    let mut patched = src;
    if !patched.ends_with('\n') {
        patched.push('\n');
    }
    patched.push_str(DB_MAKEFILE_TARGETS);
    fs::write(&path, patched)?;
    Ok(())
}

fn patch_makefile_setup_for_db(base: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let path = base.join("Makefile");
    if !path.exists() {
        return Ok(()); // no Makefile — nothing to patch
    }
    let src = fs::read_to_string(&path)?;
    let sqlx_line =
        "\t$(CARGO) install sqlx-cli --no-default-features --features postgres --locked\n";
    if src.contains("sqlx-cli") {
        return Ok(()); // idempotent
    }
    let patched = src.replace(
        "\t$(CARGO) install cargo-nextest --locked\n",
        &format!("\t$(CARGO) install cargo-nextest --locked\n{sqlx_line}"),
    );
    fs::write(&path, patched)?;
    Ok(())
}

fn patch_infra_cargo_toml(base: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let path = base.join("infrastructure/Cargo.toml");
    let mut src = fs::read_to_string(&path)?;
    if src.contains("sqlx") {
        return Ok(()); // idempotent
    }
    if !src.ends_with('\n') {
        src.push('\n');
    }
    src.push_str(
        "\n[dependencies.sqlx]\nversion = \"0.8\"\nfeatures = [\"runtime-tokio-rustls\", \"postgres\", \"macros\", \"migrate\", \"uuid\", \"chrono\", \"json\"]\n",
    );
    fs::write(&path, src)?;
    Ok(())
}

/// Write the base `.env` and `.env.example` for every new project (db or not).
pub fn apply_base_env(base: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let content = "SERVICE_PORT=8080\nENV=development\nRUST_LOG=info\nCORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173\n";
    write_file(&base.join(".env"), content)?;
    write_file(&base.join(".env.example"), content)?;
    add_env_to_gitignore(base)?;
    Ok(())
}

/// Write the extra files that `puerto new --db` adds on top of the base template.
pub fn apply_db_to_new_project(base: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let project_name = base.file_name().and_then(|n| n.to_str()).unwrap_or("myapp");
    let db_name = project_name.replace('-', "_");

    // docker-compose.yml
    write_file(
        &base.join("docker-compose.yml"),
        &docker_compose_content(project_name, &db_name),
    )?;

    // Append DB vars to .env and .env.example (apply_base_env already created them)
    let db_vars = format!(
        "\n# Database\nDB_HOST_PORT=5432\nDATABASE_URL=postgres://devuser:password@localhost:5432/{db_name}\n"
    );
    for file in &[".env", ".env.example"] {
        let path = base.join(file);
        let mut content = if path.exists() {
            fs::read_to_string(&path)?
        } else {
            String::new()
        };
        if !content.contains("DATABASE_URL") {
            if !content.ends_with('\n') && !content.is_empty() {
                content.push('\n');
            }
            content.push_str(&db_vars);
            fs::write(&path, content)?;
        }
    }

    // .cargo/config.toml — SQLX_OFFLINE so CI compiles without a live DB
    write_file(
        &base.join(".cargo/config.toml"),
        "[env]\nSQLX_OFFLINE = \"true\"\n",
    )?;

    // infrastructure/migrations/ directory (empty, sqlx needs it)
    fs::create_dir_all(base.join("infrastructure/migrations"))?;

    // infrastructure/src/db.rs
    write_file(
        &base.join("infrastructure/src/db.rs"),
        &render("infrastructure/db.tera", &tera::Context::new())
            .expect("infrastructure/db.tera render failed"),
    )?;

    // Patch infrastructure/Cargo.toml to add sqlx
    patch_infra_cargo_toml(base)?;

    // Patch Makefile: setup target + docker/sqlx targets
    patch_makefile_setup_for_db(base)?;
    add_db_makefile_targets(base)?;

    // Mark the project as db-enabled in puerto.toml
    let mut config = crate::puerto_toml::read(base)?;
    config.project.db = true;
    crate::puerto_toml::write(base, &config)?;

    Ok(())
}

/// Strip the Greeting demo entity from a freshly generated project (`puerto new --no-demo`).
/// Removes greeting files, rewrites the four affected source files, clears puerto.toml entities,
/// and regenerates bootstrap.rs with an empty API.
pub fn apply_no_demo(base: &Path) -> Result<(), Box<dyn std::error::Error>> {
    // Remove greeting directories and the sibling declaration file.
    let _ = fs::remove_dir_all(base.join("business/src/domain/greeting"));
    let _ = fs::remove_dir_all(base.join("business/src/application/greeting"));
    let _ = fs::remove_dir_all(base.join("infrastructure/src/greeting"));
    let _ = fs::remove_dir_all(base.join("presentation/src/api/greeting"));
    let _ = fs::remove_file(base.join("presentation/src/api/greeting.rs"));

    // Minimal business lib.rs: empty domain and application blocks for the patchers to fill.
    fs::write(
        base.join("business/src/lib.rs"),
        "pub mod domain {\n}\npub mod application {\n}\n",
    )?;

    // Minimal infrastructure lib.rs: no adapters yet. A `--db` project keeps its `db` module.
    let infra_lib = if crate::puerto_toml::read(base).is_ok_and(|c| c.project.db) {
        "pub mod db;\n"
    } else {
        ""
    };
    fs::write(base.join("infrastructure/src/lib.rs"), infra_lib)?;

    // Minimal presentation api.rs. `tags` must survive the demo strip: `regenerate_bootstrap`
    // always writes `api/tags.rs`, and every generated `routes.rs` does
    // `use crate::api::tags::ApiTags;` — dropping the declaration made the first scaffold into a
    // `--no-demo` project fail to compile (E0432).
    fs::write(
        base.join("presentation/src/api.rs"),
        "pub mod error;\npub mod tags;\n",
    )?;

    // Clear entities in puerto.toml.
    let mut config = crate::puerto_toml::read(base)?;
    config.entity.clear();
    crate::puerto_toml::write(base, &config)?;

    // Regenerate bootstrap.rs with no entities.
    crate::generators::bootstrap::regenerate_bootstrap(base)?;

    Ok(())
}