ralph/migration/
history.rs1use crate::constants::paths::MIGRATION_HISTORY_PATH;
16use crate::constants::versions::HISTORY_VERSION;
17use anyhow::{Context, Result};
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::fs;
21use std::path::{Path, PathBuf};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(default)]
26pub struct MigrationHistory {
27 pub version: u32,
29 pub applied_migrations: Vec<AppliedMigration>,
31}
32
33impl Default for MigrationHistory {
34 fn default() -> Self {
35 Self {
36 version: HISTORY_VERSION,
37 applied_migrations: Vec::new(),
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AppliedMigration {
45 pub id: String,
47 pub applied_at: DateTime<Utc>,
49 pub migration_type: String,
51}
52
53pub fn load_migration_history(repo_root: &Path) -> Result<MigrationHistory> {
56 let history_path = repo_root.join(MIGRATION_HISTORY_PATH);
57
58 if !history_path.exists() {
59 log::debug!(
60 "Migration history not found at {}, using default",
61 history_path.display()
62 );
63 return Ok(MigrationHistory::default());
64 }
65
66 let raw = fs::read_to_string(&history_path)
67 .with_context(|| format!("read migration history from {}", history_path.display()))?;
68
69 let history: MigrationHistory = serde_json::from_str(&raw)
70 .with_context(|| format!("parse migration history from {}", history_path.display()))?;
71
72 if history.version != HISTORY_VERSION {
74 log::warn!(
75 "Migration history version mismatch: expected {}, got {}. Attempting to proceed.",
76 HISTORY_VERSION,
77 history.version
78 );
79 }
80
81 log::debug!(
82 "Loaded migration history with {} applied migrations",
83 history.applied_migrations.len()
84 );
85
86 Ok(history)
87}
88
89pub fn save_migration_history(repo_root: &Path, history: &MigrationHistory) -> Result<()> {
91 let history_path = repo_root.join(MIGRATION_HISTORY_PATH);
92
93 if let Some(parent) = history_path.parent() {
95 fs::create_dir_all(parent)
96 .with_context(|| format!("create migration history directory {}", parent.display()))?;
97 }
98
99 let raw =
100 serde_json::to_string_pretty(history).context("serialize migration history to JSON")?;
101
102 crate::fsutil::write_atomic(&history_path, raw.as_bytes())
103 .with_context(|| format!("write migration history to {}", history_path.display()))?;
104
105 log::debug!(
106 "Saved migration history with {} applied migrations",
107 history.applied_migrations.len()
108 );
109
110 Ok(())
111}
112
113pub fn migration_history_path(repo_root: &Path) -> PathBuf {
115 repo_root.join(MIGRATION_HISTORY_PATH)
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use tempfile::TempDir;
122
123 #[test]
124 fn load_migration_history_returns_default_when_missing() {
125 let dir = TempDir::new().unwrap();
126 let history = load_migration_history(dir.path()).unwrap();
127
128 assert_eq!(history.version, HISTORY_VERSION);
129 assert!(history.applied_migrations.is_empty());
130 }
131
132 #[test]
133 fn save_and_load_migration_history_round_trips() {
134 let dir = TempDir::new().unwrap();
135
136 let mut history = MigrationHistory::default();
138 history.applied_migrations.push(AppliedMigration {
139 id: "test_migration_1".to_string(),
140 applied_at: Utc::now(),
141 migration_type: "config_key_rename".to_string(),
142 });
143 history.applied_migrations.push(AppliedMigration {
144 id: "test_migration_2".to_string(),
145 applied_at: Utc::now(),
146 migration_type: "file_rename".to_string(),
147 });
148
149 save_migration_history(dir.path(), &history).unwrap();
150
151 let loaded = load_migration_history(dir.path()).unwrap();
153
154 assert_eq!(loaded.version, HISTORY_VERSION);
155 assert_eq!(loaded.applied_migrations.len(), 2);
156 assert_eq!(loaded.applied_migrations[0].id, "test_migration_1");
157 assert_eq!(loaded.applied_migrations[1].id, "test_migration_2");
158 }
159
160 #[test]
161 fn migration_history_path_is_correct() {
162 let dir = PathBuf::from("/tmp/test_repo");
163 let path = migration_history_path(&dir);
164
165 assert_eq!(
166 path,
167 PathBuf::from("/tmp/test_repo/.ralph/cache/migrations.jsonc")
168 );
169 }
170
171 #[test]
172 fn save_migration_history_creates_parent_directories() {
173 let dir = TempDir::new().unwrap();
174 let deep_path = dir.path().join(".ralph/cache");
175
176 assert!(!deep_path.exists());
178
179 let history = MigrationHistory::default();
180 save_migration_history(dir.path(), &history).unwrap();
181
182 assert!(deep_path.exists());
184 }
185}