kimberlite_migration/
tracker.rs1use crate::{Error, Result};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct AppliedMigration {
12 pub id: u32,
14
15 pub name: String,
17
18 pub checksum: String,
20
21 pub applied_at: DateTime<Utc>,
23
24 pub applied_by: Option<String>,
26}
27
28pub struct MigrationTracker {
33 state_file: PathBuf,
34}
35
36impl MigrationTracker {
37 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 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 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, };
68
69 applied.push(record.clone());
70 self.save_state(&applied)?;
71
72 Ok(record)
73 }
74
75 pub fn list_applied(&self) -> Result<Vec<AppliedMigration>> {
77 self.load_state()
78 }
79
80 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 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 return Ok(());
95 }
96
97 self.save_state(&applied)?;
98 Ok(())
99 }
100
101 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 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 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 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 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}