Skip to main content

kimberlite_migration/
lib.rs

1//! SQL migration system for Kimberlite database.
2//!
3//! Provides file-based migration management with:
4//! - Auto-numbered SQL migration files
5//! - Checksum-based integrity validation
6//! - Migration tracking in database
7//! - Lock file to prevent tampering
8
9pub mod error;
10pub mod file;
11pub mod lock;
12pub mod tracker;
13
14pub use error::{Error, Result};
15pub use file::{Migration, MigrationFile};
16pub use lock::LockFile;
17pub use tracker::MigrationTracker;
18
19use std::path::PathBuf;
20
21/// Configuration for migration system.
22#[derive(Debug, Clone)]
23pub struct MigrationConfig {
24    /// Directory containing migration files (default: "migrations/")
25    pub migrations_dir: PathBuf,
26
27    /// Directory for state files (default: ".kimberlite/migrations/")
28    pub state_dir: PathBuf,
29
30    /// Auto-generate timestamps in migration filenames
31    pub auto_timestamp: bool,
32}
33
34impl Default for MigrationConfig {
35    fn default() -> Self {
36        Self {
37            migrations_dir: PathBuf::from("migrations"),
38            state_dir: PathBuf::from(".kimberlite/migrations"),
39            auto_timestamp: true,
40        }
41    }
42}
43
44impl MigrationConfig {
45    /// Creates config with custom migrations directory.
46    pub fn with_migrations_dir(dir: impl Into<PathBuf>) -> Self {
47        Self {
48            migrations_dir: dir.into(),
49            ..Default::default()
50        }
51    }
52
53    /// Returns path to lock file.
54    pub fn lock_file_path(&self) -> PathBuf {
55        self.state_dir.join(".lock")
56    }
57}
58
59/// Migration manager coordinates file operations and tracking.
60pub struct MigrationManager {
61    config: MigrationConfig,
62    tracker: MigrationTracker,
63}
64
65impl MigrationManager {
66    /// Creates a new migration manager.
67    pub fn new(config: MigrationConfig) -> Result<Self> {
68        let tracker = MigrationTracker::new(config.state_dir.clone())?;
69        Ok(Self { config, tracker })
70    }
71
72    /// Lists all migration files in directory.
73    pub fn list_files(&self) -> Result<Vec<MigrationFile>> {
74        MigrationFile::discover(&self.config.migrations_dir)
75    }
76
77    /// Lists pending migrations (not yet applied).
78    pub fn list_pending(&self) -> Result<Vec<MigrationFile>> {
79        let all_files = self.list_files()?;
80        let applied = self.tracker.list_applied()?;
81
82        let applied_ids: std::collections::HashSet<_> = applied.iter().map(|m| m.id).collect();
83
84        Ok(all_files
85            .into_iter()
86            .filter(|f| !applied_ids.contains(&f.migration.id))
87            .collect())
88    }
89
90    /// Creates a new migration file.
91    pub fn create(&self, name: &str) -> Result<MigrationFile> {
92        MigrationFile::create(
93            &self.config.migrations_dir,
94            name,
95            self.config.auto_timestamp,
96        )
97    }
98
99    /// Records a migration as applied.
100    pub fn record_applied(&self, file: &MigrationFile) -> Result<tracker::AppliedMigration> {
101        self.tracker.record_applied(
102            file.migration.id,
103            file.migration.name.clone(),
104            file.checksum.clone(),
105        )
106    }
107
108    /// Removes a migration record (for rollback).
109    pub fn remove_applied(&self, id: u32) -> Result<()> {
110        self.tracker.remove_applied(id)
111    }
112
113    /// Returns the SQL content for the up migration (before "-- Down Migration" marker).
114    pub fn up_sql(file: &MigrationFile) -> &str {
115        if let Some(idx) = file.migration.sql.find("-- Down Migration") {
116            file.migration.sql[..idx].trim_end()
117        } else {
118            file.migration.sql.trim()
119        }
120    }
121
122    /// Returns the SQL content for the down migration (after "-- Down Migration" marker).
123    pub fn down_sql(file: &MigrationFile) -> Option<&str> {
124        file.migration
125            .sql
126            .find("-- Down Migration")
127            .map(|idx| {
128                let after = &file.migration.sql[idx..];
129                // Skip the marker line itself
130                after.find('\n').map_or("", |nl| after[nl + 1..].trim())
131            })
132            .filter(|s| !s.is_empty())
133    }
134
135    /// Validates all migrations (checksums, sequence).
136    pub fn validate(&self) -> Result<()> {
137        let lock = LockFile::load(&self.config.lock_file_path())?;
138        let files = self.list_files()?;
139
140        lock.validate(&files)?;
141
142        // Validate sequence (no gaps)
143        for (i, file) in files.iter().enumerate() {
144            let expected_id = (i + 1) as u32;
145            if file.migration.id != expected_id {
146                return Err(Error::InvalidSequence {
147                    expected: expected_id,
148                    found: file.migration.id,
149                });
150            }
151        }
152
153        Ok(())
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use tempfile::TempDir;
161
162    #[test]
163    fn test_migration_config_default() {
164        let config = MigrationConfig::default();
165        assert_eq!(config.migrations_dir, PathBuf::from("migrations"));
166        assert_eq!(config.state_dir, PathBuf::from(".kimberlite/migrations"));
167        assert!(config.auto_timestamp);
168    }
169
170    #[test]
171    fn test_migration_config_lock_path() {
172        let config = MigrationConfig::default();
173        assert_eq!(
174            config.lock_file_path(),
175            PathBuf::from(".kimberlite/migrations/.lock")
176        );
177    }
178
179    #[test]
180    fn test_migration_manager_creation() {
181        let temp = TempDir::new().unwrap();
182        let config = MigrationConfig {
183            migrations_dir: temp.path().join("migrations"),
184            state_dir: temp.path().join("state"),
185            auto_timestamp: false,
186        };
187
188        std::fs::create_dir_all(&config.migrations_dir).unwrap();
189        std::fs::create_dir_all(&config.state_dir).unwrap();
190
191        let manager = MigrationManager::new(config);
192        assert!(manager.is_ok());
193    }
194}