use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SwappableDependency {
pub setting_key: String,
pub default_app: String,
pub default_model: String,
pub migration_name: String,
}
impl SwappableDependency {
pub fn new(
setting_key: impl Into<String>,
default_app: impl Into<String>,
default_model: impl Into<String>,
migration_name: impl Into<String>,
) -> Self {
Self {
setting_key: setting_key.into(),
default_app: default_app.into(),
default_model: default_model.into(),
migration_name: migration_name.into(),
}
}
pub fn resolve_app_label(&self, setting_value: Option<&str>) -> String {
match setting_value {
Some(value) => {
if let Some((app, _model)) = value.split_once('.') {
app.to_string()
} else {
value.to_string()
}
}
None => self.default_app.clone(),
}
}
pub fn resolve(&self, setting_value: Option<&str>) -> (String, String) {
(
self.resolve_app_label(setting_value),
self.migration_name.clone(),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DependencyCondition {
AppInstalled(String),
SettingEnabled(String),
FeatureEnabled(String),
}
impl DependencyCondition {
pub fn is_satisfied<F>(
&self,
installed_apps: &HashSet<String>,
settings_lookup: &F,
features: &HashSet<String>,
) -> bool
where
F: Fn(&str) -> Option<String>,
{
match self {
DependencyCondition::AppInstalled(app) => installed_apps.contains(app),
DependencyCondition::SettingEnabled(key) => {
if let Some(value) = settings_lookup(key) {
is_truthy(&value)
} else {
false
}
}
DependencyCondition::FeatureEnabled(feature) => features.contains(feature),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OptionalDependency {
pub app_label: String,
pub migration_name: String,
pub condition: DependencyCondition,
}
impl OptionalDependency {
pub fn new(
app_label: impl Into<String>,
migration_name: impl Into<String>,
condition: DependencyCondition,
) -> Self {
Self {
app_label: app_label.into(),
migration_name: migration_name.into(),
condition,
}
}
pub fn should_enforce<F>(
&self,
installed_apps: &HashSet<String>,
settings_lookup: &F,
features: &HashSet<String>,
) -> bool
where
F: Fn(&str) -> Option<String>,
{
self.condition
.is_satisfied(installed_apps, settings_lookup, features)
}
pub fn to_dependency_if_satisfied<F>(
&self,
installed_apps: &HashSet<String>,
settings_lookup: &F,
features: &HashSet<String>,
) -> Option<(String, String)>
where
F: Fn(&str) -> Option<String>,
{
if self.should_enforce(installed_apps, settings_lookup, features) {
Some((self.app_label.clone(), self.migration_name.clone()))
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MigrationDependency {
Required {
app_label: String,
migration_name: String,
},
Swappable(SwappableDependency),
Optional(OptionalDependency),
}
impl MigrationDependency {
pub fn required(app_label: impl Into<String>, migration_name: impl Into<String>) -> Self {
Self::Required {
app_label: app_label.into(),
migration_name: migration_name.into(),
}
}
pub fn swappable(
setting_key: impl Into<String>,
default_app: impl Into<String>,
default_model: impl Into<String>,
migration_name: impl Into<String>,
) -> Self {
Self::Swappable(SwappableDependency::new(
setting_key,
default_app,
default_model,
migration_name,
))
}
pub fn optional_app(
app_label: impl Into<String>,
migration_name: impl Into<String>,
required_app: impl Into<String>,
) -> Self {
Self::Optional(OptionalDependency::new(
app_label,
migration_name,
DependencyCondition::AppInstalled(required_app.into()),
))
}
pub fn optional_setting(
app_label: impl Into<String>,
migration_name: impl Into<String>,
setting_key: impl Into<String>,
) -> Self {
Self::Optional(OptionalDependency::new(
app_label,
migration_name,
DependencyCondition::SettingEnabled(setting_key.into()),
))
}
pub fn optional_feature(
app_label: impl Into<String>,
migration_name: impl Into<String>,
feature: impl Into<String>,
) -> Self {
Self::Optional(OptionalDependency::new(
app_label,
migration_name,
DependencyCondition::FeatureEnabled(feature.into()),
))
}
}
#[derive(Debug, Clone, Default)]
pub struct DependencyResolutionContext {
pub installed_apps: HashSet<String>,
pub swappable_settings: std::collections::HashMap<String, String>,
pub features: HashSet<String>,
}
impl DependencyResolutionContext {
pub fn new() -> Self {
Self::default()
}
pub fn with_app(mut self, app: impl Into<String>) -> Self {
self.installed_apps.insert(app.into());
self
}
pub fn with_apps(mut self, apps: impl IntoIterator<Item = impl Into<String>>) -> Self {
for app in apps {
self.installed_apps.insert(app.into());
}
self
}
pub fn with_setting(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.swappable_settings.insert(key.into(), value.into());
self
}
pub fn with_feature(mut self, feature: impl Into<String>) -> Self {
self.features.insert(feature.into());
self
}
pub fn get_setting(&self, key: &str) -> Option<&String> {
self.swappable_settings.get(key)
}
pub fn is_app_installed(&self, app: &str) -> bool {
self.installed_apps.contains(app)
}
pub fn is_feature_enabled(&self, feature: &str) -> bool {
self.features.contains(feature)
}
}
pub struct DependencyResolver<'a> {
context: &'a DependencyResolutionContext,
}
impl<'a> DependencyResolver<'a> {
pub fn new(context: &'a DependencyResolutionContext) -> Self {
Self { context }
}
pub fn resolve(&self, dependency: &MigrationDependency) -> Option<(String, String)> {
match dependency {
MigrationDependency::Required {
app_label,
migration_name,
} => Some((app_label.clone(), migration_name.clone())),
MigrationDependency::Swappable(swappable) => {
let setting_value = self
.context
.get_setting(&swappable.setting_key)
.map(|s| s.as_str());
Some(swappable.resolve(setting_value))
}
MigrationDependency::Optional(optional) => {
let settings_lookup = |key: &str| self.context.get_setting(key).cloned();
optional.to_dependency_if_satisfied(
&self.context.installed_apps,
&settings_lookup,
&self.context.features,
)
}
}
}
pub fn resolve_all(&self, dependencies: &[MigrationDependency]) -> Vec<(String, String)> {
dependencies
.iter()
.filter_map(|dep| self.resolve(dep))
.collect()
}
}
fn is_truthy(value: &str) -> bool {
let lower = value.to_lowercase();
!value.is_empty() && lower != "false" && lower != "0" && lower != "no" && lower != "off"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_swappable_dependency_resolve_with_setting() {
let dep = SwappableDependency::new("AUTH_USER_MODEL", "auth", "User", "0001_initial");
let (app, migration) = dep.resolve(Some("custom_auth.CustomUser"));
assert_eq!(app, "custom_auth");
assert_eq!(migration, "0001_initial");
}
#[test]
fn test_swappable_dependency_resolve_without_setting() {
let dep = SwappableDependency::new("AUTH_USER_MODEL", "auth", "User", "0001_initial");
let (app, migration) = dep.resolve(None);
assert_eq!(app, "auth");
assert_eq!(migration, "0001_initial");
}
#[test]
fn test_optional_dependency_app_installed() {
let dep = OptionalDependency::new(
"gis",
"0001_initial",
DependencyCondition::AppInstalled("gis".to_string()),
);
let mut apps = HashSet::new();
assert!(!dep.should_enforce(&apps, &|_| None, &HashSet::new()));
apps.insert("gis".to_string());
assert!(dep.should_enforce(&apps, &|_| None, &HashSet::new()));
}
#[test]
fn test_optional_dependency_setting_enabled() {
let dep = OptionalDependency::new(
"audit",
"0001_initial",
DependencyCondition::SettingEnabled("ENABLE_AUDIT".to_string()),
);
let apps = HashSet::new();
assert!(!dep.should_enforce(&apps, &|_| None, &HashSet::new()));
assert!(!dep.should_enforce(
&apps,
&|key| {
if key == "ENABLE_AUDIT" {
Some("false".to_string())
} else {
None
}
},
&HashSet::new()
));
assert!(dep.should_enforce(
&apps,
&|key| {
if key == "ENABLE_AUDIT" {
Some("true".to_string())
} else {
None
}
},
&HashSet::new()
));
}
#[test]
fn test_dependency_resolver() {
let context = DependencyResolutionContext::new()
.with_app("auth")
.with_app("users")
.with_setting("AUTH_USER_MODEL", "custom_auth.CustomUser");
let resolver = DependencyResolver::new(&context);
let required = MigrationDependency::required("auth", "0001_initial");
assert_eq!(
resolver.resolve(&required),
Some(("auth".to_string(), "0001_initial".to_string()))
);
let swappable =
MigrationDependency::swappable("AUTH_USER_MODEL", "auth", "User", "0001_initial");
assert_eq!(
resolver.resolve(&swappable),
Some(("custom_auth".to_string(), "0001_initial".to_string()))
);
let optional_satisfied = MigrationDependency::optional_app("auth", "0001_initial", "auth");
assert_eq!(
resolver.resolve(&optional_satisfied),
Some(("auth".to_string(), "0001_initial".to_string()))
);
let optional_not_satisfied =
MigrationDependency::optional_app("gis", "0001_initial", "gis");
assert_eq!(resolver.resolve(&optional_not_satisfied), None);
}
#[test]
fn test_resolve_all_filters_unsatisfied() {
let context = DependencyResolutionContext::new().with_app("auth");
let resolver = DependencyResolver::new(&context);
let deps = vec![
MigrationDependency::required("auth", "0001_initial"),
MigrationDependency::optional_app("gis", "0001_initial", "gis"),
MigrationDependency::required("users", "0001_initial"),
];
let resolved = resolver.resolve_all(&deps);
assert_eq!(resolved.len(), 2);
assert!(resolved.contains(&("auth".to_string(), "0001_initial".to_string())));
assert!(resolved.contains(&("users".to_string(), "0001_initial".to_string())));
}
#[test]
fn test_is_truthy() {
assert!(is_truthy("true"));
assert!(is_truthy("True"));
assert!(is_truthy("TRUE"));
assert!(is_truthy("1"));
assert!(is_truthy("yes"));
assert!(is_truthy("on"));
assert!(is_truthy("enabled"));
assert!(!is_truthy("false"));
assert!(!is_truthy("False"));
assert!(!is_truthy("FALSE"));
assert!(!is_truthy("0"));
assert!(!is_truthy("no"));
assert!(!is_truthy("off"));
assert!(!is_truthy(""));
}
}