use camino::{Utf8Path, Utf8PathBuf};
use miette::Diagnostic;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use thiserror::Error;
static MIGRATION_TIMESTAMP_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(\d{4}_\d{2}_\d{2}_\d{6}|\d{4}-\d{2}-\d{2}-\d{6}|\d{14})")
.expect("valid regex pattern")
});
fn valid_check_names_help() -> String {
format!(
"Valid check names: {}",
crate::checks::ALL_CHECK_NAMES.join(", ")
)
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file")]
IoError(#[from] std::io::Error),
#[error("Failed to parse config file")]
ParseError(#[from] toml::de::Error),
#[error("Invalid check name: {invalid_name}")]
InvalidCheckName { invalid_name: String },
#[error("Invalid timestamp format: {0}")]
InvalidTimestampFormat(String),
}
impl Diagnostic for ConfigError {
fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
match self {
Self::IoError(_) => Some(Box::new("diesel_guard::config::io_error")),
Self::ParseError(_) => Some(Box::new("diesel_guard::config::parse_error")),
Self::InvalidCheckName { .. } => Some(Box::new("diesel_guard::config::invalid_check")),
Self::InvalidTimestampFormat(_) => {
Some(Box::new("diesel_guard::config::invalid_timestamp"))
}
}
}
fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
match self {
Self::InvalidCheckName { .. } => Some(Box::new(valid_check_names_help())),
Self::InvalidTimestampFormat(_) => Some(Box::new(
"Expected format: YYYYMMDDHHMMSS, YYYY_MM_DD_HHMMSS, or YYYY-MM-DD-HHMMSS (e.g., 20240101000000, 2024_01_01_000000, or 2024-01-01-000000)",
)),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub start_after: Option<String>,
#[serde(default)]
pub check_down: bool,
#[serde(default)]
pub disable_checks: Vec<String>,
}
impl Config {
pub fn load() -> Result<Self, ConfigError> {
let config_path = Utf8PathBuf::from("diesel-guard.toml");
if !config_path.exists() {
return Ok(Self::default());
}
Self::load_from_path(&config_path)
}
pub fn load_from_path(path: &Utf8Path) -> Result<Self, ConfigError> {
let contents = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), ConfigError> {
if let Some(ref timestamp) = self.start_after {
Self::validate_timestamp(timestamp)?;
}
for check_name in &self.disable_checks {
if !crate::checks::ALL_CHECK_NAMES.contains(&check_name.as_str()) {
return Err(ConfigError::InvalidCheckName {
invalid_name: check_name.clone(),
});
}
}
Ok(())
}
fn validate_timestamp(timestamp: &str) -> Result<(), ConfigError> {
let Some(captures) = MIGRATION_TIMESTAMP_REGEX.captures(timestamp) else {
return Err(ConfigError::InvalidTimestampFormat(timestamp.to_string()));
};
if captures.get(0).unwrap().as_str() == timestamp {
Ok(())
} else {
Err(ConfigError::InvalidTimestampFormat(timestamp.to_string()))
}
}
pub fn is_check_enabled(&self, check_name: &str) -> bool {
!self.disable_checks.iter().any(|c| c == check_name)
}
pub fn should_check_migration(&self, migration_dir_name: &str) -> bool {
let Some(ref start_after) = self.start_after else {
return true; };
let Some(captures) = MIGRATION_TIMESTAMP_REGEX.captures(migration_dir_name) else {
return true; };
let migration_timestamp = &captures[1];
let start_normalized = start_after.replace(['_', '-'], "");
let migration_normalized = migration_timestamp.replace(['_', '-'], "");
migration_normalized > start_normalized
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.start_after, None);
assert!(!config.check_down);
assert_eq!(config.disable_checks.len(), 0);
}
#[test]
fn test_valid_timestamp() {
assert!(Config::validate_timestamp("2024_01_01_000000").is_ok());
assert!(Config::validate_timestamp("2023_12_31_235959").is_ok());
assert!(Config::validate_timestamp("2025_06_15_120000").is_ok());
assert!(Config::validate_timestamp("2024-01-01-000000").is_ok());
assert!(Config::validate_timestamp("2023-12-31-235959").is_ok());
assert!(Config::validate_timestamp("2025-06-15-120000").is_ok());
assert!(Config::validate_timestamp("20240101000000").is_ok());
assert!(Config::validate_timestamp("20231231235959").is_ok());
assert!(Config::validate_timestamp("20250615120000").is_ok());
}
#[test]
fn test_invalid_timestamp_format() {
assert!(Config::validate_timestamp("2024_01-01_000000").is_err());
assert!(Config::validate_timestamp("2024-01_01-000000").is_err());
assert!(Config::validate_timestamp("2024_01_01").is_err());
assert!(Config::validate_timestamp("2024_01_01_00000").is_err());
assert!(Config::validate_timestamp("2024010100000").is_err());
assert!(Config::validate_timestamp("202a_01_01_000000").is_err());
assert!(Config::validate_timestamp("202a-01-01-000000").is_err());
assert!(Config::validate_timestamp("2024/01/01/000000").is_err());
assert!(Config::validate_timestamp("2024.01.01.000000").is_err());
}
#[test]
fn test_should_check_migration_no_filter() {
let config = Config::default();
assert!(config.should_check_migration("2024_01_01_000000_create_users"));
assert!(config.should_check_migration("2020_01_01_000000_old_migration"));
}
#[test]
fn test_should_check_migration_with_filter() {
let config = Config {
start_after: Some("2024_01_01_000000".to_string()),
..Default::default()
};
assert!(config.should_check_migration("2024_01_02_000000_new_migration"));
assert!(config.should_check_migration("2024_06_15_120000_another_migration"));
assert!(!config.should_check_migration("2024_01_01_000000_exact_match"));
assert!(!config.should_check_migration("2023_12_31_235959_old_migration"));
assert!(!config.should_check_migration("2020_01_01_000000_very_old"));
}
#[test]
fn test_should_check_migration_mixed_formats() {
let config_underscores = Config {
start_after: Some("2024_01_01_000000".to_string()),
..Default::default()
};
assert!(config_underscores.should_check_migration("2024-01-02-000000_new_migration"));
assert!(config_underscores.should_check_migration("2024-06-15-120000_another_migration"));
assert!(!config_underscores.should_check_migration("2024-01-01-000000_exact_match"));
assert!(!config_underscores.should_check_migration("2023-12-31-235959_old_migration"));
assert!(config_underscores.should_check_migration("20240102000000_new_migration"));
assert!(config_underscores.should_check_migration("20240615120000_another_migration"));
assert!(!config_underscores.should_check_migration("20240101000000_exact_match"));
assert!(!config_underscores.should_check_migration("20231231235959_old_migration"));
let config_dashes = Config {
start_after: Some("2024-01-01-000000".to_string()),
..Default::default()
};
assert!(config_dashes.should_check_migration("2024_01_02_000000_new_migration"));
assert!(!config_dashes.should_check_migration("2024_01_01_000000_exact_match"));
assert!(config_dashes.should_check_migration("20240102000000_new_migration"));
assert!(!config_dashes.should_check_migration("20240101000000_exact_match"));
let config_no_sep = Config {
start_after: Some("20240101000000".to_string()),
..Default::default()
};
assert!(config_no_sep.should_check_migration("2024_01_02_000000_new_migration"));
assert!(!config_no_sep.should_check_migration("2024_01_01_000000_exact_match"));
assert!(config_no_sep.should_check_migration("2024-01-02-000000_new_migration"));
assert!(!config_no_sep.should_check_migration("2024-01-01-000000_exact_match"));
}
#[test]
fn test_is_check_enabled() {
let config = Config {
disable_checks: vec!["AddColumnCheck".to_string(), "DropColumnCheck".to_string()],
..Default::default()
};
assert!(!config.is_check_enabled("AddColumnCheck"));
assert!(!config.is_check_enabled("DropColumnCheck"));
assert!(config.is_check_enabled("AddIndexCheck"));
assert!(config.is_check_enabled("AddNotNullCheck"));
}
#[test]
fn test_invalid_check_name() {
let config_str = r#"
disable_checks = ["InvalidCheckName"]
"#;
let config: Config = toml::from_str(config_str).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_valid_check_names() {
let config_str = r#"
disable_checks = ["AddColumnCheck", "DropColumnCheck"]
"#;
let config: Config = toml::from_str(config_str).unwrap();
assert!(config.validate().is_ok());
}
#[test]
fn test_invalid_check_name_help_includes_all_checks() {
use miette::Diagnostic;
let error = ConfigError::InvalidCheckName {
invalid_name: "FooCheck".to_string(),
};
let help = error.help().unwrap().to_string();
for &check_name in crate::checks::ALL_CHECK_NAMES {
assert!(
help.contains(check_name),
"Help text should include '{}', got: {}",
check_name,
help
);
}
assert!(help.starts_with("Valid check names: "));
}
#[test]
fn test_load_from_path() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("diesel-guard.toml");
fs::write(
&config_path,
r#"
start_after = "2024_01_01_000000"
check_down = true
disable_checks = ["AddColumnCheck"]
"#,
)
.unwrap();
let config_path_utf8 = Utf8Path::from_path(&config_path).unwrap();
let config = Config::load_from_path(config_path_utf8).unwrap();
assert_eq!(config.start_after, Some("2024_01_01_000000".to_string()));
assert!(config.check_down);
assert_eq!(config.disable_checks, vec!["AddColumnCheck".to_string()]);
}
}