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 };
74
75 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
91pub 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
124pub 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
157fn 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
182fn generate_sqlx_files(
184 migrations_dir: &Path,
185 name: &str,
186 up_sql: &str,
187) -> Result<Vec<(PathBuf, String)>, GenerateError> {
188 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
199fn 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 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
223pub 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 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 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 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 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 assert!(up_content.contains("yauth_totp_secrets"));
316 assert!(up_content.contains("yauth_backup_codes"));
317 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}