kimberlite_migration/
file.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct Migration {
13 pub id: u32,
15
16 pub name: String,
18
19 pub sql: String,
21
22 pub created_at: DateTime<Utc>,
24
25 pub author: Option<String>,
27}
28
29impl Migration {
30 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 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#[derive(Debug, Clone)]
47pub struct MigrationFile {
48 pub migration: Migration,
50
51 pub path: PathBuf,
53
54 pub checksum: String,
56}
57
58impl MigrationFile {
59 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 fn parse(content: &str, path: &Path) -> Result<Migration> {
76 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 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 in_metadata = false;
102 sql_lines.push(line);
103 } else if !in_metadata {
104 sql_lines.push(line);
105 }
106 }
107
108 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 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 pub fn create(migrations_dir: &Path, name: &str, _auto_timestamp: bool) -> Result<Self> {
157 if !name
159 .chars()
160 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
161 {
162 return Err(Error::InvalidName(name.to_string()));
163 }
164
165 fs::create_dir_all(migrations_dir)?;
167
168 let next_id = Self::next_id(migrations_dir)?;
170
171 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 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(), 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 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 files.sort_by_key(|f| f.migration.id);
232
233 Ok(files)
234 }
235
236 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); }
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}