prax_migrate/
file.rs

1//! Migration file management.
2
3use std::path::{Path, PathBuf};
4
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7
8use crate::error::{MigrateResult, MigrationError};
9use crate::sql::MigrationSql;
10
11/// A migration file on disk.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct MigrationFile {
14    /// Path to the migration file.
15    pub path: PathBuf,
16    /// Migration ID (extracted from filename).
17    pub id: String,
18    /// Migration name (human readable).
19    pub name: String,
20    /// Up SQL content.
21    pub up_sql: String,
22    /// Down SQL content.
23    pub down_sql: String,
24    /// Checksum of the migration content.
25    pub checksum: String,
26}
27
28impl MigrationFile {
29    /// Create a new migration file.
30    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    /// Set the path for this migration file.
46    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
47        self.path = path.into();
48        self
49    }
50}
51
52/// Compute a checksum for migration content.
53fn 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
62/// Migration file reader/writer.
63pub struct MigrationFileManager {
64    /// Directory where migrations are stored.
65    migrations_dir: PathBuf,
66}
67
68impl MigrationFileManager {
69    /// Create a new file manager.
70    pub fn new(migrations_dir: impl Into<PathBuf>) -> Self {
71        Self {
72            migrations_dir: migrations_dir.into(),
73        }
74    }
75
76    /// Get the migrations directory.
77    pub fn migrations_dir(&self) -> &Path {
78        &self.migrations_dir
79    }
80
81    /// Ensure the migrations directory exists.
82    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    /// List all migration files in order.
90    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        // Sort by name (which should be timestamp-prefixed)
110        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    /// Read a migration from a directory.
122    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    /// Write a migration to disk.
158    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    /// Generate a new migration ID.
186    pub fn generate_id(&self) -> String {
187        Utc::now().format("%Y%m%d%H%M%S").to_string()
188    }
189}
190
191/// Check if a path is a migration directory.
192fn is_migration_dir(path: &Path) -> bool {
193    if !path.is_dir() {
194        return false;
195    }
196
197    // Must have an up.sql file
198    path.join("up.sql").exists()
199}
200
201/// Parse a migration directory name into (id, name).
202fn parse_migration_name(name: &str) -> MigrateResult<(String, String)> {
203    // Expected format: YYYYMMDDHHMMSS_name
204    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    // Validate ID looks like a timestamp
217    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}