use std::path::Path;
use crate::generators::types::{
is_enum_vo, is_option_vo, is_value_object, is_vec_vo, resolve_type,
};
use crate::puerto_toml;
use crate::validation::SHARED_VO_ALLOWED_INNER_TYPES;
const VO_ALLOWED_INNER_TYPES: &[&str] = &[
"String",
"i64",
"bool",
"f64",
"Uuid",
"DateTime<Utc>",
"Option<String>",
"Option<i64>",
"Option<bool>",
"Option<f64>",
"Option<DateTime<Utc>>",
"Vec<String>",
"Vec<i64>",
];
fn vo_inner_type_is_valid(field_type: &str) -> bool {
if field_type.starts_with("Option<") {
let inner = &field_type[7..field_type.len() - 1];
return !inner.starts_with("Vec<")
&& !inner.starts_with("Option<")
&& !inner.starts_with("HashMap<");
}
if field_type.starts_with("Vec<") {
let inner = &field_type[4..field_type.len() - 1];
return !inner.starts_with("Option<")
&& !inner.starts_with("Vec<")
&& !inner.starts_with("HashMap<");
}
!field_type.starts_with("HashMap<")
}
pub fn run_validate(cwd: &Path) -> Result<(), Box<dyn std::error::Error>> {
crate::commands::list::require_puerto_project(cwd)?;
let config = puerto_toml::read(cwd)?;
let mut errors: Vec<String> = vec![];
let mut warnings: Vec<String> = vec![];
if config.project.name.is_empty() {
errors.push("project.name is empty".into());
}
if let Some(transport) = &config.project.events {
if let Err(e) = crate::validation::validate_events_transport(transport) {
errors.push(format!("project.events: {e}"));
}
if !config.project.db {
errors.push("project.events needs project.db = true: events are written to an outbox table in the entity's transaction".into());
}
}
let mut seen_entities: Vec<String> = vec![];
for entity in &config.entity {
if let Err(e) = crate::validation::validate_entity_name(&entity.name) {
errors.push(format!("entity '{}': {}", entity.name, e));
}
if seen_entities.contains(&entity.name) {
errors.push(format!("duplicate entity name '{}'", entity.name));
}
seen_entities.push(entity.name.clone());
if entity.use_cases.is_empty() {
warnings.push(format!(
"entity '{}': has no use cases defined",
entity.name
));
}
for uc in &entity.use_cases {
if crate::validation::validate_use_case_name(uc).is_err() {
errors.push(format!(
"entity '{}': use case '{}' is not valid snake_case",
entity.name, uc
));
}
}
let snake = crate::generators::naming::pascal_to_snake(&entity.name);
if !entity.events.is_empty() && !config.project.has_events() {
errors.push(format!(
"entity '{}': declares events, but project.events is not set",
entity.name
));
}
for (i, event) in entity.events.iter().enumerate() {
if let Err(e) =
crate::validation::validate_event_name(&snake, event, &entity.events[..i])
{
errors.push(format!("entity '{}': {e}", entity.name));
}
}
let mut seen_fields: Vec<String> = vec![];
for field in &entity.fields {
if field.name.is_empty() {
errors.push(format!("entity '{}': field has empty name", entity.name));
continue;
}
if let Err(e) = crate::validation::validate_field_name(&field.name) {
errors.push(format!("entity '{}': {}", entity.name, e));
}
if config.project.db {
if let Err(e) = crate::validation::validate_sql_field_name(&field.name) {
errors.push(format!("entity '{}': {}", entity.name, e));
}
}
if seen_fields.contains(&field.name) {
errors.push(format!(
"entity '{}': duplicate field name '{}'",
entity.name, field.name
));
}
seen_fields.push(field.name.clone());
if let Err(e) = resolve_type(&field.field_type) {
errors.push(format!(
"entity '{}': field '{}' has {}",
entity.name, field.name, e
));
}
if field.field_type.starts_with("Option<") && field.unique {
warnings.push(format!(
"entity '{}': field '{}' is Option<> and marked unique — nullable unique fields may not be intended",
entity.name, field.name
));
}
if is_value_object(field) {
if let Err(e) =
crate::validation::validate_vo_name(field.value_object.as_deref().unwrap())
{
errors.push(format!(
"entity '{}': field '{}': {}",
entity.name, field.name, e
));
}
if is_enum_vo(field) {
if field.field_type != "String" {
errors.push(format!(
"entity '{}': field '{}' has enum value_object but type '{}' is not String",
entity.name, field.name, field.field_type
));
}
if field.enum_variants.is_none() {
errors.push(format!(
"entity '{}': field '{}' has enum value_object_kind but no enum_variants",
entity.name, field.name
));
}
if let Some(variants) = &field.enum_variants {
if let Err(e) = crate::validation::validate_enum_variants(variants) {
errors.push(format!(
"entity '{}': field '{}': {}",
entity.name, field.name, e
));
}
}
} else {
if !VO_ALLOWED_INNER_TYPES.contains(&field.field_type.as_str()) {
errors.push(format!(
"entity '{}': field '{}' has value_object but inner type '{}' is not a allowed (allowed: {})",
entity.name, field.name, field.field_type, VO_ALLOWED_INNER_TYPES.join(", ")
));
}
if !vo_inner_type_is_valid(&field.field_type) {
errors.push(format!(
"entity '{}': field '{}' has value_object with invalid composed inner type '{}'",
entity.name, field.name, field.field_type
));
}
if field.enum_variants.is_some() {
errors.push(format!(
"entity '{}': field '{}' has enum_variants but value_object_kind is not 'enum'",
entity.name, field.name
));
}
}
if field.unique && (is_option_vo(field) || is_vec_vo(field)) {
errors.push(format!(
"entity '{}': field '{}' is an Option/Vec value object and cannot be marked unique",
entity.name, field.name
));
}
}
}
for e in crate::validation::validate_vo_coherence(&entity.fields, &config.value_object) {
errors.push(format!("entity '{}': {}", entity.name, e));
}
}
let mut seen_shared_vo_names: Vec<String> = vec![];
for vo_def in &config.value_object {
if let Err(e) = crate::validation::validate_vo_name(&vo_def.name) {
errors.push(format!("shared value_object '{}': {}", vo_def.name, e));
}
if seen_shared_vo_names.contains(&vo_def.name) {
errors.push(format!(
"duplicate shared value_object name '{}'",
vo_def.name
));
}
seen_shared_vo_names.push(vo_def.name.clone());
if !SHARED_VO_ALLOWED_INNER_TYPES.contains(&vo_def.inner_type.as_str()) {
errors.push(format!(
"shared value_object '{}': inner type '{}' is not allowed (allowed: {})",
vo_def.name,
vo_def.inner_type,
SHARED_VO_ALLOWED_INNER_TYPES.join(", ")
));
}
}
let declared = crate::generators::worker::declared_events(&config);
let mut seen_consumers: Vec<&str> = vec![];
for consumer in &config.consumer {
if !config.project.has_events() {
errors.push(format!(
"consumer '{}': declared, but project.events is not set",
consumer.name
));
}
if let Err(e) = crate::validation::validate_consumer_name(&consumer.name) {
errors.push(format!("consumer '{}': {e}", consumer.name));
}
if seen_consumers.contains(&consumer.name.as_str()) {
errors.push(format!("duplicate consumer name '{}'", consumer.name));
}
seen_consumers.push(&consumer.name);
if consumer.events.is_empty() {
errors.push(format!(
"consumer '{}': subscribes to no event",
consumer.name
));
}
for event in &consumer.events {
if !declared.iter().any(|d| d.event_type == *event) {
errors.push(format!(
"consumer '{}': event '{event}' is not declared by any entity",
consumer.name
));
}
}
}
errors.extend(crate::generators::consistency::missing_consumer_files(
cwd, &config,
));
let mut seen_mails: Vec<&str> = vec![];
for mail in &config.mail {
if let Err(e) = crate::validation::validate_mail_name(&mail.name) {
errors.push(format!("mail '{}': {e}", mail.name));
}
if seen_mails.contains(&mail.name.as_str()) {
errors.push(format!("duplicate mail name '{}'", mail.name));
}
seen_mails.push(&mail.name);
if mail.on.is_empty() {
continue;
}
if !config.project.has_events() {
errors.push(format!(
"mail '{}': sent on events, but project.events is not set",
mail.name
));
}
for event in &mail.on {
if !declared.iter().any(|d| d.event_type == *event) {
errors.push(format!(
"mail '{}': event '{event}' is not declared by any entity",
mail.name
));
}
}
if config
.consumer
.iter()
.any(|c| c.name == format!("mail_{}", mail.name))
{
errors.push(format!(
"mail '{0}': the worker runs it as the consumer 'mail_{0}', which a [[consumer]] \
already uses",
mail.name
));
}
}
errors.extend(crate::generators::consistency::missing_mail_files(
cwd, &config,
));
for line in crate::generators::consistency::drift_lines(
&crate::generators::consistency::missing_entity_files(cwd, &config),
) {
errors.push(line);
}
for orphan in crate::generators::consistency::orphan_entity_dirs(cwd, &config) {
warnings.push(format!(
"'business/src/domain/{orphan}' exists but has no [[entity]] block — Puerto commands \
cannot see it"
));
}
if let Ok(manifest) = std::fs::read_to_string(cwd.join("Cargo.toml")) {
warnings.extend(crate::runtime::version_warnings(
&manifest,
env!("CARGO_PKG_VERSION"),
));
}
if !errors.is_empty() {
eprintln!("Validation failed:");
for e in &errors {
eprintln!(" ✗ {e}");
}
if !warnings.is_empty() {
for w in &warnings {
eprintln!(" ⚠ {w}");
}
}
let msg = errors.join("\n ");
return Err(format!("{}\n {} error(s) found", msg, errors.len()).into());
}
if !warnings.is_empty() {
eprintln!("Warnings:");
for w in &warnings {
eprintln!(" ⚠ {w}");
}
}
println!(
"✓ puerto.toml is valid — {} entit{} validated",
config.entity.len(),
if config.entity.len() == 1 { "y" } else { "ies" }
);
Ok(())
}