Skip to main content

yauth_migration/
generate.rs

1//! Migration file generators for diesel and sqlx.
2
3use std::path::{Path, PathBuf};
4
5use crate::config::YAuthConfig;
6use crate::diff::{render_changes_sql, schema_diff};
7use crate::{Dialect, YAuthSchema, collect_schema_for_plugins};
8
9/// Result of migration file generation.
10#[derive(Debug)]
11pub struct GeneratedMigration {
12    /// Files that were written (path -> content).
13    pub files: Vec<(PathBuf, String)>,
14    /// Files that should be deleted (e.g., query files for removed plugins).
15    pub removed_files: Vec<PathBuf>,
16    /// Human-readable description of what was generated.
17    pub description: String,
18}
19
20/// Error from migration generation.
21#[derive(Debug)]
22pub enum GenerateError {
23    Schema(crate::SchemaError),
24    Io(std::io::Error),
25    Config(String),
26}
27
28impl std::fmt::Display for GenerateError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            GenerateError::Schema(e) => write!(f, "schema error: {e}"),
32            GenerateError::Io(e) => write!(f, "I/O error: {e}"),
33            GenerateError::Config(msg) => write!(f, "config error: {msg}"),
34        }
35    }
36}
37
38impl std::error::Error for GenerateError {}
39
40impl From<crate::SchemaError> for GenerateError {
41    fn from(e: crate::SchemaError) -> Self {
42        GenerateError::Schema(e)
43    }
44}
45
46impl From<std::io::Error> for GenerateError {
47    fn from(e: std::io::Error) -> Self {
48        GenerateError::Io(e)
49    }
50}
51
52/// Generate the initial migration files for a yauth.toml config.
53///
54/// Creates migration files for all enabled plugins from scratch (empty -> current).
55pub fn generate_init(config: &YAuthConfig) -> Result<GeneratedMigration, GenerateError> {
56    let dialect: Dialect = config
57        .migration
58        .dialect
59        .parse()
60        .map_err(|e: String| GenerateError::Config(e))?;
61
62    let schema =
63        collect_schema_for_plugins(&config.plugins.enabled, &config.migration.table_prefix)?;
64
65    let from = YAuthSchema { tables: vec![] };
66    let changes = schema_diff(&from, &schema);
67    let (up_sql, down_sql) = render_changes_sql(&changes, dialect);
68
69    let migrations_dir = Path::new(&config.migration.migrations_dir);
70    let mut files = match config.migration.orm {
71        crate::Orm::Diesel => {
72            generate_diesel_files(migrations_dir, "yauth_init", &up_sql, &down_sql)?
73        }
74        crate::Orm::Sqlx => generate_sqlx_files(migrations_dir, "yauth_init", &up_sql)?,
75        crate::Orm::SeaOrm => {
76            generate_seaorm_files(migrations_dir, "yauth_init", &up_sql, &down_sql)?
77        }
78        crate::Orm::Toasty => {
79            // Toasty uses push_schema() — no SQL migration files needed.
80            // Only generate model .rs files.
81            vec![]
82        }
83    };
84
85    // For diesel ORM, also generate a schema.rs file
86    if config.migration.orm == crate::Orm::Diesel {
87        let schema_rs = crate::generate_diesel_schema(&schema, dialect);
88        let schema_path = migrations_dir.join("schema.rs");
89        files.push((schema_path, schema_rs));
90    }
91
92    // For SeaORM, also generate entity .rs files
93    if config.migration.orm == crate::Orm::SeaOrm {
94        let entities_dir = migrations_dir.join("entities");
95        let entity_files = crate::generate_seaorm_entities(&schema, &config.migration.table_prefix);
96        for (name, content) in entity_files {
97            files.push((entities_dir.join(name), content));
98        }
99    }
100
101    // For Toasty, generate #[derive(toasty::Model)] files (no SQL needed)
102    if config.migration.orm == crate::Orm::Toasty {
103        let model_files = crate::generate_toasty_models(&schema, &config.migration.table_prefix);
104        for (name, content) in model_files {
105            files.push((migrations_dir.join(name), content));
106        }
107    }
108
109    // For sqlx, also generate query files
110    if config.migration.orm == crate::Orm::Sqlx {
111        let queries_dir = Path::new(&config.migration.queries_dir);
112        let generated = crate::sqlx_queries::generate_queries(
113            queries_dir,
114            &config.plugins.enabled,
115            &config.migration.table_prefix,
116            dialect,
117        );
118        files.extend(generated.files);
119    }
120
121    Ok(GeneratedMigration {
122        files,
123        removed_files: vec![],
124        description: format!(
125            "Initial yauth migration with plugins: {}",
126            config.plugins.enabled.join(", ")
127        ),
128    })
129}
130
131/// Generate migration files for adding a plugin.
132///
133/// Accepts pre-computed `(up_sql, down_sql)` to avoid recomputing the schema diff
134/// (callers typically compute it for preview before calling this function).
135pub fn generate_add_plugin(
136    config: &YAuthConfig,
137    plugin_name: &str,
138    up_sql: &str,
139    down_sql: &str,
140) -> Result<GeneratedMigration, GenerateError> {
141    if up_sql.trim().is_empty() {
142        return Ok(GeneratedMigration {
143            files: vec![],
144            removed_files: vec![],
145            description: format!("No schema changes for plugin '{plugin_name}'"),
146        });
147    }
148
149    let migration_name = format!("yauth_add_{}", plugin_name.replace('-', "_"));
150
151    let migrations_dir = Path::new(&config.migration.migrations_dir);
152    let mut files = match config.migration.orm {
153        crate::Orm::Diesel => {
154            generate_diesel_files(migrations_dir, &migration_name, up_sql, down_sql)?
155        }
156        crate::Orm::Sqlx => generate_sqlx_files(migrations_dir, &migration_name, up_sql)?,
157        crate::Orm::SeaOrm => {
158            generate_seaorm_files(migrations_dir, &migration_name, up_sql, down_sql)?
159        }
160        crate::Orm::Toasty => vec![], // Toasty uses push_schema(), no migration files
161    };
162
163    // For sqlx, also generate query files for the new plugin
164    if config.migration.orm == crate::Orm::Sqlx {
165        let dialect: crate::Dialect = config
166            .migration
167            .dialect
168            .parse()
169            .map_err(|e: String| GenerateError::Config(e))?;
170        let queries_dir = Path::new(&config.migration.queries_dir);
171        let query_files = crate::sqlx_queries::plugin_queries_only(
172            queries_dir,
173            plugin_name,
174            &config.migration.table_prefix,
175            dialect,
176        );
177        files.extend(query_files);
178    }
179
180    Ok(GeneratedMigration {
181        files,
182        removed_files: vec![],
183        description: format!("Add plugin '{plugin_name}'"),
184    })
185}
186
187/// Generate migration files for removing a plugin.
188///
189/// Accepts pre-computed `(up_sql, down_sql)` to avoid recomputing the schema diff
190/// (callers typically compute it for preview before calling this function).
191pub fn generate_remove_plugin(
192    config: &YAuthConfig,
193    plugin_name: &str,
194    up_sql: &str,
195    down_sql: &str,
196) -> Result<GeneratedMigration, GenerateError> {
197    if up_sql.trim().is_empty() {
198        return Ok(GeneratedMigration {
199            files: vec![],
200            removed_files: vec![],
201            description: format!("No schema changes for removing plugin '{plugin_name}'"),
202        });
203    }
204
205    let migration_name = format!("yauth_remove_{}", plugin_name.replace('-', "_"));
206
207    let migrations_dir = Path::new(&config.migration.migrations_dir);
208    let files = match config.migration.orm {
209        crate::Orm::Diesel => {
210            generate_diesel_files(migrations_dir, &migration_name, up_sql, down_sql)?
211        }
212        crate::Orm::Sqlx => generate_sqlx_files(migrations_dir, &migration_name, up_sql)?,
213        crate::Orm::SeaOrm => {
214            generate_seaorm_files(migrations_dir, &migration_name, up_sql, down_sql)?
215        }
216        crate::Orm::Toasty => vec![], // Toasty uses push_schema(), no migration files
217    };
218
219    // For sqlx, record which query files should be deleted
220    let removed_queries = if config.migration.orm == crate::Orm::Sqlx {
221        let queries_dir = Path::new(&config.migration.queries_dir);
222        crate::sqlx_queries::plugin_query_filenames(plugin_name)
223            .into_iter()
224            .map(|f| queries_dir.join(f))
225            .collect()
226    } else {
227        vec![]
228    };
229
230    Ok(GeneratedMigration {
231        files,
232        removed_files: removed_queries,
233        description: format!("Remove plugin '{plugin_name}'"),
234    })
235}
236
237// -- ORM-specific file generators --
238
239/// Generate diesel migration files: `YYYYMMDDHHMMSS_name/up.sql` and `down.sql`.
240fn generate_diesel_files(
241    migrations_dir: &Path,
242    name: &str,
243    up_sql: &str,
244    down_sql: &str,
245) -> Result<Vec<(PathBuf, String)>, GenerateError> {
246    let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
247    let dir_name = format!("{}_{}", timestamp, name);
248    let migration_dir = migrations_dir.join(dir_name);
249
250    let up_path = migration_dir.join("up.sql");
251    let down_path = migration_dir.join("down.sql");
252
253    Ok(vec![
254        (up_path, format!("-- Generated by cargo-yauth\n{up_sql}")),
255        (
256            down_path,
257            format!("-- Generated by cargo-yauth\n{down_sql}"),
258        ),
259    ])
260}
261
262/// Generate SeaORM migration files: `mNNNNNNNNNNNNNN_name/up.sql` and `down.sql`.
263///
264/// SeaORM uses a `sea-orm-migration` crate with Rust-based migrations,
265/// but the SQL files serve as a portable reference. Users can run them
266/// via `sea-orm-cli migrate` or integrate with their own migration pipeline.
267fn generate_seaorm_files(
268    migrations_dir: &Path,
269    name: &str,
270    up_sql: &str,
271    down_sql: &str,
272) -> Result<Vec<(PathBuf, String)>, GenerateError> {
273    // Use sea-orm-migration's `mYYYYMMDDHHMMSS_name` convention
274    let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
275    let dir_name = format!("m{}_{}", timestamp, name);
276    let migration_dir = migrations_dir.join(dir_name);
277
278    let up_path = migration_dir.join("up.sql");
279    let down_path = migration_dir.join("down.sql");
280
281    Ok(vec![
282        (up_path, format!("-- Generated by cargo-yauth\n{up_sql}")),
283        (
284            down_path,
285            format!("-- Generated by cargo-yauth\n{down_sql}"),
286        ),
287    ])
288}
289
290/// Generate sqlx migration files: `NNNNNNNN_name.sql`.
291fn generate_sqlx_files(
292    migrations_dir: &Path,
293    name: &str,
294    up_sql: &str,
295) -> Result<Vec<(PathBuf, String)>, GenerateError> {
296    // Scan existing migrations to find the next number
297    let next_num = next_sqlx_number(migrations_dir);
298    let file_name = format!("{:08}_{}.sql", next_num, name);
299    let path = migrations_dir.join(file_name);
300
301    Ok(vec![(
302        path,
303        format!("-- Generated by cargo-yauth\n{up_sql}"),
304    )])
305}
306
307/// Find the next sequential number for sqlx migrations.
308fn next_sqlx_number(migrations_dir: &Path) -> u32 {
309    if !migrations_dir.exists() {
310        return 1;
311    }
312
313    let max = std::fs::read_dir(migrations_dir)
314        .ok()
315        .map(|entries| {
316            entries
317                .filter_map(|e| e.ok())
318                .filter_map(|e| {
319                    let name = e.file_name().to_string_lossy().to_string();
320                    // Parse NNNNNNNN from the beginning
321                    name.split('_').next().and_then(|n| n.parse::<u32>().ok())
322                })
323                .max()
324                .unwrap_or(0)
325        })
326        .unwrap_or(0);
327
328    max + 1
329}
330
331/// Write generated migration files to disk.
332pub fn write_migration(migration: &GeneratedMigration) -> Result<(), GenerateError> {
333    for (path, content) in &migration.files {
334        if let Some(parent) = path.parent() {
335            std::fs::create_dir_all(parent)?;
336        }
337        std::fs::write(path, content)?;
338    }
339    // Remove files (e.g., query files from removed plugins)
340    for path in &migration.removed_files {
341        if path.exists() {
342            std::fs::remove_file(path)?;
343        }
344    }
345    Ok(())
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::config::YAuthConfig;
352
353    #[test]
354    fn generate_init_diesel_postgres() {
355        let config = YAuthConfig::new(
356            crate::Orm::Diesel,
357            "postgres",
358            vec!["email-password".to_string()],
359        );
360        let result = generate_init(&config).unwrap();
361        assert!(!result.files.is_empty());
362        // Should have up.sql, down.sql, and schema.rs
363        assert_eq!(result.files.len(), 3);
364        let up_content = &result.files[0].1;
365        assert!(up_content.contains("CREATE TABLE IF NOT EXISTS yauth_users"));
366        assert!(up_content.contains("CREATE TABLE IF NOT EXISTS yauth_passwords"));
367        // schema.rs should contain diesel table! macros
368        let schema_rs = &result.files[2].1;
369        assert!(schema_rs.contains("diesel::table!"));
370        assert!(schema_rs.contains("yauth_users (id)"));
371    }
372
373    #[test]
374    fn generate_init_sqlx_sqlite() {
375        let config = YAuthConfig::new(
376            crate::Orm::Sqlx,
377            "sqlite",
378            vec!["email-password".to_string()],
379        );
380        let result = generate_init(&config).unwrap();
381        // sqlx produces a migration file + query files
382        assert!(
383            result.files.len() > 1,
384            "Should have migration + query files"
385        );
386        // First file is the migration SQL
387        let content = &result.files[0].1;
388        assert!(content.contains("CREATE TABLE IF NOT EXISTS yauth_users"));
389        // SQLite should not have UUID or TIMESTAMPTZ
390        assert!(!content.contains("UUID "));
391        assert!(!content.contains("TIMESTAMPTZ"));
392        // Query files should exist
393        let query_files: Vec<_> = result
394            .files
395            .iter()
396            .filter(|(p, _)| p.starts_with("queries"))
397            .collect();
398        assert!(!query_files.is_empty(), "Should have query files");
399        // Query files should use ? params (SQLite style)
400        for (path, content) in &query_files {
401            if content.contains("-- Params: none") {
402                continue;
403            }
404            assert!(
405                content.contains("?"),
406                "SQLite query should use ? params: {}",
407                path.display()
408            );
409        }
410    }
411
412    #[test]
413    fn generate_init_with_custom_prefix() {
414        let mut config = YAuthConfig::new(
415            crate::Orm::Diesel,
416            "postgres",
417            vec!["email-password".to_string()],
418        );
419        config.migration.table_prefix = "auth_".to_string();
420        let result = generate_init(&config).unwrap();
421        let up_content = &result.files[0].1;
422        assert!(up_content.contains("auth_users"));
423        assert!(up_content.contains("auth_passwords"));
424        assert!(!up_content.contains("yauth_"));
425    }
426
427    #[test]
428    fn generate_add_plugin_produces_incremental_sql() {
429        use crate::collect_schema_for_plugins;
430        use crate::diff::{render_changes_sql, schema_diff};
431
432        let mut config = YAuthConfig::new(
433            crate::Orm::Diesel,
434            "postgres",
435            vec!["email-password".to_string(), "mfa".to_string()],
436        );
437        config.migration.migrations_dir = "migrations".to_string();
438
439        let previous = vec!["email-password".to_string()];
440        let from = collect_schema_for_plugins(&previous, &config.migration.table_prefix).unwrap();
441        let to =
442            collect_schema_for_plugins(&config.plugins.enabled, &config.migration.table_prefix)
443                .unwrap();
444        let changes = schema_diff(&from, &to);
445        let (up_sql, down_sql) = render_changes_sql(&changes, crate::Dialect::Postgres);
446
447        let result = generate_add_plugin(&config, "mfa", &up_sql, &down_sql).unwrap();
448        assert!(!result.files.is_empty());
449        let up_content = &result.files[0].1;
450        // Should only have mfa tables, not core or email-password
451        assert!(up_content.contains("yauth_totp_secrets"));
452        assert!(up_content.contains("yauth_backup_codes"));
453        // Core tables should not be created, but FK references to yauth_users are expected
454        assert!(!up_content.contains("CREATE TABLE IF NOT EXISTS yauth_users"));
455    }
456
457    #[test]
458    fn generate_remove_plugin_produces_drop_sql() {
459        use crate::collect_schema_for_plugins;
460        use crate::diff::{render_changes_sql, schema_diff};
461
462        let config = YAuthConfig::new(
463            crate::Orm::Diesel,
464            "postgres",
465            vec!["email-password".to_string()],
466        );
467
468        let previous = vec!["email-password".to_string(), "passkey".to_string()];
469        let from = collect_schema_for_plugins(&previous, &config.migration.table_prefix).unwrap();
470        let to =
471            collect_schema_for_plugins(&config.plugins.enabled, &config.migration.table_prefix)
472                .unwrap();
473        let changes = schema_diff(&from, &to);
474        let (up_sql, down_sql) = render_changes_sql(&changes, crate::Dialect::Postgres);
475
476        let result = generate_remove_plugin(&config, "passkey", &up_sql, &down_sql).unwrap();
477        assert!(!result.files.is_empty());
478        let up_content = &result.files[0].1;
479        assert!(up_content.contains("DROP TABLE IF EXISTS yauth_webauthn_credentials"));
480    }
481
482    #[test]
483    fn generate_init_mysql_dialect() {
484        let config = YAuthConfig::new(
485            crate::Orm::Diesel,
486            "mysql",
487            vec!["email-password".to_string()],
488        );
489        let result = generate_init(&config).unwrap();
490        let up_content = &result.files[0].1;
491        assert!(up_content.contains("ENGINE=InnoDB"));
492        assert!(up_content.contains("CHAR(36)"));
493    }
494}