Skip to main content

ferro_cli/commands/
make_migration.rs

1use chrono::Local;
2use console::style;
3use std::fs;
4use std::path::Path;
5
6pub fn run(name: String) {
7    // Convert to snake_case for file name
8    let file_name = to_snake_case(&name);
9
10    // Validate the resulting name is a valid Rust identifier
11    if !is_valid_identifier(&file_name) {
12        eprintln!(
13            "{} '{}' is not a valid migration name",
14            style("Error:").red().bold(),
15            name
16        );
17        std::process::exit(1);
18    }
19
20    // Extract table name from migration name (e.g., create_users_table -> users)
21    let table_name = extract_table_name(&file_name);
22    let table_enum_name = to_pascal_case(&table_name);
23
24    let migrations_dir = Path::new("src/migrations");
25
26    // Check if migrations directory exists, create if not
27    if !migrations_dir.exists() {
28        if let Err(e) = fs::create_dir_all(migrations_dir) {
29            eprintln!(
30                "{} Failed to create migrations directory: {}",
31                style("Error:").red().bold(),
32                e
33            );
34            std::process::exit(1);
35        }
36        println!("{} Created src/migrations directory", style("✓").green());
37    }
38
39    // Generate timestamp-based filename: m{YYYYMMDD}_{HHMMSS}_{name}.rs
40    let timestamp = Local::now().format("%Y%m%d_%H%M%S").to_string();
41    let migration_file_name = format!("m{timestamp}_{file_name}");
42    let migration_file = migrations_dir.join(format!("{migration_file_name}.rs"));
43    let mod_file = migrations_dir.join("mod.rs");
44
45    // Check if migration file already exists (unlikely with timestamp)
46    if migration_file.exists() {
47        eprintln!(
48            "{} Migration '{}' already exists at {}",
49            style("Info:").yellow().bold(),
50            migration_file_name,
51            migration_file.display()
52        );
53        std::process::exit(0);
54    }
55
56    // Generate migration file content
57    let migration_content = migration_template(&table_name, &table_enum_name);
58
59    // Write migration file
60    if let Err(e) = fs::write(&migration_file, &migration_content) {
61        eprintln!(
62            "{} Failed to write migration file: {}",
63            style("Error:").red().bold(),
64            e
65        );
66        std::process::exit(1);
67    }
68    println!(
69        "{} Created {}",
70        style("✓").green(),
71        migration_file.display()
72    );
73
74    // Update or create mod.rs
75    if mod_file.exists() {
76        if let Err(e) = update_mod_file(&mod_file, &migration_file_name) {
77            eprintln!(
78                "{} Failed to update mod.rs: {}",
79                style("Error:").red().bold(),
80                e
81            );
82            std::process::exit(1);
83        }
84        println!("{} Updated src/migrations/mod.rs", style("✓").green());
85    } else {
86        // Create mod.rs with Migrator
87        let mod_content = migrator_mod_template(&migration_file_name);
88        if let Err(e) = fs::write(&mod_file, mod_content) {
89            eprintln!(
90                "{} Failed to create mod.rs: {}",
91                style("Error:").red().bold(),
92                e
93            );
94            std::process::exit(1);
95        }
96        println!("{} Created src/migrations/mod.rs", style("✓").green());
97    }
98
99    println!();
100    println!(
101        "Migration {} created successfully!",
102        style(&migration_file_name).cyan().bold()
103    );
104    println!();
105    println!("Next steps:");
106    println!(
107        "  {} Edit the migration file to define your schema",
108        style("1.").dim()
109    );
110    println!(
111        "  {} Run {} to apply the migration",
112        style("2.").dim(),
113        style("ferro migrate").cyan()
114    );
115    println!();
116}
117
118fn is_valid_identifier(name: &str) -> bool {
119    if name.is_empty() {
120        return false;
121    }
122
123    let mut chars = name.chars();
124
125    // First character must be letter or underscore
126    match chars.next() {
127        Some(c) if c.is_alphabetic() || c == '_' => {}
128        _ => return false,
129    }
130
131    // Rest must be alphanumeric or underscore
132    chars.all(|c| c.is_alphanumeric() || c == '_')
133}
134
135fn to_snake_case(s: &str) -> String {
136    let mut result = String::new();
137    for (i, c) in s.chars().enumerate() {
138        if c.is_uppercase() {
139            if i > 0 {
140                result.push('_');
141            }
142            result.push(c.to_lowercase().next().unwrap());
143        } else if c == '-' || c == ' ' {
144            result.push('_');
145        } else {
146            result.push(c);
147        }
148    }
149    result
150}
151
152fn to_pascal_case(s: &str) -> String {
153    let mut result = String::new();
154    let mut capitalize_next = true;
155
156    for c in s.chars() {
157        if c == '_' || c == '-' || c == ' ' {
158            capitalize_next = true;
159        } else if capitalize_next {
160            result.push(c.to_uppercase().next().unwrap());
161            capitalize_next = false;
162        } else {
163            result.push(c);
164        }
165    }
166    result
167}
168
169/// Extract table name from migration name
170/// e.g., "create_users_table" -> "users"
171/// e.g., "add_email_to_users" -> "users"
172/// e.g., "users" -> "users"
173fn extract_table_name(name: &str) -> String {
174    // Common patterns: create_X_table, add_Y_to_X, drop_X_table
175    if name.starts_with("create_") && name.ends_with("_table") {
176        let without_prefix = name.strip_prefix("create_").unwrap();
177        let without_suffix = without_prefix.strip_suffix("_table").unwrap();
178        return without_suffix.to_string();
179    }
180
181    if name.contains("_to_") {
182        // add_X_to_Y -> Y
183        if let Some(pos) = name.rfind("_to_") {
184            return name[pos + 4..].to_string();
185        }
186    }
187
188    if name.starts_with("drop_") && name.ends_with("_table") {
189        let without_prefix = name.strip_prefix("drop_").unwrap();
190        let without_suffix = without_prefix.strip_suffix("_table").unwrap();
191        return without_suffix.to_string();
192    }
193
194    // Default: use the name as-is (assume it's a table name)
195    name.to_string()
196}
197
198fn migration_template(table_name: &str, table_enum_name: &str) -> String {
199    format!(
200        r#"use sea_orm_migration::prelude::*;
201
202#[derive(DeriveMigrationName)]
203pub struct Migration;
204
205#[async_trait::async_trait]
206impl MigrationTrait for Migration {{
207    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
208        manager
209            .create_table(
210                Table::create()
211                    .table({table_enum_name}::Table)
212                    .if_not_exists()
213                    .col(
214                        ColumnDef::new({table_enum_name}::Id)
215                            .integer()
216                            .not_null()
217                            .auto_increment()
218                            .primary_key(),
219                    )
220                    .col(
221                        ColumnDef::new({table_enum_name}::CreatedAt)
222                            .timestamp_with_time_zone()
223                            .not_null()
224                            .default(Expr::current_timestamp()),
225                    )
226                    .col(
227                        ColumnDef::new({table_enum_name}::UpdatedAt)
228                            .timestamp_with_time_zone()
229                            .not_null()
230                            .default(Expr::current_timestamp()),
231                    )
232                    .to_owned(),
233            )
234            .await
235    }}
236
237    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
238        manager
239            .drop_table(Table::drop().table({table_enum_name}::Table).to_owned())
240            .await
241    }}
242}}
243
244/// Table and column identifiers for {table_name}
245#[derive(DeriveIden)]
246enum {table_enum_name} {{
247    Table,
248    Id,
249    CreatedAt,
250    UpdatedAt,
251}}
252"#
253    )
254}
255
256fn migrator_mod_template(migration_name: &str) -> String {
257    format!(
258        r#"pub use sea_orm_migration::prelude::*;
259
260mod {migration_name};
261
262pub struct Migrator;
263
264#[async_trait::async_trait]
265impl MigratorTrait for Migrator {{
266    fn migrations() -> Vec<Box<dyn MigrationTrait>> {{
267        vec![
268            Box::new({migration_name}::Migration),
269        ]
270    }}
271}}
272"#
273    )
274}
275
276fn update_mod_file(mod_file: &Path, migration_name: &str) -> Result<(), String> {
277    let content =
278        fs::read_to_string(mod_file).map_err(|e| format!("Failed to read mod.rs: {e}"))?;
279
280    let mod_decl = format!("mod {migration_name};");
281
282    // Check if already declared
283    if content.contains(&mod_decl) {
284        return Ok(());
285    }
286
287    let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
288
289    // Find position to insert mod declaration (after other mod declarations)
290    let mut last_mod_idx = None;
291    for (i, line) in lines.iter().enumerate() {
292        if line.trim().starts_with("mod ") && !line.contains("mod tests") {
293            last_mod_idx = Some(i);
294        }
295    }
296
297    // Insert mod declaration
298    let insert_idx = match last_mod_idx {
299        Some(idx) => idx + 1,
300        None => {
301            // Find after "pub use sea_orm_migration::prelude::*;"
302            let mut insert_idx = 0;
303            for (i, line) in lines.iter().enumerate() {
304                if line.contains("sea_orm_migration") || line.is_empty() {
305                    insert_idx = i + 1;
306                } else if line.starts_with("mod ") || line.starts_with("pub struct") {
307                    break;
308                }
309            }
310            insert_idx
311        }
312    };
313    lines.insert(insert_idx, mod_decl);
314
315    // Update migrations() vec to include the new migration
316    let box_new_line = format!("            Box::new({migration_name}::Migration),");
317    let mut insert_vec_idx = None;
318
319    for (i, line) in lines.iter().enumerate() {
320        // Handle empty vec![] on single line
321        if line.contains("vec![]") {
322            // Replace vec![] with vec![\n    Box::new(...)\n]
323            lines[i] = line.replace("vec![]", &format!("vec![\n{box_new_line}\n        ]"));
324            let new_content = lines.join("\n");
325            fs::write(mod_file, new_content).map_err(|e| format!("Failed to write mod.rs: {e}"))?;
326            return Ok(());
327        }
328        // Handle multi-line vec![ ... ]
329        if line.contains("vec![") && !line.contains("vec![]") {
330            // Find closing ] to insert before it
331            for (j, inner_line) in lines.iter().enumerate().skip(i + 1) {
332                if inner_line.trim() == "]" || inner_line.trim().starts_with("]") {
333                    insert_vec_idx = Some(j);
334                    break;
335                }
336            }
337            break;
338        }
339    }
340
341    if let Some(idx) = insert_vec_idx {
342        lines.insert(idx, box_new_line);
343    }
344
345    let new_content = lines.join("\n");
346    fs::write(mod_file, new_content).map_err(|e| format!("Failed to write mod.rs: {e}"))?;
347
348    Ok(())
349}