1use 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#[derive(Debug)]
11pub struct GeneratedMigration {
12 pub files: Vec<(PathBuf, String)>,
14 pub description: String,
16}
17
18#[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
50pub 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 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 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
103pub 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
139pub 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
175fn 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
200fn 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 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
228fn generate_sqlx_files(
230 migrations_dir: &Path,
231 name: &str,
232 up_sql: &str,
233) -> Result<Vec<(PathBuf, String)>, GenerateError> {
234 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
245fn 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 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
269pub 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 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 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 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 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 assert!(up_content.contains("yauth_totp_secrets"));
362 assert!(up_content.contains("yauth_backup_codes"));
363 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}