use std::path::PathBuf;
use sha2::{Digest, Sha256};
use crate::error::Result;
const DOWN_SEPARATOR: &str = "-- DOWN ==";
const CHECKSUM_CHUNK: usize = 8192;
pub(crate) fn read_and_checksum(path: &std::path::Path) -> Result<(String, String)> {
let raw = std::fs::read_to_string(path)?;
let mut hasher = Sha256::new();
for chunk in raw.as_bytes().chunks(CHECKSUM_CHUNK) {
hasher.update(chunk);
}
let hex: String = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
Ok((hex, raw))
}
fn split_up_down(raw: &str) -> (String, Option<String>) {
let mut up_lines: Vec<&str> = Vec::new();
let mut down_lines: Vec<&str> = Vec::new();
let mut in_down = false;
for line in raw.lines() {
if !in_down && line.trim() == DOWN_SEPARATOR {
in_down = true;
continue;
}
if in_down {
down_lines.push(line);
} else {
up_lines.push(line);
}
}
let up_sql = up_lines.join("\n");
let down_sql = if in_down {
Some(down_lines.join("\n"))
} else {
None
};
(up_sql, down_sql)
}
#[derive(Debug)]
pub struct Migration {
pub version: u32,
pub file: String,
pub name: String,
pub checksum: String,
pub created: Option<String>,
pub author: Option<String>,
pub why: Option<String>,
raw: String,
}
impl Migration {
pub(crate) fn from_metadata(
version: u32,
file: &str,
checksum: String,
raw: String,
created: Option<&str>,
author: Option<&str>,
why: Option<&str>,
) -> Self {
let name = file.strip_suffix(".sql").unwrap_or(file).to_owned();
Self {
version,
file: file.to_owned(),
name,
checksum,
raw,
created: created.map(str::to_owned),
author: author.map(str::to_owned),
why: why.map(str::to_owned),
}
}
pub(crate) fn read_up(&self) -> (String, Option<String>) {
split_up_down(&self.raw)
}
pub(crate) fn read_down(&self) -> Option<String> {
split_up_down(&self.raw).1
}
pub fn up(&self) -> String {
split_up_down(&self.raw).0
}
pub fn down(&self) -> Option<String> {
split_up_down(&self.raw).1
}
}
#[derive(Debug)]
pub struct SetupFile {
pub name: String,
pub(crate) path: PathBuf,
}
impl SetupFile {
pub(crate) fn read_sql(&self) -> Result<String> {
Ok(std::fs::read_to_string(&self.path)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn write_temp(content: &str) -> (tempfile::NamedTempFile, std::path::PathBuf) {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
let p = f.path().to_path_buf();
(f, p)
}
#[test]
fn split_with_down() {
let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
let (up, down) = split_up_down(raw);
assert_eq!(up, "CREATE TABLE t (id INT);");
assert_eq!(down.as_deref(), Some("DROP TABLE t;"));
}
#[test]
fn split_without_down() {
let raw = "CREATE TABLE t (id INT);";
let (up, down) = split_up_down(raw);
assert_eq!(up, "CREATE TABLE t (id INT);");
assert!(down.is_none());
}
#[test]
fn separator_must_be_exact_trim() {
let raw = "UP;\n-- DOWN == extra\nDOWN;";
let (_, down) = split_up_down(raw);
assert!(down.is_none(), "should not split on partial separator");
}
#[test]
fn checksum_is_of_full_file() {
use sha2::Digest;
let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
let (_tmp, path) = write_temp(raw);
let (got_checksum, got_raw) = read_and_checksum(&path).unwrap();
assert_eq!(got_raw, raw);
let mut h = Sha256::new();
h.update(raw.as_bytes());
let expected: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(got_checksum, expected);
}
#[test]
fn read_up_and_down_from_migration() {
let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
let (_tmp, path) = write_temp(raw);
let (checksum, file_raw) = read_and_checksum(&path).unwrap();
let m = Migration::from_metadata(
1,
"20260101_01_init.sql",
checksum,
file_raw,
None,
None,
None,
);
drop(path); let (up, down) = m.read_up();
assert_eq!(up, "CREATE TABLE t (id INT);");
assert_eq!(down.as_deref(), Some("DROP TABLE t;"));
assert_eq!(m.read_down().as_deref(), Some("DROP TABLE t;"));
}
#[test]
fn read_up_without_down() {
let raw = "CREATE TABLE t (id INT);";
let (_tmp, path) = write_temp(raw);
let (checksum, file_raw) = read_and_checksum(&path).unwrap();
let m = Migration::from_metadata(
1,
"20260101_01_init.sql",
checksum,
file_raw,
None,
None,
None,
);
drop(path); let (up, down) = m.read_up();
assert_eq!(up, "CREATE TABLE t (id INT);");
assert!(down.is_none());
assert!(m.read_down().is_none());
}
#[test]
fn public_up_and_down_with_separator() {
let raw = "CREATE TABLE t (id INT);\n-- DOWN ==\nDROP TABLE t;";
let (_tmp, path) = write_temp(raw);
let (checksum, file_raw) = read_and_checksum(&path).unwrap();
let m = Migration::from_metadata(
1,
"20260101_01_init.sql",
checksum,
file_raw,
None,
None,
None,
);
assert_eq!(m.up(), "CREATE TABLE t (id INT);");
assert_eq!(m.down().as_deref(), Some("DROP TABLE t;"));
}
#[test]
fn public_up_and_down_without_separator() {
let raw = "CREATE TABLE t (id INT);";
let (_tmp, path) = write_temp(raw);
let (checksum, file_raw) = read_and_checksum(&path).unwrap();
let m = Migration::from_metadata(
1,
"20260101_01_init.sql",
checksum,
file_raw,
None,
None,
None,
);
assert_eq!(m.up(), "CREATE TABLE t (id INT);");
assert!(m.down().is_none());
}
}