puerto 0.14.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');
    }
    // The adapter is the runtime's (ADR-0017); `sqlx` stays for the repositories' queries and the
    // project's `migrate!`.
    src = src.replace(
        "puerto-core = { workspace = true }\n",
        "puerto-core = { workspace = true }\npuerto-sqlx = { workspace = true }\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)?;
    add_workspace_dependency(base, "puerto-sqlx")
}

/// Adds runtime crate `name` to the root `[workspace.dependencies]`, next to the ones the template
/// declares and on the same minor as this CLI.
fn add_workspace_dependency(base: &Path, name: &str) -> Result<(), Box<dyn std::error::Error>> {
    let path = base.join("Cargo.toml");
    let src = fs::read_to_string(&path)?;
    if src.contains(&format!("{name} = ")) {
        return Ok(());
    }
    let header = "[workspace.dependencies]\n";
    let Some(at) = src.find(header) else {
        return Err(format!("{}: no [workspace.dependencies] table", path.display()).into());
    };
    let line = format!(
        "{name} = \"{}\"",
        crate::runtime::requirement(env!("CARGO_PKG_VERSION"))
    );
    // After the table's last entry: before the next table (and the blank line ahead of it), or at
    // the end of the file.
    let body = at + header.len();
    let table_end = src[body..].find("\n[").map_or(src.len(), |i| body + i + 1);
    let end = body + src[body..table_end].trim_end().len();
    let out = if end > body {
        format!("{}\n{line}{}", &src[..end], &src[end..])
    } else {
        format!("{}{line}\n{}", &src[..body], &src[body..])
    };
    fs::write(&path, out)?;
    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())?,
    )?;

    // 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(())
}

/// An already-absent file is what a removal wants; any other failure is a real error.
fn ignore_not_found(result: std::io::Result<()>) -> std::io::Result<()> {
    match result {
        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
        _ => 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.
    for dir in [
        "business/src/domain/greeting",
        "business/src/application/greeting",
        "infrastructure/src/greeting",
        "presentation/src/api/greeting",
    ] {
        ignore_not_found(fs::remove_dir_all(base.join(dir)))?;
    }
    ignore_not_found(fs::remove_file(
        base.join("presentation/src/api/greeting.rs"),
    ))?;

    // Minimal business lib.rs: empty domain and application blocks for the patchers to fill.
    // `RepositoryError` and the page types come from `puerto-core`; `pub mod shared;` arrives
    // with the first shared value object.
    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(())
}

/// The heading of the root `AGENTS.md` section that describes `web/`.
const AGENTS_WEB_SECTION: &str = "## Frontend (`web/`)";

/// Strip the web frontend from a freshly generated project (`puerto new --no-web`): the `web/`
/// directory, `[project] web`, the root `AGENTS.md` section that describes it and the spec export
/// test in `bootstrap.rs`.
pub fn apply_no_web(base: &Path) -> Result<(), Box<dyn std::error::Error>> {
    ignore_not_found(fs::remove_dir_all(base.join("web")))?;

    let agents_path = base.join("AGENTS.md");
    let agents = fs::read_to_string(&agents_path)?;
    fs::write(&agents_path, without_section(&agents, AGENTS_WEB_SECTION))?;

    let mut config = crate::puerto_toml::read(base)?;
    config.project.web = false;
    crate::puerto_toml::write(base, &config)?;

    crate::generators::bootstrap::regenerate_bootstrap(base)?;

    Ok(())
}

/// `markdown` without the `## ` section that starts at `heading`, up to the next `## ` heading.
fn without_section(markdown: &str, heading: &str) -> String {
    let Some(start) = markdown.find(&format!("{heading}\n")) else {
        return markdown.to_string();
    };
    let body = start + heading.len();
    let end = markdown[body..]
        .find("\n## ")
        .map_or(markdown.len(), |offset| body + offset + 1);
    format!("{}{}", &markdown[..start], &markdown[end..])
}

#[cfg(test)]
mod tests {
    use super::without_section;

    #[test]
    fn should_keep_the_next_section_when_removing_one_in_the_middle() {
        let markdown = "# T\n\n## A\n\na\n\n## Web\n\nw\n\n## B\n\nb\n";
        assert_eq!(
            without_section(markdown, "## Web"),
            "# T\n\n## A\n\na\n\n## B\n\nb\n"
        );
    }

    #[test]
    fn should_drop_up_to_the_end_when_the_section_is_the_last_one() {
        assert_eq!(
            without_section("## A\n\na\n\n## Web\n\nw\n", "## Web"),
            "## A\n\na\n\n"
        );
    }

    #[test]
    fn should_leave_the_markdown_alone_when_the_section_is_absent() {
        assert_eq!(without_section("## A\n\na\n", "## Web"), "## A\n\na\n");
    }
}