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 removed_files: Vec<PathBuf>,
16 pub description: String,
18}
19
20#[derive(Debug)]
22pub enum GenerateError {
23 Schema(crate::SchemaError),
24 Io(std::io::Error),
25 Config(String),
26}
27
28impl std::fmt::Display for GenerateError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 GenerateError::Schema(e) => write!(f, "schema error: {e}"),
32 GenerateError::Io(e) => write!(f, "I/O error: {e}"),
33 GenerateError::Config(msg) => write!(f, "config error: {msg}"),
34 }
35 }
36}
37
38impl std::error::Error for GenerateError {}
39
40impl From<crate::SchemaError> for GenerateError {
41 fn from(e: crate::SchemaError) -> Self {
42 GenerateError::Schema(e)
43 }
44}
45
46impl From<std::io::Error> for GenerateError {
47 fn from(e: std::io::Error) -> Self {
48 GenerateError::Io(e)
49 }
50}
51
52pub fn generate_init(config: &YAuthConfig) -> Result<GeneratedMigration, GenerateError> {
56 let dialect: Dialect = config
57 .migration
58 .dialect
59 .parse()
60 .map_err(|e: String| GenerateError::Config(e))?;
61
62 let schema =
63 collect_schema_for_plugins(&config.plugins.enabled, &config.migration.table_prefix)?;
64
65 let from = YAuthSchema { tables: vec![] };
66 let changes = schema_diff(&from, &schema);
67 let (up_sql, down_sql) = render_changes_sql(&changes, dialect);
68
69 let migrations_dir = Path::new(&config.migration.migrations_dir);
70 let mut files = match config.migration.orm {
71 crate::Orm::Diesel => {
72 generate_diesel_files(migrations_dir, "yauth_init", &up_sql, &down_sql)?
73 }
74 crate::Orm::Sqlx => generate_sqlx_files(migrations_dir, "yauth_init", &up_sql)?,
75 crate::Orm::SeaOrm => {
76 generate_seaorm_files(migrations_dir, "yauth_init", &up_sql, &down_sql)?
77 }
78 crate::Orm::Toasty => {
79 vec![]
82 }
83 };
84
85 if config.migration.orm == crate::Orm::Diesel {
87 let schema_rs = crate::generate_diesel_schema(&schema, dialect);
88 let schema_path = migrations_dir.join("schema.rs");
89 files.push((schema_path, schema_rs));
90 }
91
92 if config.migration.orm == crate::Orm::SeaOrm {
94 let entities_dir = migrations_dir.join("entities");
95 let entity_files = crate::generate_seaorm_entities(&schema, &config.migration.table_prefix);
96 for (name, content) in entity_files {
97 files.push((entities_dir.join(name), content));
98 }
99 }
100
101 if config.migration.orm == crate::Orm::Toasty {
103 let model_files = crate::generate_toasty_models(&schema, &config.migration.table_prefix);
104 for (name, content) in model_files {
105 files.push((migrations_dir.join(name), content));
106 }
107 }
108
109 if config.migration.orm == crate::Orm::Sqlx {
111 let queries_dir = Path::new(&config.migration.queries_dir);
112 let generated = crate::sqlx_queries::generate_queries(
113 queries_dir,
114 &config.plugins.enabled,
115 &config.migration.table_prefix,
116 dialect,
117 );
118 files.extend(generated.files);
119 }
120
121 Ok(GeneratedMigration {
122 files,
123 removed_files: vec![],
124 description: format!(
125 "Initial yauth migration with plugins: {}",
126 config.plugins.enabled.join(", ")
127 ),
128 })
129}
130
131pub fn generate_add_plugin(
136 config: &YAuthConfig,
137 plugin_name: &str,
138 up_sql: &str,
139 down_sql: &str,
140) -> Result<GeneratedMigration, GenerateError> {
141 if up_sql.trim().is_empty() {
142 return Ok(GeneratedMigration {
143 files: vec![],
144 removed_files: vec![],
145 description: format!("No schema changes for plugin '{plugin_name}'"),
146 });
147 }
148
149 let migration_name = format!("yauth_add_{}", plugin_name.replace('-', "_"));
150
151 let migrations_dir = Path::new(&config.migration.migrations_dir);
152 let mut files = match config.migration.orm {
153 crate::Orm::Diesel => {
154 generate_diesel_files(migrations_dir, &migration_name, up_sql, down_sql)?
155 }
156 crate::Orm::Sqlx => generate_sqlx_files(migrations_dir, &migration_name, up_sql)?,
157 crate::Orm::SeaOrm => {
158 generate_seaorm_files(migrations_dir, &migration_name, up_sql, down_sql)?
159 }
160 crate::Orm::Toasty => vec![], };
162
163 if config.migration.orm == crate::Orm::Sqlx {
165 let dialect: crate::Dialect = config
166 .migration
167 .dialect
168 .parse()
169 .map_err(|e: String| GenerateError::Config(e))?;
170 let queries_dir = Path::new(&config.migration.queries_dir);
171 let query_files = crate::sqlx_queries::plugin_queries_only(
172 queries_dir,
173 plugin_name,
174 &config.migration.table_prefix,
175 dialect,
176 );
177 files.extend(query_files);
178 }
179
180 Ok(GeneratedMigration {
181 files,
182 removed_files: vec![],
183 description: format!("Add plugin '{plugin_name}'"),
184 })
185}
186
187pub fn generate_remove_plugin(
192 config: &YAuthConfig,
193 plugin_name: &str,
194 up_sql: &str,
195 down_sql: &str,
196) -> Result<GeneratedMigration, GenerateError> {
197 if up_sql.trim().is_empty() {
198 return Ok(GeneratedMigration {
199 files: vec![],
200 removed_files: vec![],
201 description: format!("No schema changes for removing plugin '{plugin_name}'"),
202 });
203 }
204
205 let migration_name = format!("yauth_remove_{}", plugin_name.replace('-', "_"));
206
207 let migrations_dir = Path::new(&config.migration.migrations_dir);
208 let files = match config.migration.orm {
209 crate::Orm::Diesel => {
210 generate_diesel_files(migrations_dir, &migration_name, up_sql, down_sql)?
211 }
212 crate::Orm::Sqlx => generate_sqlx_files(migrations_dir, &migration_name, up_sql)?,
213 crate::Orm::SeaOrm => {
214 generate_seaorm_files(migrations_dir, &migration_name, up_sql, down_sql)?
215 }
216 crate::Orm::Toasty => vec![], };
218
219 let removed_queries = if config.migration.orm == crate::Orm::Sqlx {
221 let queries_dir = Path::new(&config.migration.queries_dir);
222 crate::sqlx_queries::plugin_query_filenames(plugin_name)
223 .into_iter()
224 .map(|f| queries_dir.join(f))
225 .collect()
226 } else {
227 vec![]
228 };
229
230 Ok(GeneratedMigration {
231 files,
232 removed_files: removed_queries,
233 description: format!("Remove plugin '{plugin_name}'"),
234 })
235}
236
237fn generate_diesel_files(
241 migrations_dir: &Path,
242 name: &str,
243 up_sql: &str,
244 down_sql: &str,
245) -> Result<Vec<(PathBuf, String)>, GenerateError> {
246 let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
247 let dir_name = format!("{}_{}", timestamp, name);
248 let migration_dir = migrations_dir.join(dir_name);
249
250 let up_path = migration_dir.join("up.sql");
251 let down_path = migration_dir.join("down.sql");
252
253 Ok(vec![
254 (up_path, format!("-- Generated by cargo-yauth\n{up_sql}")),
255 (
256 down_path,
257 format!("-- Generated by cargo-yauth\n{down_sql}"),
258 ),
259 ])
260}
261
262fn generate_seaorm_files(
268 migrations_dir: &Path,
269 name: &str,
270 up_sql: &str,
271 down_sql: &str,
272) -> Result<Vec<(PathBuf, String)>, GenerateError> {
273 let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
275 let dir_name = format!("m{}_{}", timestamp, name);
276 let migration_dir = migrations_dir.join(dir_name);
277
278 let up_path = migration_dir.join("up.sql");
279 let down_path = migration_dir.join("down.sql");
280
281 Ok(vec![
282 (up_path, format!("-- Generated by cargo-yauth\n{up_sql}")),
283 (
284 down_path,
285 format!("-- Generated by cargo-yauth\n{down_sql}"),
286 ),
287 ])
288}
289
290fn generate_sqlx_files(
292 migrations_dir: &Path,
293 name: &str,
294 up_sql: &str,
295) -> Result<Vec<(PathBuf, String)>, GenerateError> {
296 let next_num = next_sqlx_number(migrations_dir);
298 let file_name = format!("{:08}_{}.sql", next_num, name);
299 let path = migrations_dir.join(file_name);
300
301 Ok(vec![(
302 path,
303 format!("-- Generated by cargo-yauth\n{up_sql}"),
304 )])
305}
306
307fn next_sqlx_number(migrations_dir: &Path) -> u32 {
309 if !migrations_dir.exists() {
310 return 1;
311 }
312
313 let max = std::fs::read_dir(migrations_dir)
314 .ok()
315 .map(|entries| {
316 entries
317 .filter_map(|e| e.ok())
318 .filter_map(|e| {
319 let name = e.file_name().to_string_lossy().to_string();
320 name.split('_').next().and_then(|n| n.parse::<u32>().ok())
322 })
323 .max()
324 .unwrap_or(0)
325 })
326 .unwrap_or(0);
327
328 max + 1
329}
330
331pub fn write_migration(migration: &GeneratedMigration) -> Result<(), GenerateError> {
333 for (path, content) in &migration.files {
334 if let Some(parent) = path.parent() {
335 std::fs::create_dir_all(parent)?;
336 }
337 std::fs::write(path, content)?;
338 }
339 for path in &migration.removed_files {
341 if path.exists() {
342 std::fs::remove_file(path)?;
343 }
344 }
345 Ok(())
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::config::YAuthConfig;
352
353 #[test]
354 fn generate_init_diesel_postgres() {
355 let config = YAuthConfig::new(
356 crate::Orm::Diesel,
357 "postgres",
358 vec!["email-password".to_string()],
359 );
360 let result = generate_init(&config).unwrap();
361 assert!(!result.files.is_empty());
362 assert_eq!(result.files.len(), 3);
364 let up_content = &result.files[0].1;
365 assert!(up_content.contains("CREATE TABLE IF NOT EXISTS yauth_users"));
366 assert!(up_content.contains("CREATE TABLE IF NOT EXISTS yauth_passwords"));
367 let schema_rs = &result.files[2].1;
369 assert!(schema_rs.contains("diesel::table!"));
370 assert!(schema_rs.contains("yauth_users (id)"));
371 }
372
373 #[test]
374 fn generate_init_sqlx_sqlite() {
375 let config = YAuthConfig::new(
376 crate::Orm::Sqlx,
377 "sqlite",
378 vec!["email-password".to_string()],
379 );
380 let result = generate_init(&config).unwrap();
381 assert!(
383 result.files.len() > 1,
384 "Should have migration + query files"
385 );
386 let content = &result.files[0].1;
388 assert!(content.contains("CREATE TABLE IF NOT EXISTS yauth_users"));
389 assert!(!content.contains("UUID "));
391 assert!(!content.contains("TIMESTAMPTZ"));
392 let query_files: Vec<_> = result
394 .files
395 .iter()
396 .filter(|(p, _)| p.starts_with("queries"))
397 .collect();
398 assert!(!query_files.is_empty(), "Should have query files");
399 for (path, content) in &query_files {
401 if content.contains("-- Params: none") {
402 continue;
403 }
404 assert!(
405 content.contains("?"),
406 "SQLite query should use ? params: {}",
407 path.display()
408 );
409 }
410 }
411
412 #[test]
413 fn generate_init_with_custom_prefix() {
414 let mut config = YAuthConfig::new(
415 crate::Orm::Diesel,
416 "postgres",
417 vec!["email-password".to_string()],
418 );
419 config.migration.table_prefix = "auth_".to_string();
420 let result = generate_init(&config).unwrap();
421 let up_content = &result.files[0].1;
422 assert!(up_content.contains("auth_users"));
423 assert!(up_content.contains("auth_passwords"));
424 assert!(!up_content.contains("yauth_"));
425 }
426
427 #[test]
428 fn generate_add_plugin_produces_incremental_sql() {
429 use crate::collect_schema_for_plugins;
430 use crate::diff::{render_changes_sql, schema_diff};
431
432 let mut config = YAuthConfig::new(
433 crate::Orm::Diesel,
434 "postgres",
435 vec!["email-password".to_string(), "mfa".to_string()],
436 );
437 config.migration.migrations_dir = "migrations".to_string();
438
439 let previous = vec!["email-password".to_string()];
440 let from = collect_schema_for_plugins(&previous, &config.migration.table_prefix).unwrap();
441 let to =
442 collect_schema_for_plugins(&config.plugins.enabled, &config.migration.table_prefix)
443 .unwrap();
444 let changes = schema_diff(&from, &to);
445 let (up_sql, down_sql) = render_changes_sql(&changes, crate::Dialect::Postgres);
446
447 let result = generate_add_plugin(&config, "mfa", &up_sql, &down_sql).unwrap();
448 assert!(!result.files.is_empty());
449 let up_content = &result.files[0].1;
450 assert!(up_content.contains("yauth_totp_secrets"));
452 assert!(up_content.contains("yauth_backup_codes"));
453 assert!(!up_content.contains("CREATE TABLE IF NOT EXISTS yauth_users"));
455 }
456
457 #[test]
458 fn generate_remove_plugin_produces_drop_sql() {
459 use crate::collect_schema_for_plugins;
460 use crate::diff::{render_changes_sql, schema_diff};
461
462 let config = YAuthConfig::new(
463 crate::Orm::Diesel,
464 "postgres",
465 vec!["email-password".to_string()],
466 );
467
468 let previous = vec!["email-password".to_string(), "passkey".to_string()];
469 let from = collect_schema_for_plugins(&previous, &config.migration.table_prefix).unwrap();
470 let to =
471 collect_schema_for_plugins(&config.plugins.enabled, &config.migration.table_prefix)
472 .unwrap();
473 let changes = schema_diff(&from, &to);
474 let (up_sql, down_sql) = render_changes_sql(&changes, crate::Dialect::Postgres);
475
476 let result = generate_remove_plugin(&config, "passkey", &up_sql, &down_sql).unwrap();
477 assert!(!result.files.is_empty());
478 let up_content = &result.files[0].1;
479 assert!(up_content.contains("DROP TABLE IF EXISTS yauth_webauthn_credentials"));
480 }
481
482 #[test]
483 fn generate_init_mysql_dialect() {
484 let config = YAuthConfig::new(
485 crate::Orm::Diesel,
486 "mysql",
487 vec!["email-password".to_string()],
488 );
489 let result = generate_init(&config).unwrap();
490 let up_content = &result.files[0].1;
491 assert!(up_content.contains("ENGINE=InnoDB"));
492 assert!(up_content.contains("CHAR(36)"));
493 }
494}