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::Raw => generate_raw_files(migrations_dir, "yauth_init", &up_sql, &down_sql)?,
74 };
75
76 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
92pub 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
126pub 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
160fn 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
185fn generate_sqlx_files(
187 migrations_dir: &Path,
188 name: &str,
189 up_sql: &str,
190) -> Result<Vec<(PathBuf, String)>, GenerateError> {
191 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
202fn 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
221fn 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 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
245pub 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 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 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 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 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 assert!(up_content.contains("yauth_totp_secrets"));
338 assert!(up_content.contains("yauth_backup_codes"));
339 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}