database_migration/definition/
mod.rs

1use crate::config::{DEFAULT_EXCLUDED_FILES, MIGRATION_KEY_FORMAT_STR};
2use crate::error::{DefinitionError, FilePatternError};
3use crate::migration::{Migration, MigrationKind, NewMigration};
4use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
5use regex::Regex;
6use std::borrow::Cow;
7use std::ffi::OsStr;
8use std::fmt::{self, Display, Write};
9use std::path::{Path, PathBuf};
10use std::str::FromStr;
11
12pub trait ParseMigration {
13    type Err;
14
15    fn parse_migration(&self) -> Result<Migration, Self::Err>;
16}
17
18pub const SCRIPT_FILE_EXTENSION: &str = ".surql";
19pub const UP_SCRIPT_FILE_EXTENSION: &str = ".up.surql";
20pub const DOWN_SCRIPT_FILE_EXTENSION: &str = ".down.surql";
21
22fn parse_migration(path: &Path, filename: &str) -> Result<Migration, DefinitionError> {
23    if !filename.ends_with(SCRIPT_FILE_EXTENSION) {
24        return Err(DefinitionError::InvalidFilename);
25    }
26    let up = filename.ends_with(UP_SCRIPT_FILE_EXTENSION);
27    let down = filename.ends_with(DOWN_SCRIPT_FILE_EXTENSION);
28    let (kind, ext_len) = match (up, down) {
29        (false, false) => (MigrationKind::Up, SCRIPT_FILE_EXTENSION.len()),
30        (true, false) => (MigrationKind::Up, UP_SCRIPT_FILE_EXTENSION.len()),
31        (false, true) => (MigrationKind::Down, DOWN_SCRIPT_FILE_EXTENSION.len()),
32        (true, true) => return Err(DefinitionError::AmbiguousDirection),
33    };
34    if filename.contains(".up.") && filename.contains(".down.") {
35        return Err(DefinitionError::AmbiguousDirection);
36    }
37    let len = filename.len();
38    if len < 8 + ext_len {
39        return Err(DefinitionError::MissingDate);
40    }
41    let date_substr = &filename[0..8];
42    let date = NaiveDate::parse_from_str(date_substr, "%Y%m%d")
43        .map_err(|err| DefinitionError::InvalidDate(err.to_string()))?;
44    if len < 15 + ext_len || &filename[8..9] != "_" {
45        return Err(DefinitionError::MissingTime);
46    }
47    let time_substr = &filename[9..15];
48    let time = NaiveTime::parse_from_str(time_substr, "%H%M%S")
49        .map_err(|err| DefinitionError::InvalidTime(err.to_string()))?;
50    let key = NaiveDateTime::new(date, time);
51    let title = if len < 17 + ext_len || &filename[15..16] != "_" {
52        ""
53    } else {
54        &filename[16..len - ext_len].replace('_', " ")
55    };
56    let mut script_path = PathBuf::from(path);
57    script_path.push(filename);
58
59    Ok(Migration {
60        key,
61        title: title.to_string(),
62        kind,
63        script_path,
64    })
65}
66
67impl ParseMigration for str {
68    type Err = DefinitionError;
69
70    fn parse_migration(&self) -> Result<Migration, Self::Err> {
71        let (path, filename) = self
72            .rfind('/')
73            .map_or(("", self), |index| (&self[..index], &self[index + 1..]));
74
75        parse_migration(Path::new(path), filename)
76    }
77}
78
79impl ParseMigration for OsStr {
80    type Err = DefinitionError;
81
82    fn parse_migration(&self) -> Result<Migration, Self::Err> {
83        let path_str = self.to_str().ok_or(DefinitionError::InvalidUtf8Character)?;
84        ParseMigration::parse_migration(path_str)
85    }
86}
87
88impl ParseMigration for Path {
89    type Err = DefinitionError;
90
91    fn parse_migration(&self) -> Result<Migration, Self::Err> {
92        let path = self.parent().unwrap_or_else(|| Self::new(""));
93        let filename = self.file_name().ok_or(DefinitionError::InvalidFilename)?;
94        let filename = filename
95            .to_str()
96            .ok_or(DefinitionError::InvalidUtf8Character)?;
97
98        parse_migration(path, filename)
99    }
100}
101
102pub trait GetFilename {
103    fn get_filename(&self, migration: &NewMigration) -> String;
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[must_use]
108pub struct MigrationFilenameStrategy {
109    pub up_postfix: bool,
110}
111
112impl Default for MigrationFilenameStrategy {
113    fn default() -> Self {
114        Self { up_postfix: true }
115    }
116}
117
118impl MigrationFilenameStrategy {
119    pub const fn with_up_postfix(mut self, up_postfix: bool) -> Self {
120        self.up_postfix = up_postfix;
121        self
122    }
123}
124
125impl GetFilename for MigrationFilenameStrategy {
126    fn get_filename(&self, migration: &NewMigration) -> String {
127        let key = migration.key.format(MIGRATION_KEY_FORMAT_STR).to_string();
128        let title = migration.title.replace(' ', "_");
129        let extension = match (migration.kind, self.up_postfix) {
130            (MigrationKind::Up, true) => UP_SCRIPT_FILE_EXTENSION,
131            (MigrationKind::Up, false) => SCRIPT_FILE_EXTENSION,
132            (MigrationKind::Down, _) => DOWN_SCRIPT_FILE_EXTENSION,
133            (MigrationKind::Baseline, _) => panic!("baselines do not have migration scripts"),
134        };
135        if title.is_empty() {
136            format!("{key}{extension}")
137        } else {
138            format!("{key}_{title}{extension}")
139        }
140    }
141}
142
143#[derive(Clone, Debug)]
144struct FilePattern {
145    pattern: String,
146    regex: Regex,
147    filename_pattern: bool,
148}
149
150impl FilePattern {
151    #[allow(clippy::missing_const_for_fn)]
152    fn is_filename_pattern(&self) -> bool {
153        self.filename_pattern
154    }
155
156    fn is_match(&self, haystack: &str) -> bool {
157        self.regex.is_match(haystack)
158    }
159}
160
161impl PartialEq for FilePattern {
162    fn eq(&self, other: &Self) -> bool {
163        self.pattern == other.pattern
164    }
165}
166
167impl Display for FilePattern {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.write_str(&self.pattern)
170    }
171}
172
173impl FromStr for FilePattern {
174    type Err = FilePatternError;
175
176    fn from_str(pattern_str: &str) -> Result<Self, Self::Err> {
177        if pattern_str.is_empty() {
178            Err(FilePatternError::EmptySubPatternNotAllowed)
179        } else {
180            let invalid_chars = scan_for_invalid_characters(pattern_str);
181            if invalid_chars.is_empty() {
182                let filename_pattern = !pattern_str.contains('/');
183                let mut regex_pattern = String::from("^");
184                regex_pattern.push_str(
185                    &pattern_str
186                        .replace('.', "\\.")
187                        .replace("**", ".?")
188                        .replace('*', "[^/]*")
189                        .replace(".?", ".*"),
190                );
191                regex_pattern.push('$');
192                Regex::new(&regex_pattern)
193                    .map_err(|err| FilePatternError::InvalidPattern(err.to_string()))
194                    .map(|regex| Self {
195                        pattern: pattern_str.into(),
196                        regex,
197                        filename_pattern,
198                    })
199            } else {
200                Err(FilePatternError::InvalidCharacter(invalid_chars))
201            }
202        }
203    }
204}
205
206#[derive(Clone, Debug, PartialEq)]
207pub struct ExcludedFiles {
208    pattern: Vec<FilePattern>,
209}
210
211impl Default for ExcludedFiles {
212    fn default() -> Self {
213        DEFAULT_EXCLUDED_FILES.parse()
214            .unwrap_or_else(|err| panic!("failed to create default `ExcludedFiles`: {err} -- THIS IS AN IMPLEMENTATION ERROR! Please file a bug."))
215    }
216}
217
218impl ExcludedFiles {
219    pub const fn empty() -> Self {
220        Self {
221            pattern: Vec::new(),
222        }
223    }
224
225    pub fn matches(&self, path: &Path) -> bool {
226        let filename = path
227            .file_name()
228            .map_or(Cow::Borrowed(""), OsStr::to_string_lossy);
229        if filename.is_empty() {
230            return false;
231        }
232        let path_str = path.to_string_lossy().replace('\\', "/");
233        self.pattern.iter().any(|p| {
234            if p.is_filename_pattern() {
235                p.is_match(&filename)
236            } else {
237                p.is_match(&path_str)
238            }
239        })
240    }
241}
242
243impl Display for ExcludedFiles {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        let mut first = true;
246        for pattern in &self.pattern {
247            if first {
248                first = false;
249            } else {
250                f.write_char('|')?;
251            }
252            write!(f, "{pattern}")?;
253        }
254        Ok(())
255    }
256}
257
258impl FromStr for ExcludedFiles {
259    type Err = FilePatternError;
260
261    fn from_str(s: &str) -> Result<Self, Self::Err> {
262        if s.is_empty() {
263            return Ok(Self {
264                pattern: Vec::new(),
265            });
266        }
267        let pattern = s
268            .split('|')
269            .map(FromStr::from_str)
270            .collect::<Result<Vec<_>, _>>()?;
271
272        Ok(Self { pattern })
273    }
274}
275
276fn scan_for_invalid_characters(s: &str) -> Vec<char> {
277    s.chars().filter(|c| !is_valid_pattern_char(*c)).collect()
278}
279
280fn is_valid_pattern_char(c: char) -> bool {
281    match c {
282        '*' | ' ' | '_' | '-' | '.' | '/' => true,
283        _ if c.is_alphanumeric() => true,
284        _ => false,
285    }
286}
287
288#[cfg(test)]
289mod tests;