use crate::{relation, utils};
use anyhow::Result;
use heck::ToPascalCase;
use std::fs;
use std::path::Path;
pub fn regenerate(project_root: &Path) -> Result<()> {
let mut sections = Vec::new();
sections.push(generate_header(project_root));
sections.push(generate_stack_section());
sections.push(generate_cli_reference_section());
let entities = scan_entities(project_root)?;
if !entities.is_empty() {
sections.push(generate_entity_section(&entities));
sections.push(generate_relations_section(project_root, &entities)?);
sections.push(generate_api_section(&entities));
}
if has_auth(project_root) {
sections.push(generate_auth_section());
}
if has_admin(project_root) {
sections.push(generate_admin_section(&entities));
}
let addons = scan_addons(project_root);
if !addons.is_empty() {
sections.push(generate_addons_section(&addons));
}
sections.push(generate_conventions_section());
let generated = sections.join("\n\n");
let claude_path = project_root.join("CLAUDE.md");
let custom_marker = "\n## Custom Notes";
let content = if claude_path.exists() {
let existing = fs::read_to_string(&claude_path)?;
if let Some(pos) = existing.rfind(custom_marker) {
let custom_block = &existing[pos..];
if let Some(gen_pos) = generated.rfind(custom_marker) {
format!("{}\n{}", &generated[..gen_pos].trim_end(), custom_block)
} else {
format!("{}\n{}", generated.trim_end(), custom_block)
}
} else {
generated
}
} else {
generated
};
fs::write(&claude_path, &content)?;
println!(" Updated CLAUDE.md (AI context)");
Ok(())
}
fn generate_header(project_root: &Path) -> String {
let project_name = project_root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "project".to_string());
format!(
r#"# {} — Project Guide
> Auto-generated by Romance CLI. Do not edit the sections above `## Custom Notes` — they are regenerated when entities or features change.
## Project Overview
Full-stack application built with **Romance CLI** scaffold."#,
project_name
)
}
fn generate_stack_section() -> String {
r#"## Stack
- **Backend:** Rust (Axum, SeaORM, PostgreSQL)
- **Frontend:** React 19, TypeScript, TanStack Query, shadcn/ui, Vite
- **API pattern:** REST with JSON envelope `{success, data?, meta?, error?}`
- **Auth:** JWT (jsonwebtoken + argon2) — Bearer token in Authorization header
- **DB:** PostgreSQL with SeaORM migrations"#
.to_string()
}
fn generate_cli_reference_section() -> String {
r#"## Romance CLI Reference
### Commands
| Command | Description | Example |
|---------|-------------|---------|
| `romance new <name>` | Create new full-stack project | `romance new my-app` |
| `romance generate entity <name> [fields...]` | Generate CRUD entity | `romance generate entity Product title:string price:decimal` |
| `romance generate auth` | Add JWT authentication | `romance generate auth` |
| `romance generate admin` | Add admin panel (requires auth) | `romance generate admin` |
| `romance add <addon>` | Install addon | `romance add validation` |
| `romance dev` | Run backend + frontend dev servers | `romance dev` |
| `romance db migrate` | Run pending migrations | `romance db migrate` |
| `romance db rollback` | Rollback last migration | `romance db rollback` |
| `romance db seed` | Run seed data | `romance db seed` |
| `romance test` | Run tests with temp database | `romance test` |
| `romance check` | Run cargo check + tsc | `romance check` |
| `romance update` | Update scaffold to latest templates | `romance update` |
### Field Syntax
- `name:type` — required field: `title:string`
- `name:type?` — optional field: `description:text?`
- `name:type->Entity` — foreign key: `author_id:uuid->User`
- `name:has_many->Entity` — has-many relation: `posts:has_many->Post`
- `name:m2m->Entity` — many-to-many: `tags:m2m->Tag`
### Field Types
| Type | Aliases | Rust | TypeScript |
|------|---------|------|------------|
| `string` | `str` | `String` | `string` |
| `text` | — | `String` | `string` |
| `bool` | `boolean` | `bool` | `boolean` |
| `int` | `i32`, `int32`, `integer` | `i32` | `number` |
| `bigint` | `i64`, `int64` | `i64` | `number` |
| `float` | `f64`, `float64`, `double` | `f64` | `number` |
| `decimal` | `money` | `Decimal` | `number` |
| `uuid` | — | `Uuid` | `string` |
| `datetime` | `timestamp` | `DateTimeWithTimeZone` | `string` |
| `date` | — | `Date` | `string` |
| `json` | `jsonb` | `Json` | `unknown` |
| `file` | — | `String` (URL) | `string` |
| `image` | — | `String` (URL) | `string` |
### Validation Syntax
Append `[rules]` to field type: `name:string[min=3,max=100]`, `email:string[email]`, `title:string[searchable]`
Available rules: `min=N`, `max=N`, `email`, `url`, `searchable`
### Available Addons
| Addon | Command | What it adds |
|-------|---------|-------------|
| validation | `romance add validation` | Backend `validator` + frontend Zod schemas |
| security | `romance add security` | Rate limiting + security headers |
| observability | `romance add observability` | Structured tracing + request ID |
| storage | `romance add storage` | File/image upload endpoints |
| soft-delete | `romance add soft-delete` | Soft delete with restore |
| audit-log | `romance add audit-log` | CUD operation logging (requires auth) |
| search | `romance add search` | PostgreSQL full-text search |
| oauth | `romance add oauth <provider>` | Social auth (google, github, discord) |
| dashboard | `romance add dashboard` | Dev dashboard at /dev |"#
.to_string()
}
fn scan_entities(project_root: &Path) -> Result<Vec<EntityInfo>> {
let entities_dir = project_root.join("backend/src/entities");
if !entities_dir.exists() {
return Ok(Vec::new());
}
let entity_names = relation::discover_entities(project_root)?;
let mut entities = Vec::new();
for name in &entity_names {
let model_path = entities_dir.join(format!("{}.rs", name));
if !model_path.exists() {
continue;
}
let content = fs::read_to_string(&model_path)?;
if !content.contains("ROMANCE:CUSTOM") && !content.contains("ROMANCE:RELATIONS") {
continue;
}
let fields = parse_model_fields(&content);
let pascal_name = name.to_pascal_case();
entities.push(EntityInfo {
name: pascal_name,
snake_name: name.clone(),
fields,
});
}
Ok(entities)
}
fn parse_model_fields(content: &str) -> Vec<FieldInfo> {
let mut fields = Vec::new();
let mut in_model = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.contains("pub struct Model") {
in_model = true;
continue;
}
if in_model && trimmed == "}" {
break;
}
if in_model && trimmed.starts_with("pub ") {
let rest = trimmed.strip_prefix("pub ").unwrap_or(trimmed);
if let Some((name, type_part)) = rest.split_once(':') {
let name = name.trim().to_string();
let rust_type = type_part.trim().trim_end_matches(',').to_string();
if name == "id" || name == "created_at" || name == "updated_at" {
continue;
}
let optional = rust_type.starts_with("Option<");
let clean_type = if optional {
rust_type
.strip_prefix("Option<")
.and_then(|s| s.strip_suffix('>'))
.unwrap_or(&rust_type)
.to_string()
} else {
rust_type.clone()
};
let is_fk = name.ends_with("_id");
fields.push(FieldInfo {
name,
rust_type: clean_type,
optional,
is_fk,
});
}
}
}
fields
}
fn generate_entity_section(entities: &[EntityInfo]) -> String {
let mut s = String::from("## Database Schema\n\n");
for entity in entities {
s.push_str(&format!("### {} (`{}` table)\n\n", entity.name, utils::pluralize(&entity.snake_name)));
s.push_str("| Column | Type | Nullable | Notes |\n");
s.push_str("|--------|------|----------|-------|\n");
s.push_str("| `id` | UUID | no | Primary key |\n");
for field in &entity.fields {
let nullable = if field.optional { "yes" } else { "no" };
let notes = if field.is_fk {
let target = field.name.strip_suffix("_id").unwrap_or(&field.name);
format!("FK → `{}.id`", utils::pluralize(target))
} else {
String::new()
};
s.push_str(&format!(
"| `{}` | {} | {} | {} |\n",
field.name, field.rust_type, nullable, notes
));
}
s.push_str("| `created_at` | DateTimeWithTimeZone | no | |\n");
s.push_str("| `updated_at` | DateTimeWithTimeZone | no | |\n");
s.push('\n');
}
s
}
fn generate_relations_section(project_root: &Path, entities: &[EntityInfo]) -> Result<String> {
let mut s = String::from("## Relations\n\n");
let mut has_any = false;
let entities_dir = project_root.join("backend/src/entities");
for entity in entities {
let model_path = entities_dir.join(format!("{}.rs", entity.snake_name));
if !model_path.exists() {
continue;
}
let content = fs::read_to_string(&model_path)?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("belongs_to = \"") {
if let Some(target) = trimmed
.strip_prefix("belongs_to = \"super::")
.and_then(|s| s.strip_suffix("::Entity\","))
.or_else(|| {
trimmed
.strip_prefix("belongs_to = \"super::")
.and_then(|s| s.strip_suffix("::Entity\""))
})
{
s.push_str(&format!(
"- **{} → {}** (belongs_to)\n",
entity.name,
target.to_pascal_case()
));
has_any = true;
}
}
}
{
let mut in_related_impl = false;
let mut related_target: Option<String> = None;
let mut has_via = false;
let mut brace_depth = 0;
for line in content.lines() {
let trimmed = line.trim();
if !in_related_impl
&& trimmed.starts_with("impl Related<super::")
&& trimmed.contains("::Entity>")
{
related_target = trimmed
.strip_prefix("impl Related<super::")
.and_then(|s| s.split("::Entity>").next())
.map(|s| s.to_string());
in_related_impl = true;
has_via = false;
brace_depth = 0;
}
if in_related_impl {
brace_depth += trimmed.matches('{').count();
brace_depth -= trimmed.matches('}').count();
if trimmed.contains("fn via()") {
has_via = true;
}
if brace_depth == 0 {
if has_via {
if let Some(ref target) = related_target {
let a = &entity.name;
let b = target.to_pascal_case();
if a.as_str() <= b.as_str() {
s.push_str(&format!(
"- **{} ↔ {}** (many-to-many)\n",
a, b
));
has_any = true;
}
}
}
in_related_impl = false;
related_target = None;
}
}
}
}
}
let junction_entities = relation::discover_entities(project_root)?;
for name in &junction_entities {
if name.contains('_') {
let model_path = entities_dir.join(format!("{}.rs", name));
if model_path.exists() {
let content = fs::read_to_string(&model_path)?;
if !content.contains("ROMANCE:CUSTOM") {
s.push_str(&format!("- Junction table: `{}`\n", name));
has_any = true;
}
}
}
}
if !has_any {
s.push_str("No relations defined yet.\n");
}
Ok(s)
}
fn generate_api_section(entities: &[EntityInfo]) -> String {
let mut s = String::from("## API Endpoints\n\n");
s.push_str("All responses use the envelope: `{success: bool, data?: T, meta?: {page?}, error?: {code, message}}`\n\n");
for entity in entities {
let plural = utils::pluralize(&entity.snake_name);
s.push_str(&format!("### {} CRUD\n\n", entity.name));
s.push_str(&format!("| Method | Path | Description |\n"));
s.push_str("|--------|------|-------------|\n");
s.push_str(&format!(
"| GET | `/api/{}?page=1&per_page=20` | List (paginated) |\n",
plural
));
s.push_str(&format!(
"| POST | `/api/{}` | Create |\n",
plural
));
s.push_str(&format!(
"| GET | `/api/{}/:id` | Get by ID |\n",
plural
));
s.push_str(&format!(
"| PUT | `/api/{}/:id` | Update |\n",
plural
));
s.push_str(&format!(
"| DELETE | `/api/{}/:id` | Delete |\n",
plural
));
for field in &entity.fields {
if field.is_fk {
let _target = field.name.strip_suffix("_id").unwrap_or(&field.name);
}
}
s.push('\n');
}
s
}
fn has_auth(project_root: &Path) -> bool {
project_root.join("backend/src/auth.rs").exists()
}
fn has_admin(project_root: &Path) -> bool {
project_root.join("backend/src/handlers/admin.rs").exists()
}
fn generate_auth_section() -> String {
r#"## Authentication
JWT-based auth with argon2 password hashing.
| Method | Path | Description | Auth required |
|--------|------|-------------|---------------|
| POST | `/api/auth/register` | Register new user | No |
| POST | `/api/auth/login` | Login, returns JWT | No |
| GET | `/api/auth/me` | Get current user | Yes |
**Token usage:** Include `Authorization: Bearer <token>` header. Token is stored in `localStorage` under key `auth_token`.
**User roles:** `user` (default), `admin`
**Axum extractors:**
- `AuthUser` — requires valid JWT, any role
- `AdminUser` — requires valid JWT with `admin` role"#
.to_string()
}
fn generate_admin_section(entities: &[EntityInfo]) -> String {
let mut s = String::from("## Admin Panel\n\n");
s.push_str("Admin routes require `admin` role JWT. All entity CRUD is mirrored under `/api/admin/`.\n\n");
s.push_str("| Method | Path | Description |\n");
s.push_str("|--------|------|-------------|\n");
s.push_str("| GET | `/api/admin/dashboard` | Dashboard stats (entity counts) |\n");
for entity in entities {
s.push_str(&format!(
"| GET/POST/PUT/DELETE | `/api/admin/{}/*` | Admin CRUD for {} |\n",
utils::pluralize(&entity.snake_name), entity.name
));
}
s.push_str("\n**Frontend:** `/admin` route with sidebar navigation, dashboard, and entity management pages.\n");
s
}
fn generate_conventions_section() -> String {
r#"## Conventions
- **Entity files:** `backend/src/entities/{snake}.rs`, `handlers/{snake}.rs`, `routes/{snake}.rs`
- **Frontend features:** `frontend/src/features/{camelCase}/` with types.ts, api.ts, hooks.ts, List/Form/Detail components
- **Migrations:** `backend/migration/src/m{timestamp}_create_{snake}_table.rs`
- **Markers:** `// === ROMANCE:TYPE ===` comments are insertion points — do not remove them
- **Custom code:** Everything below `// === ROMANCE:CUSTOM ===` is preserved on re-generation
- **API envelope:** All responses wrapped in `{success, data?, meta?, error?}`
- **Pagination:** `?page=1&per_page=20` query params, response includes `meta.page` with `total`, `total_pages`, `has_next`, `has_prev`
## Custom Notes
<!-- Add your project-specific notes below this line. This section is preserved on re-generation. -->"#
.to_string()
}
fn scan_addons(project_root: &Path) -> Vec<String> {
let mut addons = Vec::new();
if project_root.join("backend/src/validation.rs").exists() {
addons.push("validation".to_string());
}
if project_root.join("backend/src/middleware/security_headers.rs").exists() {
addons.push("security".to_string());
}
if project_root.join("backend/src/middleware/request_id.rs").exists() {
addons.push("observability".to_string());
}
if project_root.join("backend/src/storage.rs").exists() {
addons.push("storage".to_string());
}
if project_root.join("backend/src/soft_delete.rs").exists() {
addons.push("soft-delete".to_string());
}
if project_root.join("backend/src/audit.rs").exists() {
addons.push("audit-log".to_string());
}
if project_root.join("backend/src/oauth.rs").exists() {
addons.push("oauth".to_string());
}
if project_root.join("backend/src/search.rs").exists() {
addons.push("search".to_string());
}
if project_root.join("frontend/src/features/dev/DevDashboard.tsx").exists() {
addons.push("dashboard".to_string());
}
if project_root.join("backend/src/tenant.rs").exists() {
addons.push("multitenancy".to_string());
}
addons
}
fn generate_addons_section(addons: &[String]) -> String {
let mut s = String::from("## Installed Addons\n\n");
for addon in addons {
match addon.as_str() {
"validation" => {
s.push_str("### Validation\n");
s.push_str("- Backend: `validator` crate with `#[validate]` derives and `ValidatedJson<T>` extractor\n");
s.push_str("- Frontend: Zod schemas generated alongside types\n");
s.push_str("- Field syntax: `name:string[min=3,max=100]`, `email:string[email]`\n\n");
}
"security" => {
s.push_str("### Security Middleware\n");
s.push_str("- Security headers (X-Content-Type-Options, X-Frame-Options, HSTS, CSP)\n");
s.push_str("- Rate limiting (configurable in romance.toml)\n");
s.push_str("- CORS (configurable origins)\n\n");
}
"observability" => {
s.push_str("### Observability\n");
s.push_str("- Structured JSON logging with `tracing`\n");
s.push_str("- Request ID propagation (X-Request-Id header)\n");
s.push_str("- Configure via RUST_LOG env var\n\n");
}
"storage" => {
s.push_str("### File Storage\n");
s.push_str("- Upload endpoint: `POST /api/upload`, `POST /api/upload/image`\n");
s.push_str("- Local storage backend (S3 supported via config)\n");
s.push_str("- Field types: `file`, `image` (stored as URL string in DB)\n\n");
}
"soft-delete" => {
s.push_str("### Soft Delete\n");
s.push_str("- Entities have `deleted_at` column (NULL = not deleted)\n");
s.push_str("- DELETE sets `deleted_at` (soft delete)\n");
s.push_str("- `POST /:id/restore` to restore\n");
s.push_str("- `DELETE /:id/permanent` for hard delete\n\n");
}
"audit-log" => {
s.push_str("### Audit Log\n");
s.push_str("- All CUD operations logged to `audit_entries` table\n");
s.push_str("- Fields: entity_type, entity_id, action, user_id, changes (JSONB)\n");
s.push_str("- Admin viewer at `/admin/audit-log`\n\n");
}
"oauth" => {
s.push_str("### OAuth\n");
s.push_str("- Social auth flow: `GET /api/auth/oauth/{provider}` → redirect → callback\n");
s.push_str("- Users table has `oauth_provider` and `oauth_id` columns\n");
s.push_str("- Configure client ID/secret in `.env`\n\n");
}
"search" => {
s.push_str("### Full-Text Search\n");
s.push_str("- PostgreSQL-based search with ILIKE/tsvector\n");
s.push_str("- Field syntax: `title:string[searchable]`\n");
s.push_str("- Search endpoint: `GET /api/{entities}/search?q=term`\n\n");
}
"dashboard" => {
s.push_str("### Dev Dashboard\n");
s.push_str("- Developer dashboard at `/dev`\n");
s.push_str("- Entity counts, API endpoint reference\n\n");
}
"multitenancy" => {
s.push_str("### Multitenancy\n");
s.push_str("- Row-level tenant isolation: all entities get `tenant_id` column\n");
s.push_str("- `TenantGuard` extractor reads tenant_id from JWT claims\n");
s.push_str("- All CRUD queries automatically filter by tenant_id\n");
s.push_str("- Tenant admin API: `POST/GET /api/tenants` (admin-only)\n");
s.push_str("- Users belong to a tenant (`users.tenant_id` FK)\n\n");
}
_ => {}
}
}
s
}
struct EntityInfo {
name: String,
snake_name: String,
fields: Vec<FieldInfo>,
}
struct FieldInfo {
name: String,
rust_type: String,
optional: bool,
is_fk: bool,
}