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