Skip to main content

yauth_migration/
generate.rs

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