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}