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