Skip to main content

vespertide_macro/
lib.rs

1// MigrationOptions and MigrationError are now in vespertide-core
2
3use std::env;
4use std::fmt::Write;
5use std::path::PathBuf;
6
7use proc_macro::TokenStream;
8use syn::parse::{Parse, ParseStream};
9use syn::{Ident, Token};
10use vespertide_loader::{load_config_or_default, load_migrations_at_compile_time};
11use vespertide_planner::apply_action;
12use vespertide_query::{DatabaseBackend, build_plan_queries};
13
14struct MacroInput {
15    pool: proc_macro2::TokenStream,
16    version_table: Option<String>,
17    verbose: bool,
18}
19
20impl Parse for MacroInput {
21    fn parse(input: ParseStream) -> syn::Result<Self> {
22        let mut pool_tokens = Vec::new();
23        while !input.is_empty() && !input.peek(Token![,]) {
24            pool_tokens.push(input.parse::<proc_macro2::TokenTree>()?);
25        }
26        let pool: proc_macro2::TokenStream = pool_tokens.into_iter().collect();
27        let mut version_table = None;
28        let mut verbose = false;
29
30        while !input.is_empty() {
31            input.parse::<Token![,]>()?;
32            if input.is_empty() {
33                break;
34            }
35
36            let key: Ident = input.parse()?;
37            if key == "version_table" {
38                input.parse::<Token![=]>()?;
39                let value: syn::LitStr = input.parse()?;
40                version_table = Some(value.value());
41            } else if key == "verbose" {
42                verbose = true;
43            } else {
44                return Err(syn::Error::new(
45                    key.span(),
46                    "unsupported option for vespertide_migration!",
47                ));
48            }
49        }
50
51        Ok(MacroInput {
52            pool,
53            version_table,
54            verbose,
55        })
56    }
57}
58
59/// Generated migration block with raw SQL strings for string-based codegen.
60#[derive(Debug)]
61pub(crate) struct MigrationBlock {
62    /// Migration version number
63    pub version: u32,
64    /// Migration ID for validation
65    pub migration_id: String,
66    /// Migration comment (for verbose logging)
67    pub comment: String,
68    /// PostgreSQL SQL statements
69    pub pg_sqls: Vec<String>,
70    /// MySQL SQL statements
71    pub mysql_sqls: Vec<String>,
72    /// SQLite SQL statements
73    pub sqlite_sqls: Vec<String>,
74}
75
76pub(crate) fn build_migration_block(
77    migration: &vespertide_core::MigrationPlan,
78    baseline_schema: &mut Vec<vespertide_core::TableDef>,
79) -> Result<MigrationBlock, String> {
80    let version = migration.version;
81
82    // Use the current baseline schema (from all previous migrations)
83    let queries = build_plan_queries(migration, baseline_schema).map_err(|e| {
84        format!(
85            "Failed to build queries for migration version {}: {}",
86            version, e
87        )
88    })?;
89
90    // Update baseline schema incrementally by applying each action
91    for action in &migration.actions {
92        let _ = apply_action(baseline_schema, action);
93    }
94
95    // Flatten all SQL into per-backend string arrays
96    let mut pg_sqls = Vec::new();
97    let mut mysql_sqls = Vec::new();
98    let mut sqlite_sqls = Vec::new();
99
100    for q in &queries {
101        for stmt in &q.postgres {
102            pg_sqls.push(stmt.build(DatabaseBackend::Postgres));
103        }
104        for stmt in &q.mysql {
105            mysql_sqls.push(stmt.build(DatabaseBackend::MySql));
106        }
107        for stmt in &q.sqlite {
108            sqlite_sqls.push(stmt.build(DatabaseBackend::Sqlite));
109        }
110    }
111
112    let comment = migration.comment.as_deref().unwrap_or("").to_string();
113
114    Ok(MigrationBlock {
115        version,
116        migration_id: migration.id.clone(),
117        comment,
118        pg_sqls,
119        mysql_sqls,
120        sqlite_sqls,
121    })
122}
123
124fn generate_migration_code(
125    pool: &proc_macro2::TokenStream,
126    version_table: &str,
127    migration_blocks: Vec<MigrationBlock>,
128    verbose: bool,
129) -> proc_macro2::TokenStream {
130    // Build entire output as a String, parse once at the end.
131    // This avoids per-token IPC overhead from quote! (see rust-lang/rust#65080).
132    let pool_str = pool.to_string();
133    let mut code = String::with_capacity(1_048_576);
134
135    code.push_str("{\n");
136
137    // Emit compact SQL blobs (outside async block for zero runtime cost).
138    for b in &migration_blocks {
139        write_sql_blob(&mut code, &format!("__V{}_PG", b.version), &b.pg_sqls);
140        write_sql_blob(&mut code, &format!("__V{}_MYSQL", b.version), &b.mysql_sqls);
141        write_sql_blob(
142            &mut code,
143            &format!("__V{}_SQLITE", b.version),
144            &b.sqlite_sqls,
145        );
146    }
147
148    code.push_str(
149        "static __VESPERTIDE_MIGRATIONS: &[::vespertide::runtime::EmbeddedMigration] = &[\n",
150    );
151    for b in &migration_blocks {
152        writeln!(code, "::vespertide::runtime::EmbeddedMigration::new({}u32, {:?}, {:?}, __V{}_PG, __V{}_MYSQL, __V{}_SQLITE),", b.version, b.migration_id, b.comment, b.version, b.version, b.version).unwrap();
153    }
154    code.push_str("];\n");
155
156    code.push_str("async {\n");
157    writeln!(code, "let __pool = &{pool_str};").unwrap();
158    writeln!(code, "::vespertide::runtime::run_embedded_migrations(__pool, {version_table:?}, {verbose}, __VESPERTIDE_MIGRATIONS).await").unwrap();
159    code.push_str("}\n"); // async block
160    code.push_str("}\n"); // outer block
161
162    // Single parse — ONE IPC call to rustc tokenizer
163    code.parse().unwrap_or_else(|e| panic!("vespertide_migration codegen produced invalid Rust:\n{e}\n\nGenerated code (first 2000 chars):\n{}", &code[..code.len().min(2000)]))
164}
165
166/// Write a compact SQL blob declaration into the code string.
167fn write_sql_blob(code: &mut String, ident: &str, sqls: &[String]) {
168    let mut blob = String::new();
169    for sql in sqls {
170        blob.push_str(sql);
171        blob.push('\0');
172    }
173
174    writeln!(code, "static {ident}: &str = {blob:?};").unwrap();
175}
176
177/// Inner implementation that works with proc_macro2::TokenStream for testability.
178pub(crate) fn vespertide_migration_impl(
179    input: proc_macro2::TokenStream,
180) -> proc_macro2::TokenStream {
181    let input: MacroInput = match syn::parse2(input) {
182        Ok(input) => input,
183        Err(e) => return e.to_compile_error(),
184    };
185    let pool = &input.pool;
186    let verbose = input.verbose;
187
188    // Get project root from CARGO_MANIFEST_DIR (same as load_migrations_at_compile_time)
189    let project_root = match env::var("CARGO_MANIFEST_DIR") {
190        Ok(dir) => Some(PathBuf::from(dir)),
191        Err(_) => None,
192    };
193
194    // Load config to get prefix
195    let config = match load_config_or_default(project_root) {
196        Ok(config) => config,
197        #[cfg(not(tarpaulin_include))]
198        Err(e) => {
199            return syn::Error::new(
200                proc_macro2::Span::call_site(),
201                format!("Failed to load config at compile time: {}", e),
202            )
203            .to_compile_error();
204        }
205    };
206    let prefix = config.prefix();
207
208    // Apply prefix to version_table if not explicitly provided
209    let version_table = input
210        .version_table
211        .map(|vt| config.apply_prefix(&vt))
212        .unwrap_or_else(|| config.apply_prefix("vespertide_version"));
213
214    // Load migration files and build SQL at compile time
215    let migrations = match load_migrations_at_compile_time() {
216        Ok(migrations) => migrations,
217        Err(e) => {
218            return syn::Error::new(
219                proc_macro2::Span::call_site(),
220                format!("Failed to load migrations at compile time: {}", e),
221            )
222            .to_compile_error();
223        }
224    };
225    // Apply prefix to migrations and build SQL using incremental baseline schema
226    let mut baseline_schema = Vec::new();
227    let mut migration_blocks = Vec::new();
228
229    #[cfg(not(tarpaulin_include))]
230    for migration in &migrations {
231        // Apply prefix to migration table names
232        let prefixed_migration = migration.clone().with_prefix(prefix);
233        match build_migration_block(&prefixed_migration, &mut baseline_schema) {
234            Ok(block) => migration_blocks.push(block),
235            Err(e) => {
236                return syn::Error::new(proc_macro2::Span::call_site(), e).to_compile_error();
237            }
238        }
239    }
240
241    generate_migration_code(pool, &version_table, migration_blocks, verbose)
242}
243
244/// Zero-runtime migration entry point.
245#[cfg(not(tarpaulin_include))]
246#[proc_macro]
247pub fn vespertide_migration(input: TokenStream) -> TokenStream {
248    vespertide_migration_impl(input.into()).into()
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use std::fs::File;
255    use std::io::Write;
256    use tempfile::tempdir;
257    use vespertide_core::{
258        ColumnDef, ColumnType, MigrationAction, MigrationPlan, SimpleColumnType, StrOrBoolOrArray,
259    };
260
261    #[test]
262    fn test_macro_expansion_with_runtime_macros() {
263        // Create a temporary directory with test files
264        let dir = tempdir().unwrap();
265
266        // Create a test file that uses the macro
267        let test_file_path = dir.path().join("test_macro.rs");
268        let mut test_file = File::create(&test_file_path).unwrap();
269        writeln!(
270            test_file,
271            r#"vespertide_migration!(pool, version_table = "test_versions");"#
272        )
273        .unwrap();
274
275        // Use runtime-macros to emulate macro expansion
276        let file = File::open(&test_file_path).unwrap();
277        let result = runtime_macros::emulate_functionlike_macro_expansion(
278            file,
279            &[("vespertide_migration", vespertide_migration_impl)],
280        );
281
282        // The macro will fail because there's no vespertide config, but
283        // the important thing is that it runs and covers the macro code
284        // We expect an error due to missing config
285        assert!(result.is_ok() || result.is_err());
286    }
287
288    #[test]
289    fn test_macro_with_simple_pool() {
290        let dir = tempdir().unwrap();
291        let test_file_path = dir.path().join("test_simple.rs");
292        let mut test_file = File::create(&test_file_path).unwrap();
293        writeln!(test_file, r#"vespertide_migration!(db_pool);"#).unwrap();
294
295        let file = File::open(&test_file_path).unwrap();
296        let result = runtime_macros::emulate_functionlike_macro_expansion(
297            file,
298            &[("vespertide_migration", vespertide_migration_impl)],
299        );
300
301        assert!(result.is_ok() || result.is_err());
302    }
303
304    #[test]
305    fn test_macro_parsing_invalid_option() {
306        // Test that invalid options produce a compile error
307        let input: proc_macro2::TokenStream = "pool, invalid_option = \"value\"".parse().unwrap();
308        let output = vespertide_migration_impl(input);
309        let output_str = output.to_string();
310        // Should contain an error message about unsupported option
311        assert!(output_str.contains("unsupported option"));
312    }
313
314    #[test]
315    fn test_macro_parsing_valid_input() {
316        // Test that valid input is parsed correctly
317        // The macro will either succeed (if migrations dir exists and is empty)
318        // or fail with a migration loading error
319        let input: proc_macro2::TokenStream = "my_pool".parse().unwrap();
320        let output = vespertide_migration_impl(input);
321        let output_str = output.to_string();
322        // Should produce output (either success or migration loading error)
323        assert!(!output_str.is_empty());
324        // If error, it should mention "Failed to load"
325        // If success, it should contain "async"
326        assert!(
327            output_str.contains("async") || output_str.contains("Failed to load"),
328            "Unexpected output: {}",
329            output_str
330        );
331    }
332
333    #[test]
334    fn test_macro_parsing_with_version_table() {
335        let input: proc_macro2::TokenStream =
336            r#"pool, version_table = "custom_versions""#.parse().unwrap();
337        let output = vespertide_migration_impl(input);
338        let output_str = output.to_string();
339        assert!(!output_str.is_empty());
340    }
341
342    #[test]
343    fn test_macro_parsing_trailing_comma() {
344        let input: proc_macro2::TokenStream = "pool,".parse().unwrap();
345        let output = vespertide_migration_impl(input);
346        let output_str = output.to_string();
347        assert!(!output_str.is_empty());
348    }
349
350    fn test_column(name: &str) -> ColumnDef {
351        ColumnDef {
352            name: name.into(),
353            r#type: ColumnType::Simple(SimpleColumnType::Integer),
354            nullable: false,
355            default: None,
356            comment: None,
357            primary_key: None,
358            unique: None,
359            index: None,
360            foreign_key: None,
361        }
362    }
363
364    /// Concatenate all SQL strings from a block for assertion testing.
365    fn block_to_string(block: &MigrationBlock) -> String {
366        let mut result = String::new();
367        for sql in &block.pg_sqls {
368            result.push_str(sql);
369            result.push(' ');
370        }
371        for sql in &block.mysql_sqls {
372            result.push_str(sql);
373            result.push(' ');
374        }
375        for sql in &block.sqlite_sqls {
376            result.push_str(sql);
377            result.push(' ');
378        }
379        result
380    }
381
382    #[test]
383    fn test_build_migration_block_create_table() {
384        let migration = MigrationPlan {
385            id: String::new(),
386            version: 1,
387            comment: None,
388            created_at: None,
389            actions: vec![MigrationAction::CreateTable {
390                table: "users".into(),
391                columns: vec![test_column("id")],
392                constraints: vec![],
393            }],
394        };
395
396        let mut baseline = Vec::new();
397        let result = build_migration_block(&migration, &mut baseline);
398
399        assert!(result.is_ok());
400        let block = result.unwrap();
401        let block_str = block_to_string(&block);
402
403        // Verify statics contain SQL and metadata is correct
404        assert!(block_str.contains("CREATE TABLE"));
405        assert_eq!(block.version, 1);
406
407        // Verify baseline schema was updated
408        assert_eq!(baseline.len(), 1);
409        assert_eq!(baseline[0].name, "users");
410    }
411
412    #[test]
413    fn test_build_migration_block_add_column() {
414        // First create the table
415        let create_migration = MigrationPlan {
416            id: String::new(),
417            version: 1,
418            comment: None,
419            created_at: None,
420            actions: vec![MigrationAction::CreateTable {
421                table: "users".into(),
422                columns: vec![test_column("id")],
423                constraints: vec![],
424            }],
425        };
426
427        let mut baseline = Vec::new();
428        let _ = build_migration_block(&create_migration, &mut baseline);
429
430        // Now add a column
431        let add_column_migration = MigrationPlan {
432            id: String::new(),
433            version: 2,
434            comment: None,
435            created_at: None,
436            actions: vec![MigrationAction::AddColumn {
437                table: "users".into(),
438                column: Box::new(ColumnDef {
439                    name: "email".into(),
440                    r#type: ColumnType::Simple(SimpleColumnType::Text),
441                    nullable: true,
442                    default: None,
443                    comment: None,
444                    primary_key: None,
445                    unique: None,
446                    index: None,
447                    foreign_key: None,
448                }),
449                fill_with: None,
450            }],
451        };
452
453        let result = build_migration_block(&add_column_migration, &mut baseline);
454        assert!(result.is_ok());
455        let block = result.unwrap();
456        let block_str = block_to_string(&block);
457
458        assert_eq!(block.version, 2);
459        assert!(block_str.contains("ALTER TABLE"));
460        assert!(block_str.contains("ADD COLUMN"));
461    }
462
463    #[test]
464    fn test_build_migration_block_multiple_actions() {
465        let migration = MigrationPlan {
466            id: String::new(),
467            version: 1,
468            comment: None,
469            created_at: None,
470            actions: vec![
471                MigrationAction::CreateTable {
472                    table: "users".into(),
473                    columns: vec![test_column("id")],
474                    constraints: vec![],
475                },
476                MigrationAction::CreateTable {
477                    table: "posts".into(),
478                    columns: vec![test_column("id")],
479                    constraints: vec![],
480                },
481            ],
482        };
483
484        let mut baseline = Vec::new();
485        let result = build_migration_block(&migration, &mut baseline);
486
487        assert!(result.is_ok());
488        assert_eq!(baseline.len(), 2);
489    }
490
491    #[test]
492    fn test_generate_migration_code() {
493        let pool: proc_macro2::TokenStream = "db_pool".parse().unwrap();
494        let version_table = "test_versions";
495
496        // Create a simple migration block
497        let migration = MigrationPlan {
498            id: String::new(),
499            version: 1,
500            comment: None,
501            created_at: None,
502            actions: vec![MigrationAction::CreateTable {
503                table: "users".into(),
504                columns: vec![test_column("id")],
505                constraints: vec![],
506            }],
507        };
508
509        let mut baseline = Vec::new();
510        let block = build_migration_block(&migration, &mut baseline).unwrap();
511
512        let generated = generate_migration_code(&pool, version_table, vec![block], false);
513        let generated_str = generated.to_string();
514
515        // Verify the generated code structure
516        assert!(generated_str.contains("async"));
517        assert!(generated_str.contains("db_pool"));
518        assert!(generated_str.contains("test_versions"));
519        assert!(generated_str.contains("run_embedded_migrations"));
520        assert!(generated_str.contains("EmbeddedMigration"));
521        assert!(generated_str.contains("1u32"));
522    }
523
524    #[test]
525    fn test_generate_migration_code_empty_migrations() {
526        let pool: proc_macro2::TokenStream = "pool".parse().unwrap();
527        let version_table = "vespertide_version";
528
529        let generated = generate_migration_code(&pool, version_table, vec![], false);
530        let generated_str = generated.to_string();
531
532        // Should still generate the wrapper code
533        assert!(generated_str.contains("async"));
534        assert!(generated_str.contains("vespertide_version"));
535    }
536
537    #[test]
538    fn test_generate_migration_code_multiple_blocks() {
539        let pool: proc_macro2::TokenStream = "connection".parse().unwrap();
540
541        let mut baseline = Vec::new();
542
543        let migration1 = MigrationPlan {
544            id: String::new(),
545            version: 1,
546            comment: None,
547            created_at: None,
548            actions: vec![MigrationAction::CreateTable {
549                table: "users".into(),
550                columns: vec![test_column("id")],
551                constraints: vec![],
552            }],
553        };
554        let block1 = build_migration_block(&migration1, &mut baseline).unwrap();
555
556        let migration2 = MigrationPlan {
557            id: String::new(),
558            version: 2,
559            comment: None,
560            created_at: None,
561            actions: vec![MigrationAction::CreateTable {
562                table: "posts".into(),
563                columns: vec![test_column("id")],
564                constraints: vec![],
565            }],
566        };
567        let block2 = build_migration_block(&migration2, &mut baseline).unwrap();
568
569        let generated = generate_migration_code(&pool, "migrations", vec![block1, block2], false);
570        let generated_str = generated.to_string();
571
572        // Both migration versions should be present in the metadata array
573        assert!(generated_str.contains("1u32"));
574        assert!(generated_str.contains("2u32"));
575        assert!(generated_str.contains("__VESPERTIDE_MIGRATIONS"));
576    }
577
578    #[test]
579    fn test_generate_migration_code_delegates_runtime_execution() {
580        let pool: proc_macro2::TokenStream = "db_pool".parse().unwrap();
581
582        let migration = MigrationPlan {
583            id: String::new(),
584            version: 1,
585            comment: Some("initial".into()),
586            created_at: None,
587            actions: vec![MigrationAction::CreateTable {
588                table: "users".into(),
589                columns: vec![test_column("id")],
590                constraints: vec![],
591            }],
592        };
593
594        let mut baseline = Vec::new();
595        let block = build_migration_block(&migration, &mut baseline).unwrap();
596
597        let generated = generate_migration_code(&pool, "vespertide_version", vec![block], false);
598        let generated_str = generated.to_string();
599
600        assert!(generated_str.contains("run_embedded_migrations"));
601        assert!(generated_str.contains("EmbeddedMigration"));
602        assert!(!generated_str.contains("SELECT MAX"));
603        assert!(!generated_str.contains("execute_raw"));
604    }
605
606    #[test]
607    fn test_build_migration_block_generates_all_backends() {
608        let migration = MigrationPlan {
609            id: String::new(),
610            version: 1,
611            comment: None,
612            created_at: None,
613            actions: vec![MigrationAction::CreateTable {
614                table: "test_table".into(),
615                columns: vec![test_column("id")],
616                constraints: vec![],
617            }],
618        };
619
620        let mut baseline = Vec::new();
621        let result = build_migration_block(&migration, &mut baseline);
622        assert!(result.is_ok());
623
624        let block = result.unwrap();
625
626        // All three backend SQL arrays should be populated
627        assert!(
628            !block.pg_sqls.is_empty(),
629            "PostgreSQL SQL should not be empty"
630        );
631        assert!(
632            !block.mysql_sqls.is_empty(),
633            "MySQL SQL should not be empty"
634        );
635        assert!(
636            !block.sqlite_sqls.is_empty(),
637            "SQLite SQL should not be empty"
638        );
639
640        // Each should contain CREATE TABLE SQL
641        assert!(block.pg_sqls.iter().any(|s| s.contains("CREATE TABLE")));
642        assert!(block.mysql_sqls.iter().any(|s| s.contains("CREATE TABLE")));
643        assert!(block.sqlite_sqls.iter().any(|s| s.contains("CREATE TABLE")));
644    }
645
646    #[test]
647    fn test_build_migration_block_with_delete_table() {
648        // First create the table
649        let create_migration = MigrationPlan {
650            id: String::new(),
651            version: 1,
652            comment: None,
653            created_at: None,
654            actions: vec![MigrationAction::CreateTable {
655                table: "temp_table".into(),
656                columns: vec![test_column("id")],
657                constraints: vec![],
658            }],
659        };
660
661        let mut baseline = Vec::new();
662        let _ = build_migration_block(&create_migration, &mut baseline);
663        assert_eq!(baseline.len(), 1);
664
665        // Now delete it
666        let delete_migration = MigrationPlan {
667            id: String::new(),
668            version: 2,
669            comment: None,
670            created_at: None,
671            actions: vec![MigrationAction::DeleteTable {
672                table: "temp_table".into(),
673            }],
674        };
675
676        let result = build_migration_block(&delete_migration, &mut baseline);
677        assert!(result.is_ok());
678        let block = result.unwrap();
679        assert!(block.pg_sqls.iter().any(|s| s.contains("DROP TABLE")));
680
681        // Baseline should be empty after delete
682        assert_eq!(baseline.len(), 0);
683    }
684
685    #[test]
686    fn test_build_migration_block_with_index() {
687        let migration = MigrationPlan {
688            id: String::new(),
689            version: 1,
690            comment: None,
691            created_at: None,
692            actions: vec![MigrationAction::CreateTable {
693                table: "users".into(),
694                columns: vec![
695                    test_column("id"),
696                    ColumnDef {
697                        name: "email".into(),
698                        r#type: ColumnType::Simple(SimpleColumnType::Text),
699                        nullable: true,
700                        default: None,
701                        comment: None,
702                        primary_key: None,
703                        unique: None,
704                        index: Some(StrOrBoolOrArray::Bool(true)),
705                        foreign_key: None,
706                    },
707                ],
708                constraints: vec![],
709            }],
710        };
711
712        let mut baseline = Vec::new();
713        let result = build_migration_block(&migration, &mut baseline);
714        assert!(result.is_ok());
715
716        // Table should be normalized with index
717        let table = &baseline[0];
718        let normalized = table.clone().normalize();
719        assert!(normalized.is_ok());
720    }
721
722    #[test]
723    fn test_build_migration_block_error_nonexistent_table() {
724        // Try to add column to a table that doesn't exist - should fail
725        let migration = MigrationPlan {
726            id: String::new(),
727            version: 1,
728            comment: None,
729            created_at: None,
730            actions: vec![MigrationAction::AddColumn {
731                table: "nonexistent_table".into(),
732                column: Box::new(test_column("new_col")),
733                fill_with: None,
734            }],
735        };
736
737        let mut baseline = Vec::new();
738        let result = build_migration_block(&migration, &mut baseline);
739
740        assert!(result.is_err());
741        let err = result.unwrap_err();
742        assert!(err.contains("Failed to build queries for migration version 1"));
743    }
744
745    #[test]
746    fn test_vespertide_migration_impl_loading_error() {
747        // Save original CARGO_MANIFEST_DIR
748        let original = std::env::var("CARGO_MANIFEST_DIR").ok();
749
750        // Remove CARGO_MANIFEST_DIR to trigger loading error
751        unsafe {
752            std::env::remove_var("CARGO_MANIFEST_DIR");
753        }
754
755        let input: proc_macro2::TokenStream = "pool".parse().unwrap();
756        let output = vespertide_migration_impl(input);
757        let output_str = output.to_string();
758
759        // Should contain error about failed loading
760        assert!(
761            output_str.contains("Failed to load migrations at compile time"),
762            "Expected loading error, got: {}",
763            output_str
764        );
765
766        // Restore CARGO_MANIFEST_DIR
767        if let Some(val) = original {
768            unsafe {
769                std::env::set_var("CARGO_MANIFEST_DIR", val);
770            }
771        }
772    }
773
774    #[test]
775    fn test_vespertide_migration_impl_with_valid_project() {
776        use std::fs;
777
778        // Create a temporary directory with a valid vespertide project
779        let dir = tempdir().unwrap();
780        let project_dir = dir.path();
781
782        // Create vespertide.json config
783        let config_content = r#"{
784            "modelsDir": "models",
785            "migrationsDir": "migrations",
786            "tableNamingCase": "snake",
787            "columnNamingCase": "snake",
788            "modelFormat": "json"
789        }"#;
790        fs::write(project_dir.join("vespertide.json"), config_content).unwrap();
791
792        // Create empty models and migrations directories
793        fs::create_dir_all(project_dir.join("models")).unwrap();
794        fs::create_dir_all(project_dir.join("migrations")).unwrap();
795
796        // Save original CARGO_MANIFEST_DIR and set to temp dir
797        let original = std::env::var("CARGO_MANIFEST_DIR").ok();
798        unsafe {
799            std::env::set_var("CARGO_MANIFEST_DIR", project_dir);
800        }
801
802        let input: proc_macro2::TokenStream = "pool".parse().unwrap();
803        let output = vespertide_migration_impl(input);
804        let output_str = output.to_string();
805
806        // Should produce valid async code since there are no migrations
807        assert!(
808            output_str.contains("async"),
809            "Expected async block, got: {}",
810            output_str
811        );
812        assert!(
813            output_str.contains("run_embedded_migrations"),
814            "Expected runtime helper delegation, got: {}",
815            output_str
816        );
817
818        // Restore CARGO_MANIFEST_DIR
819        if let Some(val) = original {
820            unsafe {
821                std::env::set_var("CARGO_MANIFEST_DIR", val);
822            }
823        } else {
824            unsafe {
825                std::env::remove_var("CARGO_MANIFEST_DIR");
826            }
827        }
828    }
829
830    #[test]
831    fn test_build_migration_block_verbose_create_table() {
832        let migration = MigrationPlan {
833            id: String::new(),
834            version: 1,
835            comment: Some("initial setup".into()),
836            created_at: None,
837            actions: vec![MigrationAction::CreateTable {
838                table: "users".into(),
839                columns: vec![test_column("id")],
840                constraints: vec![],
841            }],
842        };
843
844        let mut baseline = Vec::new();
845        let result = build_migration_block(&migration, &mut baseline);
846
847        assert!(result.is_ok());
848        let block = result.unwrap();
849
850        // Metadata should capture comment for verbose logging in generate_migration_code
851        assert_eq!(block.version, 1);
852        assert_eq!(block.comment, "initial setup");
853        // SQL should contain CREATE TABLE
854        assert!(block.pg_sqls.iter().any(|s| s.contains("CREATE TABLE")));
855    }
856
857    #[test]
858    fn test_build_migration_block_verbose_multiple_actions() {
859        let migration = MigrationPlan {
860            id: String::new(),
861            version: 1,
862            comment: None,
863            created_at: None,
864            actions: vec![
865                MigrationAction::CreateTable {
866                    table: "users".into(),
867                    columns: vec![test_column("id")],
868                    constraints: vec![],
869                },
870                MigrationAction::CreateTable {
871                    table: "posts".into(),
872                    columns: vec![test_column("id")],
873                    constraints: vec![],
874                },
875            ],
876        };
877
878        let mut baseline = Vec::new();
879        let result = build_migration_block(&migration, &mut baseline);
880
881        assert!(result.is_ok());
882        assert_eq!(baseline.len(), 2);
883        // Metadata should be set even with multiple actions
884        assert_eq!(result.as_ref().unwrap().version, 1);
885    }
886
887    #[test]
888    fn test_build_migration_block_verbose_add_column() {
889        // Create table first
890        let create = MigrationPlan {
891            id: String::new(),
892            version: 1,
893            comment: None,
894            created_at: None,
895            actions: vec![MigrationAction::CreateTable {
896                table: "users".into(),
897                columns: vec![test_column("id")],
898                constraints: vec![],
899            }],
900        };
901        let mut baseline = Vec::new();
902        let _ = build_migration_block(&create, &mut baseline);
903
904        // Add column
905        let add_col = MigrationPlan {
906            id: String::new(),
907            version: 2,
908            comment: Some("add email".into()),
909            created_at: None,
910            actions: vec![MigrationAction::AddColumn {
911                table: "users".into(),
912                column: Box::new(ColumnDef {
913                    name: "email".into(),
914                    r#type: ColumnType::Simple(SimpleColumnType::Text),
915                    nullable: true,
916                    default: None,
917                    comment: None,
918                    primary_key: None,
919                    unique: None,
920                    index: None,
921                    foreign_key: None,
922                }),
923                fill_with: None,
924            }],
925        };
926
927        let result = build_migration_block(&add_col, &mut baseline);
928        assert!(result.is_ok());
929        let block = result.unwrap();
930        assert_eq!(block.version, 2);
931        assert_eq!(block.comment, "add email");
932        // SQL should contain ALTER TABLE for the add column action
933        assert!(block.pg_sqls.iter().any(|s| s.contains("ALTER TABLE")));
934    }
935
936    #[test]
937    fn test_generate_migration_code_verbose() {
938        let pool: proc_macro2::TokenStream = "db_pool".parse().unwrap();
939        let version_table = "test_versions";
940
941        let migration = MigrationPlan {
942            id: String::new(),
943            version: 1,
944            comment: None,
945            created_at: None,
946            actions: vec![MigrationAction::CreateTable {
947                table: "users".into(),
948                columns: vec![test_column("id")],
949                constraints: vec![],
950            }],
951        };
952
953        let mut baseline = Vec::new();
954        let block = build_migration_block(&migration, &mut baseline).unwrap();
955
956        let generated = generate_migration_code(&pool, version_table, vec![block], true);
957        let generated_str = generated.to_string();
958
959        assert!(generated_str.contains("run_embedded_migrations"));
960        assert!(generated_str.contains("async"));
961    }
962
963    #[test]
964    fn test_macro_parsing_verbose_flag() {
965        // Test parsing the "verbose" keyword
966        let input: proc_macro2::TokenStream = "pool, verbose".parse().unwrap();
967        let output = vespertide_migration_impl(input);
968        let output_str = output.to_string();
969        // Should produce output (either success or migration loading error)
970        assert!(!output_str.is_empty());
971    }
972
973    #[test]
974    fn test_vespertide_migration_impl_with_migrations() {
975        use std::fs;
976
977        // Create a temporary directory with a valid vespertide project and migrations
978        let dir = tempdir().unwrap();
979        let project_dir = dir.path();
980
981        // Create vespertide.json config
982        let config_content = r#"{
983            "modelsDir": "models",
984            "migrationsDir": "migrations",
985            "tableNamingCase": "snake",
986            "columnNamingCase": "snake",
987            "modelFormat": "json"
988        }"#;
989        fs::write(project_dir.join("vespertide.json"), config_content).unwrap();
990
991        // Create models and migrations directories
992        fs::create_dir_all(project_dir.join("models")).unwrap();
993        fs::create_dir_all(project_dir.join("migrations")).unwrap();
994
995        // Create a migration file
996        let migration_content = r#"{
997            "version": 1,
998            "actions": [
999                {
1000                    "type": "create_table",
1001                    "table": "users",
1002                    "columns": [
1003                        {"name": "id", "type": "integer", "nullable": false}
1004                    ],
1005                    "constraints": []
1006                }
1007            ]
1008        }"#;
1009        fs::write(
1010            project_dir.join("migrations").join("0001_initial.json"),
1011            migration_content,
1012        )
1013        .unwrap();
1014
1015        // Save original CARGO_MANIFEST_DIR and set to temp dir
1016        let original = std::env::var("CARGO_MANIFEST_DIR").ok();
1017        unsafe {
1018            std::env::set_var("CARGO_MANIFEST_DIR", project_dir);
1019        }
1020
1021        let input: proc_macro2::TokenStream = "pool".parse().unwrap();
1022        let output = vespertide_migration_impl(input);
1023        let output_str = output.to_string();
1024
1025        // Should produce valid async code with migration
1026        assert!(
1027            output_str.contains("async"),
1028            "Expected async block, got: {}",
1029            output_str
1030        );
1031
1032        // Restore CARGO_MANIFEST_DIR
1033        if let Some(val) = original {
1034            unsafe {
1035                std::env::set_var("CARGO_MANIFEST_DIR", val);
1036            }
1037        } else {
1038            unsafe {
1039                std::env::remove_var("CARGO_MANIFEST_DIR");
1040            }
1041        }
1042    }
1043
1044    #[test]
1045    fn test_vespertide_migration_impl_ignores_invalid_models() {
1046        use std::fs;
1047
1048        let dir = tempdir().unwrap();
1049        let project_dir = dir.path();
1050
1051        let config_content = r#"{
1052            "modelsDir": "models",
1053            "migrationsDir": "migrations",
1054            "tableNamingCase": "snake",
1055            "columnNamingCase": "snake",
1056            "modelFormat": "json"
1057        }"#;
1058        fs::write(project_dir.join("vespertide.json"), config_content).unwrap();
1059
1060        fs::create_dir_all(project_dir.join("models")).unwrap();
1061        fs::create_dir_all(project_dir.join("migrations")).unwrap();
1062
1063        fs::write(
1064            project_dir.join("models").join("broken.json"),
1065            r#"{
1066                "name": "broken",
1067                "columns": [
1068                    {"name": "user_id", "type": "integer", "nullable": false, "foreign_key": "invalid_format"}
1069                ],
1070                "constraints": []
1071            }"#,
1072        )
1073        .unwrap();
1074
1075        fs::write(
1076            project_dir.join("migrations").join("0001_initial.json"),
1077            r#"{
1078                "version": 1,
1079                "actions": [
1080                    {
1081                        "type": "create_table",
1082                        "table": "users",
1083                        "columns": [
1084                            {"name": "id", "type": "integer", "nullable": false}
1085                        ],
1086                        "constraints": []
1087                    }
1088                ]
1089            }"#,
1090        )
1091        .unwrap();
1092
1093        let original = std::env::var("CARGO_MANIFEST_DIR").ok();
1094        unsafe {
1095            std::env::set_var("CARGO_MANIFEST_DIR", project_dir);
1096        }
1097
1098        let input: proc_macro2::TokenStream = "pool".parse().unwrap();
1099        let output = vespertide_migration_impl(input);
1100        let output_str = output.to_string();
1101
1102        assert!(
1103            output_str.contains("async"),
1104            "Expected migration code generation to ignore invalid models, got: {}",
1105            output_str
1106        );
1107
1108        if let Some(val) = original {
1109            unsafe {
1110                std::env::set_var("CARGO_MANIFEST_DIR", val);
1111            }
1112        } else {
1113            unsafe {
1114                std::env::remove_var("CARGO_MANIFEST_DIR");
1115            }
1116        }
1117    }
1118}