Skip to main content

kimberlite_migration/
file.rs

1//! Migration file format and parsing.
2
3use crate::{Error, Result};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// A SQL migration.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct Migration {
13    /// Sequential ID (1, 2, 3, ...)
14    pub id: u32,
15
16    /// Human-readable name (e.g., "`add_patients_table`")
17    pub name: String,
18
19    /// SQL content
20    pub sql: String,
21
22    /// Creation timestamp
23    pub created_at: DateTime<Utc>,
24
25    /// Author (optional)
26    pub author: Option<String>,
27}
28
29impl Migration {
30    /// Computes SHA-256 checksum of migration content.
31    pub fn checksum(&self) -> String {
32        use std::fmt::Write;
33        let mut hasher = Sha256::new();
34        hasher.update(self.sql.as_bytes());
35        let result = hasher.finalize();
36        // Convert to hex string
37        let mut hex = String::with_capacity(64);
38        for byte in &result {
39            write!(&mut hex, "{byte:02x}").expect("String write cannot fail");
40        }
41        hex
42    }
43}
44
45/// A migration file on disk.
46#[derive(Debug, Clone)]
47pub struct MigrationFile {
48    /// Parsed migration data
49    pub migration: Migration,
50
51    /// File path
52    pub path: PathBuf,
53
54    /// SHA-256 checksum
55    pub checksum: String,
56}
57
58impl MigrationFile {
59    /// Parses a migration file from disk.
60    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
61        let path = path.as_ref();
62        let content = fs::read_to_string(path).map_err(Error::Io)?;
63
64        let migration = Self::parse(&content, path)?;
65        let checksum = migration.checksum();
66
67        Ok(Self {
68            migration,
69            path: path.to_path_buf(),
70            checksum,
71        })
72    }
73
74    /// Parses migration from file content.
75    fn parse(content: &str, path: &Path) -> Result<Migration> {
76        // Extract metadata from comments at top of file
77        let mut name: Option<String> = None;
78        let mut created_at: Option<DateTime<Utc>> = None;
79        let mut author: Option<String> = None;
80        let mut sql_lines = Vec::new();
81        let mut in_metadata = true;
82
83        for line in content.lines() {
84            let trimmed = line.trim();
85
86            // Parse metadata comments
87            if in_metadata && trimmed.starts_with("--") {
88                let comment = trimmed.trim_start_matches("--").trim();
89
90                if let Some(rest) = comment.strip_prefix("Migration:") {
91                    name = Some(rest.trim().to_string());
92                } else if let Some(rest) = comment.strip_prefix("Created:") {
93                    created_at = DateTime::parse_from_rfc3339(rest.trim())
94                        .ok()
95                        .map(|dt| dt.with_timezone(&Utc));
96                } else if let Some(rest) = comment.strip_prefix("Author:") {
97                    author = Some(rest.trim().to_string());
98                }
99            } else if !trimmed.is_empty() && !trimmed.starts_with("--") {
100                // End of metadata section
101                in_metadata = false;
102                sql_lines.push(line);
103            } else if !in_metadata {
104                sql_lines.push(line);
105            }
106        }
107
108        // Extract ID from filename (e.g., "0001_add_users.sql" -> 1)
109        let filename =
110            path.file_name()
111                .and_then(|n| n.to_str())
112                .ok_or_else(|| Error::ParseError {
113                    path: path.to_path_buf(),
114                    reason: "Invalid filename".to_string(),
115                })?;
116
117        let id_str = filename
118            .split('_')
119            .next()
120            .ok_or_else(|| Error::ParseError {
121                path: path.to_path_buf(),
122                reason: "Filename must start with numeric ID".to_string(),
123            })?;
124
125        let id: u32 = id_str.parse().map_err(|_| Error::ParseError {
126            path: path.to_path_buf(),
127            reason: format!("Invalid migration ID: {id_str}"),
128        })?;
129
130        // If no name in comments, extract from filename
131        if name.is_none() {
132            let name_part = filename
133                .trim_end_matches(".sql")
134                .split('_')
135                .skip(1)
136                .collect::<Vec<_>>()
137                .join("_");
138            name = Some(name_part);
139        }
140
141        let sql = sql_lines.join("\n");
142
143        Ok(Migration {
144            id,
145            name: name.ok_or_else(|| Error::ParseError {
146                path: path.to_path_buf(),
147                reason: "Missing migration name".to_string(),
148            })?,
149            sql,
150            created_at: created_at.unwrap_or_else(Utc::now),
151            author,
152        })
153    }
154
155    /// Creates a new migration file.
156    pub fn create(migrations_dir: &Path, name: &str, _auto_timestamp: bool) -> Result<Self> {
157        // Validate name (alphanumeric + underscores only)
158        if !name
159            .chars()
160            .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
161        {
162            return Err(Error::InvalidName(name.to_string()));
163        }
164
165        // Create migrations directory if it doesn't exist
166        fs::create_dir_all(migrations_dir)?;
167
168        // Find next ID
169        let next_id = Self::next_id(migrations_dir)?;
170
171        // Generate filename
172        let filename = format!(
173            "{:04}_{}.sql",
174            next_id,
175            name.replace(' ', "_").to_lowercase()
176        );
177
178        let path = migrations_dir.join(&filename);
179        let created_at = Utc::now();
180
181        // Generate file content with metadata
182        let content = format!(
183            "-- Migration: {}\n\
184             -- Created: {}\n\
185             -- Author: \n\n\
186             -- Up Migration\n\
187             -- TODO: Add your SQL here\n\n\
188             -- Down Migration (optional)\n\
189             -- TODO: Add rollback SQL here\n",
190            name,
191            created_at.to_rfc3339()
192        );
193
194        fs::write(&path, &content)?;
195
196        let migration = Migration {
197            id: next_id,
198            name: name.to_string(),
199            sql: String::new(), // Empty until user fills it in
200            created_at,
201            author: None,
202        };
203
204        let checksum = migration.checksum();
205
206        Ok(Self {
207            migration,
208            path,
209            checksum,
210        })
211    }
212
213    /// Discovers all migration files in directory.
214    pub fn discover(migrations_dir: &Path) -> Result<Vec<Self>> {
215        if !migrations_dir.exists() {
216            return Ok(Vec::new());
217        }
218
219        let mut files = Vec::new();
220
221        for entry in fs::read_dir(migrations_dir)? {
222            let entry = entry?;
223            let path = entry.path();
224
225            if path.extension().and_then(|s| s.to_str()) == Some("sql") {
226                files.push(Self::load(&path)?);
227            }
228        }
229
230        // Sort by ID
231        files.sort_by_key(|f| f.migration.id);
232
233        Ok(files)
234    }
235
236    /// Finds the next available migration ID.
237    fn next_id(migrations_dir: &Path) -> Result<u32> {
238        let existing = Self::discover(migrations_dir)?;
239
240        Ok(existing.iter().map(|f| f.migration.id).max().unwrap_or(0) + 1)
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use tempfile::TempDir;
248
249    #[test]
250    fn test_parse_migration_with_metadata() {
251        let content = r"-- Migration: Add users table
252-- Created: 2026-02-01T10:00:00Z
253-- Author: alice@example.com
254
255CREATE TABLE users (
256    id BIGINT NOT NULL,
257    name TEXT NOT NULL
258);
259";
260
261        let temp = TempDir::new().unwrap();
262        let path = temp.path().join("0001_add_users.sql");
263        fs::write(&path, content).unwrap();
264
265        let file = MigrationFile::load(&path).unwrap();
266
267        assert_eq!(file.migration.id, 1);
268        assert_eq!(file.migration.name, "Add users table");
269        assert_eq!(file.migration.author, Some("alice@example.com".to_string()));
270        assert!(file.migration.sql.contains("CREATE TABLE users"));
271    }
272
273    #[test]
274    fn test_parse_migration_without_metadata() {
275        let content = "CREATE TABLE users (id BIGINT);";
276
277        let temp = TempDir::new().unwrap();
278        let path = temp.path().join("0002_create_users.sql");
279        fs::write(&path, content).unwrap();
280
281        let file = MigrationFile::load(&path).unwrap();
282
283        assert_eq!(file.migration.id, 2);
284        assert_eq!(file.migration.name, "create_users");
285        assert_eq!(file.migration.author, None);
286    }
287
288    #[test]
289    fn test_create_migration() {
290        let temp = TempDir::new().unwrap();
291        let file = MigrationFile::create(temp.path(), "add_patients", true).unwrap();
292
293        assert_eq!(file.migration.id, 1);
294        assert_eq!(file.migration.name, "add_patients");
295        assert!(file.path.exists());
296
297        let content = fs::read_to_string(&file.path).unwrap();
298        assert!(content.contains("Migration: add_patients"));
299    }
300
301    #[test]
302    fn test_discover_migrations() {
303        let temp = TempDir::new().unwrap();
304
305        MigrationFile::create(temp.path(), "first", false).unwrap();
306        MigrationFile::create(temp.path(), "second", false).unwrap();
307
308        let files = MigrationFile::discover(temp.path()).unwrap();
309
310        assert_eq!(files.len(), 2);
311        assert_eq!(files[0].migration.id, 1);
312        assert_eq!(files[1].migration.id, 2);
313    }
314
315    #[test]
316    fn test_checksum_consistency() {
317        let migration = Migration {
318            id: 1,
319            name: "test".to_string(),
320            sql: "SELECT 1;".to_string(),
321            created_at: Utc::now(),
322            author: None,
323        };
324
325        let checksum1 = migration.checksum();
326        let checksum2 = migration.checksum();
327
328        assert_eq!(checksum1, checksum2);
329        assert_eq!(checksum1.len(), 64); // SHA-256 hex is 64 chars
330    }
331
332    #[test]
333    fn test_invalid_migration_name() {
334        let temp = TempDir::new().unwrap();
335        let result = MigrationFile::create(temp.path(), "invalid/name", false);
336
337        assert!(matches!(result, Err(Error::InvalidName(_))));
338    }
339}