1use std::path::PathBuf;
2
3use sha2::{Digest, Sha256};
4
5use crate::error::Result;
6
7const DOWN_SEPARATOR: &str = "-- DOWN ==";
9
10const CHECKSUM_CHUNK: usize = 8192;
12
13pub(crate) fn read_and_checksum(path: &std::path::Path) -> Result<(String, String)> {
18 let raw = std::fs::read_to_string(path)?;
19 let mut hasher = Sha256::new();
20 for chunk in raw.as_bytes().chunks(CHECKSUM_CHUNK) {
21 hasher.update(chunk);
22 }
23 let hex: String = hasher
24 .finalize()
25 .iter()
26 .map(|b| format!("{b:02x}"))
27 .collect();
28 Ok((hex, raw))
29}
30
31fn split_up_down(raw: &str) -> (String, Option<String>) {
34 let mut up_lines: Vec<&str> = Vec::new();
35 let mut down_lines: Vec<&str> = Vec::new();
36 let mut in_down = false;
37 for line in raw.lines() {
38 if !in_down && line.trim() == DOWN_SEPARATOR {
39 in_down = true;
40 continue;
41 }
42 if in_down {
43 down_lines.push(line);
44 } else {
45 up_lines.push(line);
46 }
47 }
48 let up_sql = up_lines.join("\n");
49 let down_sql = if in_down {
50 Some(down_lines.join("\n"))
51 } else {
52 None
53 };
54 (up_sql, down_sql)
55}
56
57#[derive(Debug)]
61pub struct Migration {
62 pub version: u32,
63 pub file: String,
64 pub name: String,
66 pub checksum: String,
68 pub created: Option<String>,
70 pub author: Option<String>,
72 pub why: Option<String>,
73 raw: String,
75}
76
77impl Migration {
78 pub(crate) fn from_metadata(
81 version: u32,
82 file: &str,
83 checksum: String,
84 raw: String,
85 created: Option<&str>,
86 author: Option<&str>,
87 why: Option<&str>,
88 ) -> Self {
89 let name = file.strip_suffix(".sql").unwrap_or(file).to_owned();
90 Self {
91 version,
92 file: file.to_owned(),
93 name,
94 checksum,
95 raw,
96 created: created.map(str::to_owned),
97 author: author.map(str::to_owned),
98 why: why.map(str::to_owned),
99 }
100 }
101
102 pub(crate) fn read_up(&self) -> (String, Option<String>) {
104 split_up_down(&self.raw)
105 }
106
107 pub(crate) fn read_down(&self) -> Option<String> {
109 split_up_down(&self.raw).1
110 }
111
112 pub fn up(&self) -> String {
117 split_up_down(&self.raw).0
118 }
119
120 pub fn down(&self) -> Option<String> {
123 split_up_down(&self.raw).1
124 }
125}
126
127#[derive(Debug)]
130pub struct SetupFile {
131 pub name: String,
132 pub(crate) path: PathBuf,
133}
134
135impl SetupFile {
136 pub(crate) fn read_sql(&self) -> Result<String> {
137 Ok(std::fs::read_to_string(&self.path)?)
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use std::io::Write as _;
145
146 fn write_temp(content: &str) -> (tempfile::NamedTempFile, std::path::PathBuf) {
147 let mut f = tempfile::NamedTempFile::new().unwrap();
148 f.write_all(content.as_bytes()).unwrap();
149 let p = f.path().to_path_buf();
150 (f, p)
151 }
152
153 #[test]
154 fn split_with_down() {
155 let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
156 let (up, down) = split_up_down(raw);
157 assert_eq!(up, "CREATE TABLE t (id INT);");
158 assert_eq!(down.as_deref(), Some("DROP TABLE t;"));
159 }
160
161 #[test]
162 fn split_without_down() {
163 let raw = "CREATE TABLE t (id INT);";
164 let (up, down) = split_up_down(raw);
165 assert_eq!(up, "CREATE TABLE t (id INT);");
166 assert!(down.is_none());
167 }
168
169 #[test]
170 fn separator_must_be_exact_trim() {
171 let raw = "UP;\n-- DOWN == extra\nDOWN;";
173 let (_, down) = split_up_down(raw);
174 assert!(down.is_none(), "should not split on partial separator");
175 }
176
177 #[test]
178 fn checksum_is_of_full_file() {
179 use sha2::Digest;
180 let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
181 let (_tmp, path) = write_temp(raw);
182 let (got_checksum, got_raw) = read_and_checksum(&path).unwrap();
183 assert_eq!(got_raw, raw);
184
185 let mut h = Sha256::new();
186 h.update(raw.as_bytes());
187 let expected: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
188 assert_eq!(got_checksum, expected);
189 }
190
191 #[test]
192 fn read_up_and_down_from_migration() {
193 let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
194 let (_tmp, path) = write_temp(raw);
195 let (checksum, file_raw) = read_and_checksum(&path).unwrap();
196 let m = Migration::from_metadata(
197 1,
198 "20260101_01_init.sql",
199 checksum,
200 file_raw,
201 None,
202 None,
203 None,
204 );
205 drop(path); let (up, down) = m.read_up();
207 assert_eq!(up, "CREATE TABLE t (id INT);");
208 assert_eq!(down.as_deref(), Some("DROP TABLE t;"));
209 assert_eq!(m.read_down().as_deref(), Some("DROP TABLE t;"));
210 }
211
212 #[test]
213 fn read_up_without_down() {
214 let raw = "CREATE TABLE t (id INT);";
215 let (_tmp, path) = write_temp(raw);
216 let (checksum, file_raw) = read_and_checksum(&path).unwrap();
217 let m = Migration::from_metadata(
218 1,
219 "20260101_01_init.sql",
220 checksum,
221 file_raw,
222 None,
223 None,
224 None,
225 );
226 drop(path); let (up, down) = m.read_up();
228 assert_eq!(up, "CREATE TABLE t (id INT);");
229 assert!(down.is_none());
230 assert!(m.read_down().is_none());
231 }
232
233 #[test]
234 fn public_up_and_down_with_separator() {
235 let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
236 let (_tmp, path) = write_temp(raw);
237 let (checksum, file_raw) = read_and_checksum(&path).unwrap();
238 let m = Migration::from_metadata(
239 1,
240 "20260101_01_init.sql",
241 checksum,
242 file_raw,
243 None,
244 None,
245 None,
246 );
247 assert_eq!(m.up(), "CREATE TABLE t (id INT);");
248 assert_eq!(m.down().as_deref(), Some("DROP TABLE t;"));
249 }
250
251 #[test]
252 fn public_up_and_down_without_separator() {
253 let raw = "CREATE TABLE t (id INT);";
254 let (_tmp, path) = write_temp(raw);
255 let (checksum, file_raw) = read_and_checksum(&path).unwrap();
256 let m = Migration::from_metadata(
257 1,
258 "20260101_01_init.sql",
259 checksum,
260 file_raw,
261 None,
262 None,
263 None,
264 );
265 assert_eq!(m.up(), "CREATE TABLE t (id INT);");
266 assert!(m.down().is_none());
267 }
268}