Skip to main content

cargo_mate/tools/
migration_gen.rs

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, &timestamp);
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}