1use super::history;
22use crate::config::Resolved;
23use anyhow::{Context, Result};
24use std::{
25 env,
26 path::{Path, PathBuf},
27};
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum MigrationCheckResult {
32 Current,
34 Pending(Vec<&'static Migration>),
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Migration {
41 pub id: &'static str,
43 pub description: &'static str,
45 pub migration_type: MigrationType,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum MigrationType {
52 ConfigKeyRename {
54 old_key: &'static str,
56 new_key: &'static str,
58 },
59 ConfigKeyRemove {
61 key: &'static str,
63 },
64 ConfigCiGateRewrite,
66 ConfigLegacyContractUpgrade,
68 FileRename {
70 old_path: &'static str,
72 new_path: &'static str,
74 },
75 ReadmeUpdate {
77 from_version: u32,
79 to_version: u32,
81 },
82}
83
84#[derive(Debug, Clone)]
86pub struct MigrationContext {
87 pub repo_root: PathBuf,
89 pub project_config_path: PathBuf,
91 pub global_config_path: Option<PathBuf>,
93 pub resolved_config: crate::contracts::Config,
95 pub migration_history: history::MigrationHistory,
97}
98
99impl MigrationContext {
100 pub fn from_resolved(resolved: &Resolved) -> Result<Self> {
102 Self::build(
103 resolved.repo_root.clone(),
104 resolved
105 .project_config_path
106 .clone()
107 .unwrap_or_else(|| resolved.repo_root.join(".ralph/config.jsonc")),
108 resolved.global_config_path.clone(),
109 resolved.config.clone(),
110 )
111 }
112
113 pub fn discover_from_cwd() -> Result<Self> {
116 let cwd = env::current_dir().context("resolve current working directory")?;
117 Self::discover_from_dir(&cwd)
118 }
119
120 pub fn discover_from_dir(start: &Path) -> Result<Self> {
123 let repo_root = crate::config::find_repo_root(start);
124 let project_config_path = crate::config::project_config_path(&repo_root);
125 let global_config_path = crate::config::global_config_path();
126
127 Self::build(
128 repo_root,
129 project_config_path,
130 global_config_path,
131 crate::contracts::Config::default(),
132 )
133 }
134
135 fn build(
136 repo_root: PathBuf,
137 project_config_path: PathBuf,
138 global_config_path: Option<PathBuf>,
139 resolved_config: crate::contracts::Config,
140 ) -> Result<Self> {
141 let migration_history =
142 history::load_migration_history(&repo_root).context("load migration history")?;
143
144 Ok(Self {
145 repo_root,
146 project_config_path,
147 global_config_path,
148 resolved_config,
149 migration_history,
150 })
151 }
152
153 pub fn is_migration_applied(&self, migration_id: &str) -> bool {
155 self.migration_history
156 .applied_migrations
157 .iter()
158 .any(|migration| migration.id == migration_id)
159 }
160
161 pub fn file_exists(&self, path: &str) -> bool {
163 self.repo_root.join(path).exists()
164 }
165
166 pub fn resolve_path(&self, path: &str) -> PathBuf {
168 self.repo_root.join(path)
169 }
170}
171
172#[derive(Debug, Clone)]
174pub struct MigrationStatus<'a> {
175 pub migration: &'a Migration,
177 pub applied: bool,
179 pub applicable: bool,
181}
182
183impl<'a> MigrationStatus<'a> {
184 pub fn status_text(&self) -> &'static str {
186 if self.applied {
187 "applied"
188 } else if self.applicable {
189 "pending"
190 } else {
191 "not applicable"
192 }
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::migration::history;
200 use tempfile::TempDir;
201
202 fn create_test_context(dir: &TempDir) -> MigrationContext {
203 let repo_root = dir.path().to_path_buf();
204 let project_config_path = repo_root.join(".ralph/config.jsonc");
205
206 MigrationContext {
207 repo_root,
208 project_config_path,
209 global_config_path: None,
210 resolved_config: crate::contracts::Config::default(),
211 migration_history: history::MigrationHistory::default(),
212 }
213 }
214
215 #[test]
216 fn migration_context_detects_applied_migration() {
217 let dir = TempDir::new().unwrap();
218 let mut ctx = create_test_context(&dir);
219
220 assert!(!ctx.is_migration_applied("test_migration"));
221
222 ctx.migration_history
223 .applied_migrations
224 .push(history::AppliedMigration {
225 id: "test_migration".to_string(),
226 applied_at: chrono::Utc::now(),
227 migration_type: "test".to_string(),
228 });
229
230 assert!(ctx.is_migration_applied("test_migration"));
231 }
232
233 #[test]
234 fn migration_context_file_exists_check() {
235 let dir = TempDir::new().unwrap();
236 let ctx = create_test_context(&dir);
237
238 std::fs::create_dir_all(dir.path().join(".ralph")).unwrap();
239 std::fs::write(dir.path().join(".ralph/queue.json"), "{}").unwrap();
240
241 assert!(ctx.file_exists(".ralph/queue.json"));
242 assert!(!ctx.file_exists(".ralph/done.json"));
243 }
244
245 #[test]
246 fn migration_context_discovers_repo_without_resolving_config() {
247 let dir = TempDir::new().unwrap();
248 let ralph_dir = dir.path().join(".ralph");
249 std::fs::create_dir_all(&ralph_dir).unwrap();
250 std::fs::write(
251 ralph_dir.join("config.jsonc"),
252 r#"{"version":1,"agent":{"git_commit_push_enabled":true}}"#,
253 )
254 .unwrap();
255
256 let ctx = MigrationContext::discover_from_dir(dir.path()).unwrap();
257
258 assert_eq!(ctx.repo_root, dir.path());
259 assert_eq!(ctx.project_config_path, ralph_dir.join("config.jsonc"));
260 }
261
262 #[test]
263 fn migration_status_reports_display_text() {
264 let migration = Migration {
265 id: "test",
266 description: "test migration",
267 migration_type: MigrationType::ConfigCiGateRewrite,
268 };
269
270 assert_eq!(
271 MigrationStatus {
272 migration: &migration,
273 applied: true,
274 applicable: true,
275 }
276 .status_text(),
277 "applied"
278 );
279 assert_eq!(
280 MigrationStatus {
281 migration: &migration,
282 applied: false,
283 applicable: true,
284 }
285 .status_text(),
286 "pending"
287 );
288 assert_eq!(
289 MigrationStatus {
290 migration: &migration,
291 applied: false,
292 applicable: false,
293 }
294 .status_text(),
295 "not applicable"
296 );
297 }
298}