1use console::style;
4use sea_orm::{ConnectionTrait, Database, DbBackend, Statement};
5use std::env;
6use std::fs;
7use std::path::Path;
8use std::process::Command;
9
10use crate::templates;
11use crate::templates::{ColumnInfo, TableInfo};
12
13pub fn run(skip_migrations: bool, regenerate_models: bool) {
14 if !Path::new("src/models").exists() && !Path::new("src/migrations").exists() {
16 eprintln!(
17 "{} Not in a Ferro project directory",
18 style("Error:").red().bold()
19 );
20 std::process::exit(1);
21 }
22
23 if !skip_migrations {
25 run_migrations();
26 }
27
28 generate_entities(regenerate_models);
30}
31
32fn run_migrations() {
33 if !Path::new("src/migrations").exists() {
34 println!(
35 "{} No migrations directory found, skipping migrations",
36 style("Info:").yellow()
37 );
38 return;
39 }
40
41 if !Path::new("src/bin/migrate.rs").exists() {
42 println!(
43 "{} Migration binary not found, skipping migrations",
44 style("Info:").yellow()
45 );
46 return;
47 }
48
49 println!("{} Running pending migrations...", style("→").cyan());
50
51 let status = Command::new("cargo")
52 .args(["run", "--bin", "migrate", "--", "up"])
53 .status()
54 .expect("Failed to execute cargo command");
55
56 if !status.success() {
57 eprintln!("{} Migration failed", style("Error:").red().bold());
58 std::process::exit(1);
59 }
60 println!("{} Migrations complete", style("✓").green());
61}
62
63fn generate_entities(regenerate_models: bool) {
64 dotenvy::dotenv().ok();
66
67 let database_url = match env::var("DATABASE_URL") {
68 Ok(url) => url,
69 Err(_) => {
70 eprintln!(
71 "{} DATABASE_URL not set in .env",
72 style("Error:").red().bold()
73 );
74 std::process::exit(1);
75 }
76 };
77
78 println!("{} Discovering database schema...", style("→").cyan());
79
80 let rt = tokio::runtime::Runtime::new().unwrap();
82 rt.block_on(async {
83 discover_and_generate(&database_url, regenerate_models).await;
84 });
85}
86
87async fn discover_and_generate(database_url: &str, regenerate_models: bool) {
88 let is_sqlite = database_url.starts_with("sqlite");
89
90 let db = match Database::connect(database_url).await {
92 Ok(db) => db,
93 Err(e) => {
94 eprintln!(
95 "{} Failed to connect to database: {}",
96 style("Error:").red().bold(),
97 e
98 );
99 std::process::exit(1);
100 }
101 };
102
103 let tables = if is_sqlite {
105 discover_sqlite_tables(&db).await
106 } else {
107 discover_postgres_tables(&db).await
108 };
109
110 let tables: Vec<_> = tables
112 .into_iter()
113 .filter(|t| t.name != "seaql_migrations" && !t.name.starts_with("_"))
114 .collect();
115
116 if tables.is_empty() {
117 println!("{} No tables found in database", style("Info:").yellow());
118 return;
119 }
120
121 println!(
122 "{} Found {} table(s): {}",
123 style("✓").green(),
124 tables.len(),
125 tables
126 .iter()
127 .map(|t| t.name.as_str())
128 .collect::<Vec<_>>()
129 .join(", ")
130 );
131
132 let models_dir = Path::new("src/models");
134 if !models_dir.exists() {
135 fs::create_dir_all(models_dir).expect("Failed to create models directory");
136 println!("{} Created src/models directory", style("✓").green());
137 }
138
139 let entities_dir = models_dir.join("entities");
141 if !entities_dir.exists() {
142 fs::create_dir_all(&entities_dir).expect("Failed to create entities directory");
143 println!(
144 "{} Created src/models/entities directory",
145 style("✓").green()
146 );
147 }
148
149 for table in &tables {
151 generate_entity_file(table, &entities_dir);
152 if regenerate_models {
153 generate_user_file(table, models_dir);
154 } else {
155 generate_user_file_if_not_exists(table, models_dir);
156 }
157 }
158
159 update_entities_mod(&tables, &entities_dir);
161 update_models_mod(&tables, models_dir);
162
163 println!();
164 println!(
165 "{} Entity files generated successfully!",
166 style("✓").green().bold()
167 );
168 println!();
169 println!("Generated files:");
170 for table in &tables {
171 println!(
172 " {} src/models/entities/{}.rs (auto-generated)",
173 style("•").dim(),
174 table.name
175 );
176 println!(
177 " {} src/models/{}.rs (user customizations)",
178 style("•").dim(),
179 table.name
180 );
181 }
182}
183
184async fn discover_sqlite_tables(db: &sea_orm::DatabaseConnection) -> Vec<TableInfo> {
185 let mut tables = Vec::new();
186
187 let table_names: Vec<String> = db
189 .query_all(Statement::from_string(
190 DbBackend::Sqlite,
191 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
192 ))
193 .await
194 .unwrap_or_default()
195 .iter()
196 .filter_map(|row| row.try_get_by_index::<String>(0).ok())
197 .collect();
198
199 for table_name in table_names {
200 let columns = discover_sqlite_columns(db, &table_name).await;
201 tables.push(TableInfo {
202 name: table_name,
203 columns,
204 });
205 }
206
207 tables
208}
209
210async fn discover_sqlite_columns(
211 db: &sea_orm::DatabaseConnection,
212 table_name: &str,
213) -> Vec<ColumnInfo> {
214 let query = format!("PRAGMA table_info({table_name})");
215 let rows = db
216 .query_all(Statement::from_string(DbBackend::Sqlite, query))
217 .await
218 .unwrap_or_default();
219
220 rows.iter()
221 .filter_map(|row| {
222 let name: String = row.try_get_by_index(1).ok()?;
223 let col_type: String = row.try_get_by_index(2).ok()?;
224 let notnull: i32 = row.try_get_by_index(3).ok()?;
225 let pk: i32 = row.try_get_by_index(5).ok()?;
226
227 Some(ColumnInfo {
228 name,
229 col_type,
230 is_nullable: notnull == 0,
231 is_primary_key: pk > 0,
232 })
233 })
234 .collect()
235}
236
237async fn discover_postgres_tables(db: &sea_orm::DatabaseConnection) -> Vec<TableInfo> {
238 let mut tables = Vec::new();
239
240 let table_names: Vec<String> = db
242 .query_all(Statement::from_string(
243 DbBackend::Postgres,
244 "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'",
245 ))
246 .await
247 .unwrap_or_default()
248 .iter()
249 .filter_map(|row| row.try_get_by_index::<String>(0).ok())
250 .collect();
251
252 for table_name in table_names {
253 let columns = discover_postgres_columns(db, &table_name).await;
254 tables.push(TableInfo {
255 name: table_name,
256 columns,
257 });
258 }
259
260 tables
261}
262
263async fn discover_postgres_columns(
264 db: &sea_orm::DatabaseConnection,
265 table_name: &str,
266) -> Vec<ColumnInfo> {
267 let query = format!(
268 r#"
269 SELECT
270 c.column_name,
271 c.data_type,
272 c.is_nullable,
273 CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_pk
274 FROM information_schema.columns c
275 LEFT JOIN (
276 SELECT ku.column_name
277 FROM information_schema.table_constraints tc
278 JOIN information_schema.key_column_usage ku
279 ON tc.constraint_name = ku.constraint_name
280 WHERE tc.constraint_type = 'PRIMARY KEY'
281 AND tc.table_name = '{table_name}'
282 ) pk ON c.column_name = pk.column_name
283 WHERE c.table_name = '{table_name}'
284 ORDER BY c.ordinal_position
285 "#
286 );
287
288 let rows = db
289 .query_all(Statement::from_string(DbBackend::Postgres, query))
290 .await
291 .unwrap_or_default();
292
293 rows.iter()
294 .filter_map(|row| {
295 let name: String = row.try_get_by_index(0).ok()?;
296 let col_type: String = row.try_get_by_index(1).ok()?;
297 let is_nullable_str: String = row.try_get_by_index(2).ok()?;
298 let is_pk: bool = row.try_get_by_index(3).ok()?;
299
300 Some(ColumnInfo {
301 name,
302 col_type,
303 is_nullable: is_nullable_str == "YES",
304 is_primary_key: is_pk,
305 })
306 })
307 .collect()
308}
309
310fn generate_entity_file(table: &TableInfo, entities_dir: &Path) {
311 let entity_file = entities_dir.join(format!("{}.rs", table.name));
312 let content = templates::entity_template(&table.name, &table.columns);
313
314 fs::write(&entity_file, content).expect("Failed to write entity file");
315 println!(
316 "{} Generated src/models/entities/{}.rs",
317 style("✓").green(),
318 table.name
319 );
320}
321
322fn generate_user_file_if_not_exists(table: &TableInfo, models_dir: &Path) {
323 let user_file = models_dir.join(format!("{}.rs", table.name));
324
325 if user_file.exists() {
327 println!(
328 "{} Skipped src/models/{}.rs (already exists)",
329 style("•").dim(),
330 table.name
331 );
332 return;
333 }
334
335 let struct_name = to_pascal_case(&singularize(&table.name));
336 let content = templates::user_model_template(&table.name, &struct_name, &table.columns);
337
338 fs::write(&user_file, content).expect("Failed to write user model file");
339 println!(
340 "{} Created src/models/{}.rs",
341 style("✓").green(),
342 table.name
343 );
344}
345
346fn generate_user_file(table: &TableInfo, models_dir: &Path) {
347 let user_file = models_dir.join(format!("{}.rs", table.name));
348 let struct_name = to_pascal_case(&singularize(&table.name));
349 let content = templates::user_model_template(&table.name, &struct_name, &table.columns);
350
351 fs::write(&user_file, content).expect("Failed to write user model file");
352 println!(
353 "{} Regenerated src/models/{}.rs",
354 style("✓").green(),
355 table.name
356 );
357}
358
359fn update_entities_mod(tables: &[TableInfo], entities_dir: &Path) {
360 let mod_file = entities_dir.join("mod.rs");
361 let content = templates::entities_mod_template(tables);
362
363 fs::write(&mod_file, content).expect("Failed to write entities/mod.rs");
364 println!("{} Updated src/models/entities/mod.rs", style("✓").green());
365}
366
367fn update_models_mod(tables: &[TableInfo], models_dir: &Path) {
368 let mod_file = models_dir.join("mod.rs");
369
370 let existing_content = if mod_file.exists() {
372 fs::read_to_string(&mod_file).unwrap_or_default()
373 } else {
374 "//! Application models\n\n".to_string()
375 };
376
377 let mut lines: Vec<String> = existing_content.lines().map(String::from).collect();
378
379 let has_entities_mod = lines.iter().any(|l| {
381 let trimmed = l.trim();
382 trimmed == "pub mod entities;" || trimmed == "mod entities;"
383 });
384
385 let mut insert_idx = 0;
387 for (i, line) in lines.iter().enumerate() {
388 if line.starts_with("//!") || line.is_empty() {
389 insert_idx = i + 1;
390 } else {
391 break;
392 }
393 }
394
395 if !has_entities_mod {
397 lines.insert(insert_idx, "pub mod entities;".to_string());
398 insert_idx += 1;
399 }
400
401 for table in tables {
403 let mod_decl = format!("pub mod {};", table.name);
404 let alt_mod_decl = format!("mod {};", table.name);
405
406 if !lines
407 .iter()
408 .any(|l| l.trim() == mod_decl || l.trim() == alt_mod_decl)
409 {
410 let mut last_mod_idx = insert_idx;
412 for (i, line) in lines.iter().enumerate() {
413 if line.trim().starts_with("pub mod ") || line.trim().starts_with("mod ") {
414 last_mod_idx = i + 1;
415 }
416 }
417 lines.insert(last_mod_idx, mod_decl);
418 }
419 }
420
421 let content = lines.join("\n");
422 fs::write(&mod_file, content).expect("Failed to write models/mod.rs");
423 println!("{} Updated src/models/mod.rs", style("✓").green());
424}
425
426fn to_pascal_case(s: &str) -> String {
427 let mut result = String::new();
428 let mut capitalize_next = true;
429
430 for c in s.chars() {
431 if c == '_' || c == '-' || c == ' ' {
432 capitalize_next = true;
433 } else if capitalize_next {
434 result.push(c.to_uppercase().next().unwrap());
435 capitalize_next = false;
436 } else {
437 result.push(c);
438 }
439 }
440 result
441}
442
443fn singularize(word: &str) -> String {
444 if let Some(stem) = word.strip_suffix("ies") {
446 format!("{stem}y")
447 } else if let Some(stem) = word.strip_suffix("es") {
448 if word.ends_with("ses") || word.ends_with("xes") {
449 word.to_string()
450 } else {
451 stem.to_string()
452 }
453 } else if let Some(stem) = word.strip_suffix('s') {
454 if word.ends_with("ss") || word.ends_with("us") {
455 word.to_string()
456 } else {
457 stem.to_string()
458 }
459 } else {
460 word.to_string()
461 }
462}