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