1use anyhow::{
2 Context,
3 Result as AnyhowResult,
4};
5use indoc::indoc;
6use regex::Regex;
7use std::collections::BTreeMap;
8use std::fs::{
9 self,
10 File,
11};
12use std::io::prelude::*;
13use std::io::BufReader;
14use std::path::Path;
15use std::string::ToString;
16use std::time::{
17 SystemTime,
18 UNIX_EPOCH,
19};
20
21pub type VecStr = Vec<String>;
22
23#[derive(Debug)]
24pub struct MigrationFile {
25 pub content_up: Option<VecStr>,
26 pub content_down: Option<VecStr>,
27 pub number: i64,
28 pub filename: String,
29}
30
31impl MigrationFile {
32 fn new(filename: &str, number: i64) -> Self {
33 Self {
34 content_up: None,
35 content_down: None,
36 filename: filename.to_owned(),
37 number,
38 }
39 }
40}
41
42pub type MigrationFiles = BTreeMap<i64, MigrationFile>;
44
45fn parse_file(filename: &str) -> AnyhowResult<MigrationFile> {
47 let re = Regex::new(r"^(?P<number>[0-9]{13})_(?P<name>[_0-9a-zA-Z]*)\.sql$")?;
49
50 let result = re
52 .captures(filename)
53 .with_context(|| format!("Invalid filename found on {filename}"))?;
54
55 let number = result
57 .name("number")
58 .context("The migration file timestamp is missing")?
59 .as_str()
60 .parse::<i64>()?;
61
62 Ok(MigrationFile::new(filename, number))
63}
64
65pub fn build_migration_list(path: &Path) -> AnyhowResult<MigrationFiles> {
67 let mut files: MigrationFiles = BTreeMap::new();
68 let entries = fs::read_dir(path)?.filter_map(Result::ok).collect::<Vec<_>>();
69
70 for entry in entries {
71 let filename = entry.file_name();
72 let Ok(info) = parse_file(filename.to_str().context("Filename is not valid")?) else {
73 continue;
74 };
75
76 let file = File::open(entry.path())?;
77 let mut buf_reader = BufReader::new(file);
78 let mut content = String::new();
79 buf_reader.read_to_string(&mut content)?;
80
81 let split_vec: Vec<String> = content.split('\n').map(ToString::to_string).collect();
82
83 let pos_up = split_vec
84 .iter()
85 .position(|s| s == "-- !UP" || s == "-- !UP\r")
86 .context("Parser can't find the UP migration")?;
87 let pos_down = split_vec
88 .iter()
89 .position(|s| s == "-- !DOWN" || s == "-- !DOWN\r")
90 .context("Parser can't find the DOWN migration")?;
91
92 let content_up = &split_vec[(pos_up + 1)..pos_down];
93 let content_down = &split_vec[(pos_down + 1)..];
94
95 let migration = MigrationFile {
96 content_up: Some(content_up.to_vec()),
97 content_down: Some(content_down.to_vec()),
98 ..info
99 };
100
101 log::trace!("Running the migration: {:?} {:?}", migration, migration.filename);
102 files.insert(migration.number, migration);
103 }
104
105 Ok(files)
106}
107
108fn timestamp() -> String {
110 let start = SystemTime::now();
111 let since_the_epoch = start.duration_since(UNIX_EPOCH).expect("Time went backwards");
112 since_the_epoch.as_millis().to_string()
113}
114
115pub fn create_migration_file(path: &Path, slug: &str) -> AnyhowResult<()> {
117 let filename = timestamp() + "_" + slug + ".sql";
118 let filepath = path.join(filename);
119
120 log::trace!("Creating new migration file: {:?}", filepath);
121 let mut f = File::create(filepath)?;
122 let contents = indoc! {"\
123 -- # Put your SQL below migration seperator.
124 -- !UP
125
126 -- !DOWN
127 "};
128
129 f.write_all(contents.as_bytes())?;
130 f.sync_all()?;
131
132 Ok(())
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn it_should_parse_correct_migration_filename() {
141 let result = parse_file("0000000000000_initial.sql").unwrap();
142 assert_eq!(result.number, 0);
143 assert_eq!(result.filename, "0000000000000_initial.sql");
144 }
145}