Skip to main content

kimberlite_migration/
tracker.rs

1//! Migration tracking system.
2
3use crate::{Error, Result};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::path::PathBuf;
8
9/// A migration that has been applied to the database.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct AppliedMigration {
12    /// Migration ID
13    pub id: u32,
14
15    /// Migration name
16    pub name: String,
17
18    /// SHA-256 checksum
19    pub checksum: String,
20
21    /// When it was applied
22    pub applied_at: DateTime<Utc>,
23
24    /// Who applied it (optional)
25    pub applied_by: Option<String>,
26}
27
28/// Tracks applied migrations using a simple JSON file.
29///
30/// In a production implementation, this would be stored in the database itself
31/// as a projection. For now, we use a file-based tracker for simplicity.
32pub struct MigrationTracker {
33    state_file: PathBuf,
34}
35
36impl MigrationTracker {
37    /// Creates a new migration tracker.
38    pub fn new(state_dir: impl Into<PathBuf>) -> Result<Self> {
39        let state_dir = state_dir.into();
40        fs::create_dir_all(&state_dir)?;
41
42        let state_file = state_dir.join("applied.toml");
43
44        Ok(Self { state_file })
45    }
46
47    /// Records a migration as applied.
48    pub fn record_applied(
49        &self,
50        id: u32,
51        name: String,
52        checksum: String,
53    ) -> Result<AppliedMigration> {
54        let mut applied = self.load_state()?;
55
56        // Check if already applied
57        if applied.iter().any(|m| m.id == id) {
58            return Err(Error::AlreadyApplied(id));
59        }
60
61        let record = AppliedMigration {
62            id,
63            name,
64            checksum,
65            applied_at: Utc::now(),
66            applied_by: None, // TODO: Get from environment
67        };
68
69        applied.push(record.clone());
70        self.save_state(&applied)?;
71
72        Ok(record)
73    }
74
75    /// Lists all applied migrations.
76    pub fn list_applied(&self) -> Result<Vec<AppliedMigration>> {
77        self.load_state()
78    }
79
80    /// Checks if a migration has been applied.
81    pub fn is_applied(&self, id: u32) -> Result<bool> {
82        let applied = self.load_state()?;
83        Ok(applied.iter().any(|m| m.id == id))
84    }
85
86    /// Removes a migration record (for rollback).
87    pub fn remove_applied(&self, id: u32) -> Result<()> {
88        let mut applied = self.load_state()?;
89        let initial_len = applied.len();
90        applied.retain(|m| m.id != id);
91
92        if applied.len() == initial_len {
93            // Migration wasn't in the list — not an error for idempotency
94            return Ok(());
95        }
96
97        self.save_state(&applied)?;
98        Ok(())
99    }
100
101    /// Gets the last applied migration ID.
102    pub fn last_applied_id(&self) -> Result<Option<u32>> {
103        let applied = self.load_state()?;
104        Ok(applied.iter().map(|m| m.id).max())
105    }
106
107    /// Loads state from file.
108    fn load_state(&self) -> Result<Vec<AppliedMigration>> {
109        if !self.state_file.exists() {
110            return Ok(Vec::new());
111        }
112
113        let content = fs::read_to_string(&self.state_file)?;
114
115        if content.trim().is_empty() {
116            return Ok(Vec::new());
117        }
118
119        let state: MigrationState = toml::from_str(&content)?;
120
121        Ok(state.migrations)
122    }
123
124    /// Saves state to file.
125    fn save_state(&self, migrations: &[AppliedMigration]) -> Result<()> {
126        let state = MigrationState {
127            migrations: migrations.to_vec(),
128        };
129
130        let content = toml::to_string_pretty(&state)?;
131        fs::write(&self.state_file, content)?;
132
133        Ok(())
134    }
135}
136
137#[derive(Debug, Serialize, Deserialize)]
138struct MigrationState {
139    migrations: Vec<AppliedMigration>,
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use tempfile::TempDir;
146
147    #[test]
148    fn test_tracker_creation() {
149        let temp = TempDir::new().unwrap();
150        let tracker = MigrationTracker::new(temp.path().to_path_buf()).unwrap();
151
152        // State file is created on demand, not immediately
153        assert!(tracker.state_file.parent().unwrap().exists());
154        assert_eq!(tracker.list_applied().unwrap().len(), 0);
155    }
156
157    #[test]
158    fn test_record_and_list_applied() {
159        let temp = TempDir::new().unwrap();
160        let tracker = MigrationTracker::new(temp.path().to_path_buf()).unwrap();
161
162        tracker
163            .record_applied(1, "first".to_string(), "abc123".to_string())
164            .unwrap();
165        tracker
166            .record_applied(2, "second".to_string(), "def456".to_string())
167            .unwrap();
168
169        let applied = tracker.list_applied().unwrap();
170
171        assert_eq!(applied.len(), 2);
172        assert_eq!(applied[0].id, 1);
173        assert_eq!(applied[0].name, "first");
174        assert_eq!(applied[1].id, 2);
175    }
176
177    #[test]
178    fn test_is_applied() {
179        let temp = TempDir::new().unwrap();
180        let tracker = MigrationTracker::new(temp.path().to_path_buf()).unwrap();
181
182        tracker
183            .record_applied(1, "first".to_string(), "abc123".to_string())
184            .unwrap();
185
186        assert!(tracker.is_applied(1).unwrap());
187        assert!(!tracker.is_applied(2).unwrap());
188    }
189
190    #[test]
191    fn test_already_applied_error() {
192        let temp = TempDir::new().unwrap();
193        let tracker = MigrationTracker::new(temp.path().to_path_buf()).unwrap();
194
195        tracker
196            .record_applied(1, "first".to_string(), "abc123".to_string())
197            .unwrap();
198
199        let result = tracker.record_applied(1, "first".to_string(), "abc123".to_string());
200
201        assert!(matches!(result, Err(Error::AlreadyApplied(1))));
202    }
203
204    #[test]
205    fn test_last_applied_id() {
206        let temp = TempDir::new().unwrap();
207        let tracker = MigrationTracker::new(temp.path().to_path_buf()).unwrap();
208
209        assert_eq!(tracker.last_applied_id().unwrap(), None);
210
211        tracker
212            .record_applied(1, "first".to_string(), "abc123".to_string())
213            .unwrap();
214        assert_eq!(tracker.last_applied_id().unwrap(), Some(1));
215
216        tracker
217            .record_applied(2, "second".to_string(), "def456".to_string())
218            .unwrap();
219        assert_eq!(tracker.last_applied_id().unwrap(), Some(2));
220    }
221
222    #[test]
223    fn test_persistence() {
224        let temp = TempDir::new().unwrap();
225
226        {
227            let tracker = MigrationTracker::new(temp.path().to_path_buf()).unwrap();
228            tracker
229                .record_applied(1, "first".to_string(), "abc123".to_string())
230                .unwrap();
231        }
232
233        // Create new tracker instance, should load existing state
234        let tracker = MigrationTracker::new(temp.path().to_path_buf()).unwrap();
235        let applied = tracker.list_applied().unwrap();
236
237        assert_eq!(applied.len(), 1);
238        assert_eq!(applied[0].id, 1);
239    }
240}