use crate::commands::list::{require_puerto_project, run_list};
use crate::commands::new::extract_template;
use crate::commands::validate;
use crate::generators::conflict::OverwritePolicy;
use crate::puerto_toml::ValueObjectDefinition;
use crate::{puerto_toml, scaffold, snippets};
use cargo_generate::{GenerateArgs, TemplatePath, Vcs, generate};
use std::fs;
use std::path::{Path, PathBuf};
fn temp_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("puerto_test_{name}"))
}
fn cleanup(path: &Path) {
let _ = fs::remove_dir_all(path);
}
fn generate_project(name: &str, destination: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
let tmp = extract_template()?;
let args = GenerateArgs {
template_path: TemplatePath {
path: Some(tmp.path().to_string_lossy().into_owned()),
..Default::default()
},
name: Some(name.to_string()),
destination: Some(destination.to_path_buf()),
vcs: Some(Vcs::None),
no_workspace: true,
..Default::default()
};
let output_dir = generate(args)?;
drop(tmp);
Ok(output_dir)
}
#[test]
fn cli_supports_version_flag() {
use clap::CommandFactory;
let version = crate::Cli::command().get_version().map(str::to_string);
assert_eq!(version.as_deref(), Some(env!("CARGO_PKG_VERSION")));
let err = crate::Cli::command()
.try_get_matches_from(["puerto", "--version"])
.expect_err("--version short-circuits parsing");
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
}
#[test]
fn cli_definition_is_well_formed() {
use clap::CommandFactory;
crate::Cli::command().debug_assert();
}
#[test]
fn new_name_flag_sets_project_name() {
let dir = temp_dir("new_name_flag");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("explicit-name".into()), false, &dir).unwrap();
let content = fs::read_to_string(output.join("presentation/Cargo.toml")).unwrap();
assert!(content.contains("name = \"explicit-name\""));
cleanup(&dir);
}
#[test]
fn new_db_flag_creates_db_files() {
let dir = temp_dir("new_db_flag");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("db-project".into()), true, &dir).unwrap();
assert!(output.join("docker-compose.yml").exists());
assert!(output.join(".env").exists());
assert!(output.join(".env.example").exists());
assert!(output.join(".cargo/config.toml").exists());
assert!(output.join("infrastructure/migrations").is_dir());
assert!(output.join("infrastructure/src/db.rs").exists());
cleanup(&dir);
}
#[test]
fn new_without_db_flag_has_no_db_files() {
let dir = temp_dir("new_no_db");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("plain-project".into()), false, &dir).unwrap();
assert!(!output.join(".env.example").exists());
assert!(!output.join(".cargo/config.toml").exists());
assert!(!output.join("infrastructure/src/db.rs").exists());
cleanup(&dir);
}
#[test]
fn creates_project_structure() {
let dir = temp_dir("cg_structure");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("test-app", &dir).unwrap();
assert!(output.join("Cargo.toml").exists());
assert!(output.join("business/src/lib.rs").exists());
assert!(output.join("infrastructure/src/lib.rs").exists());
assert!(output.join("presentation/src/main.rs").exists());
cleanup(&dir);
}
#[test]
fn presentation_cargo_toml_has_project_name() {
let dir = temp_dir("cg_cargo");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("my-puerto-app", &dir).unwrap();
let content = fs::read_to_string(output.join("presentation/Cargo.toml")).unwrap();
assert!(content.contains("name = \"my-puerto-app\""));
cleanup(&dir);
}
#[test]
fn main_rs_is_minimal_entry_point() {
let dir = temp_dir("cg_main");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("cool-project", &dir).unwrap();
let content = fs::read_to_string(output.join("presentation/src/main.rs")).unwrap();
assert!(content.contains("generated::bootstrap::build_app"));
assert!(content.contains("mod generated"));
assert!(!content.contains("{{project-name}}"));
cleanup(&dir);
}
#[test]
fn bootstrap_rs_wires_ddd_layers() {
let dir = temp_dir("cg_bootstrap");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("api-test", &dir).unwrap();
let content =
fs::read_to_string(output.join("presentation/src/generated/bootstrap.rs")).unwrap();
assert!(content.contains("GetGreetingUseCaseImpl"));
assert!(content.contains("InMemoryGreetingRepository"));
assert!(content.contains("GreetingApi"));
assert!(content.contains("OpenApiService"));
assert!(content.contains("8080"));
cleanup(&dir);
}
#[test]
fn ddd_layers_exist() {
let dir = temp_dir("cg_ddd");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("ddd-app", &dir).unwrap();
assert!(
output
.join("business/src/domain/greeting/model.rs")
.exists()
);
assert!(
output
.join("business/src/domain/greeting/errors.rs")
.exists()
);
assert!(
output
.join("business/src/domain/greeting/repository.rs")
.exists()
);
assert!(
output
.join("business/src/domain/greeting/use_cases/get_greeting.rs")
.exists()
);
assert!(
output
.join("business/src/application/greeting/get_greeting.rs")
.exists()
);
assert!(
output
.join("infrastructure/src/greeting/repository.rs")
.exists()
);
assert!(
output
.join("presentation/src/api/greeting/routes.rs")
.exists()
);
assert!(output.join("presentation/src/api/greeting/dto.rs").exists());
assert!(
output
.join("presentation/src/api/greeting/error_mapper.rs")
.exists()
);
assert!(
output
.join("presentation/src/api/greeting/responses.rs")
.exists()
);
assert!(output.join("puerto.toml").exists());
assert!(output.join("presentation/src/generated.rs").exists());
assert!(
output
.join("presentation/src/generated/bootstrap.rs")
.exists()
);
assert!(output.join("presentation/src/api/tags.rs").exists());
cleanup(&dir);
}
#[test]
fn puerto_toml_has_project_name_and_greeting_entity() {
let dir = temp_dir("cg_puerto_toml");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("my-app", &dir).unwrap();
let content = fs::read_to_string(output.join("puerto.toml")).unwrap();
assert!(content.contains("name = \"my-app\""));
assert!(content.contains("name = \"Greeting\""));
assert!(content.contains("get_greeting"));
cleanup(&dir);
}
#[test]
#[ignore = "slow: compiles and tests a full generated project"]
fn generated_project_compiles_and_tests_pass() {
let dir = temp_dir("cg_compile");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("compile-test", &dir).unwrap();
let result = std::process::Command::new("cargo")
.args(["test", "--workspace"])
.current_dir(&output)
.output()
.expect("failed to run cargo test");
if !result.status.success() {
eprintln!("stdout:\n{}", String::from_utf8_lossy(&result.stdout));
eprintln!("stderr:\n{}", String::from_utf8_lossy(&result.stderr));
panic!("cargo test failed in generated project");
}
cleanup(&dir);
}
#[test]
fn scaffold_creates_all_files_for_single_word_entity() {
let dir = temp_dir("scaffold_single_word");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
assert!(dir.join("business/src/domain/product/model.rs").exists());
assert!(dir.join("business/src/domain/product/errors.rs").exists());
assert!(
dir.join("business/src/domain/product/repository.rs")
.exists()
);
assert!(
dir.join("business/src/domain/product/use_cases/create_product.rs")
.exists()
);
assert!(
dir.join("business/src/application/product/create_product.rs")
.exists()
);
assert!(
dir.join("infrastructure/src/product/repository.rs")
.exists()
);
assert!(dir.join("presentation/src/api/product.rs").exists());
assert!(dir.join("presentation/src/api/product/dto.rs").exists());
assert!(
dir.join("presentation/src/api/product/responses.rs")
.exists()
);
assert!(
dir.join("presentation/src/api/product/error_mapper.rs")
.exists()
);
assert!(dir.join("presentation/src/api/product/routes.rs").exists());
cleanup(&dir);
}
#[test]
fn scaffold_creates_all_files_for_multi_word_entity() {
let dir = temp_dir("scaffold_multi_word");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run("OrderItem", &dir, false, false, &[], &[]).unwrap();
assert!(dir.join("business/src/domain/order_item/model.rs").exists());
assert!(
dir.join("business/src/domain/order_item/use_cases/create_order_item.rs")
.exists()
);
assert!(
dir.join("business/src/application/order_item/create_order_item.rs")
.exists()
);
assert!(
dir.join("infrastructure/src/order_item/repository.rs")
.exists()
);
assert!(
dir.join("presentation/src/api/order_item/routes.rs")
.exists()
);
cleanup(&dir);
}
#[test]
fn scaffold_normalizes_lowercase_input() {
let dir = temp_dir("scaffold_lowercase");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run("product", &dir, false, false, &[], &[]).unwrap();
assert!(dir.join("business/src/domain/product/model.rs").exists());
assert!(
dir.join("business/src/application/product/create_product.rs")
.exists()
);
cleanup(&dir);
}
#[test]
fn scaffold_substitutes_pascal_name_in_model() {
let dir = temp_dir("scaffold_model_content");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("business/src/domain/product/model.rs")).unwrap();
assert!(content.contains("pub struct ProductProps"));
assert!(content.contains("pub struct Product {"));
assert!(content.contains("ProductError::ValidationError"));
assert!(content.contains("product.validation_error.name_empty"));
cleanup(&dir);
}
#[test]
fn scaffold_substitutes_pascal_name_in_errors() {
let dir = temp_dir("scaffold_errors_content");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("business/src/domain/product/errors.rs")).unwrap();
assert!(content.contains("pub enum ProductError"));
assert!(content.contains("product.not_found"));
assert!(content.contains("product.repository_error"));
cleanup(&dir);
}
#[test]
fn scaffold_substitutes_pascal_name_in_use_case_impl() {
let dir = temp_dir("scaffold_impl_content");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let content =
fs::read_to_string(dir.join("business/src/application/product/create_product.rs")).unwrap();
assert!(!content.contains("Create{Pascal}UseCaseImpl"));
assert!(content.contains("CreateProductUseCaseImpl"));
assert!(content.contains("ProductRepositoryTrait"));
assert!(content.contains("product.validation_error.name_empty"));
cleanup(&dir);
}
#[test]
fn scaffold_crud_impls_import_model_struct_in_tests() {
let dir = temp_dir("scaffold_model_import");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run("Product", &dir, false, true, &[], &[]).unwrap();
for uc in &[
"get_product",
"list_product",
"update_product",
"delete_product",
] {
let path = dir.join(format!("business/src/application/product/{uc}.rs"));
let content = fs::read_to_string(&path).unwrap();
assert!(
content.contains("model::{Product, ProductProps}"),
"{uc}.rs test block missing `model::{{Product, ProductProps}}` import"
);
}
cleanup(&dir);
}
fn setup_puerto_stubs(base: &Path) {
fs::create_dir_all(base.join("business/src")).unwrap();
fs::write(
base.join("business/src/lib.rs"),
"pub mod domain {\n pub mod greeting {\n pub mod errors;\n pub mod model;\n pub mod repository;\n pub mod use_cases {\n pub mod get_greeting;\n }\n }\n}\npub mod application {\n pub mod greeting {\n pub mod get_greeting;\n }\n}\n",
)
.unwrap();
fs::create_dir_all(base.join("infrastructure/src")).unwrap();
fs::write(
base.join("infrastructure/src/lib.rs"),
"pub mod greeting {\n pub mod repository;\n}\n",
)
.unwrap();
fs::create_dir_all(base.join("presentation/src")).unwrap();
fs::write(
base.join("presentation/src/api.rs"),
"pub mod error;\npub mod greeting;\n",
)
.unwrap();
fs::write(
base.join("puerto.toml"),
"[project]\nname = \"test-app\"\n\n[[entity]]\nname = \"Greeting\"\nuse_cases = [\"get_greeting\"]\n",
)
.unwrap();
}
#[test]
fn scaffold_patches_business_lib_rs() {
let dir = temp_dir("scaffold_patch_business");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("business/src/lib.rs")).unwrap();
assert!(content.contains("pub mod product {"));
assert!(content.contains("pub mod errors;"));
assert!(content.contains("pub mod use_cases {"));
assert!(!content.contains("pub mod use_cases;")); assert!(content.contains("pub mod create_product;"));
assert!(content.contains("pub mod greeting {"));
cleanup(&dir);
}
#[test]
fn scaffold_patches_infra_lib_rs() {
let dir = temp_dir("scaffold_patch_infra");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("infrastructure/src/lib.rs")).unwrap();
assert!(content.contains("pub mod product {"));
assert!(content.contains("pub mod repository;"));
assert!(content.contains("pub mod greeting {"));
cleanup(&dir);
}
#[test]
fn scaffold_patches_api_rs() {
let dir = temp_dir("scaffold_patch_api");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("presentation/src/api.rs")).unwrap();
assert!(content.contains("pub mod product;"));
assert!(content.contains("pub mod error;"));
assert!(content.contains("pub mod greeting;"));
cleanup(&dir);
}
#[test]
fn scaffold_updates_puerto_toml_and_regenerates_bootstrap() {
let dir = temp_dir("scaffold_bootstrap");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let toml = fs::read_to_string(dir.join("puerto.toml")).unwrap();
assert!(toml.contains("name = \"Product\""));
assert!(toml.contains("create_product"));
assert!(toml.contains("name = \"Greeting\""));
let bootstrap =
fs::read_to_string(dir.join("presentation/src/generated/bootstrap.rs")).unwrap();
assert!(bootstrap.contains("CreateProductUseCaseImpl"));
assert!(bootstrap.contains("InMemoryProductRepository"));
assert!(bootstrap.contains("ProductApi"));
assert!(bootstrap.contains("GetGreetingUseCaseImpl"));
assert!(bootstrap.contains("InMemoryGreetingRepository"));
assert!(bootstrap.contains("GreetingApi"));
cleanup(&dir);
}
fn setup_use_case_stubs(base: &Path) {
fs::write(
base.join("puerto.toml"),
"[project]\nname = \"test-app\"\n\n[[entity]]\nname = \"Product\"\nuse_cases = [\"create_product\"]\n",
)
.unwrap();
fs::create_dir_all(base.join("business/src/domain/product")).unwrap();
fs::write(
base.join("business/src/lib.rs"),
"pub mod domain {\n pub mod product {\n pub mod errors;\n pub mod model;\n pub mod repository;\n pub mod use_cases {\n pub mod create_product;\n }\n }\n}\npub mod application {\n pub mod product {\n pub mod create_product;\n }\n}\n",
)
.unwrap();
fs::create_dir_all(base.join("business/src/application/product")).unwrap();
}
#[test]
fn use_case_creates_trait_file() {
let dir = temp_dir("uc_trait");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
scaffold::run_use_case("Product", "delete_product", &dir).unwrap();
assert!(
dir.join("business/src/domain/product/use_cases/delete_product.rs")
.exists()
);
cleanup(&dir);
}
#[test]
fn use_case_creates_impl_file() {
let dir = temp_dir("uc_impl");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
scaffold::run_use_case("Product", "delete_product", &dir).unwrap();
assert!(
dir.join("business/src/application/product/delete_product.rs")
.exists()
);
cleanup(&dir);
}
#[test]
fn use_case_trait_file_has_correct_content() {
let dir = temp_dir("uc_trait_content");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
scaffold::run_use_case("Product", "delete_product", &dir).unwrap();
let content =
fs::read_to_string(dir.join("business/src/domain/product/use_cases/delete_product.rs"))
.unwrap();
assert!(content.contains("DeleteProductParams"));
assert!(content.contains("DeleteProductUseCaseTrait"));
cleanup(&dir);
}
#[test]
fn use_case_impl_file_has_correct_content() {
let dir = temp_dir("uc_impl_content");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
scaffold::run_use_case("Product", "delete_product", &dir).unwrap();
let content =
fs::read_to_string(dir.join("business/src/application/product/delete_product.rs")).unwrap();
assert!(content.contains("DeleteProductUseCaseImpl"));
assert!(content.contains("ProductRepositoryTrait"));
assert!(content.contains("DeleteProductUseCaseTrait"));
cleanup(&dir);
}
#[test]
fn use_case_patches_business_lib_rs() {
let dir = temp_dir("uc_patch_lib");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
scaffold::run_use_case("Product", "delete_product", &dir).unwrap();
let content = fs::read_to_string(dir.join("business/src/lib.rs")).unwrap();
assert!(content.contains("pub mod delete_product;"));
assert!(content.contains("pub mod create_product;")); assert_eq!(content.matches("pub mod delete_product;").count(), 2);
cleanup(&dir);
}
#[test]
fn use_case_updates_puerto_toml() {
let dir = temp_dir("uc_puerto_toml");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
scaffold::run_use_case("Product", "delete_product", &dir).unwrap();
let content = fs::read_to_string(dir.join("puerto.toml")).unwrap();
assert!(content.contains("delete_product"));
assert!(content.contains("create_product"));
cleanup(&dir);
}
#[test]
fn use_case_regenerates_bootstrap() {
let dir = temp_dir("uc_bootstrap");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
scaffold::run_use_case("Product", "delete_product", &dir).unwrap();
let content = fs::read_to_string(dir.join("presentation/src/generated/bootstrap.rs")).unwrap();
assert!(content.contains("DeleteProductUseCaseImpl"));
assert!(content.contains("CreateProductUseCaseImpl"));
cleanup(&dir);
}
#[test]
fn use_case_normalizes_entity_casing() {
let dir = temp_dir("uc_normalize");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
scaffold::run_use_case("product", "delete_product", &dir).unwrap();
assert!(
dir.join("business/src/domain/product/use_cases/delete_product.rs")
.exists()
);
cleanup(&dir);
}
#[test]
fn use_case_errors_when_entity_not_in_puerto_toml() {
let dir = temp_dir("uc_missing_entity");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
let result = scaffold::run_use_case("NonExistent", "do_something", &dir);
assert!(result.is_err());
cleanup(&dir);
}
fn new_project_non_interactive(
name: Option<String>,
db: bool,
destination: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let tmp = extract_template()?;
let args = GenerateArgs {
template_path: TemplatePath {
path: Some(tmp.path().to_string_lossy().into_owned()),
..Default::default()
},
name: name.clone(),
destination: Some(destination.to_path_buf()),
vcs: Some(Vcs::None),
no_workspace: true,
..Default::default()
};
let output = generate(args)?;
drop(tmp);
if db {
scaffold::apply_db_to_new_project(&output)?;
}
Ok(output)
}
fn generate_db_project(
name: &str,
destination: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = generate_project(name, destination)?;
scaffold::apply_db_to_new_project(&output)?;
Ok(output)
}
#[test]
fn db_project_has_env_example() {
let dir = temp_dir("db_env");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_db_project("db-app", &dir).unwrap();
let content = fs::read_to_string(output.join(".env.example")).unwrap();
assert!(content.contains("DATABASE_URL"));
assert!(content.contains("db_app"));
cleanup(&dir);
}
#[test]
fn db_project_has_env_file_with_project_db_name() {
let dir = temp_dir("db_env_file");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_db_project("my-api", &dir).unwrap();
let content = fs::read_to_string(output.join(".env")).unwrap();
assert!(content.contains("DATABASE_URL"));
assert!(content.contains("my_api"));
cleanup(&dir);
}
#[test]
fn db_project_has_docker_compose_with_project_name() {
let dir = temp_dir("db_docker_compose");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_db_project("my-api", &dir).unwrap();
let content = fs::read_to_string(output.join("docker-compose.yml")).unwrap();
assert!(content.contains("postgres:16"));
assert!(content.contains("my-api-postgres"));
assert!(content.contains("my_api"));
assert!(content.contains("5432"));
cleanup(&dir);
}
#[test]
fn db_project_has_gitignore_with_env() {
let dir = temp_dir("db_gitignore");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_db_project("db-app", &dir).unwrap();
let gitignore_path = output.join(".gitignore");
if gitignore_path.exists() {
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains(".env"));
}
cleanup(&dir);
}
#[test]
fn db_project_makefile_has_docker_and_sqlx_targets() {
let dir = temp_dir("db_makefile_targets");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_db_project("db-app", &dir).unwrap();
let content = fs::read_to_string(output.join("Makefile")).unwrap();
assert!(content.contains("docker-compose/up"));
assert!(content.contains("docker-compose/down"));
assert!(content.contains("sqlx/migrate"));
assert!(content.contains("sqlx/prepare"));
assert!(content.contains("sqlx/check"));
assert!(content.contains("sqlx/online"));
assert!(content.contains("sqlx/offline"));
assert!(content.contains("reset-db"));
assert!(content.contains("test/infrastructure"));
cleanup(&dir);
}
#[test]
fn db_project_has_cargo_config_with_sqlx_offline() {
let dir = temp_dir("db_cargo_config");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_db_project("db-app", &dir).unwrap();
let content = fs::read_to_string(output.join(".cargo/config.toml")).unwrap();
assert!(content.contains("SQLX_OFFLINE"));
cleanup(&dir);
}
#[test]
fn db_project_has_migrations_dir() {
let dir = temp_dir("db_migrations");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_db_project("db-app", &dir).unwrap();
assert!(output.join("infrastructure/migrations").is_dir());
cleanup(&dir);
}
#[test]
fn db_project_has_db_rs_with_pool_function() {
let dir = temp_dir("db_db_rs");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_db_project("db-app", &dir).unwrap();
let content = fs::read_to_string(output.join("infrastructure/src/db.rs")).unwrap();
assert!(content.contains("create_postgres_pool"));
assert!(content.contains("PgPool"));
cleanup(&dir);
}
#[test]
fn db_project_infrastructure_cargo_has_sqlx() {
let dir = temp_dir("db_infra_cargo");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_db_project("db-app", &dir).unwrap();
let content = fs::read_to_string(output.join("infrastructure/Cargo.toml")).unwrap();
assert!(content.contains("sqlx"));
assert!(content.contains("postgres"));
cleanup(&dir);
}
#[test]
fn db_project_puerto_toml_has_project_db_true() {
let dir = temp_dir("db_proj_toml");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_db_project("db-app", &dir).unwrap();
let config = puerto_toml::read(&output).unwrap();
assert!(
config.project.db,
"project.db should be true for --db projects"
);
cleanup(&dir);
}
#[test]
fn no_db_project_puerto_toml_omits_project_db() {
let dir = temp_dir("no_db_proj_toml");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("plain-app", &dir).unwrap();
let config = puerto_toml::read(&output).unwrap();
assert!(
!config.project.db,
"project.db should be false for non-db projects"
);
let content = fs::read_to_string(output.join("puerto.toml")).unwrap();
assert!(
!content.contains("db = true"),
"puerto.toml should not contain db = true for non-db projects"
);
cleanup(&dir);
}
fn setup_db_puerto_stubs(base: &Path) {
setup_puerto_stubs(base);
fs::create_dir_all(base.join("infrastructure/migrations")).unwrap();
}
#[test]
fn scaffold_db_creates_entity_rs() {
let dir = temp_dir("scaffold_db_entity");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_db_puerto_stubs(&dir);
scaffold::run("Product", &dir, true, false, &[], &[]).unwrap();
assert!(dir.join("infrastructure/src/product/entity.rs").exists());
cleanup(&dir);
}
#[test]
fn scaffold_db_repository_uses_pgpool() {
let dir = temp_dir("scaffold_db_repo");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_db_puerto_stubs(&dir);
scaffold::run("Product", &dir, true, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("infrastructure/src/product/repository.rs")).unwrap();
assert!(content.contains("PgPool"));
assert!(content.contains("PgProductRepository"));
assert!(!content.contains("InMemoryProductRepository"));
cleanup(&dir);
}
#[test]
fn scaffold_db_puerto_toml_has_db_true() {
let dir = temp_dir("scaffold_db_toml");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_db_puerto_stubs(&dir);
scaffold::run("Product", &dir, true, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("puerto.toml")).unwrap();
assert!(content.contains("db = true"));
cleanup(&dir);
}
#[test]
fn scaffold_db_bootstrap_uses_pg_repo() {
let dir = temp_dir("scaffold_db_bootstrap");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_db_puerto_stubs(&dir);
scaffold::run("Product", &dir, true, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("presentation/src/generated/bootstrap.rs")).unwrap();
assert!(content.contains("PgProductRepository"));
assert!(!content.contains("InMemoryProductRepository"));
assert!(content.contains("InMemoryGreetingRepository"));
cleanup(&dir);
}
#[test]
fn scaffold_without_db_still_uses_inmemory() {
let dir = temp_dir("scaffold_no_db");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("infrastructure/src/product/repository.rs")).unwrap();
assert!(content.contains("InMemoryProductRepository"));
assert!(!content.contains("PgPool"));
assert!(!dir.join("infrastructure/src/product/entity.rs").exists());
cleanup(&dir);
}
#[test]
fn migration_creates_migrations_dir_when_missing() {
let dir = temp_dir("migration_no_dir");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run_migration("add_products_table", &dir, None).unwrap();
assert!(
dir.join("infrastructure/migrations").exists(),
"expected infrastructure/migrations to be created automatically"
);
cleanup(&dir);
}
#[test]
fn use_case_rerun_is_refused_and_leaves_files_untouched() {
let dir = temp_dir("uc_rerun_refused");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
scaffold::run_use_case("Product", "delete_product", &dir).unwrap();
let impl_path = dir.join("business/src/application/product/delete_product.rs");
fs::write(&impl_path, "// implemented by hand\n").unwrap();
let result = scaffold::run_use_case("Product", "delete_product", &dir);
assert!(result.is_err(), "a second run must be refused");
assert!(result.unwrap_err().to_string().contains("already exists"));
assert_eq!(
fs::read_to_string(&impl_path).unwrap(),
"// implemented by hand\n"
);
let toml = fs::read_to_string(dir.join("puerto.toml")).unwrap();
assert_eq!(toml.matches("delete_product").count(), 1);
cleanup(&dir);
}
#[test]
fn new_project_bootstrap_injects_no_logger() {
let dir = temp_dir("logger_bootstrap");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("logger-test", &dir).unwrap();
let content =
fs::read_to_string(output.join("presentation/src/generated/bootstrap.rs")).unwrap();
assert!(!content.contains("logger"), "{content}");
let main = fs::read_to_string(output.join("presentation/src/main.rs")).unwrap();
assert!(main.contains("tracing_subscriber::fmt()"), "{main}");
cleanup(&dir);
}
#[test]
fn scaffold_use_case_impl_logs_with_tracing() {
let dir = temp_dir("logger_scaffold_impl");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let content =
fs::read_to_string(dir.join("business/src/application/product/create_product.rs")).unwrap();
assert!(content.contains("tracing::info!"), "{content}");
assert!(!content.contains("logger"), "{content}");
cleanup(&dir);
}
#[test]
fn use_case_generator_impl_logs_with_tracing() {
let dir = temp_dir("logger_uc_impl");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
scaffold::run_use_case("Product", "delete_product", &dir).unwrap();
let content =
fs::read_to_string(dir.join("business/src/application/product/delete_product.rs")).unwrap();
assert!(content.contains("tracing::info!"), "{content}");
assert!(!content.contains("logger"), "{content}");
cleanup(&dir);
}
#[test]
fn scaffold_bootstrap_wires_use_cases_without_a_logger() {
let dir = temp_dir("logger_bootstrap_scaffold");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("presentation/src/generated/bootstrap.rs")).unwrap();
assert!(content.contains(
"let create_product = Arc::new(CreateProductUseCaseImpl { repository: Arc::new(InMemoryProductRepository::new()) as Arc<dyn ProductRepositoryTrait> });"
), "{content}");
assert!(!content.contains("logger"), "{content}");
cleanup(&dir);
}
#[test]
fn new_project_creates_zed_snippet_file() {
let dir = temp_dir("snippets_zed_new");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("snip-test".into()), false, &dir).unwrap();
snippets::apply(&output, None).unwrap();
assert!(output.join(".zed/snippets/rust.json").exists());
cleanup(&dir);
}
#[test]
fn new_project_creates_vscode_snippet_file() {
let dir = temp_dir("snippets_vscode_new");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("snip-test".into()), false, &dir).unwrap();
snippets::apply(&output, None).unwrap();
assert!(output.join(".vscode/puerto.code-snippets").exists());
cleanup(&dir);
}
#[test]
fn snippet_files_contain_valid_json() {
let dir = temp_dir("snippets_valid_json");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
snippets::apply(&dir, None).unwrap();
let zed = fs::read_to_string(dir.join(".zed/snippets/rust.json")).unwrap();
assert!(
serde_json::from_str::<serde_json::Value>(&zed).is_ok(),
"zed snippet JSON invalid"
);
let vscode = fs::read_to_string(dir.join(".vscode/puerto.code-snippets")).unwrap();
assert!(
serde_json::from_str::<serde_json::Value>(&vscode).is_ok(),
"vscode snippet JSON invalid"
);
let zed_sql = fs::read_to_string(dir.join(".zed/snippets/sql.json")).unwrap();
assert!(
serde_json::from_str::<serde_json::Value>(&zed_sql).is_ok(),
"zed sql snippet JSON invalid"
);
let vscode_sql = fs::read_to_string(dir.join(".vscode/puerto.sql.code-snippets")).unwrap();
assert!(
serde_json::from_str::<serde_json::Value>(&vscode_sql).is_ok(),
"vscode sql snippet JSON invalid"
);
cleanup(&dir);
}
#[test]
fn generate_snippets_ide_zed_creates_only_zed_file() {
let dir = temp_dir("snippets_ide_zed");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
snippets::run(&dir, Some("zed")).unwrap();
assert!(dir.join(".zed/snippets/rust.json").exists());
assert!(!dir.join(".vscode/puerto.code-snippets").exists());
cleanup(&dir);
}
#[test]
fn generate_snippets_ide_vscode_creates_only_vscode_file() {
let dir = temp_dir("snippets_ide_vscode");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
snippets::run(&dir, Some("vscode")).unwrap();
assert!(!dir.join(".zed/snippets/rust.json").exists());
assert!(dir.join(".vscode/puerto.code-snippets").exists());
cleanup(&dir);
}
#[test]
fn generate_snippets_is_idempotent() {
let dir = temp_dir("snippets_idempotent");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
snippets::run(&dir, None).unwrap();
snippets::run(&dir, None).unwrap();
assert!(dir.join(".zed/snippets/rust.json").exists());
assert!(dir.join(".vscode/puerto.code-snippets").exists());
cleanup(&dir);
}
#[test]
fn generate_snippets_unknown_ide_returns_error() {
let dir = temp_dir("snippets_unknown_ide");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let result = snippets::run(&dir, Some("neovim"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unknown IDE"));
cleanup(&dir);
}
#[test]
fn snippets_json_contains_vo_prefixes() {
let json: serde_json::Value =
serde_json::from_str(snippets::SNIPPETS_JSON).expect("SNIPPETS_JSON must be valid JSON");
let prefixes: Vec<&str> = json
.as_object()
.unwrap()
.values()
.filter_map(|v| v.get("prefix")?.as_str())
.collect();
for expected in &[
"vo-string",
"vo-numeric",
"vo-enum",
"vo-option-construct",
"vo-vec-construct",
] {
assert!(
prefixes.contains(expected),
"SNIPPETS_JSON missing prefix '{expected}'"
);
}
}
fn setup_puerto_stubs_for_crud(base: &Path) {
setup_puerto_stubs(base);
}
#[test]
fn scaffold_crud_creates_all_domain_use_case_files() {
let dir = temp_dir("scaffold_crud_domain_uc");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs_for_crud(&dir);
scaffold::run("Product", &dir, false, true, &[], &[]).unwrap();
assert!(
dir.join("business/src/domain/product/use_cases/create_product.rs")
.exists()
);
assert!(
dir.join("business/src/domain/product/use_cases/get_product.rs")
.exists()
);
assert!(
dir.join("business/src/domain/product/use_cases/list_product.rs")
.exists()
);
assert!(
dir.join("business/src/domain/product/use_cases/update_product.rs")
.exists()
);
assert!(
dir.join("business/src/domain/product/use_cases/delete_product.rs")
.exists()
);
cleanup(&dir);
}
#[test]
fn scaffold_crud_creates_all_application_use_case_files() {
let dir = temp_dir("scaffold_crud_app_uc");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs_for_crud(&dir);
scaffold::run("Product", &dir, false, true, &[], &[]).unwrap();
assert!(
dir.join("business/src/application/product/create_product.rs")
.exists()
);
assert!(
dir.join("business/src/application/product/get_product.rs")
.exists()
);
assert!(
dir.join("business/src/application/product/list_product.rs")
.exists()
);
assert!(
dir.join("business/src/application/product/update_product.rs")
.exists()
);
assert!(
dir.join("business/src/application/product/delete_product.rs")
.exists()
);
cleanup(&dir);
}
#[test]
fn scaffold_crud_repository_has_find_all() {
let dir = temp_dir("scaffold_crud_repo");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs_for_crud(&dir);
scaffold::run("Product", &dir, false, true, &[], &[]).unwrap();
let content =
fs::read_to_string(dir.join("business/src/domain/product/repository.rs")).unwrap();
assert!(content.contains("async fn find_all"));
assert!(content.contains("async fn find_by_id"));
assert!(content.contains("async fn save"));
cleanup(&dir);
}
#[test]
fn scaffold_crud_routes_has_all_http_methods() {
let dir = temp_dir("scaffold_crud_routes");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs_for_crud(&dir);
scaffold::run("Product", &dir, false, true, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("presentation/src/api/product/routes.rs")).unwrap();
assert!(content.contains("method = \"post\""));
assert!(content.contains("method = \"get\""));
assert!(content.contains("method = \"put\""));
assert!(content.contains("method = \"delete\""));
assert!(content.contains("pub struct ProductApi"));
assert!(content.contains("pub create_product"));
assert!(content.contains("pub get_product"));
assert!(content.contains("pub list_product"));
assert!(content.contains("pub update_product"));
assert!(content.contains("pub delete_product"));
cleanup(&dir);
}
#[test]
fn scaffold_crud_patches_business_lib_with_all_use_cases() {
let dir = temp_dir("scaffold_crud_lib");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs_for_crud(&dir);
scaffold::run("Product", &dir, false, true, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("business/src/lib.rs")).unwrap();
assert!(content.contains("pub mod use_cases {"));
assert!(content.contains("pub mod create_product;"));
assert!(content.contains("pub mod get_product;"));
assert!(content.contains("pub mod list_product;"));
assert!(content.contains("pub mod update_product;"));
assert!(content.contains("pub mod delete_product;"));
let app_start = content
.find("pub mod application {")
.expect("application block missing");
let app_block = &content[app_start..];
assert!(
app_block.contains("pub mod product {"),
"application block missing pub mod product"
);
let product_in_app = app_block.find("pub mod product {").unwrap();
let product_block = &app_block[product_in_app..];
assert!(
product_block.contains("pub mod create_product;"),
"application.product missing create_product"
);
assert!(
product_block.contains("pub mod get_product;"),
"application.product missing get_product"
);
assert!(
product_block.contains("pub mod list_product;"),
"application.product missing list_product"
);
assert!(
product_block.contains("pub mod update_product;"),
"application.product missing update_product"
);
assert!(
product_block.contains("pub mod delete_product;"),
"application.product missing delete_product"
);
cleanup(&dir);
}
#[test]
fn scaffold_crud_bootstrap_wires_all_use_cases() {
let dir = temp_dir("scaffold_crud_bootstrap");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs_for_crud(&dir);
fs::create_dir_all(dir.join("presentation/src/generated")).unwrap();
fs::write(
dir.join("presentation/src/generated/bootstrap.rs"),
"// placeholder\n",
)
.unwrap();
scaffold::run("Product", &dir, false, true, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("presentation/src/generated/bootstrap.rs")).unwrap();
assert!(content.contains("CreateProductUseCaseImpl"));
assert!(content.contains("GetProductUseCaseImpl"));
assert!(content.contains("ListProductUseCaseImpl"));
assert!(content.contains("UpdateProductUseCaseImpl"));
assert!(content.contains("DeleteProductUseCaseImpl"));
cleanup(&dir);
}
#[test]
fn generate_domain_creates_domain_files_and_mother() {
let dir = temp_dir("gen_domain");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("myapp", &dir).unwrap();
scaffold::run_generate_domain("Widget", &output, &[]).unwrap();
assert!(output.join("business/src/domain/widget/model.rs").exists());
assert!(output.join("business/src/domain/widget/errors.rs").exists());
assert!(
output
.join("business/src/domain/widget/repository.rs")
.exists()
);
assert!(
output
.join("business/src/domain/widget/use_cases/create_widget.rs")
.exists()
);
assert!(
output
.join("business/src/domain/widget/use_cases/delete_widget.rs")
.exists()
);
assert!(
output
.join("business/src/tests/mothers/widget_mother.rs")
.exists()
);
let lib = fs::read_to_string(output.join("business/src/lib.rs")).unwrap();
assert!(lib.contains("pub mod widget"));
assert!(lib.contains("pub mod widget_mother;"));
let toml = fs::read_to_string(output.join("puerto.toml")).unwrap();
assert!(toml.contains("Widget"));
cleanup(&dir);
}
#[test]
fn generate_domain_writes_cli_fields_into_model_and_manifest() {
let dir = temp_dir("gen_domain_fields");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("myapp", &dir).unwrap();
let fields = vec![
puerto_toml::parse_field_arg("amount:i64").unwrap(),
puerto_toml::parse_field_arg("status:Status:enum:Draft/Paid").unwrap(),
];
scaffold::run_generate_domain("Invoice", &output, &fields).unwrap();
let model = fs::read_to_string(output.join("business/src/domain/invoice/model.rs")).unwrap();
assert!(model.contains("pub amount: i64"), "got:\n{model}");
assert!(model.contains("pub status: Status"), "got:\n{model}");
let config = puerto_toml::read(&output).unwrap();
let invoice = config
.entity
.iter()
.find(|e| e.name == "Invoice")
.expect("Invoice in puerto.toml");
let names: Vec<&str> = invoice.fields.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, vec!["amount", "status"]);
cleanup(&dir);
}
#[test]
fn generate_domain_fields_reach_the_later_layers() {
let dir = temp_dir("gen_domain_fields_layers");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("myapp", &dir).unwrap();
let fields = vec![puerto_toml::parse_field_arg("amount:i64").unwrap()];
scaffold::run_generate_domain("Invoice", &output, &fields).unwrap();
scaffold::run_generate_application("Invoice", &output).unwrap();
scaffold::run_generate_repository("Invoice", &output).unwrap();
scaffold::run_generate_presentation("Invoice", &output).unwrap();
let impl_rs =
fs::read_to_string(output.join("business/src/application/invoice/create_invoice.rs"))
.unwrap();
assert!(impl_rs.contains("amount"), "got:\n{impl_rs}");
let dto = fs::read_to_string(output.join("presentation/src/api/invoice/dto.rs")).unwrap();
assert!(dto.contains("pub amount: i64"), "got:\n{dto}");
cleanup(&dir);
}
#[test]
fn generate_domain_rejects_conflicting_value_object_types() {
let dir = temp_dir("gen_domain_vo_conflict");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("myapp", &dir).unwrap();
let fields = vec![
puerto_toml::parse_field_arg("a:Code:String").unwrap(),
puerto_toml::parse_field_arg("b:Code:i64").unwrap(),
];
let result = scaffold::run_generate_domain("Invoice", &output, &fields);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("Code"),
"error should name the conflicting value object"
);
cleanup(&dir);
}
#[test]
fn generate_application_creates_use_case_impls() {
let dir = temp_dir("gen_application");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("myapp", &dir).unwrap();
scaffold::run_generate_domain("Widget", &output, &[]).unwrap();
scaffold::run_generate_application("Widget", &output).unwrap();
assert!(
output
.join("business/src/application/widget/create_widget.rs")
.exists()
);
assert!(
output
.join("business/src/application/widget/get_widget.rs")
.exists()
);
assert!(
output
.join("business/src/application/widget/list_widget.rs")
.exists()
);
assert!(
output
.join("business/src/application/widget/update_widget.rs")
.exists()
);
assert!(
output
.join("business/src/application/widget/delete_widget.rs")
.exists()
);
let lib = fs::read_to_string(output.join("business/src/lib.rs")).unwrap();
assert!(lib.contains("pub mod create_widget;"));
cleanup(&dir);
}
#[test]
fn generate_application_errors_without_prior_domain() {
let dir = temp_dir("gen_app_no_domain");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("myapp", &dir).unwrap();
let result = scaffold::run_generate_application("Ghost", &output);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("not found in puerto.toml")
);
cleanup(&dir);
}
#[test]
fn generate_repository_creates_infra_files() {
let dir = temp_dir("gen_repository");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("myapp", &dir).unwrap();
scaffold::run_generate_domain("Widget", &output, &[]).unwrap();
scaffold::run_generate_repository("Widget", &output).unwrap();
assert!(
output
.join("infrastructure/src/widget/repository.rs")
.exists()
);
let repo = fs::read_to_string(output.join("infrastructure/src/widget/repository.rs")).unwrap();
assert!(repo.contains("InMemoryWidgetRepository"));
cleanup(&dir);
}
#[test]
fn generate_presentation_creates_all_files_and_regenerates_bootstrap() {
let dir = temp_dir("gen_presentation");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("myapp", &dir).unwrap();
scaffold::run_generate_domain("Widget", &output, &[]).unwrap();
scaffold::run_generate_repository("Widget", &output).unwrap();
scaffold::run_generate_presentation("Widget", &output).unwrap();
assert!(output.join("presentation/src/api/widget.rs").exists());
assert!(
output
.join("presentation/src/api/widget/routes.rs")
.exists()
);
assert!(output.join("presentation/src/api/widget/dto.rs").exists());
assert!(
output
.join("presentation/src/api/widget/responses.rs")
.exists()
);
assert!(
output
.join("presentation/src/api/widget/error_mapper.rs")
.exists()
);
let bootstrap =
fs::read_to_string(output.join("presentation/src/generated/bootstrap.rs")).unwrap();
assert!(bootstrap.contains("WidgetApi"));
cleanup(&dir);
}
#[test]
fn generate_presentation_errors_without_prior_domain() {
let dir = temp_dir("gen_pres_no_domain");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("myapp", &dir).unwrap();
let result = scaffold::run_generate_presentation("Ghost", &output);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("not found in puerto.toml")
);
cleanup(&dir);
}
#[test]
fn generate_scaffold_includes_object_mother() {
let dir = temp_dir("scaffold_with_mother");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("myapp", &dir).unwrap();
scaffold::run("Widget", &output, false, true, &[], &[]).unwrap();
assert!(
output
.join("business/src/tests/mothers/widget_mother.rs")
.exists()
);
let mother =
fs::read_to_string(output.join("business/src/tests/mothers/widget_mother.rs")).unwrap();
assert!(mother.contains("WidgetMother"));
assert!(mother.contains("pub fn random()"));
cleanup(&dir);
}
#[test]
fn list_fails_outside_puerto_project() {
let dir = temp_dir("list_no_toml");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let result = run_list(&dir);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("puerto.toml not found"));
cleanup(&dir);
}
#[test]
fn list_succeeds_inside_puerto_project() {
let dir = temp_dir("list_with_toml");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("list-app", &dir).unwrap();
run_list(&output).unwrap();
cleanup(&dir);
}
#[test]
fn generate_scaffold_fails_outside_puerto_project() {
let dir = temp_dir("gen_no_toml");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let result = require_puerto_project(&dir);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("puerto.toml not found")
);
cleanup(&dir);
}
#[test]
fn no_demo_has_no_greeting_files() {
let dir = temp_dir("no_demo_files");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("no-demo-app".into()), false, &dir).unwrap();
scaffold::apply_no_demo(&output).unwrap();
assert!(!output.join("business/src/domain/greeting").exists());
assert!(!output.join("business/src/application/greeting").exists());
assert!(!output.join("infrastructure/src/greeting").exists());
assert!(!output.join("presentation/src/api/greeting").exists());
assert!(!output.join("presentation/src/api/greeting.rs").exists());
cleanup(&dir);
}
#[test]
fn no_demo_puerto_toml_has_no_entity_block() {
let dir = temp_dir("no_demo_toml");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("no-demo-app".into()), false, &dir).unwrap();
scaffold::apply_no_demo(&output).unwrap();
let content = fs::read_to_string(output.join("puerto.toml")).unwrap();
assert!(
!content.contains("[[entity]]"),
"puerto.toml should have no entity blocks"
);
assert!(
!content.contains("Greeting"),
"puerto.toml should not mention Greeting"
);
assert!(
content.contains("[project]"),
"puerto.toml should still have [project]"
);
cleanup(&dir);
}
#[test]
fn no_demo_lib_files_have_no_greeting_modules() {
let dir = temp_dir("no_demo_lib");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("no-demo-app".into()), false, &dir).unwrap();
scaffold::apply_no_demo(&output).unwrap();
let biz_lib = fs::read_to_string(output.join("business/src/lib.rs")).unwrap();
assert!(
!biz_lib.contains("greeting"),
"business lib.rs should not reference greeting"
);
let infra_lib = fs::read_to_string(output.join("infrastructure/src/lib.rs")).unwrap();
assert!(
!infra_lib.contains("greeting"),
"infra lib.rs should not reference greeting"
);
let api_rs = fs::read_to_string(output.join("presentation/src/api.rs")).unwrap();
assert!(
!api_rs.contains("greeting"),
"api.rs should not reference greeting"
);
assert!(
api_rs.contains("pub mod error"),
"api.rs should retain error module"
);
cleanup(&dir);
}
#[test]
fn no_demo_bootstrap_has_no_greeting() {
let dir = temp_dir("no_demo_bootstrap");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("no-demo-app".into()), false, &dir).unwrap();
scaffold::apply_no_demo(&output).unwrap();
let content =
fs::read_to_string(output.join("presentation/src/generated/bootstrap.rs")).unwrap();
assert!(
!content.contains("Greeting"),
"bootstrap.rs should have no Greeting reference"
);
assert!(
!content.contains("greeting"),
"bootstrap.rs should have no greeting reference"
);
cleanup(&dir);
}
#[test]
fn default_project_still_has_greeting_files() {
let dir = temp_dir("with_demo");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("demo-app".into()), false, &dir).unwrap();
assert!(
output
.join("business/src/domain/greeting/model.rs")
.exists()
);
assert!(
output
.join("presentation/src/api/greeting/routes.rs")
.exists()
);
cleanup(&dir);
}
#[test]
fn no_demo_then_scaffold_generates_new_entity() {
let dir = temp_dir("no_demo_then_scaffold");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = new_project_non_interactive(Some("no-demo-app".into()), false, &dir).unwrap();
scaffold::apply_no_demo(&output).unwrap();
scaffold::run_scaffold("Player", &output, &[], OverwritePolicy::Ask).unwrap();
assert!(output.join("business/src/domain/player/model.rs").exists());
assert!(
output
.join("presentation/src/api/player/routes.rs")
.exists()
);
assert!(!output.join("business/src/domain/greeting").exists());
cleanup(&dir);
}
fn setup_puerto_stubs_with_project_db(base: &Path) {
fs::create_dir_all(base.join("business/src")).unwrap();
fs::write(
base.join("business/src/lib.rs"),
"pub mod domain {\n pub mod greeting {\n pub mod errors;\n pub mod model;\n pub mod repository;\n pub mod use_cases {\n pub mod get_greeting;\n }\n }\n}\npub mod application {\n pub mod greeting {\n pub mod get_greeting;\n }\n}\n",
).unwrap();
fs::create_dir_all(base.join("infrastructure/src")).unwrap();
fs::write(
base.join("infrastructure/src/lib.rs"),
"pub mod greeting {\n pub mod repository;\n}\n",
)
.unwrap();
fs::create_dir_all(base.join("presentation/src")).unwrap();
fs::write(
base.join("presentation/src/api.rs"),
"pub mod error;\npub mod greeting;\n",
)
.unwrap();
fs::write(
base.join("puerto.toml"),
"[project]\nname = \"test-app\"\ndb = true\n\n[[entity]]\nname = \"Greeting\"\nuse_cases = [\"get_greeting\"]\n",
).unwrap();
}
#[test]
fn scaffold_7_4_infers_db_from_project_toml() {
let dir = temp_dir("scaffold_74_infer_db");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs_with_project_db(&dir);
fs::create_dir_all(dir.join("infrastructure/migrations")).unwrap();
scaffold::run_scaffold("Team", &dir, &[], OverwritePolicy::Ask).unwrap();
let content = fs::read_to_string(dir.join("infrastructure/src/team/repository.rs")).unwrap();
assert!(content.contains("PgTeamRepository"));
assert!(!content.contains("InMemoryTeamRepository"));
cleanup(&dir);
}
#[test]
fn scaffold_7_4_non_db_project_uses_inmemory() {
let dir = temp_dir("scaffold_74_no_db");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run_scaffold("Team", &dir, &[], OverwritePolicy::Ask).unwrap();
let content = fs::read_to_string(dir.join("infrastructure/src/team/repository.rs")).unwrap();
assert!(content.contains("InMemoryTeamRepository"));
assert!(!content.contains("PgTeamRepository"));
cleanup(&dir);
}
#[test]
fn scaffold_7_4_always_generates_5_use_cases() {
let dir = temp_dir("scaffold_74_5_uc");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run_scaffold("Team", &dir, &[], OverwritePolicy::Ask).unwrap();
assert!(
dir.join("business/src/domain/team/use_cases/create_team.rs")
.exists()
);
assert!(
dir.join("business/src/domain/team/use_cases/get_team.rs")
.exists()
);
assert!(
dir.join("business/src/domain/team/use_cases/list_team.rs")
.exists()
);
assert!(
dir.join("business/src/domain/team/use_cases/update_team.rs")
.exists()
);
assert!(
dir.join("business/src/domain/team/use_cases/delete_team.rs")
.exists()
);
cleanup(&dir);
}
#[test]
fn scaffold_7_4_db_project_auto_creates_migration() {
let dir = temp_dir("scaffold_74_migration");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs_with_project_db(&dir);
scaffold::run_scaffold("Team", &dir, &[], OverwritePolicy::Ask).unwrap();
assert!(
dir.join("infrastructure/migrations").is_dir(),
"run_migration should create infrastructure/migrations/ when project.db = true"
);
cleanup(&dir);
}
#[test]
fn scaffold_7_4_non_db_project_does_not_create_migrations_dir() {
let dir = temp_dir("scaffold_74_no_migration");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run_scaffold("Team", &dir, &[], OverwritePolicy::Ask).unwrap();
assert!(
!dir.join("infrastructure/migrations").exists(),
"run_migration should NOT be called when project.db = false"
);
cleanup(&dir);
}
#[test]
fn parse_puerto_toml_with_fields() {
let dir = temp_dir("toml_fields_parse");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Product"
use_cases = ["create_product"]
[[entity.fields]]
name = "name"
type = "String"
[[entity.fields]]
name = "price"
type = "i64"
[[entity.fields]]
name = "sku"
type = "String"
unique = true
[[entity.fields]]
name = "description"
type = "Option<String>"
"#,
)
.unwrap();
let config = puerto_toml::read(&dir).unwrap();
assert_eq!(config.entity.len(), 1);
let product = &config.entity[0];
assert_eq!(product.name, "Product");
assert_eq!(product.fields.len(), 4);
assert_eq!(product.fields[0].name, "name");
assert_eq!(product.fields[0].field_type, "String");
assert!(!product.fields[0].unique);
assert_eq!(product.fields[1].name, "price");
assert_eq!(product.fields[1].field_type, "i64");
assert_eq!(product.fields[2].name, "sku");
assert!(product.fields[2].unique);
assert_eq!(product.fields[3].name, "description");
assert_eq!(product.fields[3].field_type, "Option<String>");
cleanup(&dir);
}
#[test]
fn parse_puerto_toml_without_fields_backwards_compat() {
let dir = temp_dir("toml_no_fields");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Greeting"
use_cases = ["get_greeting"]
"#,
)
.unwrap();
let config = puerto_toml::read(&dir).unwrap();
assert_eq!(config.entity.len(), 1);
let greeting = &config.entity[0];
assert_eq!(greeting.name, "Greeting");
assert!(greeting.fields.is_empty());
cleanup(&dir);
}
#[test]
fn add_entity_with_fields() {
let dir = temp_dir("toml_add_entity_fields");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
"#,
)
.unwrap();
let fields = vec![
puerto_toml::Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
puerto_toml::Field {
name: "quantity".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
puerto_toml::add_entity(
&dir,
"Product",
vec!["create_product".into()],
false,
fields,
)
.unwrap();
let config = puerto_toml::read(&dir).unwrap();
assert_eq!(config.entity.len(), 1);
assert_eq!(config.entity[0].fields.len(), 2);
assert_eq!(config.entity[0].fields[0].name, "title");
assert_eq!(config.entity[0].fields[0].field_type, "String");
cleanup(&dir);
}
#[test]
fn type_registry_resolve_all_types() {
use crate::generators::types::resolve_type;
let valid_types = [
"String",
"i64",
"bool",
"f64",
"Option<String>",
"Option<i64>",
"Option<bool>",
"Option<f64>",
"Uuid",
"DateTime<Utc>",
"Option<DateTime<Utc>>",
"Vec<String>",
"Vec<i64>",
"HashMap<String, String>",
];
for t in &valid_types {
assert!(resolve_type(t).is_ok(), "expected '{t}' to resolve");
}
}
#[test]
fn type_registry_rejects_unknown() {
use crate::generators::types::resolve_type;
let err = resolve_type("FooBar").unwrap_err();
assert!(err.contains("unsupported field type 'FooBar'"));
}
#[test]
fn generate_model_backward_compat_no_fields() {
use crate::generators::domain::generate_model;
let result = generate_model("Product", "product", &[], &[]);
assert!(result.contains("pub struct ProductProps"));
assert!(result.contains("pub name: String,"));
assert!(result.contains("pub struct Product"));
assert!(result.contains("pub id: Uuid,"));
assert!(result.contains("pub name: String,"));
assert!(result.contains("if props.name.trim().is_empty()"));
assert!(result.contains("ProductError::ValidationError(\"name_empty\""));
assert!(result.contains("should_create_product_when_name_is_valid"));
assert!(result.contains("should_reject_product_when_name_is_empty"));
assert!(result.contains("should_reject_product_when_name_is_only_whitespace"));
}
#[test]
fn generate_model_with_custom_fields() {
use crate::generators::domain::generate_model;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
Field {
name: "description".into(),
field_type: "Option<String>".into(),
unique: false,
..Default::default()
},
];
let result = generate_model("Product", "product", &fields, &[]);
assert!(result.contains("pub struct ProductProps"));
assert!(result.contains("pub title: String,"));
assert!(result.contains("pub price: i64,"));
assert!(result.contains("pub description: Option<String>,"));
assert!(result.contains("pub struct Product"));
assert!(result.contains("pub id: Uuid,"));
assert!(result.contains("pub title: String,"));
assert!(result.contains("pub price: i64,"));
assert!(result.contains("pub description: Option<String>,"));
assert!(result.contains("if props.title.trim().is_empty()"));
assert!(result.contains("title_empty"));
assert!(result.contains("should_create_product_when_fields_are_valid"));
assert!(result.contains("should_reject_product_when_title_is_empty"));
assert!(result.contains("should_reject_product_when_title_is_only_whitespace"));
assert!(
!result.contains("if props.price.trim().is_empty()"),
"non-String types should not have empty checks"
);
assert!(
!result.contains("if props.description.trim().is_empty()"),
"Option<String> types should not have empty checks"
);
}
#[test]
fn generate_model_with_uuid_field() {
use crate::generators::domain::generate_model;
use crate::puerto_toml::Field;
let fields = vec![Field {
name: "category_id".into(),
field_type: "Uuid".into(),
unique: false,
..Default::default()
}];
let result = generate_model("Item", "item", &fields, &[]);
assert!(result.contains("use uuid::Uuid;"));
assert!(result.contains("pub category_id: Uuid,"));
assert!(result.contains("should_create_item_when_fields_are_valid"));
}
#[test]
fn generate_model_with_hashmap_field() {
use crate::generators::domain::generate_model;
use crate::puerto_toml::Field;
let fields = vec![Field {
name: "metadata".into(),
field_type: "HashMap<String, String>".into(),
unique: false,
..Default::default()
}];
let result = generate_model("Config", "config", &fields, &[]);
assert!(result.contains("use std::collections::HashMap;"));
assert!(result.contains("pub metadata: HashMap<String, String>,"));
}
#[test]
fn generate_model_multiple_string_fields_has_validations() {
use crate::generators::domain::generate_model;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "name".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "sku".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_model("Product", "product", &fields, &[]);
assert!(result.contains("if props.name.trim().is_empty()"));
assert!(result.contains("name_empty"));
assert!(result.contains("if props.sku.trim().is_empty()"));
assert!(result.contains("sku_empty"));
assert!(result.contains("should_reject_product_when_name_is_empty"));
assert!(result.contains("should_reject_product_when_sku_is_empty"));
assert!(
!result.contains("if props.price.trim().is_empty()"),
"non-String types should not have empty checks"
);
}
#[test]
fn generate_mother_backward_compat_no_fields() {
use crate::generators::domain::generate_mother;
let result = generate_mother("Product", "product", &[], &[]);
assert!(result.contains("pub struct ProductMother"));
assert!(result.contains("name: Option<String>,"));
assert!(result.contains("pub fn with_name(mut self, name: &str) -> Self"));
assert!(result.contains("pub fn with_empty_name(mut self) -> Self"));
assert!(result.contains("Product::new(ProductProps"));
assert!(result.contains("self.name.unwrap_or_else(|| \"example\".to_string())"));
assert!(result.contains("pub fn random()"));
assert!(result.contains("pub fn random_vec"));
}
#[test]
fn generate_mother_with_custom_fields() {
use crate::generators::domain::generate_mother;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
Field {
name: "description".into(),
field_type: "Option<String>".into(),
unique: false,
..Default::default()
},
];
let result = generate_mother("Product", "product", &fields, &[]);
assert!(result.contains("title: Option<String>,"));
assert!(result.contains("price: Option<i64>,"));
assert!(result.contains("description: Option<String>,"));
assert!(result.contains("pub fn with_title(mut self, title: &str) -> Self"));
assert!(result.contains("self.title = Some(title.to_string())"));
assert!(result.contains("pub fn with_price(mut self, price: i64) -> Self"));
assert!(result.contains("self.price = Some(price)"));
assert!(result.contains("pub fn with_description(mut self, description: &str) -> Self"));
assert!(result.contains("self.description = Some(description.to_string())"));
assert!(result.contains("pub fn with_empty_title(mut self) -> Self"));
assert!(result.contains("self.title = Some(String::new())"));
assert!(result.contains("self.price.unwrap_or(42)"));
assert!(result.contains("self.description"));
assert!(result.contains("pub fn random()"));
assert!(result.contains("pub fn random_vec"));
}
#[test]
fn scaffold_with_empty_fields_produces_backward_compat_model() {
let dir = temp_dir("scaffold_empty_fields");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run("Product", &dir, false, false, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("business/src/domain/product/model.rs")).unwrap();
assert!(content.contains("pub struct ProductProps"));
assert!(content.contains("pub name: String,"));
assert!(content.contains("pub struct Product"));
assert!(content.contains("ProductError::ValidationError(\"name_empty\""));
assert!(content.contains("should_create_product_when_name_is_valid"));
let mother =
fs::read_to_string(dir.join("business/src/tests/mothers/product_mother.rs")).unwrap();
assert!(mother.contains("pub struct ProductMother"));
assert!(mother.contains("with_name"));
cleanup(&dir);
}
#[test]
fn scaffold_crud_with_empty_fields_produces_backward_compat_model() {
let dir = temp_dir("scaffold_crud_empty_fields");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run("Product", &dir, false, true, &[], &[]).unwrap();
let content = fs::read_to_string(dir.join("business/src/domain/product/model.rs")).unwrap();
assert!(content.contains("pub struct ProductProps"));
assert!(content.contains("pub name: String,"));
let mother =
fs::read_to_string(dir.join("business/src/tests/mothers/product_mother.rs")).unwrap();
assert!(mother.contains("ProductMother"));
assert!(mother.contains("with_name"));
cleanup(&dir);
}
#[test]
fn generate_create_use_case_trait_with_custom_fields() {
use crate::generators::domain::generate_create_use_case_trait;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
Field {
name: "description".into(),
field_type: "Option<String>".into(),
unique: false,
..Default::default()
},
];
let result = generate_create_use_case_trait("Product", "product", &fields);
assert!(result.contains("pub struct CreateProductParams"));
assert!(result.contains("pub title: String,"));
assert!(result.contains("pub price: i64,"));
assert!(result.contains("pub description: Option<String>,"));
assert!(result.contains("pub trait CreateProductUseCaseTrait"));
assert!(
!result.contains("pub name: String,"),
"should not contain hardcoded 'name' field"
);
}
#[test]
fn generate_create_use_case_trait_backward_compat_no_fields() {
use crate::generators::domain::generate_create_use_case_trait;
let result = generate_create_use_case_trait("Product", "product", &[]);
assert!(result.contains("pub struct CreateProductParams"));
assert!(result.contains("pub name: String,"));
assert!(result.contains("pub trait CreateProductUseCaseTrait"));
}
#[test]
fn generate_update_use_case_trait_with_custom_fields() {
use crate::generators::domain::generate_update_use_case_trait;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_update_use_case_trait("Product", "product", &fields);
assert!(result.contains("pub struct UpdateProductParams"));
assert!(result.contains("pub id: Uuid,"));
assert!(result.contains("pub title: String,"));
assert!(result.contains("pub price: i64,"));
assert!(result.contains("pub trait UpdateProductUseCaseTrait"));
}
#[test]
fn generate_update_use_case_trait_includes_uuid_fields() {
use crate::generators::domain::generate_update_use_case_trait;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "category_id".into(),
field_type: "Uuid".into(),
unique: false,
..Default::default()
},
Field {
name: "name".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
];
let result = generate_update_use_case_trait("Item", "item", &fields);
assert!(result.contains("pub name: String,"));
assert!(result.contains("pub id: Uuid,"));
assert!(result.contains("pub category_id: Uuid,"));
assert_eq!(
result.matches("use uuid::Uuid;").count(),
1,
"a Uuid field must not duplicate the import the template already writes (E0252)"
);
}
#[test]
fn generate_create_use_case_trait_includes_uuid_fields() {
use crate::generators::domain::generate_create_use_case_trait;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "category_id".into(),
field_type: "Uuid".into(),
unique: false,
..Default::default()
},
Field {
name: "name".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
];
let result = generate_create_use_case_trait("Item", "item", &fields);
assert!(result.contains("pub name: String,"));
assert!(result.contains("pub category_id: Uuid,"));
assert!(
!result.contains("pub id: Uuid,"),
"Create params must not carry the system id"
);
assert_eq!(result.matches("use uuid::Uuid;").count(), 1);
}
#[test]
fn generate_create_use_case_impl_with_custom_fields() {
use crate::generators::application::generate_create_use_case_impl;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_create_use_case_impl("Product", "product", &fields, &[]);
assert!(result.contains("CreateProductUseCaseImpl"));
assert!(result.contains("CreateProductParams"));
assert!(result.contains("title: params.title,"));
assert!(result.contains("price: params.price,"));
assert!(result.contains("Product::new(ProductProps"));
assert!(result.contains("tracing::info!(\"create_product: start\");"));
}
#[test]
fn generate_update_use_case_impl_with_custom_fields() {
use crate::generators::application::generate_update_use_case_impl;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_update_use_case_impl("Product", "product", &fields, &[]);
assert!(result.contains("UpdateProductUseCaseImpl"));
assert!(result.contains("UpdateProductParams"));
assert!(result.contains("entity.title = params.title;"));
assert!(result.contains("entity.price = params.price;"));
assert!(result.contains("if params.title.trim().is_empty()"));
assert!(
!result.contains("if params.price.trim().is_empty()"),
"non-String types should not have empty validation"
);
assert!(result.contains("ProductError::ValidationError(\"title_empty\""));
}
#[test]
fn generate_update_use_case_impl_assigns_uuid_fields() {
use crate::generators::application::generate_update_use_case_impl;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "category_id".into(),
field_type: "Uuid".into(),
unique: false,
..Default::default()
},
Field {
name: "name".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
];
let result = generate_update_use_case_impl("Item", "item", &fields, &[]);
assert!(result.contains("entity.name = params.name;"));
assert!(result.contains("entity.category_id = params.category_id;"));
assert!(result.contains(" use uuid::Uuid;"));
assert!(
!result.contains("\nuse uuid::Uuid;"),
"primitive imports must not sit at file level in the impl"
);
assert_eq!(result.matches("use uuid::Uuid;").count(), 1);
}
#[test]
fn generate_create_use_case_impl_passes_uuid_fields_to_props() {
use crate::generators::application::generate_create_use_case_impl;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "category_id".into(),
field_type: "Uuid".into(),
unique: false,
..Default::default()
},
Field {
name: "name".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
];
let result = generate_create_use_case_impl("Item", "item", &fields, &[]);
assert!(result.contains("category_id: params.category_id,"));
assert!(result.contains("name: params.name,"));
assert!(result.contains(" category_id: Uuid::new_v4(),"));
assert!(result.contains(" use uuid::Uuid;"));
assert!(
!result.contains("\nuse uuid::Uuid;"),
"primitive imports must not sit at file level in the impl"
);
}
#[test]
fn scaffold_crud_writes_dynamic_use_case_params() {
let dir = temp_dir("scaffold_crud_dynamic_params");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "test-app"
[[entity]]
name = "Product"
use_cases = ["create_product", "get_product", "list_product", "update_product", "delete_product"]
[[entity.fields]]
name = "name"
type = "String"
[[entity.fields]]
name = "price"
type = "i64"
"#,
)
.unwrap();
let config = puerto_toml::read(&dir).unwrap();
let fields = config.entity[0].fields.clone();
let pascal = "Product";
let snake = "product";
let create_trait =
crate::generators::domain::generate_create_use_case_trait(pascal, snake, &fields);
assert!(create_trait.contains("pub name: String,"));
assert!(create_trait.contains("pub price: i64,"));
assert!(
!create_trait.contains("pub id: Uuid,"),
"Create params should not have id"
);
let update_trait =
crate::generators::domain::generate_update_use_case_trait(pascal, snake, &fields);
assert!(update_trait.contains("pub id: Uuid,"));
assert!(update_trait.contains("pub name: String,"));
assert!(update_trait.contains("pub price: i64,"));
cleanup(&dir);
}
#[test]
fn write_application_files_with_fields_generates_dynamic_impls() {
let dir = temp_dir("app_files_with_fields");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::create_dir_all(dir.join("business/src/application/product")).unwrap();
let fields = vec![
crate::puerto_toml::Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
crate::puerto_toml::Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
crate::generators::application::write_application_files(
"Product",
"product",
&dir,
&fields,
&[],
)
.unwrap();
let create_content =
fs::read_to_string(dir.join("business/src/application/product/create_product.rs")).unwrap();
assert!(create_content.contains("title: params.title,"));
assert!(create_content.contains("price: params.price,"));
assert!(create_content.contains("Product::new(ProductProps"));
let update_content =
fs::read_to_string(dir.join("business/src/application/product/update_product.rs")).unwrap();
assert!(update_content.contains("entity.title = params.title;"));
assert!(update_content.contains("entity.price = params.price;"));
assert!(update_content.contains("if params.title.trim().is_empty()"));
cleanup(&dir);
}
#[test]
fn generate_get_use_case_impl_with_custom_fields() {
use crate::generators::application::generate_get_use_case_impl;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_get_use_case_impl("Product", "product", &fields, &[]);
assert!(result.contains("GetProductUseCaseImpl"));
assert!(result.contains("GetProductParams"));
assert!(result.contains("should_return_product_when_id_exists"));
assert!(result.contains("should_return_not_found_when_id_does_not_exist"));
assert!(result.contains("Product::new(ProductProps"));
assert!(result.contains("title: \"example\".to_string(),"));
assert!(result.contains("price: 42,"));
assert!(!result.contains("logger"));
}
#[test]
fn generate_get_use_case_impl_backward_compat_no_fields() {
use crate::generators::application::generate_get_use_case_impl;
let result = generate_get_use_case_impl("Product", "product", &[], &[]);
assert!(result.contains("GetProductUseCaseImpl"));
assert!(result.contains("Product::new(ProductProps"));
assert!(result.contains("name: \"example\".to_string(),"));
assert!(result.contains("should_return_product_when_id_exists"));
}
#[test]
fn generate_list_use_case_impl_with_custom_fields() {
use crate::generators::application::generate_list_use_case_impl;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_list_use_case_impl("Product", "product", &fields, &[]);
assert!(result.contains("ListProductUseCaseImpl"));
assert!(result.contains("Product::new(ProductProps"));
assert!(result.contains("should_return_all_products"));
assert!(result.contains("should_return_empty_list_when_no_products_exist"));
assert!(result.contains("title: \"first\".to_string(),"));
assert!(result.contains("title: \"second\".to_string(),"));
assert!(result.contains("price: 42,"));
assert!(!result.contains("logger"));
}
#[test]
fn generate_delete_use_case_impl_with_custom_fields() {
use crate::generators::application::generate_delete_use_case_impl;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_delete_use_case_impl("Product", "product", &fields, &[]);
assert!(result.contains("DeleteProductUseCaseImpl"));
assert!(result.contains("Product::new(ProductProps"));
assert!(result.contains("should_soft_delete_product_when_id_exists"));
assert!(result.contains("should_return_not_found_when_product_does_not_exist"));
assert!(result.contains("title: \"example\".to_string(),"));
assert!(result.contains("price: 42,"));
assert!(!result.contains("logger"));
}
#[test]
fn generate_create_use_case_impl_with_custom_fields_has_tests() {
use crate::generators::application::generate_create_use_case_impl;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_create_use_case_impl("Product", "product", &fields, &[]);
assert!(result.contains("#[cfg(test)]"));
assert!(result.contains("should_create_product_when_fields_are_valid"));
assert!(result.contains("should_return_error_when_title_is_empty"));
assert!(!result.contains("logger"));
assert!(result.contains("MockProductRepository"));
assert!(result.contains("title: \"example\".to_string(),"));
assert!(result.contains("price: 42,"));
}
#[test]
fn generate_update_use_case_impl_with_custom_fields_has_tests() {
use crate::generators::application::generate_update_use_case_impl;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_update_use_case_impl("Product", "product", &fields, &[]);
assert!(result.contains("#[cfg(test)]"));
assert!(result.contains("should_update_product_when_params_are_valid"));
assert!(result.contains("should_return_not_found_when_product_does_not_exist"));
assert!(result.contains("should_return_error_when_title_is_empty"));
assert!(result.contains("Product::new(ProductProps"));
assert!(!result.contains("logger"));
assert!(result.contains("title: \"original\".to_string(),"));
assert!(result.contains("title: \"updated\".to_string(),"));
assert!(result.contains("price: 42,"));
}
#[test]
fn write_application_files_with_fields_generates_dynamic_get_list_delete() {
let dir = temp_dir("app_files_dynamic_all");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::create_dir_all(dir.join("business/src/application/product")).unwrap();
let fields = vec![
crate::puerto_toml::Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
crate::puerto_toml::Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
crate::generators::application::write_application_files(
"Product",
"product",
&dir,
&fields,
&[],
)
.unwrap();
let get_content =
fs::read_to_string(dir.join("business/src/application/product/get_product.rs")).unwrap();
assert!(get_content.contains("GetProductUseCaseImpl"));
assert!(get_content.contains("should_return_product_when_id_exists"));
assert!(get_content.contains("Product::new(ProductProps"));
assert!(get_content.contains("title: \"example\".to_string(),"));
let list_content =
fs::read_to_string(dir.join("business/src/application/product/list_product.rs")).unwrap();
assert!(list_content.contains("ListProductUseCaseImpl"));
assert!(list_content.contains("should_return_all_products"));
assert!(list_content.contains("title: \"first\".to_string(),"));
let delete_content =
fs::read_to_string(dir.join("business/src/application/product/delete_product.rs")).unwrap();
assert!(delete_content.contains("DeleteProductUseCaseImpl"));
assert!(delete_content.contains("should_soft_delete_product_when_id_exists"));
assert!(delete_content.contains("Product::new(ProductProps"));
cleanup(&dir);
}
#[test]
fn generate_infra_entity_backward_compat() {
use crate::generators::infrastructure::generate_infra_entity;
let result = generate_infra_entity("Product", "product", &[], &[]);
assert!(result.contains("pub struct ProductDb"));
assert!(result.contains("pub id: Uuid"));
assert!(result.contains("pub name: String"));
assert!(result.contains("impl TryFrom<ProductDb> for Product"));
assert!(result.contains("impl From<&Product> for ProductDb"));
assert!(result.contains("name: row.name"));
assert!(result.contains("name: entity.name.clone()"));
}
#[test]
fn generate_infra_entity_with_custom_fields() {
use crate::generators::infrastructure::generate_infra_entity;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
Field {
name: "active".into(),
field_type: "Option<bool>".into(),
unique: false,
..Default::default()
},
];
let result = generate_infra_entity("Product", "product", &fields, &[]);
assert!(result.contains("pub struct ProductDb"));
assert!(result.contains("pub title: String"));
assert!(result.contains("pub price: i64"));
assert!(result.contains("pub active: Option<bool>"));
assert!(!result.contains("pub name: String"));
assert!(result.contains("title: row.title"));
assert!(result.contains("price: row.price"));
assert!(result.contains("title: entity.title.clone()"));
assert!(result.contains("price: entity.price,"));
}
#[test]
fn create_table_sql_backward_compat() {
use crate::generators::infrastructure::create_table_sql;
let result = create_table_sql("product", &[]);
assert!(result.contains("CREATE TABLE products"));
assert!(result.contains("id UUID PRIMARY KEY"));
assert!(result.contains("name TEXT NOT NULL"));
assert!(result.contains("created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()"));
assert!(result.contains("deleted BOOLEAN NOT NULL DEFAULT FALSE"));
assert!(result.contains("deleted_at TIMESTAMPTZ"));
}
#[test]
fn create_table_sql_with_custom_fields() {
use crate::generators::infrastructure::create_table_sql;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
Field {
name: "description".into(),
field_type: "Option<String>".into(),
unique: false,
..Default::default()
},
Field {
name: "tags".into(),
field_type: "Vec<String>".into(),
unique: false,
..Default::default()
},
];
let result = create_table_sql("product", &fields);
assert!(result.contains("CREATE TABLE products"));
assert!(result.contains("title TEXT NOT NULL"));
assert!(result.contains("price BIGINT NOT NULL"));
assert!(result.contains("description TEXT,"));
assert!(result.contains("tags TEXT[] NOT NULL DEFAULT '{}'"));
assert!(!result.contains("name TEXT"));
}
#[test]
fn create_table_sql_emits_unique_index_for_unique_fields() {
use crate::generators::infrastructure::create_table_sql;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "sku".into(),
field_type: "String".into(),
unique: true,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
..Default::default()
},
];
let result = create_table_sql("product", &fields);
assert!(result.contains("sku TEXT NOT NULL"));
assert!(
result.contains(
"CREATE UNIQUE INDEX products_sku_key ON products (sku) WHERE deleted = false;"
),
"unique field must produce a unique index, got:\n{result}"
);
assert!(!result.contains("price_key"));
}
#[test]
fn create_table_sql_emits_unique_index_for_unique_value_object_field() {
use crate::generators::infrastructure::create_table_sql;
use crate::puerto_toml::Field;
let fields = vec![Field {
name: "sku".into(),
field_type: "String".into(),
unique: true,
value_object: Some("Sku".into()),
..Default::default()
}];
let result = create_table_sql("item", &fields);
assert!(
result.contains("CREATE UNIQUE INDEX items_sku_key ON items (sku) WHERE deleted = false;")
);
}
#[test]
fn create_table_sql_omits_unique_index_when_no_field_is_unique() {
use crate::generators::infrastructure::create_table_sql;
let result = create_table_sql("product", &[]);
assert!(!result.contains("CREATE UNIQUE INDEX"));
}
#[test]
fn generate_crud_infra_db_repository_backward_compat() {
use crate::generators::infrastructure::generate_crud_infra_db_repository;
let result = generate_crud_infra_db_repository("Product", "product", &[], &[]);
assert!(result.contains("pub struct PgProductRepository"));
assert!(result.contains("async fn find_all"));
assert!(result.contains("async fn find_by_id"));
assert!(result.contains("async fn save"));
assert!(result.contains("id, created_at, updated_at, deleted, deleted_at, name"));
assert!(result.contains("should_persist_and_retrieve_by_id"));
assert!(result.contains("should_list_all_products_excluding_deleted"));
assert!(result.contains("ProductProps"));
assert!(result.contains("name: \"example\".to_string()"));
}
#[test]
fn generate_crud_infra_db_repository_with_custom_fields() {
use crate::generators::infrastructure::generate_crud_infra_db_repository;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_crud_infra_db_repository("Product", "product", &fields, &[]);
assert!(result.contains("id, created_at, updated_at, deleted, deleted_at, title, price"));
assert!(result.contains("$1, $2, $3, $4, $5, $6, $7"));
assert!(result.contains("title = $6, price = $7"));
assert!(result.contains("db.title"));
assert!(result.contains("db.price"));
assert!(result.contains("title: \"example\".to_string()"));
assert!(result.contains("price: 42"));
assert!(result.contains("assert_eq!(found.title, entity.title)"));
assert!(result.contains("assert_eq!(found.price, entity.price)"));
assert!(result.contains("entity.title = \"updated\".to_string()"));
}
#[test]
fn generate_crud_dto_backward_compat() {
use crate::generators::presentation::generate_crud_dto;
let result = generate_crud_dto("Product", "product", &[]);
assert!(result.contains("pub struct ProductDto"));
assert!(result.contains("pub id: Uuid"));
assert!(result.contains("pub name: String"));
assert!(result.contains("pub fn from_domain"));
assert!(result.contains("name: entity.name.clone()"));
assert!(result.contains("pub struct CreateProductRequest"));
assert!(result.contains("pub struct UpdateProductRequest"));
}
#[test]
fn generate_crud_dto_with_custom_fields() {
use crate::generators::presentation::generate_crud_dto;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
Field {
name: "description".into(),
field_type: "Option<String>".into(),
unique: false,
..Default::default()
},
];
let result = generate_crud_dto("Product", "product", &fields);
assert!(result.contains("pub struct ProductDto"));
assert!(result.contains("pub id: Uuid"));
assert!(result.contains("pub title: String"));
assert!(result.contains("pub price: i64"));
assert!(result.contains("pub description: Option<String>"));
assert!(!result.contains("pub name: String"));
assert!(result.contains("title: entity.title.clone()"));
assert!(result.contains("price: entity.price,"));
assert!(result.contains("pub struct CreateProductRequest"));
assert!(result.contains("pub struct UpdateProductRequest"));
}
#[test]
fn generate_crud_dto_and_routes_carry_uuid_fields() {
use crate::generators::presentation::{generate_crud_dto, generate_crud_routes};
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "category_id".into(),
field_type: "Uuid".into(),
unique: false,
..Default::default()
},
];
let dto = generate_crud_dto("Product", "product", &fields);
assert!(dto.contains("pub category_id: Uuid"));
assert!(dto.contains("category_id: entity.category_id,"));
let create_request = dto
.split("pub struct CreateProductRequest")
.nth(1)
.expect("CreateProductRequest missing");
assert!(
create_request.contains("pub category_id: Uuid"),
"CreateProductRequest must carry the Uuid field"
);
let routes = generate_crud_routes("Product", "product", &fields);
assert_eq!(
routes.matches("category_id: body.category_id,").count(),
2,
"both the create and the update handler must pass the Uuid field through"
);
}
#[test]
fn generate_crud_routes_with_custom_fields() {
use crate::generators::presentation::generate_crud_routes;
use crate::puerto_toml::Field;
let fields = vec![
Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
let result = generate_crud_routes("Product", "product", &fields);
assert!(result.contains("method = \"post\""));
assert!(result.contains("method = \"get\""));
assert!(result.contains("method = \"put\""));
assert!(result.contains("method = \"delete\""));
assert!(result.contains("pub struct ProductApi"));
assert!(result.contains("title: body.title.clone()"));
assert!(result.contains("price: body.price,"));
assert!(result.contains("id: id.0,"));
}
#[test]
fn write_repository_files_with_fields_generates_dynamic_entity_and_repo() {
let dir = temp_dir("repo_files_dynamic");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let fields = vec![
crate::puerto_toml::Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
crate::puerto_toml::Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
crate::generators::infrastructure::write_repository_files(
"Product",
"product",
&dir,
true,
&fields,
&[],
)
.unwrap();
let entity = fs::read_to_string(dir.join("infrastructure/src/product/entity.rs")).unwrap();
assert!(entity.contains("pub title: String"));
assert!(entity.contains("pub price: i64"));
assert!(!entity.contains("pub name: String"));
let repo = fs::read_to_string(dir.join("infrastructure/src/product/repository.rs")).unwrap();
assert!(repo.contains("title, price"));
assert!(repo.contains("PgProductRepository"));
cleanup(&dir);
}
#[test]
fn write_presentation_files_with_fields_generates_dynamic_dto_and_routes() {
let dir = temp_dir("pres_files_dynamic");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let fields = vec![
crate::puerto_toml::Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
crate::puerto_toml::Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
crate::generators::presentation::write_presentation_files("Product", "product", &dir, &fields)
.unwrap();
let dto = fs::read_to_string(dir.join("presentation/src/api/product/dto.rs")).unwrap();
assert!(dto.contains("pub title: String"));
assert!(dto.contains("pub price: i64"));
assert!(!dto.contains("pub name: String"));
let routes = fs::read_to_string(dir.join("presentation/src/api/product/routes.rs")).unwrap();
assert!(routes.contains("title: body.title.clone()"));
assert!(routes.contains("price: body.price,"));
cleanup(&dir);
}
#[test]
fn parse_field_arg_basic() {
let field = puerto_toml::parse_field_arg("title:String").unwrap();
assert_eq!(field.name, "title");
assert_eq!(field.field_type, "String");
assert!(!field.unique);
}
#[test]
fn parse_field_arg_with_unique() {
let field = puerto_toml::parse_field_arg("sku:String!").unwrap();
assert_eq!(field.name, "sku");
assert_eq!(field.field_type, "String");
assert!(field.unique);
}
#[test]
fn parse_field_arg_option_primitive() {
let field = puerto_toml::parse_field_arg("description:opt:String").unwrap();
assert_eq!(field.name, "description");
assert_eq!(field.field_type, "Option<String>");
assert!(!field.unique);
assert!(field.value_object.is_none());
}
#[test]
fn parse_field_arg_vec_primitive() {
let field = puerto_toml::parse_field_arg("tags:vec:String").unwrap();
assert_eq!(field.name, "tags");
assert_eq!(field.field_type, "Vec<String>");
assert!(field.value_object.is_none());
}
#[test]
fn parse_field_arg_map_shorthand() {
let field = puerto_toml::parse_field_arg("metadata:map").unwrap();
assert_eq!(field.name, "metadata");
assert_eq!(field.field_type, "HashMap<String, String>");
}
#[test]
fn parse_field_arg_datetime_shorthand() {
let field = puerto_toml::parse_field_arg("published_at:DateTime").unwrap();
assert_eq!(field.name, "published_at");
assert_eq!(field.field_type, "DateTime<Utc>");
}
#[test]
fn parse_field_arg_string_vo() {
let field = puerto_toml::parse_field_arg("name:Name:String").unwrap();
assert_eq!(field.name, "name");
assert_eq!(field.field_type, "String");
assert_eq!(field.value_object, Some("Name".to_string()));
assert!(!field.unique);
}
#[test]
fn parse_field_arg_numeric_vo() {
let field = puerto_toml::parse_field_arg("age:Age:i64").unwrap();
assert_eq!(field.name, "age");
assert_eq!(field.field_type, "i64");
assert_eq!(field.value_object, Some("Age".to_string()));
}
#[test]
fn parse_field_arg_float_vo() {
let field = puerto_toml::parse_field_arg("height:Height:f64").unwrap();
assert_eq!(field.name, "height");
assert_eq!(field.field_type, "f64");
assert_eq!(field.value_object, Some("Height".to_string()));
}
#[test]
fn parse_field_arg_unique_vo() {
let field = puerto_toml::parse_field_arg("sku:Sku:String!").unwrap();
assert_eq!(field.name, "sku");
assert_eq!(field.field_type, "String");
assert_eq!(field.value_object, Some("Sku".to_string()));
assert!(field.unique);
}
#[test]
fn parse_field_arg_option_vo() {
let field = puerto_toml::parse_field_arg("middle_name:MiddleName:opt:String").unwrap();
assert_eq!(field.name, "middle_name");
assert_eq!(field.field_type, "Option<String>");
assert_eq!(field.value_object, Some("MiddleName".to_string()));
assert!(field.value_object_kind.is_none());
}
#[test]
fn parse_field_arg_vec_vo() {
let field = puerto_toml::parse_field_arg("tags:Tag:vec:String").unwrap();
assert_eq!(field.name, "tags");
assert_eq!(field.field_type, "Vec<String>");
assert_eq!(field.value_object, Some("Tag".to_string()));
}
#[test]
fn parse_field_arg_enum_vo() {
let field =
puerto_toml::parse_field_arg("status:Status:enum:Pending/Confirmed/Cancelled").unwrap();
assert_eq!(field.name, "status");
assert_eq!(field.field_type, "String");
assert_eq!(field.value_object, Some("Status".to_string()));
assert_eq!(field.value_object_kind, Some("enum".to_string()));
assert_eq!(
field.enum_variants,
Some(vec![
"Pending".to_string(),
"Confirmed".to_string(),
"Cancelled".to_string()
])
);
}
#[test]
fn parse_field_arg_vo_with_datetime_shorthand() {
let field = puerto_toml::parse_field_arg("occurred:OccurredAt:DateTime").unwrap();
assert_eq!(field.field_type, "DateTime<Utc>");
assert_eq!(field.value_object, Some("OccurredAt".to_string()));
}
#[test]
fn parse_field_arg_option_vo_with_datetime_shorthand() {
let field = puerto_toml::parse_field_arg("scheduled:ScheduledAt:opt:DateTime").unwrap();
assert_eq!(field.field_type, "Option<DateTime<Utc>>");
assert_eq!(field.value_object, Some("ScheduledAt".to_string()));
}
#[test]
fn parse_field_arg_rejects_unknown_keyword() {
let err = puerto_toml::parse_field_arg("tags:unknown:String").unwrap_err();
assert!(err.contains("not a known keyword") || err.contains("PascalCase VO name"));
}
#[test]
fn parse_field_arg_rejects_empty_enum_variants() {
let err = puerto_toml::parse_field_arg("status:Status:enum:").unwrap_err();
assert!(err.contains("Enum variants cannot be empty") || err.contains("PascalCase"));
}
#[test]
fn parse_field_arg_rejects_lowercase_vo_name() {
let err = puerto_toml::parse_field_arg("name:myName:String").unwrap_err();
assert!(err.contains("PascalCase") || err.contains("not a known keyword"));
}
#[test]
fn parse_field_arg_uuid() {
let field = puerto_toml::parse_field_arg("category_id:Uuid").unwrap();
assert_eq!(field.name, "category_id");
assert_eq!(field.field_type, "Uuid");
}
#[test]
fn parse_field_arg_uuid_unique() {
let field = puerto_toml::parse_field_arg("external_id:Uuid!").unwrap();
assert_eq!(field.name, "external_id");
assert_eq!(field.field_type, "Uuid");
assert!(field.unique);
}
#[test]
fn parse_field_arg_rejects_no_colon() {
let err = puerto_toml::parse_field_arg("titleString").unwrap_err();
assert!(err.contains("invalid field argument"));
assert!(err.contains("Expected format: name:Type"));
}
#[test]
fn parse_field_arg_rejects_empty_type() {
let err = puerto_toml::parse_field_arg("title:").unwrap_err();
assert!(err.contains("Type cannot be empty"));
}
#[test]
fn parse_field_arg_rejects_uppercase_name() {
let err = puerto_toml::parse_field_arg("Title:String").unwrap_err();
assert!(err.contains("invalid field name"));
assert!(err.contains("snake_case"));
}
#[test]
fn parse_field_arg_rejects_name_starting_with_digit() {
let err = puerto_toml::parse_field_arg("1title:String").unwrap_err();
assert!(err.contains("invalid field name"));
}
#[test]
fn parse_field_arg_rejects_leading_underscore_name() {
assert!(puerto_toml::parse_field_arg("_private:String").is_err());
}
#[test]
fn parse_field_arg_accepts_name_with_digits() {
let field = puerto_toml::parse_field_arg("address2:String").unwrap();
assert_eq!(field.name, "address2");
}
#[test]
fn validate_cli_fields_against_type_registry() {
let fields = vec![
puerto_toml::Field {
name: "title".into(),
field_type: "String".into(),
unique: false,
..Default::default()
},
puerto_toml::Field {
name: "price".into(),
field_type: "i64".into(),
unique: false,
..Default::default()
},
];
assert!(crate::generators::types::validate_fields(&fields).is_ok());
}
#[test]
fn validate_cli_fields_rejects_unknown_type() {
let fields = vec![puerto_toml::Field {
name: "title".into(),
field_type: "UnknownType".into(),
unique: false,
..Default::default()
}];
assert!(crate::generators::types::validate_fields(&fields).is_err());
}
#[test]
fn validate_valid_config() {
let dir = temp_dir("validate_valid");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Product"
use_cases = ["create_product", "get_product"]
[[entity.fields]]
name = "title"
type = "String"
[[entity.fields]]
name = "price"
type = "i64"
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_ok());
cleanup(&dir);
}
#[test]
fn validate_rejects_unknown_field_type() {
let dir = temp_dir("validate_bad_type");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Product"
use_cases = ["create_product"]
[[entity.fields]]
name = "title"
type = "FancyType"
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("unsupported field type 'FancyType'"),
"expected type error, got: {err}"
);
cleanup(&dir);
}
#[test]
fn validate_rejects_invalid_entity_name() {
let dir = temp_dir("validate_bad_entity");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "lowercase_product"
use_cases = ["create_product"]
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("PascalCase"),
"expected PascalCase error, got: {err}"
);
cleanup(&dir);
}
#[test]
fn validate_rejects_invalid_use_case_name() {
let dir = temp_dir("validate_bad_usecase");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Product"
use_cases = ["CreateProduct"]
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("snake_case"),
"expected snake_case error, got: {err}"
);
cleanup(&dir);
}
#[test]
fn validate_rejects_duplicate_field_name() {
let dir = temp_dir("validate_dup_field");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Product"
use_cases = ["create_product"]
[[entity.fields]]
name = "title"
type = "String"
[[entity.fields]]
name = "title"
type = "i64"
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("duplicate field name 'title'"),
"expected duplicate error, got: {err}"
);
cleanup(&dir);
}
#[test]
fn validate_warns_on_option_unique_field() {
let dir = temp_dir("validate_opt_unique");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Product"
use_cases = ["create_product"]
[[entity.fields]]
name = "email"
type = "Option<String>"
unique = true
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_ok());
cleanup(&dir);
}
#[test]
fn validate_rejects_duplicate_entity_name() {
let dir = temp_dir("validate_dup_entity");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Product"
use_cases = ["create_product"]
[[entity]]
name = "Product"
use_cases = ["get_product"]
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("duplicate entity name 'Product'"),
"expected duplicate entity error, got: {err}"
);
cleanup(&dir);
}
#[test]
fn validate_rejects_invalid_field_name() {
let dir = temp_dir("validate_bad_field_name");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Product"
use_cases = ["create_product"]
[[entity.fields]]
name = "BadName"
type = "String"
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("snake_case"),
"expected snake_case error, got: {err}"
);
cleanup(&dir);
}
#[test]
fn is_shared_vo_detection() {
use crate::generators::types::is_shared_vo;
use crate::puerto_toml::{Field, ValueObjectDefinition};
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let email_field = Field {
name: "email".into(),
field_type: "String".into(),
value_object: Some("Email".to_string()),
..Default::default()
};
let local_field = Field {
name: "name".into(),
field_type: "String".into(),
value_object: Some("Name".to_string()),
..Default::default()
};
let primitive_field = Field {
name: "age".into(),
field_type: "i64".into(),
..Default::default()
};
assert!(is_shared_vo(&email_field, &shared_vos));
assert!(!is_shared_vo(&local_field, &shared_vos));
assert!(!is_shared_vo(&primitive_field, &shared_vos));
}
#[test]
fn generate_shared_value_objects_string() {
use crate::generators::domain::generate_shared_value_objects;
use crate::puerto_toml::ValueObjectDefinition;
let vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let result = generate_shared_value_objects(&vos);
assert!(result.contains("pub struct Email"));
assert!(result.contains("fn new(value: String)"));
assert!(result.contains("fn value(&self) -> &str"));
assert!(result.contains("EmailError::Invalid"));
assert!(result.contains("trimmed.is_empty()"));
}
#[test]
fn generate_shared_value_objects_numeric() {
use crate::generators::domain::generate_shared_value_objects;
use crate::puerto_toml::ValueObjectDefinition;
let vos = vec![ValueObjectDefinition {
name: "Amount".to_string(),
inner_type: "i64".to_string(),
}];
let result = generate_shared_value_objects(&vos);
assert!(result.contains("pub struct Amount(i64)"));
assert!(result.contains("fn new(value: i64)"));
assert!(result.contains("fn value(&self) -> i64"));
}
#[test]
fn generate_shared_errors_combined_output() {
use crate::generators::domain::generate_shared_errors_combined;
use crate::puerto_toml::ValueObjectDefinition;
let vos = vec![
ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
},
ValueObjectDefinition {
name: "Money".to_string(),
inner_type: "i64".to_string(),
},
];
let result = generate_shared_errors_combined(&vos);
assert!(result.contains("pub enum EmailError"));
assert!(result.contains("pub enum MoneyError"));
assert!(result.contains("shared.value_object.email.invalid"));
assert!(result.contains("shared.value_object.money.invalid"));
}
#[test]
fn generate_model_with_shared_vo_imports() {
use crate::generators::domain::generate_model;
use crate::puerto_toml::{Field, ValueObjectDefinition};
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let fields = vec![Field {
name: "email".into(),
field_type: "String".into(),
value_object: Some("Email".to_string()),
..Default::default()
}];
let result = generate_model("User", "user", &fields, &shared_vos);
assert!(result.contains("use crate::domain::shared::value_objects::Email"));
assert!(result.contains("pub email: Email"));
}
#[test]
fn generate_model_local_vo_uses_local_import() {
use crate::generators::domain::generate_model;
use crate::puerto_toml::{Field, ValueObjectDefinition};
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let fields = vec![Field {
name: "name".into(),
field_type: "String".into(),
value_object: Some("Name".to_string()),
..Default::default()
}];
let result = generate_model("User", "user", &fields, &shared_vos);
assert!(result.contains("use super::value_objects::Name"));
assert!(!result.contains("shared::value_objects::Name"));
}
#[test]
fn generate_create_use_case_shared_vo() {
use crate::generators::application::generate_create_use_case_impl;
use crate::puerto_toml::{Field, ValueObjectDefinition};
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let fields = vec![Field {
name: "email".into(),
field_type: "String".into(),
value_object: Some("Email".to_string()),
..Default::default()
}];
let result = generate_create_use_case_impl("User", "user", &fields, &shared_vos);
assert!(result.contains("use crate::domain::shared::value_objects::Email"));
assert!(result.contains(".map_err(|_| UserError::InvalidEmail)?"));
}
#[test]
fn generate_infra_entity_shared_vo_import_path() {
use crate::generators::infrastructure::generate_infra_entity;
use crate::puerto_toml::{Field, ValueObjectDefinition};
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let fields = vec![Field {
name: "email".into(),
field_type: "String".into(),
value_object: Some("Email".to_string()),
..Default::default()
}];
let result = generate_infra_entity("User", "user", &fields, &shared_vos);
assert!(result.contains("use business::domain::shared::value_objects::Email"));
assert!(!result.contains("use business::domain::user::value_objects::Email"));
}
#[test]
fn generate_infra_entity_shared_vo_try_from_map_err() {
use crate::generators::infrastructure::generate_infra_entity;
use crate::puerto_toml::{Field, ValueObjectDefinition};
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let fields = vec![Field {
name: "email".into(),
field_type: "String".into(),
value_object: Some("Email".to_string()),
..Default::default()
}];
let result = generate_infra_entity("User", "user", &fields, &shared_vos);
assert!(result.contains(".map_err(|_| UserError::InvalidEmail)?"));
}
#[test]
fn generate_infra_entity_local_vo_uses_plain_question_mark() {
use crate::generators::infrastructure::generate_infra_entity;
use crate::puerto_toml::{Field, ValueObjectDefinition};
let shared_vos: Vec<ValueObjectDefinition> = vec![];
let fields = vec![Field {
name: "name".into(),
field_type: "String".into(),
value_object: Some("Name".to_string()),
..Default::default()
}];
let result = generate_infra_entity("Product", "product", &fields, &shared_vos);
assert!(result.contains("Name::new(row.name)?"));
assert!(!result.contains(".map_err"));
}
#[test]
fn patch_business_lib_shared_adds_mod_declaration() {
use crate::patchers::lib_rs::patch_business_lib_shared;
let dir = temp_dir("patch_lib_shared");
cleanup(&dir);
fs::create_dir_all(dir.join("business/src")).unwrap();
let lib_content = r#"pub mod domain {
pub mod greeting {
pub mod model;
}
}
pub mod application {
}
"#;
fs::write(dir.join("business/src/lib.rs"), lib_content).unwrap();
patch_business_lib_shared(&dir).unwrap();
let result = fs::read_to_string(dir.join("business/src/lib.rs")).unwrap();
assert!(result.contains("pub mod shared;"));
patch_business_lib_shared(&dir).unwrap();
let count = result.matches("pub mod shared;").count();
assert_eq!(count, 1);
cleanup(&dir);
}
#[test]
fn scaffold_with_shared_vo_writes_shared_files() {
let dir = temp_dir("scaffold_shared_vo");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[value_object]]
name = "Email"
inner_type = "String"
"#,
)
.unwrap();
scaffold::run(
"User",
&dir,
false,
true,
&[puerto_toml::Field {
name: "email".into(),
field_type: "String".into(),
value_object: Some("Email".to_string()),
..Default::default()
}],
&[crate::puerto_toml::ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}],
)
.unwrap();
assert!(
dir.join("business/src/domain/shared/value_objects.rs")
.exists()
);
assert!(dir.join("business/src/domain/shared/errors.rs").exists());
assert!(dir.join("business/src/domain/shared/mod.rs").exists());
cleanup(&dir);
}
#[test]
fn scaffold_with_all_shared_vos_does_not_patch_local_value_objects_mod() {
let dir = temp_dir("scaffold_shared_vo_no_local");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("puerto.toml"), "[project]\nname = \"my-app\"\n").unwrap();
scaffold::run(
"Customer",
&dir,
false,
true,
&[puerto_toml::Field {
name: "email".into(),
field_type: "String".into(),
value_object: Some("Email".to_string()),
..Default::default()
}],
&[ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}],
)
.unwrap();
assert!(
!dir.join("business/src/domain/customer/value_objects.rs")
.exists(),
"local value_objects.rs must not be created when all VOs are shared"
);
assert!(
dir.join("business/src/domain/shared/value_objects.rs")
.exists()
);
cleanup(&dir);
}
#[test]
fn value_object_adds_entry_to_puerto_toml() {
let dir = temp_dir("vo_add_entry");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("puerto.toml"), "[project]\nname = \"my-app\"\n").unwrap();
puerto_toml::add_value_object(&dir, "Email", "String").unwrap();
let config = puerto_toml::read(&dir).unwrap();
assert_eq!(config.value_object.len(), 1);
assert_eq!(config.value_object[0].name, "Email");
assert_eq!(config.value_object[0].inner_type, "String");
cleanup(&dir);
}
#[test]
fn value_object_add_is_idempotent() {
let dir = temp_dir("vo_idempotent");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("puerto.toml"), "[project]\nname = \"my-app\"\n").unwrap();
puerto_toml::add_value_object(&dir, "Email", "String").unwrap();
puerto_toml::add_value_object(&dir, "Email", "String").unwrap();
let config = puerto_toml::read(&dir).unwrap();
assert_eq!(config.value_object.len(), 1, "duplicate entry added");
cleanup(&dir);
}
#[test]
fn value_object_multiple_entries_preserved() {
let dir = temp_dir("vo_multiple");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("puerto.toml"), "[project]\nname = \"my-app\"\n").unwrap();
puerto_toml::add_value_object(&dir, "Email", "String").unwrap();
puerto_toml::add_value_object(&dir, "Money", "i64").unwrap();
let config = puerto_toml::read(&dir).unwrap();
assert_eq!(config.value_object.len(), 2);
assert_eq!(config.value_object[0].name, "Email");
assert_eq!(config.value_object[1].name, "Money");
cleanup(&dir);
}
#[test]
fn infer_shared_vo_string_type_from_name() {
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let fields = vec![puerto_toml::Field {
name: "email".to_string(),
field_type: "Email".to_string(),
..Default::default()
}];
let result = puerto_toml::apply_shared_vo_inference(fields, &shared_vos);
assert_eq!(result[0].field_type, "String");
assert_eq!(result[0].value_object, Some("Email".to_string()));
assert!(result[0].value_object_kind.is_none());
assert!(!result[0].unique);
}
#[test]
fn infer_shared_vo_numeric_type_from_name() {
let shared_vos = vec![ValueObjectDefinition {
name: "Money".to_string(),
inner_type: "i64".to_string(),
}];
let fields = vec![puerto_toml::Field {
name: "price".to_string(),
field_type: "Money".to_string(),
..Default::default()
}];
let result = puerto_toml::apply_shared_vo_inference(fields, &shared_vos);
assert_eq!(result[0].field_type, "i64");
assert_eq!(result[0].value_object, Some("Money".to_string()));
}
#[test]
fn infer_shared_vo_preserves_unique_flag() {
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let fields = vec![puerto_toml::Field {
name: "email".to_string(),
field_type: "Email".to_string(),
unique: true,
..Default::default()
}];
let result = puerto_toml::apply_shared_vo_inference(fields, &shared_vos);
assert_eq!(result[0].field_type, "String");
assert_eq!(result[0].value_object, Some("Email".to_string()));
assert!(result[0].unique);
}
#[test]
fn infer_shared_vo_leaves_explicit_vo_field_unchanged() {
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let fields = vec![puerto_toml::Field {
name: "email".to_string(),
field_type: "String".to_string(),
value_object: Some("Email".to_string()),
..Default::default()
}];
let result = puerto_toml::apply_shared_vo_inference(fields, &shared_vos);
assert_eq!(result[0].field_type, "String");
assert_eq!(result[0].value_object, Some("Email".to_string()));
}
#[test]
fn infer_shared_vo_leaves_primitive_field_unchanged() {
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let fields = vec![puerto_toml::Field {
name: "title".to_string(),
field_type: "String".to_string(),
..Default::default()
}];
let result = puerto_toml::apply_shared_vo_inference(fields, &shared_vos);
assert_eq!(result[0].field_type, "String");
assert!(result[0].value_object.is_none());
}
#[test]
fn infer_shared_vo_with_empty_registry_is_identity() {
let fields = vec![puerto_toml::Field {
name: "email".to_string(),
field_type: "Email".to_string(),
..Default::default()
}];
let result = puerto_toml::apply_shared_vo_inference(fields, &[]);
assert_eq!(result[0].field_type, "Email");
assert!(result[0].value_object.is_none());
}
#[test]
fn infer_shared_vo_only_matches_registered_vo_names() {
let shared_vos = vec![ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
}];
let fields = vec![puerto_toml::Field {
name: "phone".to_string(),
field_type: "Phone".to_string(),
..Default::default()
}];
let result = puerto_toml::apply_shared_vo_inference(fields, &shared_vos);
assert_eq!(result[0].field_type, "Phone");
assert!(result[0].value_object.is_none());
}
#[test]
fn infer_shared_vo_handles_multiple_fields() {
let shared_vos = vec![
ValueObjectDefinition {
name: "Email".to_string(),
inner_type: "String".to_string(),
},
ValueObjectDefinition {
name: "Money".to_string(),
inner_type: "i64".to_string(),
},
];
let fields = vec![
puerto_toml::Field {
name: "email".to_string(),
field_type: "Email".to_string(),
..Default::default()
},
puerto_toml::Field {
name: "price".to_string(),
field_type: "Money".to_string(),
..Default::default()
},
puerto_toml::Field {
name: "active".to_string(),
field_type: "bool".to_string(),
..Default::default()
},
];
let result = puerto_toml::apply_shared_vo_inference(fields, &shared_vos);
assert_eq!(result[0].field_type, "String");
assert_eq!(result[0].value_object, Some("Email".to_string()));
assert_eq!(result[1].field_type, "i64");
assert_eq!(result[1].value_object, Some("Money".to_string()));
assert_eq!(result[2].field_type, "bool");
assert!(result[2].value_object.is_none());
}
struct Scenario<'a> {
name: &'a str,
no_demo: bool,
shared_vos: &'a [(&'a str, &'a str)],
entities: &'a [(&'a str, &'a [&'a str])],
use_cases: &'a [(&'a str, &'a str)],
}
fn assert_scenario_compiles(scenario: &Scenario) {
let dir = scenario_workspace(scenario.name);
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project(&format!("compile-{}", scenario.name), &dir).unwrap();
apply_scenario(scenario, &output);
assert_generated_project_is_clean(&output, &dir, scenario.name, &[]);
cleanup(&dir);
}
fn apply_scenario(scenario: &Scenario, output: &Path) {
if scenario.no_demo {
scaffold::apply_no_demo(output).unwrap();
}
for (vo_name, inner) in scenario.shared_vos {
puerto_toml::add_value_object(output, vo_name, inner).unwrap();
}
let shared_vos = puerto_toml::read(output).unwrap().value_object;
for (entity, field_args) in scenario.entities {
let parsed: Vec<puerto_toml::Field> = field_args
.iter()
.map(|arg| puerto_toml::parse_field_arg(arg).unwrap())
.collect();
let fields = puerto_toml::apply_shared_vo_inference(parsed, &shared_vos);
crate::generators::types::validate_fields(&fields).unwrap();
scaffold::run_scaffold(entity, output, &fields, OverwritePolicy::Ask).unwrap();
}
for (entity, action) in scenario.use_cases {
scaffold::run_use_case(entity, action, output).unwrap();
}
}
fn assert_generated_project_is_clean(output: &Path, dir: &Path, name: &str, envs: &[(&str, &str)]) {
for args in [
&[
"clippy",
"--workspace",
"--all-targets",
"--",
"-D",
"warnings",
][..],
&["test", "--workspace"][..],
] {
let result = std::process::Command::new("cargo")
.args(args)
.env("CARGO_TARGET_DIR", dir.join("target"))
.envs(envs.iter().copied())
.current_dir(output)
.output()
.expect("failed to run cargo");
if !result.status.success() {
eprintln!("stdout:\n{}", String::from_utf8_lossy(&result.stdout));
eprintln!("stderr:\n{}", String::from_utf8_lossy(&result.stderr));
panic!(
"scenario '{name}': generated project failed `cargo {}`",
args.join(" ")
);
}
}
}
fn scenario_workspace(scenario: &str) -> PathBuf {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../target/compile-matrix")
.join(scenario);
let _ = fs::create_dir_all(&root);
root
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_no_demo_then_scaffold() {
assert_scenario_compiles(&Scenario {
name: "no_demo",
no_demo: true,
shared_vos: &[],
use_cases: &[],
entities: &[("Persona", &["name:String"])],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_all_primitive_field_types() {
assert_scenario_compiles(&Scenario {
name: "primitives",
no_demo: false,
shared_vos: &[],
use_cases: &[],
entities: &[(
"Product",
&[
"title:String",
"price:i64",
"in_stock:bool",
"rate:f64",
"released_at:DateTime",
"meta:map",
"category_id:Uuid",
],
)],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_uuid_value_object() {
assert_scenario_compiles(&Scenario {
name: "uuid_vo",
no_demo: false,
shared_vos: &[],
use_cases: &[],
entities: &[("Booking", &["reference:String", "tenant_id:TenantId:Uuid"])],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_option_and_vec_primitives() {
assert_scenario_compiles(&Scenario {
name: "opt_vec_prim",
no_demo: false,
shared_vos: &[],
use_cases: &[],
entities: &[(
"Article",
&[
"title:String",
"summary:opt:String",
"views:opt:i64",
"tags:vec:String",
"scores:vec:i64",
],
)],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_plain_and_unique_value_objects() {
assert_scenario_compiles(&Scenario {
name: "plain_vo",
no_demo: false,
shared_vos: &[],
use_cases: &[],
entities: &[(
"Item",
&["name:Name:String", "sku:Sku:String!", "weight:Weight:i64"],
)],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_option_and_vec_value_objects() {
assert_scenario_compiles(&Scenario {
name: "opt_vec_vo",
no_demo: false,
shared_vos: &[],
use_cases: &[],
entities: &[(
"Persona",
&[
"name:String",
"middle_name:MiddleName:opt:String",
"tags:Tag:vec:String",
],
)],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_enum_value_object() {
assert_scenario_compiles(&Scenario {
name: "enum_vo",
no_demo: false,
shared_vos: &[],
use_cases: &[],
entities: &[(
"Order",
&[
"reference:String",
"status:Status:enum:Pending/Confirmed/Cancelled",
],
)],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_shared_value_objects() {
assert_scenario_compiles(&Scenario {
name: "shared_vo",
no_demo: false,
shared_vos: &[("Email", "String"), ("Money", "i64")],
use_cases: &[],
entities: &[("Customer", &["name:String", "email:Email", "credit:Money"])],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_multiple_entities() {
assert_scenario_compiles(&Scenario {
name: "multi_entity",
no_demo: false,
shared_vos: &[],
use_cases: &[],
entities: &[
("Product", &["title:String", "price:i64"]),
("Category", &["name:Name:String"]),
],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_every_value_object_kind_at_once() {
assert_scenario_compiles(&Scenario {
name: "vo_kitchen_sink",
no_demo: false,
shared_vos: &[("Email", "String")],
use_cases: &[],
entities: &[(
"Account",
&[
"title:String",
"price:i64",
"name:Name:String",
"sku:Sku:String!",
"note:opt:String",
"middle_name:MiddleName:opt:String",
"tags:Tag:vec:String",
"status:Status:enum:Active/Archived",
"email:Email",
],
)],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_domain_first_layered_flow_and_fields() {
let dir = scenario_workspace("layered_fields");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("compile-layered-fields", &dir).unwrap();
let fields: Vec<puerto_toml::Field> = [
"amount:i64",
"reference:Reference:String",
"note:opt:String",
]
.iter()
.map(|arg| puerto_toml::parse_field_arg(arg).unwrap())
.collect();
scaffold::run_generate_domain("Invoice", &output, &fields).unwrap();
scaffold::run_generate_application("Invoice", &output).unwrap();
scaffold::run_generate_repository("Invoice", &output).unwrap();
scaffold::run_generate_presentation("Invoice", &output).unwrap();
assert_generated_project_is_clean(&output, &dir, "layered_fields", &[]);
cleanup(&dir);
}
#[test]
fn bootstrap_with_entity_without_use_cases_is_syntactically_valid() {
let entities = vec![puerto_toml::Entity {
name: "Invoice".to_string(),
use_cases: vec![],
db: false,
fields: vec![],
}];
let content = crate::generators::bootstrap::generate_bootstrap_content(&entities);
assert!(!content.contains("{ ,"));
assert!(content.contains("let invoice_api = InvoiceApi {};"));
}
#[test]
fn bootstrap_with_use_cases_lists_them_in_the_api_initializer() {
let entities = vec![puerto_toml::Entity {
name: "Product".to_string(),
use_cases: vec!["create_product".into(), "get_product".into()],
db: false,
fields: vec![],
}];
let content = crate::generators::bootstrap::generate_bootstrap_content(&entities);
assert!(content.contains("let product_api = ProductApi { create_product, get_product };"));
}
#[test]
fn no_demo_keeps_tags_module_declared() {
let dir = temp_dir("no_demo_tags");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("no-demo-tags", &dir).unwrap();
scaffold::apply_no_demo(&output).unwrap();
let api_rs = fs::read_to_string(output.join("presentation/src/api.rs")).unwrap();
assert!(
api_rs.contains("pub mod tags;"),
"generated routes.rs import crate::api::tags::ApiTags — the declaration must survive \
--no-demo. Got:\n{api_rs}"
);
cleanup(&dir);
}
#[test]
fn api_rs_patch_restores_missing_tags_module() {
let dir = temp_dir("api_rs_tags_restore");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("tags-restore", &dir).unwrap();
fs::write(output.join("presentation/src/api.rs"), "pub mod error;\n").unwrap();
crate::patchers::api_rs::patch_api_rs(&output, "product").unwrap();
let api_rs = fs::read_to_string(output.join("presentation/src/api.rs")).unwrap();
assert!(api_rs.contains("pub mod tags;"));
assert!(api_rs.contains("pub mod product;"));
cleanup(&dir);
}
fn scaffold_test_field(name: &str, ty: &str) -> puerto_toml::Field {
puerto_toml::Field {
name: name.to_string(),
field_type: ty.to_string(),
..Default::default()
}
}
#[test]
fn rescaffold_refuses_to_overwrite_and_writes_nothing() {
let dir = temp_dir("rescaffold_refuses");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("rescaffold-refuses", &dir).unwrap();
scaffold::run_scaffold(
"Product",
&output,
&[scaffold_test_field("title", "String")],
OverwritePolicy::Ask,
)
.unwrap();
let model_path = output.join("business/src/domain/product/model.rs");
let hand_written = format!(
"{}\n// hand-written business rule\n",
fs::read_to_string(&model_path).unwrap()
);
fs::write(&model_path, &hand_written).unwrap();
let result = scaffold::run_scaffold(
"Product",
&output,
&[scaffold_test_field("other", "i64")],
OverwritePolicy::Ask,
);
assert!(result.is_err(), "re-scaffold should refuse without --force");
let err = result.unwrap_err().to_string();
assert!(
err.contains("--force"),
"error should point at --force: {err}"
);
assert_eq!(
fs::read_to_string(&model_path).unwrap(),
hand_written,
"no file may be touched when the command is refused"
);
cleanup(&dir);
}
#[test]
fn rescaffold_with_force_overwrites_and_syncs_puerto_toml() {
let dir = temp_dir("rescaffold_force");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("rescaffold-force", &dir).unwrap();
scaffold::run_scaffold(
"Product",
&output,
&[scaffold_test_field("title", "String")],
OverwritePolicy::Ask,
)
.unwrap();
scaffold::run_scaffold(
"Product",
&output,
&[scaffold_test_field("headline", "String")],
OverwritePolicy::Force,
)
.unwrap();
let model = fs::read_to_string(output.join("business/src/domain/product/model.rs")).unwrap();
assert!(model.contains("pub headline: String"));
assert!(!model.contains("pub title: String"));
let config = puerto_toml::read(&output).unwrap();
let product = config
.entity
.iter()
.find(|e| e.name == "Product")
.expect("Product in puerto.toml");
let field_names: Vec<&str> = product.fields.iter().map(|f| f.name.as_str()).collect();
assert_eq!(field_names, vec!["headline"]);
cleanup(&dir);
}
#[test]
fn rescaffold_preserves_use_cases_added_by_generate_use_case() {
let dir = temp_dir("rescaffold_keeps_uc");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("rescaffold-keeps-uc", &dir).unwrap();
scaffold::run_scaffold(
"Product",
&output,
&[scaffold_test_field("title", "String")],
OverwritePolicy::Ask,
)
.unwrap();
scaffold::run_use_case("Product", "archive_product", &output).unwrap();
scaffold::run_scaffold(
"Product",
&output,
&[scaffold_test_field("title", "String")],
OverwritePolicy::Force,
)
.unwrap();
let config = puerto_toml::read(&output).unwrap();
let product = config
.entity
.iter()
.find(|e| e.name == "Product")
.expect("Product in puerto.toml");
assert!(
product.use_cases.contains(&"archive_product".to_string()),
"a custom use case must survive a re-scaffold: {:?}",
product.use_cases
);
cleanup(&dir);
}
fn declare_phantom_entity(base: &Path, name: &str) {
puerto_toml::add_entity(
base,
name,
vec![format!("create_{}", name.to_lowercase())],
false,
vec![],
)
.unwrap();
}
#[test]
fn validate_fails_on_declared_but_unscaffolded_entity() {
let dir = temp_dir("drift_validate");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("drift-validate", &dir).unwrap();
declare_phantom_entity(&output, "Invoice");
let result = validate::run_validate(&output);
assert!(result.is_err(), "a phantom entity must fail validation");
let err = result.unwrap_err().to_string();
assert!(
err.contains("Invoice") && err.contains("puerto generate scaffold Invoice"),
"error should name the entity and the fix: {err}"
);
cleanup(&dir);
}
#[test]
fn validate_passes_on_a_freshly_generated_project() {
let dir = temp_dir("drift_validate_clean");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("drift-clean", &dir).unwrap();
assert!(validate::run_validate(&output).is_ok());
cleanup(&dir);
}
#[test]
fn validate_warns_but_passes_on_an_undeclared_domain_module() {
let dir = temp_dir("drift_orphan");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("drift-orphan", &dir).unwrap();
fs::create_dir_all(output.join("business/src/domain/ghost")).unwrap();
assert!(validate::run_validate(&output).is_ok());
let orphans = crate::generators::consistency::orphan_entity_dirs(
&output,
&puerto_toml::read(&output).unwrap(),
);
assert_eq!(orphans, vec!["ghost".to_string()]);
cleanup(&dir);
}
#[test]
fn bootstrap_command_refuses_when_declared_code_is_missing() {
let dir = temp_dir("drift_bootstrap");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("drift-bootstrap", &dir).unwrap();
let before =
fs::read_to_string(output.join("presentation/src/generated/bootstrap.rs")).unwrap();
declare_phantom_entity(&output, "Invoice");
let result = scaffold::run_bootstrap_command(&output, false);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("--allow-missing"), "got: {err}");
assert_eq!(
fs::read_to_string(output.join("presentation/src/generated/bootstrap.rs")).unwrap(),
before,
"a refused command must not have written anything"
);
cleanup(&dir);
}
#[test]
fn bootstrap_command_writes_anyway_with_allow_missing() {
let dir = temp_dir("drift_bootstrap_allow");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("drift-bootstrap-allow", &dir).unwrap();
declare_phantom_entity(&output, "Invoice");
scaffold::run_bootstrap_command(&output, true).unwrap();
let bootstrap =
fs::read_to_string(output.join("presentation/src/generated/bootstrap.rs")).unwrap();
assert!(bootstrap.contains("InvoiceApi"));
cleanup(&dir);
}
#[test]
fn scaffolding_the_declared_entity_clears_the_drift() {
let dir = temp_dir("drift_cleared");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("drift-cleared", &dir).unwrap();
declare_phantom_entity(&output, "Invoice");
scaffold::run_scaffold("Invoice", &output, &[], OverwritePolicy::Ask).unwrap();
assert!(validate::run_validate(&output).is_ok());
assert!(scaffold::run_bootstrap_command(&output, false).is_ok());
cleanup(&dir);
}
#[test]
fn parse_field_arg_rejects_system_field_names() {
for name in crate::validation::SYSTEM_FIELDS {
let arg = format!("{name}:String");
let result = puerto_toml::parse_field_arg(&arg);
assert!(result.is_err(), "'{arg}' must be rejected");
assert!(result.unwrap_err().contains("reserved"));
}
}
#[test]
fn parse_field_arg_rejects_rust_keyword_field_names() {
let result = puerto_toml::parse_field_arg("type:String");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Rust keyword"));
}
#[test]
fn parse_field_arg_rejects_leading_underscore_field_name() {
assert!(puerto_toml::parse_field_arg("_hidden:String").is_err());
}
#[test]
fn parse_field_arg_rejects_duplicate_enum_variants() {
let result = puerto_toml::parse_field_arg("status:Status:enum:Active/Active");
assert!(result.is_err());
assert!(result.unwrap_err().contains("duplicate enum variant"));
}
#[test]
fn validate_rejects_hand_edited_system_field_name() {
let dir = temp_dir("validate_system_field");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Product"
use_cases = ["create_product"]
[[entity.fields]]
name = "created_at"
type = "DateTime<Utc>"
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("reserved"));
cleanup(&dir);
}
#[test]
fn validate_rejects_entity_name_that_collides_with_generated_code() {
let dir = temp_dir("validate_reserved_entity");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Error"
use_cases = ["create_error"]
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("collides"));
cleanup(&dir);
}
#[test]
fn validate_rejects_conflicting_value_object_inner_types() {
let dir = temp_dir("validate_vo_conflict");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
[[entity]]
name = "Product"
use_cases = ["create_product"]
[[entity.fields]]
name = "code"
type = "String"
value_object = "Code"
[[entity.fields]]
name = "other_code"
type = "i64"
value_object = "Code"
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("declared twice"),
"one value_objects.rs cannot define Code as both String and i64"
);
cleanup(&dir);
}
#[test]
fn scaffold_refuses_an_entity_name_that_collides_with_generated_code() {
let dir = temp_dir("scaffold_reserved_name");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("reserved-name", &dir).unwrap();
let result = scaffold::run_scaffold("Tracing", &output, &[], OverwritePolicy::Ask);
assert!(result.is_err());
assert!(
!output.join("business/src/domain/tracing").is_dir(),
"nothing may be written for a refused name"
);
cleanup(&dir);
}
#[test]
fn model_does_not_reimport_chrono_or_uuid_for_field_types() {
let fields = vec![
puerto_toml::Field {
name: "released_at".into(),
field_type: "DateTime<Utc>".into(),
..Default::default()
},
puerto_toml::Field {
name: "external_id".into(),
field_type: "Uuid".into(),
..Default::default()
},
];
let model = crate::generators::domain::generate_model("Product", "product", &fields, &[]);
assert_eq!(model.matches("use chrono::{DateTime, Utc};").count(), 1);
assert_eq!(model.matches("use uuid::Uuid;").count(), 1);
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_enum_vo_as_only_string_field() {
assert_scenario_compiles(&Scenario {
name: "enum_only",
no_demo: false,
shared_vos: &[],
use_cases: &[],
entities: &[("Ticket", &["state:State:enum:Open/Closed"])],
});
}
#[test]
#[ignore = "slow: compiles and tests a generated project"]
fn compiles_with_custom_use_case_after_scaffold() {
assert_scenario_compiles(&Scenario {
name: "custom_use_case",
no_demo: false,
shared_vos: &[],
use_cases: &[("Product", "archive_product")],
entities: &[("Product", &["title:String", "price:i64"])],
});
}
fn test_database_server() -> Option<String> {
std::env::var("PUERTO_TEST_DATABASE_URL")
.ok()
.map(|u| u.trim_end_matches('/').to_string())
.filter(|u| !u.is_empty())
}
#[test]
#[ignore = "slow: needs Postgres — run via `make test/db`"]
fn postgres_project_with_every_type_compiles_lints_and_passes_integration_tests() {
let Some(server) = test_database_server() else {
eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
return;
};
let dir = scenario_workspace("postgres_every_type");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("compile-postgres", &dir).unwrap();
crate::generators::project::apply_db_to_new_project(&output).unwrap();
apply_scenario(
&Scenario {
name: "postgres_every_type",
no_demo: false,
shared_vos: &[("Email", "String")],
use_cases: &[("Product", "archive_product")],
entities: &[
(
"Product",
&[
"title:String",
"sku:String!",
"price:i64!",
"rating:f64",
"active:bool",
"ref_id:Uuid",
"published_at:DateTime",
"summary:opt:String",
"views:opt:i64",
"expires_at:opt:DateTime",
"tags:vec:String",
"scores:vec:i64",
"meta:map",
],
),
(
"Customer",
&[
"email:Email",
"name:Name:String",
"code:Code:String!",
"age:Age:i64",
"joined:Joined:DateTime",
"middle:MiddleName:opt:String",
"labels:Label:vec:String",
"status:Status:enum:Active/Inactive",
],
),
("Ticket", &["state:State:enum:Open/Closed"]),
],
},
&output,
);
let database_url = format!("{server}/puerto_matrix");
for args in [
&["database", "drop", "-y"][..],
&["database", "create"][..],
&["migrate", "run", "--source", "infrastructure/migrations"][..],
] {
let status = std::process::Command::new("sqlx")
.args(args)
.env("DATABASE_URL", &database_url)
.current_dir(&output)
.status()
.expect("sqlx CLI not found — cargo install sqlx-cli");
assert!(status.success(), "`sqlx {}` failed", args.join(" "));
}
assert_generated_project_is_clean(
&output,
&dir,
"postgres_every_type",
&[("DATABASE_URL", &database_url), ("SQLX_OFFLINE", "false")],
);
cleanup(&dir);
}
#[test]
fn use_case_rejects_an_action_that_is_not_snake_case() {
let dir = temp_dir("uc_not_snake");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_use_case_stubs(&dir);
let result = scaffold::run_use_case("Product", "ArchiveProduct", &dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("snake_case"));
assert!(
!dir.join("business/src/application/product/ArchiveProduct.rs")
.exists()
);
assert!(
!fs::read_to_string(dir.join("puerto.toml"))
.unwrap()
.contains("ArchiveProduct")
);
cleanup(&dir);
}
#[test]
fn use_case_refuses_to_replace_a_scaffolded_crud_use_case() {
let dir = temp_dir("uc_crud_clash");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("uc-crud-clash", &dir).unwrap();
scaffold::run_scaffold(
"Product",
&output,
&[scaffold_test_field("title", "String")],
OverwritePolicy::Ask,
)
.unwrap();
let impl_path = output.join("business/src/application/product/create_product.rs");
let before = fs::read_to_string(&impl_path).unwrap();
let result = scaffold::run_use_case("Product", "create_product", &output);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
assert_eq!(fs::read_to_string(&impl_path).unwrap(), before);
cleanup(&dir);
}
#[test]
fn use_case_adds_its_field_to_the_entity_api_struct() {
let dir = temp_dir("uc_routes_field");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("uc-routes-field", &dir).unwrap();
scaffold::run_scaffold(
"Product",
&output,
&[scaffold_test_field("title", "String")],
OverwritePolicy::Ask,
)
.unwrap();
scaffold::run_use_case("Product", "archive_product", &output).unwrap();
let routes = fs::read_to_string(output.join("presentation/src/api/product/routes.rs")).unwrap();
assert!(routes.contains("pub archive_product: Arc<ArchiveProductUseCaseImpl>,"));
assert!(routes.contains(
"use business::application::product::archive_product::ArchiveProductUseCaseImpl;"
));
cleanup(&dir);
}
#[test]
fn rescaffold_with_force_keeps_custom_use_case_in_the_api_struct() {
let dir = temp_dir("uc_routes_force");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("uc-routes-force", &dir).unwrap();
let fields = [scaffold_test_field("title", "String")];
scaffold::run_scaffold("Product", &output, &fields, OverwritePolicy::Ask).unwrap();
scaffold::run_use_case("Product", "archive_product", &output).unwrap();
scaffold::run_scaffold("Product", &output, &fields, OverwritePolicy::Force).unwrap();
let routes = fs::read_to_string(output.join("presentation/src/api/product/routes.rs")).unwrap();
assert_eq!(
routes
.matches("pub archive_product: Arc<ArchiveProductUseCaseImpl>,")
.count(),
1
);
cleanup(&dir);
}
#[test]
fn value_object_command_rejects_invalid_name_type_and_duplicates() {
let dir = temp_dir("vo_cmd_validation");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
assert!(scaffold::run_value_object("email", "String", &dir).is_err());
assert!(scaffold::run_value_object("Email", "Strin", &dir).is_err());
scaffold::run_value_object("Email", "String", &dir).unwrap();
let dup = scaffold::run_value_object("Email", "String", &dir);
assert!(dup.is_err());
assert!(dup.unwrap_err().to_string().contains("already declared"));
let config = puerto_toml::read(&dir).unwrap();
assert_eq!(config.value_object.len(), 1);
cleanup(&dir);
}
#[test]
fn value_object_command_expands_datetime_shorthand() {
let dir = temp_dir("vo_cmd_datetime");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
scaffold::run_value_object("When", "DateTime", &dir).unwrap();
let config = puerto_toml::read(&dir).unwrap();
assert_eq!(config.value_object[0].inner_type, "DateTime<Utc>");
cleanup(&dir);
}
#[test]
fn generate_migration_is_refused_in_a_project_without_database() {
let dir = temp_dir("migration_no_db_project");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
let result = scaffold::run_generate_migration("add_stuff", &dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("--db"));
assert!(!dir.join("infrastructure/migrations").exists());
cleanup(&dir);
}
#[test]
fn migrations_created_in_the_same_second_get_distinct_versions() {
let dir = temp_dir("migration_same_second");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
for name in ["create_a_table", "create_b_table", "create_c_table"] {
scaffold::run_migration(name, &dir, Some("SELECT 1;\n")).unwrap();
}
let mut versions: Vec<String> = fs::read_dir(dir.join("infrastructure/migrations"))
.unwrap()
.flatten()
.map(|e| {
e.file_name()
.to_string_lossy()
.split('_')
.next()
.unwrap()
.to_string()
})
.collect();
versions.sort();
versions.dedup();
assert_eq!(versions.len(), 3, "versions must be unique: {versions:?}");
assert!(
versions
.iter()
.all(|v| v.len() == 14 && v.chars().all(|c| c.is_ascii_digit()))
);
cleanup(&dir);
}
#[test]
fn migration_file_carries_the_given_body() {
let dir = temp_dir("migration_body");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
scaffold::run_migration("create_x_table", &dir, Some("CREATE TABLE xs ();\n")).unwrap();
let entry = fs::read_dir(dir.join("infrastructure/migrations"))
.unwrap()
.flatten()
.next()
.unwrap();
assert!(
entry
.file_name()
.to_string_lossy()
.ends_with("_create_x_table.sql")
);
assert!(
fs::read_to_string(entry.path())
.unwrap()
.contains("CREATE TABLE xs ();")
);
cleanup(&dir);
}
#[test]
fn scaffold_rejects_sql_reserved_field_names_in_a_database_project() {
let dir = temp_dir("sql_reserved_db");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs_with_project_db(&dir);
let result = scaffold::run_scaffold(
"Product",
&dir,
&[scaffold_test_field("desc", "String")],
OverwritePolicy::Ask,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("SQL"));
assert!(!dir.join("business/src/domain/product").exists());
cleanup(&dir);
}
#[test]
fn scaffold_accepts_sql_reserved_field_names_without_a_database() {
let dir = temp_dir("sql_reserved_no_db");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
setup_puerto_stubs(&dir);
let result = scaffold::run_scaffold(
"Product",
&dir,
&[scaffold_test_field("order", "String")],
OverwritePolicy::Ask,
);
assert!(result.is_ok(), "{result:?}");
cleanup(&dir);
}
#[test]
fn validate_rejects_sql_reserved_field_names_in_a_database_project() {
let dir = temp_dir("validate_sql_reserved");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("puerto.toml"),
r#"[project]
name = "my-app"
db = true
[[entity]]
name = "Product"
use_cases = ["create_product"]
[[entity.fields]]
name = "user"
type = "String"
"#,
)
.unwrap();
let result = validate::run_validate(&dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("SQL"));
cleanup(&dir);
}
#[test]
fn duplicate_field_names_are_rejected() {
let fields = vec![
scaffold_test_field("name", "String"),
scaffold_test_field("name", "i64"),
];
let result = crate::validation::validate_unique_field_names(&fields);
assert!(result.is_err());
assert!(result.unwrap_err().contains("'name'"));
}
#[test]
fn project_name_must_be_a_valid_package_name() {
use crate::commands::new::validate_project_name;
assert!(validate_project_name("Bad Name").is_err());
assert!(validate_project_name("MyApp").is_err());
assert!(validate_project_name("1app").is_err());
assert!(validate_project_name("my-app").is_ok());
assert!(validate_project_name("my_app2").is_ok());
}
#[test]
fn create_impl_logs_start_and_done_without_the_payload() {
let fields = [scaffold_test_field("title", "String")];
let content = crate::generators::application::generate_create_use_case_impl(
"Product",
"product",
&fields,
&[],
);
assert!(
content.contains("tracing::info!(\"create_product: start\");"),
"{content}"
);
assert!(content.contains("tracing::info!(id = %entity.id, \"create_product: done\");"));
assert!(!content.contains("Creating product"), "{content}");
}
#[test]
fn create_test_asserts_an_enum_vo_with_as_str() {
let fields = [puerto_toml::parse_field_arg("state:State:enum:Open/Closed").unwrap()];
let content = crate::generators::application::generate_create_use_case_impl(
"Ticket",
"ticket",
&fields,
&[],
);
assert!(!content.contains("state.value()"), "{content}");
}
#[test]
fn unique_index_ignores_soft_deleted_rows() {
let mut sku = scaffold_test_field("sku", "String");
sku.unique = true;
let sql = crate::generators::infrastructure::create_table_sql("product", &[sku]);
assert!(
sql.contains(
"CREATE UNIQUE INDEX products_sku_key ON products (sku) WHERE deleted = false;"
)
);
}
#[test]
fn db_entity_maps_hashmap_columns_through_sqlx_json() {
let fields = [scaffold_test_field("meta", "HashMap<String, String>")];
let entity = crate::generators::infrastructure::generate_infra_entity(
"Product",
"product",
&fields,
&[],
);
let repo = crate::generators::infrastructure::generate_crud_infra_db_repository(
"Product",
"product",
&fields,
&[],
);
assert!(entity.contains("use std::collections::HashMap;"));
assert!(entity.contains("pub meta: Json<HashMap<String, String>>,"));
assert!(
repo.contains(r#"meta as \"meta: Json<HashMap<String, String>>\""#),
"{repo}"
);
}
#[test]
fn db_project_enables_sqlx_json_and_commits_the_offline_cache() {
let dir = temp_dir("db_project_sqlx");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("db-project-sqlx", &dir).unwrap();
crate::generators::project::apply_base_env(&output).unwrap();
crate::generators::project::apply_db_to_new_project(&output).unwrap();
let infra = fs::read_to_string(output.join("infrastructure/Cargo.toml")).unwrap();
assert!(infra.contains("\"json\""), "{infra}");
let gitignore = fs::read_to_string(output.join(".gitignore")).unwrap();
assert!(!gitignore.contains(".sqlx"), "{gitignore}");
cleanup(&dir);
}
#[test]
fn pg_repository_tests_import_what_their_seed_names() {
let fields = [
scaffold_test_field("published_at", "DateTime<Utc>"),
puerto_toml::parse_field_arg("status:Status:enum:Active/Inactive").unwrap(),
];
let repo = crate::generators::infrastructure::generate_crud_infra_db_repository(
"Product",
"product",
&fields,
&[],
);
assert!(repo.contains("use business::domain::product::value_objects::Status;"));
assert!(
!repo.contains("Utc::now()"),
"a ns-precision timestamp cannot round-trip Postgres"
);
}
#[test]
fn pg_repository_seed_varies_unique_fields() {
let mut sku = scaffold_test_field("sku", "String");
sku.unique = true;
let repo = crate::generators::infrastructure::generate_crud_infra_db_repository(
"Product",
"product",
&[sku],
&[],
);
assert!(repo.contains("sku: Uuid::new_v4().to_string(),"), "{repo}");
}
fn project_files(base: &Path) -> Vec<(String, String)> {
fn walk(dir: &Path, base: &Path, out: &mut Vec<(String, String)>) {
for entry in fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name == "target" || name == ".git" {
continue;
}
if path.is_dir() {
walk(&path, base, out);
} else if let Ok(content) = fs::read_to_string(&path) {
let rel = path
.strip_prefix(base)
.unwrap()
.to_string_lossy()
.to_string();
out.push((rel, content));
}
}
}
let mut out = vec![];
walk(base, base, &mut out);
out
}
fn project_with_product(name: &str, db: bool) -> (PathBuf, PathBuf) {
let dir = temp_dir(name);
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project(&name.replace('_', "-"), &dir).unwrap();
crate::generators::project::apply_base_env(&output).unwrap();
if db {
crate::generators::project::apply_db_to_new_project(&output).unwrap();
}
let fields = [
scaffold_test_field("title", "String"),
puerto_toml::parse_field_arg("status:Status:enum:Active/Archived").unwrap(),
];
scaffold::run_scaffold("Product", &output, &fields, OverwritePolicy::Ask).unwrap();
(dir, output)
}
#[test]
fn generated_project_logs_with_tracing_and_has_no_logger_port() {
let (dir, output) = project_with_product("no_logger_port", true);
scaffold::run_use_case("Product", "archive_product", &output).unwrap();
for (path, content) in project_files(&output) {
if !path.ends_with(".rs") {
continue;
}
assert!(
!content.contains("LoggerTrait"),
"{path} still names LoggerTrait"
);
assert!(
!content.contains("MockLogger"),
"{path} still names MockLogger"
);
assert!(
!content.contains("logger:"),
"{path} still has a logger field"
);
}
assert!(!output.join("business/src/domain/logger.rs").exists());
assert!(!output.join("infrastructure/src/logger.rs").exists());
let create =
fs::read_to_string(output.join("business/src/application/product/create_product.rs"))
.unwrap();
assert!(create.contains("tracing::info!"), "{create}");
let business_toml = fs::read_to_string(output.join("business/Cargo.toml")).unwrap();
assert!(business_toml.contains("tracing"), "{business_toml}");
cleanup(&dir);
}
#[test]
fn no_demo_project_has_no_logger_module() {
let dir = temp_dir("no_demo_no_logger");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("no-demo-no-logger", &dir).unwrap();
scaffold::apply_no_demo(&output).unwrap();
let lib = fs::read_to_string(output.join("business/src/lib.rs")).unwrap();
assert!(!lib.contains("logger"), "{lib}");
let infra = fs::read_to_string(output.join("infrastructure/src/lib.rs")).unwrap();
assert!(!infra.contains("logger"), "{infra}");
cleanup(&dir);
}
#[test]
fn error_mapper_names_the_code_and_maps_validation_to_422() {
let fields = [
scaffold_test_field("title", "String"),
puerto_toml::parse_field_arg("status:Status:enum:Active/Archived").unwrap(),
];
let ctx =
crate::generators::presentation::build_error_mapper_ctx("Product", "product", &fields);
let mapper = crate::generators::render::render("presentation/error_mapper.tera", &ctx).unwrap();
assert!(mapper.contains("ErrorResponse::from_code"), "{mapper}");
assert!(!mapper.contains("\"product_error\""), "{mapper}");
assert!(
mapper.contains("ProductError::ValidationError(_) => StatusCode::UNPROCESSABLE_ENTITY")
);
assert!(mapper.contains("ProductError::InvalidStatus => StatusCode::UNPROCESSABLE_ENTITY"));
for test in [
"should_map_validation_error_to_422_with_its_code",
"should_map_not_found_to_404_with_its_code",
"should_map_duplicate_to_409_with_its_code",
"should_map_repository_error_to_500_with_its_code",
"should_map_invalid_status_to_422_with_its_code",
] {
assert!(mapper.contains(test), "missing {test}:\n{mapper}");
}
}
#[test]
fn responses_offer_422_and_no_longer_map_400() {
let ctx = crate::generators::presentation::build_simple_ctx("Product", "product");
let responses =
crate::generators::render::render("presentation/responses_crud.tera", &ctx).unwrap();
assert!(responses.contains("#[oai(status = 422)]"));
assert!(
responses.contains("StatusCode::UNPROCESSABLE_ENTITY => Self::UnprocessableEntity(error)")
);
assert!(!responses.contains("BAD_REQUEST"), "{responses}");
}
#[test]
fn shared_error_response_is_built_from_the_code() {
let dir = temp_dir("error_rs_from_code");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("error-rs-from-code", &dir).unwrap();
let error_rs = fs::read_to_string(output.join("presentation/src/api/error.rs")).unwrap();
assert!(error_rs.contains("pub fn from_code("), "{error_rs}");
cleanup(&dir);
}
#[test]
fn generated_project_serves_liveness_and_readiness_and_shuts_down_gracefully() {
let (dir, output) = project_with_product("health_no_db", false);
let bootstrap =
fs::read_to_string(output.join("presentation/src/generated/bootstrap.rs")).unwrap();
let main = fs::read_to_string(output.join("presentation/src/main.rs")).unwrap();
assert!(
bootstrap.contains(".at(\"/health\", get(crate::health::liveness))"),
"{bootstrap}"
);
assert!(bootstrap.contains("\"/health/ready\""), "{bootstrap}");
assert!(main.contains("run_with_graceful_shutdown"), "{main}");
assert!(main.contains("mod health;"), "{main}");
assert!(output.join("presentation/src/health.rs").exists());
cleanup(&dir);
}
#[test]
fn readiness_pings_the_database_in_a_db_project() {
let (dir, output) = project_with_product("health_db", true);
let bootstrap =
fs::read_to_string(output.join("presentation/src/generated/bootstrap.rs")).unwrap();
let db_rs = fs::read_to_string(output.join("infrastructure/src/db.rs")).unwrap();
assert!(
bootstrap.contains("infrastructure::db::ping(&pool)"),
"{bootstrap}"
);
assert!(db_rs.contains("pub async fn ping("), "{db_rs}");
cleanup(&dir);
}
#[test]
fn generated_project_pins_its_toolchain_and_uses_edition_2024() {
let dir = temp_dir("toolchain_pin");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("toolchain-pin", &dir).unwrap();
let toolchain = fs::read_to_string(output.join("rust-toolchain.toml")).unwrap();
assert!(toolchain.contains("channel = \"1.95.0\""), "{toolchain}");
assert!(toolchain.contains("clippy") && toolchain.contains("rustfmt"));
for krate in ["business", "infrastructure", "presentation"] {
let manifest = fs::read_to_string(output.join(format!("{krate}/Cargo.toml"))).unwrap();
assert!(
manifest.contains("edition = \"2024\""),
"{krate}: {manifest}"
);
}
let workspace = fs::read_to_string(output.join("Cargo.toml")).unwrap();
assert!(workspace.contains("resolver = \"3\""), "{workspace}");
cleanup(&dir);
}
#[test]
fn generated_agent_rules_never_describe_mod_rs_files() {
let dir = temp_dir("agents_no_mod_rs");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("agents-no-mod-rs", &dir).unwrap();
for (path, content) in project_files(&output) {
if !path.ends_with("AGENTS.md") {
continue;
}
for line in content.lines() {
if line.contains("mod.rs") {
assert!(
line.to_lowercase().contains("no `mod.rs`"),
"{path} describes a mod.rs file: {line}"
);
}
}
assert!(
!content.contains("LoggerTrait"),
"{path} documents the removed logger port"
);
}
cleanup(&dir);
}
#[test]
fn generated_project_has_a_root_entry_point_for_agents() {
let dir = temp_dir("agents_root");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("agents-root", &dir).unwrap();
let claude = fs::read_to_string(output.join("CLAUDE.md")).unwrap();
let agents = fs::read_to_string(output.join("AGENTS.md")).unwrap();
assert_eq!(claude.trim(), "@AGENTS.md");
for must in [
"puerto generate",
"generated/bootstrap.rs",
"Tier 1",
"make lint",
"one commit",
"business/AGENTS.md",
] {
assert!(agents.contains(must), "root AGENTS.md misses '{must}'");
}
cleanup(&dir);
}
#[test]
fn template_bootstrap_is_exactly_what_the_generator_writes() {
let dir = temp_dir("template_bootstrap_drift");
cleanup(&dir);
fs::create_dir_all(&dir).unwrap();
let output = generate_project("template-bootstrap-drift", &dir).unwrap();
let config = puerto_toml::read(&output).unwrap();
let shipped =
fs::read_to_string(output.join("presentation/src/generated/bootstrap.rs")).unwrap();
let tags = fs::read_to_string(output.join("presentation/src/api/tags.rs")).unwrap();
assert_eq!(
shipped,
crate::generators::bootstrap::generate_bootstrap_content(&config.entity),
"template/presentation/src/generated/bootstrap.rs is stale — regenerate it"
);
assert_eq!(
tags,
crate::generators::bootstrap::generate_tags_content(&config.entity),
"template/presentation/src/api/tags.rs is stale — regenerate it"
);
cleanup(&dir);
}
#[test]
fn api_rs_patch_separates_declarations_when_the_file_lacks_a_trailing_newline() {
let dir = temp_dir("api_rs_no_newline");
cleanup(&dir);
fs::create_dir_all(dir.join("presentation/src")).unwrap();
fs::write(dir.join("presentation/src/api.rs"), "pub mod error;").unwrap();
crate::patchers::api_rs::patch_api_rs(&dir, "product").unwrap();
assert_eq!(
fs::read_to_string(dir.join("presentation/src/api.rs")).unwrap(),
"pub mod error;\npub mod tags;\npub mod product;\n"
);
cleanup(&dir);
}
#[test]
fn insert_before_block_end_skips_nested_blocks() {
let source = "pub mod domain {\n pub mod greeting {\n pub mod model;\n }\n}\n";
let patched =
crate::patchers::lib_rs::insert_before_block_end(source, "domain", " pub mod shared;\n")
.unwrap();
assert_eq!(
patched,
"pub mod domain {\n pub mod greeting {\n pub mod model;\n }\n pub mod shared;\n}\n"
);
}
#[test]
fn infra_lib_patch_appends_on_its_own_line_and_declares_db_once() {
let dir = temp_dir("infra_lib_patch");
cleanup(&dir);
fs::create_dir_all(dir.join("infrastructure/src")).unwrap();
fs::write(dir.join("infrastructure/src/lib.rs"), "pub mod db;").unwrap();
crate::patchers::lib_rs::patch_infra_lib(&dir, "product", true).unwrap();
assert_eq!(
fs::read_to_string(dir.join("infrastructure/src/lib.rs")).unwrap(),
"pub mod db;\npub mod product {\n pub mod entity;\n pub mod repository;\n}\n"
);
cleanup(&dir);
}
#[test]
fn value_objects_module_is_declared_per_entity_even_when_another_entity_has_one() {
let dir = temp_dir("vo_module_per_entity");
cleanup(&dir);
fs::create_dir_all(dir.join("business/src")).unwrap();
fs::write(
dir.join("business/src/lib.rs"),
"pub mod domain {\n pub mod order {\n pub mod model;\n pub mod value_objects;\n }\n pub mod product {\n pub mod model;\n }\n}\n",
)
.unwrap();
crate::patchers::lib_rs::patch_business_lib_value_objects(&dir, "product").unwrap();
crate::patchers::lib_rs::patch_business_lib_value_objects(&dir, "product").unwrap();
let lib = fs::read_to_string(dir.join("business/src/lib.rs")).unwrap();
let product_block = &lib[lib.find("pub mod product {").unwrap()..];
assert_eq!(
product_block.matches("pub mod value_objects;").count(),
1,
"{lib}"
);
assert_eq!(lib.matches("pub mod value_objects;").count(), 2, "{lib}");
cleanup(&dir);
}
#[test]
fn routes_patch_appends_the_field_as_the_last_member_of_the_api_struct() {
let dir = temp_dir("routes_patch_position");
cleanup(&dir);
fs::create_dir_all(dir.join("presentation/src/api/product")).unwrap();
fs::write(
dir.join("presentation/src/api/product/routes.rs"),
"use std::sync::Arc;\n\npub struct ProductApi {\n pub get_product: Arc<GetProductUseCaseImpl>,\n}\n\nimpl ProductApi {}\n",
)
.unwrap();
crate::patchers::routes_rs::patch_routes_use_cases(
&dir,
"Product",
"product",
&["archive_product".to_string()],
)
.unwrap();
assert_eq!(
fs::read_to_string(dir.join("presentation/src/api/product/routes.rs")).unwrap(),
"use business::application::product::archive_product::ArchiveProductUseCaseImpl;\n\
use std::sync::Arc;\n\n\
pub struct ProductApi {\n pub get_product: Arc<GetProductUseCaseImpl>,\n \
/// Wired by bootstrap.rs. Call it from an endpoint in the `#[OpenApi]` impl below.\n \
#[allow(dead_code)]\n pub archive_product: Arc<ArchiveProductUseCaseImpl>,\n}\n\n\
impl ProductApi {}\n"
);
cleanup(&dir);
}
#[test]
fn api_rs_patch_adds_no_blank_lines_to_a_well_formed_or_empty_file() {
let dir = temp_dir("api_rs_well_formed");
cleanup(&dir);
fs::create_dir_all(dir.join("presentation/src")).unwrap();
let api_rs = dir.join("presentation/src/api.rs");
fs::write(&api_rs, "pub mod error;\n").unwrap();
crate::patchers::api_rs::patch_api_rs(&dir, "product").unwrap();
assert_eq!(
fs::read_to_string(&api_rs).unwrap(),
"pub mod error;\npub mod tags;\npub mod product;\n"
);
fs::write(&api_rs, "pub mod error;\npub mod tags;").unwrap();
crate::patchers::api_rs::patch_api_rs(&dir, "product").unwrap();
assert_eq!(
fs::read_to_string(&api_rs).unwrap(),
"pub mod error;\npub mod tags;\npub mod product;\n"
);
fs::write(&api_rs, "").unwrap();
crate::patchers::api_rs::patch_api_rs(&dir, "product").unwrap();
assert_eq!(
fs::read_to_string(&api_rs).unwrap(),
"pub mod tags;\npub mod product;\n"
);
cleanup(&dir);
}
#[test]
fn value_objects_check_stops_at_the_end_of_its_own_entity_block() {
let dir = temp_dir("vo_module_block_end");
cleanup(&dir);
fs::create_dir_all(dir.join("business/src")).unwrap();
fs::write(
dir.join("business/src/lib.rs"),
"pub mod domain {\n pub mod product {\n pub mod model;\n }\n pub mod order {\n pub mod value_objects;\n }\n}\n",
)
.unwrap();
crate::patchers::lib_rs::patch_business_lib_value_objects(&dir, "product").unwrap();
let lib = fs::read_to_string(dir.join("business/src/lib.rs")).unwrap();
let product_block =
&lib[lib.find("pub mod product {").unwrap()..lib.find("pub mod order {").unwrap()];
assert!(product_block.contains("pub mod value_objects;"), "{lib}");
cleanup(&dir);
}