use crate::entity::{EntityDefinition, RelationType};
use crate::generator::context::{self, markers, ProjectFeatures};
use crate::generator::plan::{self, GenerationTracker};
use crate::template::TemplateEngine;
use crate::utils;
use anyhow::Result;
use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase};
use std::path::Path;
use tera::Context;
pub fn validate(_entity: &EntityDefinition) -> Result<()> {
let app_path = Path::new("frontend/src/App.tsx");
let sidebar_path = Path::new("frontend/src/components/AppSidebar.tsx");
let checks = vec![
plan::check(app_path, markers::IMPORTS),
plan::check(app_path, markers::APP_ROUTES),
plan::check(sidebar_path, markers::NAV_LINKS),
];
plan::validate_markers(&checks)
}
pub fn generate(entity: &EntityDefinition, tracker: &mut GenerationTracker) -> Result<()> {
let engine = TemplateEngine::new()?;
let ctx = build_context(entity);
let snake_name = entity.name.to_snake_case();
let camel_name = entity.name.to_lower_camel_case();
let base = Path::new("frontend/src");
let feature_dir = base.join(format!("features/{}", camel_name));
let content = engine.render("entity/frontend/types.ts.tera", &ctx)?;
let types_path = feature_dir.join("types.ts");
utils::write_generated(&types_path, &content)?;
tracker.track(types_path);
let content = engine.render("entity/frontend/api.ts.tera", &ctx)?;
let api_path = feature_dir.join("api.ts");
utils::write_generated(&api_path, &content)?;
tracker.track(api_path);
let content = engine.render("entity/frontend/hooks.ts.tera", &ctx)?;
let hooks_path = feature_dir.join("hooks.ts");
utils::write_generated(&hooks_path, &content)?;
tracker.track(hooks_path);
let content = engine.render("entity/frontend/List.tsx.tera", &ctx)?;
let list_path = feature_dir.join(format!("{}List.tsx", entity.name));
utils::write_generated(&list_path, &content)?;
tracker.track(list_path);
let content = engine.render("entity/frontend/Form.tsx.tera", &ctx)?;
let form_path = feature_dir.join(format!("{}Form.tsx", entity.name));
utils::write_generated(&form_path, &content)?;
tracker.track(form_path);
let content = engine.render("entity/frontend/Detail.tsx.tera", &ctx)?;
let detail_path = feature_dir.join(format!("{}Detail.tsx", entity.name));
utils::write_generated(&detail_path, &content)?;
tracker.track(detail_path);
let app_path = base.join("App.tsx");
let entity_pascal = &entity.name;
let plural = utils::pluralize(&snake_name);
utils::insert_at_marker(
&app_path,
markers::IMPORTS,
&format!(
"import {entity_pascal}List from '@/features/{camel_name}/{entity_pascal}List'\nimport {entity_pascal}Form from '@/features/{camel_name}/{entity_pascal}Form'\nimport {entity_pascal}Detail from '@/features/{camel_name}/{entity_pascal}Detail'",
),
)?;
utils::insert_at_marker(
&app_path,
markers::APP_ROUTES,
&format!(
" <Route path=\"/{plural}\" element={{<{entity_pascal}List />}} />\n <Route path=\"/{plural}/new\" element={{<{entity_pascal}Form />}} />\n <Route path=\"/{plural}/:id\" element={{<{entity_pascal}Detail />}} />\n <Route path=\"/{plural}/:id/edit\" element={{<{entity_pascal}Form />}} />",
),
)?;
let sidebar_path = base.join("components/AppSidebar.tsx");
utils::insert_at_marker(
&sidebar_path,
markers::NAV_LINKS,
&format!(
" <NavLink to=\"/{plural}\" className={{navLinkClass}}>\n <LayoutList className=\"h-4 w-4\" />\n {entity_plural}\n </NavLink>",
plural = plural,
entity_plural = utils::pluralize(entity_pascal),
),
)?;
for rel in &entity.relations {
if rel.relation_type == RelationType::ManyToMany {
let rel_ctx = build_relation_context(&entity.name, &rel.target_entity);
let content = engine.render("entity/frontend/relation_hooks.ts.tera", &rel_ctx)?;
let related_snake = rel.target_entity.to_snake_case();
let rel_hooks_path = feature_dir.join(format!("{}_hooks.ts", related_snake));
utils::write_file(&rel_hooks_path, &content)?;
tracker.track(rel_hooks_path);
}
}
utils::ui::success(&format!(
"Generated frontend files for '{}' in features/{}",
entity.name, snake_name
));
Ok(())
}
fn build_relation_context(entity_name: &str, related_name: &str) -> Context {
let mut ctx = Context::new();
ctx.insert("entity_name", &entity_name.to_pascal_case());
ctx.insert("entity_name_snake", &entity_name.to_snake_case());
ctx.insert("entity_name_camel", &entity_name.to_lower_camel_case());
ctx.insert("related_name", &related_name.to_pascal_case());
ctx.insert("related_snake", &related_name.to_snake_case());
ctx.insert("related_camel", &related_name.to_lower_camel_case());
ctx
}
fn build_context(entity: &EntityDefinition) -> Context {
let mut ctx = Context::new();
ctx.insert("entity_name", &entity.name);
ctx.insert("entity_name_snake", &entity.name.to_snake_case());
ctx.insert("entity_name_camel", &entity.name.to_lower_camel_case());
let features = ProjectFeatures::load(Path::new("."));
ctx.insert("has_validation", &features.has_validation);
ctx.insert("api_prefix", &features.api_prefix);
let fields: Vec<serde_json::Value> = entity
.fields
.iter()
.map(|f| {
let validations = context::validation_rules_to_json(&f.validations);
let has_validations = !f.validations.is_empty();
let relation_snake = f.relation.as_ref().map(|r| r.to_snake_case());
let relation_camel = f.relation.as_ref().map(|r| r.to_lower_camel_case());
let relation_plural = f.relation.as_ref().map(|r| crate::utils::pluralize(&r.to_snake_case()));
let fk_options_var = if f.relation.is_some() {
let base = f.name.strip_suffix("_id").unwrap_or(&f.name);
format!("{}Options", base.to_lower_camel_case())
} else {
String::new()
};
let input_type = if f.name.contains("email") {
"email"
} else if f.name.contains("url") || f.name.contains("website") || f.name.contains("link") {
"url"
} else if f.name.contains("phone") || f.name.contains("tel") {
"tel"
} else if f.name.contains("password") || f.name.contains("secret") {
"password"
} else {
f.field_type.input_type()
};
serde_json::json!({
"name": f.name,
"ts_type": f.field_type.to_typescript(),
"shadcn_component": f.field_type.to_shadcn(),
"input_type": input_type,
"optional": f.optional,
"relation": f.relation,
"relation_snake": relation_snake,
"relation_camel": relation_camel,
"relation_plural": relation_plural,
"fk_options_var": fk_options_var,
"validations": validations,
"has_validations": has_validations,
"is_numeric": context::is_numeric(&f.field_type),
"searchable": f.searchable,
"is_file": matches!(f.field_type, crate::entity::FieldType::File),
"is_image": matches!(f.field_type, crate::entity::FieldType::Image),
"is_json": matches!(f.field_type, crate::entity::FieldType::Json),
"filter_method": context::filter_method(&f.field_type),
})
})
.collect();
ctx.insert("fields", &fields);
let has_fk_fields = entity.fields.iter().any(|f| f.relation.is_some());
ctx.insert("has_fk_fields", &has_fk_fields);
let has_status_field = entity.fields.iter().any(|f| f.name == "status");
ctx.insert("has_status_field", &has_status_field);
let has_textarea_field = entity
.fields
.iter()
.any(|f| f.field_type.to_shadcn() == "Textarea");
ctx.insert("has_textarea_field", &has_textarea_field);
let has_input_field = entity.fields.iter().any(|f| {
f.relation.is_none()
&& f.field_type.to_shadcn() != "Textarea"
&& f.field_type.to_shadcn() != "Switch"
});
ctx.insert("has_input_field", &has_input_field);
let mut seen_relations = std::collections::HashSet::new();
let fk_imports: Vec<serde_json::Value> = entity
.fields
.iter()
.filter(|f| f.relation.is_some())
.filter(|f| seen_relations.insert(f.relation.as_ref().unwrap().clone()))
.map(|f| {
let rel = f.relation.as_ref().unwrap();
serde_json::json!({
"relation_camel": rel.to_lower_camel_case(),
})
})
.collect();
ctx.insert("fk_imports", &fk_imports);
let m2m_relations: Vec<serde_json::Value> = entity
.relations
.iter()
.filter(|r| r.relation_type == RelationType::ManyToMany)
.map(|r| {
serde_json::json!({
"target": r.target_entity,
"target_snake": r.target_entity.to_snake_case(),
"target_camel": r.target_entity.to_lower_camel_case(),
})
})
.collect();
ctx.insert("m2m_relations", &m2m_relations);
let has_many_relations: Vec<serde_json::Value> = entity
.relations
.iter()
.filter(|r| r.relation_type == RelationType::HasMany)
.map(|r| {
serde_json::json!({
"target": r.target_entity,
"target_snake": r.target_entity.to_snake_case(),
"target_camel": r.target_entity.to_lower_camel_case(),
})
})
.collect();
ctx.insert("has_many_relations", &has_many_relations);
let has_any_validations = entity.fields.iter().any(|f| !f.validations.is_empty());
ctx.insert("has_any_validations", &has_any_validations);
ctx
}