1use super::{Tool, Result, ToolError, common_options, parse_output_format, OutputFormat};
2use clap::{Arg, ArgMatches, Command};
3use colored::*;
4use std::path::Path;
5use std::fs;
6use std::collections::HashMap;
7use syn::{parse_file, Item, ItemStruct, Fields, Field, Type, Path as SynPath};
8use quote::ToTokens;
9use serde::{Deserialize, Serialize};
10#[derive(Debug, Clone)]
11pub struct MigrationGenTool;
12#[derive(Debug, Clone, Serialize, Deserialize)]
13struct StructDefinition {
14 name: String,
15 fields: Vec<FieldDefinition>,
16 file_path: String,
17 line_number: usize,
18}
19#[derive(Debug, Clone, Serialize, Deserialize)]
20struct FieldDefinition {
21 name: String,
22 ty: String,
23 is_optional: bool,
24 attributes: Vec<String>,
25 comment: Option<String>,
26}
27#[derive(Debug, Clone, Serialize, Deserialize)]
28struct MigrationPlan {
29 up_sql: String,
30 down_sql: String,
31 description: String,
32 timestamp: String,
33 table_name: String,
34 changes: Vec<String>,
35}
36#[derive(Debug, Clone, Serialize, Deserialize)]
37struct MigrationReport {
38 migrations_generated: usize,
39 tables_created: usize,
40 files_created: Vec<String>,
41 total_changes: usize,
42 migration_plan: Vec<MigrationPlan>,
43}
44impl MigrationGenTool {
45 pub fn new() -> Self {
46 Self
47 }
48 fn parse_rust_structs(&self, file_path: &str) -> Result<Vec<StructDefinition>> {
49 let content = fs::read_to_string(file_path)?;
50 let syntax = parse_file(&content)?;
51 let mut structs = Vec::new();
52 for (i, item) in syntax.items.iter().enumerate() {
53 if let Item::Struct(struct_def) = item {
54 let struct_def = self.parse_struct_definition(struct_def, file_path, i)?;
55 structs.push(struct_def);
56 }
57 }
58 Ok(structs)
59 }
60 fn parse_struct_definition(
61 &self,
62 struct_def: &ItemStruct,
63 file_path: &str,
64 line_number: usize,
65 ) -> Result<StructDefinition> {
66 let name = struct_def.ident.to_string();
67 let mut fields = Vec::new();
68 if let Fields::Named(named_fields) = &struct_def.fields {
69 for field in &named_fields.named {
70 let field_def = self.parse_field_definition(field)?;
71 fields.push(field_def);
72 }
73 }
74 Ok(StructDefinition {
75 name,
76 fields,
77 file_path: file_path.to_string(),
78 line_number,
79 })
80 }
81 fn parse_field_definition(&self, field: &Field) -> Result<FieldDefinition> {
82 let name = field
83 .ident
84 .as_ref()
85 .ok_or_else(|| ToolError::ExecutionFailed("Field without name".to_string()))?
86 .to_string();
87 let ty = self.type_to_sql_type(&field.ty)?;
88 let is_optional = self.is_optional_type(&field.ty);
89 let mut attributes = Vec::new();
90 for attr in &field.attrs {
91 let attr_str = attr.to_token_stream().to_string();
92 attributes.push(attr_str);
93 }
94 let comment = field
95 .attrs
96 .iter()
97 .find(|attr| attr.path().segments.last().unwrap().ident == "doc")
98 .and_then(|attr| {
99 if let Ok(syn::Meta::NameValue(meta)) = attr.parse_args::<syn::Meta>() {
100 Some("doc_comment_placeholder".to_string())
101 } else {
102 None
103 }
104 });
105 Ok(FieldDefinition {
106 name,
107 ty,
108 is_optional,
109 attributes,
110 comment,
111 })
112 }
113 fn type_to_sql_type(&self, ty: &Type) -> Result<String> {
114 match ty {
115 Type::Path(type_path) => {
116 if let Some(segment) = type_path.path.segments.last() {
117 match segment.ident.to_string().as_str() {
118 "String" => Ok("VARCHAR(255)".to_string()),
119 "i32" | "i64" | "isize" => Ok("INTEGER".to_string()),
120 "u32" | "u64" | "usize" => Ok("BIGINT UNSIGNED".to_string()),
121 "f32" | "f64" => Ok("DECIMAL(10,2)".to_string()),
122 "bool" => Ok("BOOLEAN".to_string()),
123 "NaiveDateTime" | "DateTime" => Ok("TIMESTAMP".to_string()),
124 "NaiveDate" => Ok("DATE".to_string()),
125 "Uuid" => Ok("UUID".to_string()),
126 "Vec" => Ok("JSON".to_string()),
127 "HashMap" | "BTreeMap" => Ok("JSON".to_string()),
128 "Option" => {
129 if let syn::PathArguments::AngleBracketed(args) = &segment
130 .arguments
131 {
132 if let Some(syn::GenericArgument::Type(inner_ty)) = args
133 .args
134 .first()
135 {
136 let inner_sql = self.type_to_sql_type(inner_ty)?;
137 Ok(inner_sql)
138 } else {
139 Ok("VARCHAR(255)".to_string())
140 }
141 } else {
142 Ok("VARCHAR(255)".to_string())
143 }
144 }
145 _ => Ok("VARCHAR(255)".to_string()),
146 }
147 } else {
148 Ok("VARCHAR(255)".to_string())
149 }
150 }
151 _ => Ok("VARCHAR(255)".to_string()),
152 }
153 }
154 fn is_optional_type(&self, ty: &Type) -> bool {
155 if let Type::Path(type_path) = ty {
156 if let Some(segment) = type_path.path.segments.last() {
157 segment.ident == "Option"
158 } else {
159 false
160 }
161 } else {
162 false
163 }
164 }
165 fn generate_create_table_sql(&self, struct_def: &StructDefinition) -> String {
166 let table_name = self.struct_name_to_table_name(&struct_def.name);
167 let mut sql = format!("CREATE TABLE {} (\n", table_name);
168 sql.push_str(" id SERIAL PRIMARY KEY,\n");
169 for (i, field) in struct_def.fields.iter().enumerate() {
170 let column_name = self.field_name_to_column_name(&field.name);
171 let sql_type = &field.ty;
172 let nullable = if field.is_optional { "" } else { " NOT NULL" };
173 let comma = if i < struct_def.fields.len() - 1 { "," } else { "" };
174 sql.push_str(&format!(" {} {}{}", column_name, sql_type, nullable));
175 if let Some(comment) = &field.comment {
176 sql.push_str(&format!(" -- {}", comment));
177 }
178 sql.push_str(&format!("{}\n", comma));
179 }
180 sql.push_str(");\n");
181 sql
182 }
183 fn generate_migration_sql(&self, struct_def: &StructDefinition) -> MigrationPlan {
184 let table_name = self.struct_name_to_table_name(&struct_def.name);
185 let up_sql = self.generate_create_table_sql(struct_def);
186 let down_sql = format!("DROP TABLE IF EXISTS {};\n", table_name);
187 let description = format!(
188 "Create {} table for {} struct", table_name, struct_def.name
189 );
190 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
191 let changes = vec![
192 format!("Create table with {} columns", struct_def.fields.len())
193 ];
194 MigrationPlan {
195 up_sql,
196 down_sql,
197 description,
198 timestamp,
199 table_name,
200 changes,
201 }
202 }
203 fn struct_name_to_table_name(&self, struct_name: &str) -> String {
204 let mut table_name = String::new();
205 for (i, c) in struct_name.chars().enumerate() {
206 if c.is_uppercase() && i > 0 {
207 table_name.push('_');
208 }
209 table_name.push(c.to_lowercase().next().unwrap());
210 }
211 format!("{}_table", table_name)
212 }
213 fn field_name_to_column_name(&self, field_name: &str) -> String {
214 let mut column_name = String::new();
215 for (i, c) in field_name.chars().enumerate() {
216 if c.is_uppercase() && i > 0 {
217 column_name.push('_');
218 }
219 column_name.push(c.to_lowercase().next().unwrap());
220 }
221 column_name
222 }
223 fn generate_migration_file(
224 &self,
225 plan: &MigrationPlan,
226 output_dir: &str,
227 ) -> Result<String> {
228 let file_name = format!("{}_{}.sql", plan.timestamp, plan.table_name);
229 let file_path = Path::new(output_dir).join(&file_name);
230 fs::create_dir_all(output_dir)?;
231 let mut content = format!("-- Migration: {}\n", plan.description);
232 content.push_str(&format!("-- Generated at: {}\n", plan.timestamp));
233 content.push_str("-- +migrate Up\n");
234 content.push_str(&plan.up_sql);
235 content.push_str("-- +migrate Down\n");
236 content.push_str(&plan.down_sql);
237 fs::write(&file_path, content)?;
238 Ok(file_path.to_string_lossy().to_string())
239 }
240 fn find_existing_migration(
241 &self,
242 table_name: &str,
243 migrations_dir: &str,
244 ) -> Option<String> {
245 if let Ok(entries) = fs::read_dir(migrations_dir) {
246 for entry in entries.flatten() {
247 if let Some(file_name) = entry.file_name().to_str() {
248 if file_name.contains(&table_name) && file_name.ends_with(".sql") {
249 return Some(entry.path().to_string_lossy().to_string());
250 }
251 }
252 }
253 }
254 None
255 }
256 fn compare_struct_with_existing(
257 &self,
258 new_struct: &StructDefinition,
259 existing_migration: &str,
260 ) -> Result<Vec<String>> {
261 let existing_content = fs::read_to_string(existing_migration)?;
262 let mut changes = Vec::new();
263 let existing_columns = self.extract_columns_from_sql(&existing_content)?;
264 for field in &new_struct.fields {
265 let column_name = self.field_name_to_column_name(&field.name);
266 if let Some(existing_col) = existing_columns.get(&column_name) {
267 if existing_col != &field.ty {
268 changes
269 .push(
270 format!(
271 "ALTER COLUMN {} TYPE {} -> {}", column_name, existing_col,
272 field.ty
273 ),
274 );
275 }
276 } else {
277 changes.push(format!("ADD COLUMN {} {}", column_name, field.ty));
278 }
279 }
280 Ok(changes)
281 }
282 fn extract_columns_from_sql(
283 &self,
284 sql_content: &str,
285 ) -> Result<HashMap<String, String>> {
286 let mut columns = HashMap::new();
287 for line in sql_content.lines() {
288 let line = line.trim();
289 if line.starts_with("CREATE TABLE") || line.is_empty()
290 || line.starts_with("--")
291 {
292 continue;
293 }
294 if line.starts_with(");") {
295 break;
296 }
297 if let Some((column_name, column_type)) = self.parse_column_definition(line)
298 {
299 columns.insert(column_name, column_type);
300 }
301 }
302 Ok(columns)
303 }
304 fn parse_column_definition(&self, line: &str) -> Option<(String, String)> {
305 let line = line.trim().trim_end_matches(',');
306 let parts: Vec<&str> = line.split_whitespace().collect();
307 if parts.len() >= 2 {
308 let column_name = parts[0].to_string();
309 let column_type = parts[1..].join(" ");
310 Some((column_name, column_type))
311 } else {
312 None
313 }
314 }
315 fn generate_alter_migration(
316 &self,
317 struct_def: &StructDefinition,
318 changes: &[String],
319 timestamp: &str,
320 ) -> MigrationPlan {
321 let table_name = self.struct_name_to_table_name(&struct_def.name);
322 let mut up_sql = format!("-- Alter {} table\n", table_name);
323 let mut down_sql = format!("-- Revert {} table changes\n", table_name);
324 for change in changes {
325 if change.starts_with("ADD COLUMN") {
326 up_sql.push_str(&format!("ALTER TABLE {} {};\n", table_name, change));
327 down_sql
328 .push_str(
329 &format!("-- Would need to drop column for full revert\n"),
330 );
331 } else if change.starts_with("ALTER COLUMN") {
332 up_sql.push_str(&format!("-- {} (manual review required)\n", change));
333 down_sql
334 .push_str(
335 &format!("-- Revert {} (manual review required)\n", change),
336 );
337 }
338 }
339 let description = format!(
340 "Alter {} table - {} changes", table_name, changes.len()
341 );
342 MigrationPlan {
343 up_sql,
344 down_sql,
345 description,
346 timestamp: timestamp.to_string(),
347 table_name,
348 changes: changes.to_vec(),
349 }
350 }
351 fn display_report(
352 &self,
353 report: &MigrationReport,
354 output_format: OutputFormat,
355 verbose: bool,
356 ) {
357 match output_format {
358 OutputFormat::Human => {
359 println!(
360 "\nšļø {} - SQL Migration Generation Report",
361 "CargoMate MigrationGen".bold().blue()
362 );
363 println!("{}", "ā".repeat(60).blue());
364 println!("\nš Summary:");
365 println!(" ⢠Migrations Generated: {}", report.migrations_generated);
366 println!(" ⢠Tables Created: {}", report.tables_created);
367 println!(" ⢠Total Changes: {}", report.total_changes);
368 if !report.files_created.is_empty() {
369 println!("\nš Files Created:");
370 for file in &report.files_created {
371 println!(" ⢠{}", file.green());
372 }
373 }
374 if verbose {
375 println!("\nš§ Migration Plans:");
376 for plan in &report.migration_plan {
377 println!(
378 " \nš {} ({})", plan.description.cyan(), plan.timestamp
379 );
380 println!(" Table: {}", plan.table_name.yellow());
381 if !plan.changes.is_empty() {
382 println!(" Changes:");
383 for change in &plan.changes {
384 println!(" ⢠{}", change);
385 }
386 }
387 println!(" Up SQL Preview:");
388 for line in plan.up_sql.lines().take(5) {
389 println!(" {}", line.dimmed());
390 }
391 if plan.up_sql.lines().count() > 5 {
392 println!(
393 " ... ({} more lines)", plan.up_sql.lines().count() - 5
394 );
395 }
396 }
397 }
398 println!("\nš” Next Steps:");
399 println!(" 1. Review generated migration files");
400 println!(" 2. Test migrations on a development database");
401 println!(" 3. Run migrations in your CI/CD pipeline");
402 println!(" 4. Create database backups before running migrations");
403 println!("\nš§ Common Migration Commands:");
404 println!(" ⢠PostgreSQL: psql -f migration.sql");
405 println!(" ⢠MySQL: mysql < migration.sql");
406 println!(" ⢠SQLite: sqlite3 database.db < migration.sql");
407 println!(" ⢠Diesel: diesel migration run");
408 println!(" ⢠SeaORM: sea-orm-cli migrate up");
409 }
410 OutputFormat::Json => {
411 let json = serde_json::to_string_pretty(report)
412 .unwrap_or_else(|_| "{}".to_string());
413 println!("{}", json);
414 }
415 OutputFormat::Table => {
416 println!(
417 "{:<25} {:<20} {:<15} {:<10}", "Table", "Description", "Changes",
418 "Timestamp"
419 );
420 println!("{}", "ā".repeat(75));
421 for plan in &report.migration_plan {
422 println!(
423 "{:<25} {:<20} {:<15} {:<10}", plan.table_name, plan.description
424 .chars().take(18).collect::< String > (), plan.changes.len()
425 .to_string(), plan.timestamp.chars().take(8).collect::< String >
426 ()
427 );
428 }
429 }
430 }
431 }
432}
433impl Tool for MigrationGenTool {
434 fn name(&self) -> &'static str {
435 "migration-gen"
436 }
437 fn description(&self) -> &'static str {
438 "Generate SQL migrations from struct changes"
439 }
440 fn command(&self) -> Command {
441 Command::new(self.name())
442 .about(self.description())
443 .long_about(
444 "Generate SQL migration scripts from Rust struct definitions. \
445 Automatically detects changes between struct versions and creates \
446 appropriate ALTER/CREATE TABLE statements.
447
448EXAMPLES:
449 cm tool migration-gen --input src/models.rs --output migrations/
450 cm tool migration-gen --input src/user.rs --database postgres
451 cm tool migration-gen --existing-schema schema.sql --diff",
452 )
453 .args(
454 &[
455 Arg::new("input")
456 .long("input")
457 .short('i')
458 .help("Input Rust file containing struct definitions")
459 .required(true),
460 Arg::new("output")
461 .long("output")
462 .short('o')
463 .help("Output directory for migration files")
464 .default_value("migrations/"),
465 Arg::new("database")
466 .long("database")
467 .short('d')
468 .help("Target database (postgres, mysql, sqlite, mssql)")
469 .default_value("postgres"),
470 Arg::new("existing-schema")
471 .long("existing-schema")
472 .help("Path to existing schema file for diff"),
473 Arg::new("diff")
474 .long("diff")
475 .help("Generate diff migrations against existing schema")
476 .action(clap::ArgAction::SetTrue),
477 Arg::new("migrations-dir")
478 .long("migrations-dir")
479 .help("Directory containing existing migrations")
480 .default_value("migrations/"),
481 Arg::new("framework")
482 .long("framework")
483 .short('f')
484 .help("Migration framework (diesel, seaorm, raw)")
485 .default_value("raw"),
486 Arg::new("dry-run")
487 .long("dry-run")
488 .help("Show what would be generated without creating files")
489 .action(clap::ArgAction::SetTrue),
490 ],
491 )
492 .args(&common_options())
493 }
494 fn execute(&self, matches: &ArgMatches) -> Result<()> {
495 let input_file = matches.get_one::<String>("input").unwrap();
496 let output_dir = matches.get_one::<String>("output").unwrap();
497 let database = matches.get_one::<String>("database").unwrap();
498 let existing_schema = matches.get_one::<String>("existing-schema");
499 let diff = matches.get_flag("diff");
500 let migrations_dir = matches.get_one::<String>("migrations-dir").unwrap();
501 let framework = matches.get_one::<String>("framework").unwrap();
502 let dry_run = matches.get_flag("dry-run");
503 let output_format = parse_output_format(matches);
504 let verbose = matches.get_flag("verbose");
505 println!(
506 "šļø {} - Generating SQL Migrations", "CargoMate MigrationGen".bold()
507 .blue()
508 );
509 if !Path::new(input_file).exists() {
510 return Err(
511 ToolError::InvalidArguments(
512 format!("Input file {} not found", input_file),
513 ),
514 );
515 }
516 let structs = self.parse_rust_structs(input_file)?;
517 if structs.is_empty() {
518 return Err(
519 ToolError::ExecutionFailed(
520 "No struct definitions found in input file".to_string(),
521 ),
522 );
523 }
524 if verbose {
525 println!("\nš Found {} struct(s):", structs.len());
526 for struct_def in &structs {
527 println!(
528 " ⢠{} - {} fields", struct_def.name.green(), struct_def.fields
529 .len()
530 );
531 }
532 }
533 let mut migration_plans = Vec::new();
534 let mut files_created = Vec::new();
535 for struct_def in &structs {
536 if diff {
537 let table_name = self.struct_name_to_table_name(&struct_def.name);
538 if let Some(existing_migration) = self
539 .find_existing_migration(&table_name, migrations_dir)
540 {
541 if let Ok(changes) = self
542 .compare_struct_with_existing(struct_def, &existing_migration)
543 {
544 if !changes.is_empty() {
545 let timestamp = chrono::Utc::now()
546 .format("%Y%m%d_%H%M%S")
547 .to_string();
548 let plan = self
549 .generate_alter_migration(struct_def, &changes, ×tamp);
550 migration_plans.push(plan);
551 }
552 }
553 } else {
554 let plan = self.generate_migration_sql(struct_def);
555 migration_plans.push(plan);
556 }
557 } else {
558 let plan = self.generate_migration_sql(struct_def);
559 migration_plans.push(plan);
560 }
561 }
562 if migration_plans.is_empty() {
563 println!("{}", "No migrations needed - no changes detected".yellow());
564 return Ok(());
565 }
566 for plan in &migration_plans {
567 if !dry_run {
568 match self.generate_migration_file(plan, output_dir) {
569 Ok(file_path) => {
570 files_created.push(file_path);
571 if verbose {
572 println!("ā
Generated migration: {}", plan.table_name);
573 }
574 }
575 Err(e) => {
576 println!(
577 "ā Failed to generate migration for {}: {}", plan
578 .table_name, e
579 );
580 }
581 }
582 }
583 }
584 let report = MigrationReport {
585 migrations_generated: migration_plans.len(),
586 tables_created: migration_plans
587 .iter()
588 .filter(|p| p.up_sql.contains("CREATE TABLE"))
589 .count(),
590 files_created: files_created.clone(),
591 total_changes: migration_plans.iter().map(|p| p.changes.len()).sum(),
592 migration_plan: migration_plans,
593 };
594 self.display_report(&report, output_format, verbose);
595 if dry_run {
596 println!("\nš Dry run complete - no files were created");
597 } else if !files_created.is_empty() {
598 println!("\nā
Generated {} migration file(s)", files_created.len());
599 }
600 Ok(())
601 }
602}
603impl Default for MigrationGenTool {
604 fn default() -> Self {
605 Self::new()
606 }
607}