Skip to main content

ralph/migration/
history.rs

1//! Migration history persistence for tracking applied migrations.
2//!
3//! Responsibilities:
4//! - Load and save migration history from `.ralph/cache/migrations.jsonc`.
5//! - Provide default history for new projects.
6//!
7//! Not handled here:
8//! - Migration execution logic (see `super::mod.rs`).
9//! - Config file modifications (see `config_migrations.rs`).
10//!
11//! Invariants/assumptions:
12//! - History file is stored in `.ralph/cache/migrations.jsonc`.
13//! - History format is versioned for future compatibility.
14
15use 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/// Migration history tracking all applied migrations.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(default)]
26pub struct MigrationHistory {
27    /// Schema version for migration history.
28    pub version: u32,
29    /// List of applied migrations.
30    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/// A single applied migration entry.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AppliedMigration {
45    /// Unique identifier for the migration.
46    pub id: String,
47    /// Timestamp when the migration was applied.
48    pub applied_at: DateTime<Utc>,
49    /// Type of migration (for informational purposes).
50    pub migration_type: String,
51}
52
53/// Load migration history from the repo.
54/// Returns default (empty) history if file doesn't exist.
55pub 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    // Validate version
73    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
89/// Save migration history to the repo.
90pub fn save_migration_history(repo_root: &Path, history: &MigrationHistory) -> Result<()> {
91    let history_path = repo_root.join(MIGRATION_HISTORY_PATH);
92
93    // Ensure parent directory exists
94    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
113/// Get the path to the migration history file.
114pub 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        // Create and save a history
137        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        // Load it back
152        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        // Ensure the directory doesn't exist yet
177        assert!(!deep_path.exists());
178
179        let history = MigrationHistory::default();
180        save_migration_history(dir.path(), &history).unwrap();
181
182        // Directory should now exist
183        assert!(deep_path.exists());
184    }
185}