1use std::path::{Path, PathBuf};
4
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7
8use crate::error::{MigrateResult, MigrationError};
9use crate::sql::MigrationSql;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct MigrationFile {
14 pub path: PathBuf,
16 pub id: String,
18 pub name: String,
20 pub up_sql: String,
22 pub down_sql: String,
24 pub checksum: String,
26}
27
28impl MigrationFile {
29 pub fn new(id: impl Into<String>, name: impl Into<String>, sql: MigrationSql) -> Self {
31 let id = id.into();
32 let name = name.into();
33 let checksum = compute_checksum(&sql.up);
34
35 Self {
36 path: PathBuf::new(),
37 id,
38 name,
39 up_sql: sql.up,
40 down_sql: sql.down,
41 checksum,
42 }
43 }
44
45 pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
47 self.path = path.into();
48 self
49 }
50}
51
52fn compute_checksum(content: &str) -> String {
54 use std::collections::hash_map::DefaultHasher;
55 use std::hash::{Hash, Hasher};
56
57 let mut hasher = DefaultHasher::new();
58 content.hash(&mut hasher);
59 format!("{:016x}", hasher.finish())
60}
61
62pub struct MigrationFileManager {
64 migrations_dir: PathBuf,
66}
67
68impl MigrationFileManager {
69 pub fn new(migrations_dir: impl Into<PathBuf>) -> Self {
71 Self {
72 migrations_dir: migrations_dir.into(),
73 }
74 }
75
76 pub fn migrations_dir(&self) -> &Path {
78 &self.migrations_dir
79 }
80
81 pub async fn ensure_dir(&self) -> MigrateResult<()> {
83 tokio::fs::create_dir_all(&self.migrations_dir)
84 .await
85 .map_err(MigrationError::Io)?;
86 Ok(())
87 }
88
89 pub async fn list_migrations(&self) -> MigrateResult<Vec<MigrationFile>> {
91 let mut migrations = Vec::new();
92
93 if !self.migrations_dir.exists() {
94 return Ok(migrations);
95 }
96
97 let mut entries = tokio::fs::read_dir(&self.migrations_dir)
98 .await
99 .map_err(MigrationError::Io)?;
100
101 let mut paths = Vec::new();
102 while let Some(entry) = entries.next_entry().await.map_err(MigrationError::Io)? {
103 let path = entry.path();
104 if path.is_dir() && is_migration_dir(&path) {
105 paths.push(path);
106 }
107 }
108
109 paths.sort();
111
112 for path in paths {
113 if let Ok(migration) = self.read_migration(&path).await {
114 migrations.push(migration);
115 }
116 }
117
118 Ok(migrations)
119 }
120
121 async fn read_migration(&self, path: &Path) -> MigrateResult<MigrationFile> {
123 let dir_name = path
124 .file_name()
125 .and_then(|n| n.to_str())
126 .ok_or_else(|| MigrationError::InvalidMigration("Invalid path".to_string()))?;
127
128 let (id, name) = parse_migration_name(dir_name)?;
129
130 let up_path = path.join("up.sql");
131 let down_path = path.join("down.sql");
132
133 let up_sql = tokio::fs::read_to_string(&up_path)
134 .await
135 .map_err(MigrationError::Io)?;
136
137 let down_sql = if down_path.exists() {
138 tokio::fs::read_to_string(&down_path)
139 .await
140 .map_err(MigrationError::Io)?
141 } else {
142 String::new()
143 };
144
145 let checksum = compute_checksum(&up_sql);
146
147 Ok(MigrationFile {
148 path: path.to_path_buf(),
149 id,
150 name,
151 up_sql,
152 down_sql,
153 checksum,
154 })
155 }
156
157 pub async fn write_migration(&self, migration: &MigrationFile) -> MigrateResult<PathBuf> {
159 self.ensure_dir().await?;
160
161 let timestamp = Utc::now().format("%Y%m%d%H%M%S");
162 let dir_name = format!("{}_{}", timestamp, migration.name);
163 let migration_dir = self.migrations_dir.join(&dir_name);
164
165 tokio::fs::create_dir_all(&migration_dir)
166 .await
167 .map_err(MigrationError::Io)?;
168
169 let up_path = migration_dir.join("up.sql");
170 let down_path = migration_dir.join("down.sql");
171
172 tokio::fs::write(&up_path, &migration.up_sql)
173 .await
174 .map_err(MigrationError::Io)?;
175
176 if !migration.down_sql.is_empty() {
177 tokio::fs::write(&down_path, &migration.down_sql)
178 .await
179 .map_err(MigrationError::Io)?;
180 }
181
182 Ok(migration_dir)
183 }
184
185 pub fn generate_id(&self) -> String {
187 Utc::now().format("%Y%m%d%H%M%S").to_string()
188 }
189}
190
191fn is_migration_dir(path: &Path) -> bool {
193 if !path.is_dir() {
194 return false;
195 }
196
197 path.join("up.sql").exists()
199}
200
201fn parse_migration_name(name: &str) -> MigrateResult<(String, String)> {
203 let parts: Vec<&str> = name.splitn(2, '_').collect();
205
206 if parts.len() != 2 {
207 return Err(MigrationError::InvalidMigration(format!(
208 "Invalid migration name format: {}",
209 name
210 )));
211 }
212
213 let id = parts[0].to_string();
214 let name = parts[1].to_string();
215
216 if id.len() != 14 || !id.chars().all(|c| c.is_ascii_digit()) {
218 return Err(MigrationError::InvalidMigration(format!(
219 "Invalid migration ID (expected timestamp): {}",
220 id
221 )));
222 }
223
224 Ok((id, name))
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn test_parse_migration_name() {
233 let (id, name) = parse_migration_name("20231215120000_create_users").unwrap();
234 assert_eq!(id, "20231215120000");
235 assert_eq!(name, "create_users");
236 }
237
238 #[test]
239 fn test_parse_migration_name_invalid() {
240 assert!(parse_migration_name("invalid").is_err());
241 assert!(parse_migration_name("abc_test").is_err());
242 }
243
244 #[test]
245 fn test_compute_checksum() {
246 let checksum1 = compute_checksum("CREATE TABLE users();");
247 let checksum2 = compute_checksum("CREATE TABLE users();");
248 let checksum3 = compute_checksum("DROP TABLE users;");
249
250 assert_eq!(checksum1, checksum2);
251 assert_ne!(checksum1, checksum3);
252 }
253
254 #[test]
255 fn test_migration_file_new() {
256 let sql = MigrationSql {
257 up: "CREATE TABLE users();".to_string(),
258 down: "DROP TABLE users;".to_string(),
259 };
260
261 let migration = MigrationFile::new("20231215120000", "create_users", sql);
262 assert_eq!(migration.id, "20231215120000");
263 assert_eq!(migration.name, "create_users");
264 assert!(!migration.checksum.is_empty());
265 }
266}