use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct PuertoConfig {
pub project: Project,
#[serde(default)]
pub entity: Vec<Entity>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub value_object: Vec<ValueObjectDefinition>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ValueObjectDefinition {
pub name: String,
#[serde(rename = "type")]
pub inner_type: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Project {
pub name: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub db: bool,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Entity {
pub name: String,
pub use_cases: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub db: bool,
#[serde(default)]
pub fields: Vec<Field>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct Field {
pub name: String,
#[serde(rename = "type")]
pub field_type: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unique: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_object: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_object_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enum_variants: Option<Vec<String>>,
}
pub fn parse_field_arg(s: &str) -> Result<Field, String> {
let (raw, unique) = if let Some(stripped) = s.strip_suffix('!') {
(stripped, true)
} else {
(s, false)
};
let parts: Vec<&str> = raw.split(':').collect();
if parts.len() == 1 || parts[0].is_empty() {
return Err(format!(
"invalid field argument '{}'. Expected format: name:Type (e.g. title:String)",
s
));
}
let name = parts[0].to_string();
crate::validation::validate_field_name(&name)?;
let field = parse_field_shape(s, name, &parts, unique)?;
crate::validation::validate_field(&field)?;
Ok(field)
}
fn parse_field_shape(s: &str, name: String, parts: &[&str], unique: bool) -> Result<Field, String> {
match parts.len() {
1 => unreachable!("handled above"),
2 => {
let ft = expand_type(parts[1]);
if ft.is_empty() {
return Err(format!(
"invalid field argument '{}'. Type cannot be empty",
s
));
}
Ok(Field {
name,
field_type: ft,
unique,
..Default::default()
})
}
3 => {
let second = parts[1];
let third = parts[2];
match second {
"opt" => Ok(Field {
name,
field_type: format!("Option<{}>", expand_type(third)),
unique,
..Default::default()
}),
"vec" => Ok(Field {
name,
field_type: format!("Vec<{}>", expand_type(third)),
unique,
..Default::default()
}),
vo_name if is_valid_vo_name(vo_name) => Ok(Field {
name,
field_type: expand_type(third),
unique,
value_object: Some(vo_name.to_string()),
..Default::default()
}),
_ => Err(format!(
"invalid field argument '{}'. '{}' is not a known keyword (opt, vec) or a PascalCase VO name",
s, second
)),
}
}
4 => {
let vo_name = parts[1];
let modifier = parts[2];
let rest = parts[3];
if !is_valid_vo_name(vo_name) {
return Err(format!(
"invalid value object name '{}'. Must be PascalCase (starts with uppercase, alphanumeric)",
vo_name
));
}
match modifier {
"opt" => Ok(Field {
name,
field_type: format!("Option<{}>", expand_type(rest)),
unique,
value_object: Some(vo_name.to_string()),
..Default::default()
}),
"vec" => Ok(Field {
name,
field_type: format!("Vec<{}>", expand_type(rest)),
unique,
value_object: Some(vo_name.to_string()),
..Default::default()
}),
"enum" => {
if rest.is_empty() {
return Err(format!(
"invalid field argument '{}'. Enum variants cannot be empty",
s
));
}
let variants: Vec<String> =
rest.split('/').map(|v| v.trim().to_string()).collect();
for variant in &variants {
if !is_valid_vo_name(variant) {
return Err(format!(
"invalid enum variant '{}'. Must be PascalCase (starts with uppercase, alphanumeric)",
variant
));
}
}
Ok(Field {
name,
field_type: "String".to_string(),
unique,
value_object: Some(vo_name.to_string()),
value_object_kind: Some("enum".to_string()),
enum_variants: Some(variants),
})
}
_ => Err(format!(
"invalid field argument '{}'. Modifier '{}' is not recognized (use: opt, vec, enum)",
s, modifier
)),
}
}
_ => Err(format!(
"invalid field argument '{}'. Too many colon-separated parts (expected 2–4)",
s
)),
}
}
fn expand_type(t: &str) -> String {
match t {
"DateTime" => "DateTime<Utc>".to_string(),
"map" => "HashMap<String, String>".to_string(),
other => other.to_string(),
}
}
fn is_valid_vo_name(name: &str) -> bool {
crate::validation::is_pascal_case(name)
}
pub fn read(base: &Path) -> Result<PuertoConfig, Box<dyn std::error::Error>> {
let path = base.join("puerto.toml");
let src = std::fs::read_to_string(&path)?;
Ok(toml::from_str(&src)?)
}
pub fn write(base: &Path, config: &PuertoConfig) -> Result<(), Box<dyn std::error::Error>> {
let path = base.join("puerto.toml");
std::fs::write(path, toml::to_string_pretty(config)?)?;
Ok(())
}
pub fn add_entity(
base: &Path,
name: &str,
use_cases: Vec<String>,
db: bool,
fields: Vec<Field>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut config = read(base)?;
match config.entity.iter_mut().find(|e| e.name == name) {
Some(existing) => {
for uc in use_cases {
if !existing.use_cases.contains(&uc) {
existing.use_cases.push(uc);
}
}
existing.db = db;
existing.fields = fields;
}
None => config.entity.push(Entity {
name: name.to_string(),
use_cases,
db,
fields,
}),
}
write(base, &config)
}
pub fn apply_shared_vo_inference(
fields: Vec<Field>,
shared_vos: &[ValueObjectDefinition],
) -> Vec<Field> {
if shared_vos.is_empty() {
return fields;
}
fields
.into_iter()
.map(|f| {
if f.value_object.is_none() {
if let Some(svo) = shared_vos.iter().find(|v| v.name == f.field_type) {
return Field {
field_type: svo.inner_type.clone(),
value_object: Some(svo.name.clone()),
..f
};
}
}
f
})
.collect()
}
pub fn add_value_object(
base: &Path,
name: &str,
inner_type: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut config = read(base)?;
if config.value_object.iter().any(|vo| vo.name == name) {
return Ok(());
}
config.value_object.push(ValueObjectDefinition {
name: name.to_string(),
inner_type: inner_type.to_string(),
});
write(base, &config)
}
pub fn add_use_case(
base: &Path,
entity_name: &str,
action: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut config = read(base)?;
let entity = config
.entity
.iter_mut()
.find(|e| e.name == entity_name)
.ok_or_else(|| format!("entity '{entity_name}' not found in puerto.toml"))?;
if !entity.use_cases.contains(&action.to_string()) {
entity.use_cases.push(action.to_string());
write(base, &config)?;
}
Ok(())
}