Skip to main content

ralph/migration/
types.rs

1//! Purpose: Own migration data models, status enums, and context construction.
2//!
3//! Responsibilities:
4//! - Define migration model types used by migration orchestration and callers.
5//! - Build `MigrationContext` values from resolved config or filesystem discovery.
6//! - Provide display-oriented status helpers for migration listings.
7//!
8//! Scope:
9//! - Type definitions and context-building only; migration execution remains in
10//!   `mod.rs`.
11//!
12//! Usage:
13//! - Re-exported through `crate::migration::{Migration, MigrationContext, ...}`.
14//! - Used by migration orchestration, CLI surfaces, and sanity checks.
15//!
16//! Invariants/Assumptions:
17//! - `MigrationContext` points at a single repo root and config pair.
18//! - Context discovery does not require config parsing to succeed.
19//! - Migration history is loaded eagerly during context construction.
20
21use super::history;
22use crate::config::Resolved;
23use anyhow::{Context, Result};
24use std::{
25    env,
26    path::{Path, PathBuf},
27};
28
29/// Result of checking migration status.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum MigrationCheckResult {
32    /// No pending migrations.
33    Current,
34    /// Pending migrations available.
35    Pending(Vec<&'static Migration>),
36}
37
38/// A single migration definition.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Migration {
41    /// Unique identifier for this migration (e.g., "config_key_rename_2026_01").
42    pub id: &'static str,
43    /// Human-readable description of what this migration does.
44    pub description: &'static str,
45    /// The type of migration to perform.
46    pub migration_type: MigrationType,
47}
48
49/// Types of migrations supported.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum MigrationType {
52    /// Rename a config key (old_key -> new_key).
53    ConfigKeyRename {
54        /// Dot-separated path to the old key (e.g., "agent.runner_cli").
55        old_key: &'static str,
56        /// Dot-separated path to the new key (e.g., "agent.runner_options").
57        new_key: &'static str,
58    },
59    /// Remove a deprecated config key.
60    ConfigKeyRemove {
61        /// Dot-separated path to the key to remove (e.g., "agent.legacy_flag").
62        key: &'static str,
63    },
64    /// Rewrite legacy CI gate string config to structured argv/shell config.
65    ConfigCiGateRewrite,
66    /// Upgrade pre-0.3 config contract keys and version markers.
67    ConfigLegacyContractUpgrade,
68    /// Rename/move a file.
69    FileRename {
70        /// Path to the old file, relative to repo root.
71        old_path: &'static str,
72        /// Path to the new file, relative to repo root.
73        new_path: &'static str,
74    },
75    /// Update README template.
76    ReadmeUpdate {
77        /// The version to update from (inclusive).
78        from_version: u32,
79        /// The version to update to.
80        to_version: u32,
81    },
82}
83
84/// Context for migration operations.
85#[derive(Debug, Clone)]
86pub struct MigrationContext {
87    /// Repository root directory.
88    pub repo_root: PathBuf,
89    /// Path to project config file.
90    pub project_config_path: PathBuf,
91    /// Path to global config file (if any).
92    pub global_config_path: Option<PathBuf>,
93    /// Currently resolved configuration.
94    pub resolved_config: crate::contracts::Config,
95    /// Loaded migration history.
96    pub migration_history: history::MigrationHistory,
97}
98
99impl MigrationContext {
100    /// Create a new migration context from resolved config.
101    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    /// Create a migration context from the current working directory without
114    /// requiring configuration parsing to succeed.
115    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    /// Create a migration context from an arbitrary directory without
121    /// requiring configuration parsing to succeed.
122    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    /// Check if a migration has already been applied.
154    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    /// Check if a file exists relative to repo root.
162    pub fn file_exists(&self, path: &str) -> bool {
163        self.repo_root.join(path).exists()
164    }
165
166    /// Get full path for a repo-relative path.
167    pub fn resolve_path(&self, path: &str) -> PathBuf {
168        self.repo_root.join(path)
169    }
170}
171
172/// Status of a migration for display.
173#[derive(Debug, Clone)]
174pub struct MigrationStatus<'a> {
175    /// The migration definition.
176    pub migration: &'a Migration,
177    /// Whether this migration has been applied.
178    pub applied: bool,
179    /// Whether this migration is applicable in the current context.
180    pub applicable: bool,
181}
182
183impl<'a> MigrationStatus<'a> {
184    /// Get a display status string.
185    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}