toasty_cli/migration/
history_file.rs

1use anyhow::{Result, bail};
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::path::Path;
5use std::str::FromStr;
6
7const HISTORY_FILE_VERSION: u32 = 1;
8
9/// A TOML-serializable record of all migrations that have been generated.
10///
11/// The history file lives at `<migration_path>/history.toml` and is the
12/// source of truth for which migrations exist and what order they were
13/// created in. Each entry is a [`HistoryFileMigration`].
14///
15/// The file carries a version number. [`HistoryFile::load`] and the
16/// [`FromStr`] implementation reject files whose version does not match the
17/// current format.
18///
19/// # Examples
20///
21/// ```
22/// use toasty_cli::{HistoryFile, HistoryFileMigration};
23///
24/// let mut history = HistoryFile::new();
25/// assert_eq!(history.next_migration_number(), 0);
26///
27/// history.add_migration(HistoryFileMigration {
28///     id: 100,
29///     name: "0000_init.sql".to_string(),
30///     snapshot_name: "0000_snapshot.toml".to_string(),
31///     checksum: None,
32/// });
33/// assert_eq!(history.next_migration_number(), 1);
34/// assert_eq!(history.migrations().len(), 1);
35///
36/// // Round-trip through TOML serialization
37/// let serialized = history.to_string();
38/// let restored: HistoryFile = serialized.parse().unwrap();
39/// assert_eq!(restored.migrations()[0].id, 100);
40/// ```
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct HistoryFile {
43    /// History file format version
44    version: u32,
45
46    /// Migration history
47    migrations: Vec<HistoryFileMigration>,
48}
49
50/// A single entry in the migration history.
51///
52/// Each entry records the randomly-assigned ID used by the database driver to
53/// track application status, the migration SQL file name, the companion
54/// snapshot file name, and an optional checksum.
55///
56/// # Examples
57///
58/// ```
59/// use toasty_cli::HistoryFileMigration;
60///
61/// let entry = HistoryFileMigration {
62///     id: 42,
63///     name: "0001_create_users.sql".to_string(),
64///     snapshot_name: "0001_snapshot.toml".to_string(),
65///     checksum: None,
66/// };
67/// assert_eq!(entry.id, 42);
68/// assert_eq!(entry.name, "0001_create_users.sql");
69/// ```
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct HistoryFileMigration {
72    /// Random unique identifier for this migration.
73    pub id: u64,
74
75    /// Migration name/identifier.
76    pub name: String,
77
78    /// Name of the snapshot generated alongside this migration.
79    pub snapshot_name: String,
80
81    /// Optional checksum of the migration file to detect changes
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub checksum: Option<String>,
84}
85
86impl HistoryFile {
87    /// Create a new empty history file
88    pub fn new() -> Self {
89        Self {
90            version: HISTORY_FILE_VERSION,
91            migrations: Vec::new(),
92        }
93    }
94
95    /// Load a history file from a TOML file
96    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
97        let contents = std::fs::read_to_string(path.as_ref())?;
98        contents.parse()
99    }
100
101    /// Save the history file to a TOML file
102    pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
103        std::fs::write(path.as_ref(), self.to_string())?;
104        Ok(())
105    }
106
107    /// Loads the history file, or returns an empty one if it does not exist
108    pub fn load_or_default(path: impl AsRef<Path>) -> Result<Self> {
109        if std::fs::exists(&path)? {
110            return Self::load(path);
111        }
112        Ok(Self::default())
113    }
114
115    /// Returns the ordered list of migrations in this history.
116    ///
117    /// Migrations appear in the order they were added. An empty slice means no
118    /// migrations have been recorded yet.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use toasty_cli::{HistoryFile, HistoryFileMigration};
124    ///
125    /// let mut history = HistoryFile::new();
126    /// assert!(history.migrations().is_empty());
127    ///
128    /// history.add_migration(HistoryFileMigration {
129    ///     id: 1,
130    ///     name: "0001_init.sql".to_string(),
131    ///     snapshot_name: "0001_snapshot.toml".to_string(),
132    ///     checksum: None,
133    /// });
134    /// assert_eq!(history.migrations().len(), 1);
135    /// assert_eq!(history.migrations()[0].name, "0001_init.sql");
136    /// ```
137    pub fn migrations(&self) -> &[HistoryFileMigration] {
138        &self.migrations
139    }
140
141    /// Get the next migration number by parsing the last migration's name
142    pub fn next_migration_number(&self) -> u32 {
143        self.migrations
144            .last()
145            .and_then(|m| {
146                // Extract the first 4 digits from the migration name (e.g., "0001_migration.sql" -> 1)
147                m.name.split('_').next()?.parse::<u32>().ok()
148            })
149            .map(|n| n + 1)
150            .unwrap_or(0)
151    }
152
153    /// Add a migration to the history
154    pub fn add_migration(&mut self, migration: HistoryFileMigration) {
155        self.migrations.push(migration);
156    }
157
158    /// Remove a migration from the history by index
159    pub fn remove_migration(&mut self, index: usize) {
160        self.migrations.remove(index);
161    }
162}
163
164impl Default for HistoryFile {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl FromStr for HistoryFile {
171    type Err = anyhow::Error;
172
173    fn from_str(s: &str) -> Result<Self> {
174        let file: HistoryFile = toml::from_str(s)?;
175
176        // Validate version
177        if file.version != HISTORY_FILE_VERSION {
178            bail!(
179                "Unsupported history file version: {}. Expected version {}",
180                file.version,
181                HISTORY_FILE_VERSION
182            );
183        }
184
185        Ok(file)
186    }
187}
188
189impl fmt::Display for HistoryFile {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        let toml_str = toml::to_string_pretty(self).map_err(|_| fmt::Error)?;
192        write!(f, "{}", toml_str)
193    }
194}