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 crate::Orm::Toasty => {
77 vec![]
80 }
81 };
82
83 if config.migration.orm == crate::Orm::Diesel {
85 let schema_rs = crate::generate_diesel_schema(&schema, dialect);
86 let schema_path = migrations_dir.join("schema.rs");
87 files.push((schema_path, schema_rs));
88 }
89
90 if config.migration.orm == crate::Orm::SeaOrm {
92 let entities_dir = migrations_dir.join("entities");
93 let entity_files = crate::generate_seaorm_entities(&schema, &config.migration.table_prefix);
94 for (name, content) in entity_files {
95 files.push((entities_dir.join(name), content));
96 }
97 }
98
99 if config.migration.orm == crate::Orm::Toasty {
101 let model_files = crate::generate_toasty_models(&schema, &config.migration.table_prefix);
102 for (name, content) in model_files {
103 files.push((migrations_dir.join(name), content));
104 }
105 }
106
107 Ok(GeneratedMigration {
108 files,
109 description: format!(
110 "Initial yauth migration with plugins: {}",
111 config.plugins.enabled.join(", ")
112 ),
113 })
114}
115
116pub fn generate_add_plugin(
121 config: &YAuthConfig,
122 plugin_name: &str,
123 up_sql: &str,
124 down_sql: &str,
125) -> Result<GeneratedMigration, GenerateError> {
126 if up_sql.trim().is_empty() {
127 return Ok(GeneratedMigration {
128 files: vec![],
129 description: format!("No schema changes for plugin '{plugin_name}'"),
130 });
131 }
132
133 let migration_name = format!("yauth_add_{}", plugin_name.replace('-', "_"));
134
135 let migrations_dir = Path::new(&config.migration.migrations_dir);
136 let files = match config.migration.orm {
137 crate::Orm::Diesel => {
138 generate_diesel_files(migrations_dir, &migration_name, up_sql, down_sql)?
139 }
140 crate::Orm::Sqlx => generate_sqlx_files(migrations_dir, &migration_name, up_sql)?,
141 crate::Orm::SeaOrm => {
142 generate_seaorm_files(migrations_dir, &migration_name, up_sql, down_sql)?
143 }
144 crate::Orm::Toasty => vec![], };
146
147 Ok(GeneratedMigration {
148 files,
149 description: format!("Add plugin '{plugin_name}'"),
150 })
151}
152
153pub fn generate_remove_plugin(
158 config: &YAuthConfig,
159 plugin_name: &str,
160 up_sql: &str,
161 down_sql: &str,
162) -> Result<GeneratedMigration, GenerateError> {
163 if up_sql.trim().is_empty() {
164 return Ok(GeneratedMigration {
165 files: vec![],
166 description: format!("No schema changes for removing plugin '{plugin_name}'"),
167 });
168 }
169
170 let migration_name = format!("yauth_remove_{}", plugin_name.replace('-', "_"));
171
172 let migrations_dir = Path::new(&config.migration.migrations_dir);
173 let files = match config.migration.orm {
174 crate::Orm::Diesel => {
175 generate_diesel_files(migrations_dir, &migration_name, up_sql, down_sql)?
176 }
177 crate::Orm::Sqlx => generate_sqlx_files(migrations_dir, &migration_name, up_sql)?,
178 crate::Orm::SeaOrm => {
179 generate_seaorm_files(migrations_dir, &migration_name, up_sql, down_sql)?
180 }
181 crate::Orm::Toasty => vec![], };
183
184 Ok(GeneratedMigration {
185 files,
186 description: format!("Remove plugin '{plugin_name}'"),
187 })
188}
189
190fn generate_diesel_files(
194 migrations_dir: &Path,
195 name: &str,
196 up_sql: &str,
197 down_sql: &str,
198) -> Result<Vec<(PathBuf, String)>, GenerateError> {
199 let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
200 let dir_name = format!("{}_{}", timestamp, name);
201 let migration_dir = migrations_dir.join(dir_name);
202
203 let up_path = migration_dir.join("up.sql");
204 let down_path = migration_dir.join("down.sql");
205
206 Ok(vec![
207 (up_path, format!("-- Generated by cargo-yauth\n{up_sql}")),
208 (
209 down_path,
210 format!("-- Generated by cargo-yauth\n{down_sql}"),
211 ),
212 ])
213}
214
215fn generate_seaorm_files(
221 migrations_dir: &Path,
222 name: &str,
223 up_sql: &str,
224 down_sql: &str,
225) -> Result<Vec<(PathBuf, String)>, GenerateError> {
226 let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
228 let dir_name = format!("m{}_{}", timestamp, name);
229 let migration_dir = migrations_dir.join(dir_name);
230
231 let up_path = migration_dir.join("up.sql");
232 let down_path = migration_dir.join("down.sql");
233
234 Ok(vec![
235 (up_path, format!("-- Generated by cargo-yauth\n{up_sql}")),
236 (
237 down_path,
238 format!("-- Generated by cargo-yauth\n{down_sql}"),
239 ),
240 ])
241}
242
243fn generate_sqlx_files(
245 migrations_dir: &Path,
246 name: &str,
247 up_sql: &str,
248) -> Result<Vec<(PathBuf, String)>, GenerateError> {
249 let next_num = next_sqlx_number(migrations_dir);
251 let file_name = format!("{:08}_{}.sql", next_num, name);
252 let path = migrations_dir.join(file_name);
253
254 Ok(vec![(
255 path,
256 format!("-- Generated by cargo-yauth\n{up_sql}"),
257 )])
258}
259
260fn next_sqlx_number(migrations_dir: &Path) -> u32 {
262 if !migrations_dir.exists() {
263 return 1;
264 }
265
266 let max = std::fs::read_dir(migrations_dir)
267 .ok()
268 .map(|entries| {
269 entries
270 .filter_map(|e| e.ok())
271 .filter_map(|e| {
272 let name = e.file_name().to_string_lossy().to_string();
273 name.split('_').next().and_then(|n| n.parse::<u32>().ok())
275 })
276 .max()
277 .unwrap_or(0)
278 })
279 .unwrap_or(0);
280
281 max + 1
282}
283
284pub fn write_migration(migration: &GeneratedMigration) -> Result<(), GenerateError> {
286 for (path, content) in &migration.files {
287 if let Some(parent) = path.parent() {
288 std::fs::create_dir_all(parent)?;
289 }
290 std::fs::write(path, content)?;
291 }
292 Ok(())
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::config::YAuthConfig;
299
300 #[test]
301 fn generate_init_diesel_postgres() {
302 let config = YAuthConfig::new(
303 crate::Orm::Diesel,
304 "postgres",
305 vec!["email-password".to_string()],
306 );
307 let result = generate_init(&config).unwrap();
308 assert!(!result.files.is_empty());
309 assert_eq!(result.files.len(), 3);
311 let up_content = &result.files[0].1;
312 assert!(up_content.contains("CREATE TABLE IF NOT EXISTS yauth_users"));
313 assert!(up_content.contains("CREATE TABLE IF NOT EXISTS yauth_passwords"));
314 let schema_rs = &result.files[2].1;
316 assert!(schema_rs.contains("diesel::table!"));
317 assert!(schema_rs.contains("yauth_users (id)"));
318 }
319
320 #[test]
321 fn generate_init_sqlx_sqlite() {
322 let config = YAuthConfig::new(
323 crate::Orm::Sqlx,
324 "sqlite",
325 vec!["email-password".to_string()],
326 );
327 let result = generate_init(&config).unwrap();
328 assert_eq!(result.files.len(), 1);
330 let content = &result.files[0].1;
331 assert!(content.contains("CREATE TABLE IF NOT EXISTS yauth_users"));
332 assert!(!content.contains("UUID "));
334 assert!(!content.contains("TIMESTAMPTZ"));
335 }
336
337 #[test]
338 fn generate_init_with_custom_prefix() {
339 let mut config = YAuthConfig::new(
340 crate::Orm::Diesel,
341 "postgres",
342 vec!["email-password".to_string()],
343 );
344 config.migration.table_prefix = "auth_".to_string();
345 let result = generate_init(&config).unwrap();
346 let up_content = &result.files[0].1;
347 assert!(up_content.contains("auth_users"));
348 assert!(up_content.contains("auth_passwords"));
349 assert!(!up_content.contains("yauth_"));
350 }
351
352 #[test]
353 fn generate_add_plugin_produces_incremental_sql() {
354 use crate::collect_schema_for_plugins;
355 use crate::diff::{render_changes_sql, schema_diff};
356
357 let mut config = YAuthConfig::new(
358 crate::Orm::Diesel,
359 "postgres",
360 vec!["email-password".to_string(), "mfa".to_string()],
361 );
362 config.migration.migrations_dir = "migrations".to_string();
363
364 let previous = vec!["email-password".to_string()];
365 let from = collect_schema_for_plugins(&previous, &config.migration.table_prefix).unwrap();
366 let to =
367 collect_schema_for_plugins(&config.plugins.enabled, &config.migration.table_prefix)
368 .unwrap();
369 let changes = schema_diff(&from, &to);
370 let (up_sql, down_sql) = render_changes_sql(&changes, crate::Dialect::Postgres);
371
372 let result = generate_add_plugin(&config, "mfa", &up_sql, &down_sql).unwrap();
373 assert!(!result.files.is_empty());
374 let up_content = &result.files[0].1;
375 assert!(up_content.contains("yauth_totp_secrets"));
377 assert!(up_content.contains("yauth_backup_codes"));
378 assert!(!up_content.contains("CREATE TABLE IF NOT EXISTS yauth_users"));
380 }
381
382 #[test]
383 fn generate_remove_plugin_produces_drop_sql() {
384 use crate::collect_schema_for_plugins;
385 use crate::diff::{render_changes_sql, schema_diff};
386
387 let config = YAuthConfig::new(
388 crate::Orm::Diesel,
389 "postgres",
390 vec!["email-password".to_string()],
391 );
392
393 let previous = vec!["email-password".to_string(), "passkey".to_string()];
394 let from = collect_schema_for_plugins(&previous, &config.migration.table_prefix).unwrap();
395 let to =
396 collect_schema_for_plugins(&config.plugins.enabled, &config.migration.table_prefix)
397 .unwrap();
398 let changes = schema_diff(&from, &to);
399 let (up_sql, down_sql) = render_changes_sql(&changes, crate::Dialect::Postgres);
400
401 let result = generate_remove_plugin(&config, "passkey", &up_sql, &down_sql).unwrap();
402 assert!(!result.files.is_empty());
403 let up_content = &result.files[0].1;
404 assert!(up_content.contains("DROP TABLE IF EXISTS yauth_webauthn_credentials"));
405 }
406
407 #[test]
408 fn generate_init_mysql_dialect() {
409 let config = YAuthConfig::new(
410 crate::Orm::Diesel,
411 "mysql",
412 vec!["email-password".to_string()],
413 );
414 let result = generate_init(&config).unwrap();
415 let up_content = &result.files[0].1;
416 assert!(up_content.contains("ENGINE=InnoDB"));
417 assert!(up_content.contains("CHAR(36)"));
418 }
419}