use crate::model::{GamePath, MaybeScope, ScopeType};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
mod families;
pub(crate) mod load_rules;
mod transform;
#[cfg(test)]
pub(crate) use families::eu4_content_families;
pub use families::{EU4_LOCALISATION_LANGUAGE_HEADERS, eu4, eu4_content_family_for_root_family};
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ContentFamilyId(Arc<str>);
impl ContentFamilyId {
pub fn new(id: impl Into<Arc<str>>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ScriptFileKind(Arc<str>);
impl ScriptFileKind {
pub fn new(name: impl Into<Arc<str>>) -> Self {
Self(name.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScalarMergePolicy {
#[default]
Conflict,
Sum,
Avg,
Max,
Min,
LastWriter,
CoordinateFirstWriter,
GuiWidget,
}
impl ScalarMergePolicy {
pub fn reduce_numeric_values<'a>(
self,
values: impl IntoIterator<Item = &'a str>,
) -> Option<String> {
let values = values.into_iter().collect::<Vec<_>>();
values.first()?;
let numbers = values
.iter()
.map(|value| parse_finite_number(value))
.collect::<Option<Vec<_>>>()?;
let reduced = match self {
Self::Sum => numbers.iter().sum(),
Self::Avg => numbers.iter().sum::<f64>() / numbers.len() as f64,
Self::Max => numbers.into_iter().reduce(f64::max)?,
Self::Min => numbers.into_iter().reduce(f64::min)?,
Self::Conflict | Self::LastWriter | Self::CoordinateFirstWriter | Self::GuiWidget => {
return None;
}
};
format_reduced_number(reduced, &values)
}
pub fn reduce_numeric_pair(self, left: &str, right: &str) -> Option<String> {
self.reduce_numeric_values([left, right])
}
}
fn parse_finite_number(value: &str) -> Option<f64> {
value.parse::<f64>().ok().filter(|value| value.is_finite())
}
fn format_reduced_number(value: f64, inputs: &[&str]) -> Option<String> {
if !value.is_finite() {
return None;
}
if value == 0.0 {
return Some("0".to_string());
}
let absolute = value.abs();
let mut text = if !(1e-15..1e15).contains(&absolute) {
format!("{value}")
} else {
let fixed = format!("{value:.15}");
fixed
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
};
let omits_leading_zero = inputs.iter().all(|input| {
input
.strip_prefix(['+', '-'])
.unwrap_or(input)
.starts_with('.')
});
if omits_leading_zero {
if let Some(fraction) = text.strip_prefix("0.") {
text = format!(".{fraction}");
} else if let Some(fraction) = text.strip_prefix("-0.") {
text = format!("-.{fraction}");
}
}
Some(text)
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ListMergePolicy {
#[default]
Union,
UnionWithRename,
OrderedUnion,
Replace,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OneSidedRemovalPolicy {
#[default]
Remove,
PreserveIfParentSurvives,
PreserveAdditiveStructure,
PreserveBooleanAlternatives,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NestedInsertionPolicy {
#[default]
MatchByKey,
SourceIsolatedAppend,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlockMergePolicy {
#[default]
Recursive,
Replace,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BooleanMergePolicy {
And,
#[default]
Or,
Replace,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NamedContainerPolicy {
#[default]
Conflict,
SuffixRename,
OverlayWins,
ScrollStack,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ScalarReducerRule {
pub path_suffix: &'static [&'static str],
pub reducer: ScalarMergePolicy,
}
impl ScalarReducerRule {
pub const fn new(path_suffix: &'static [&'static str], reducer: ScalarMergePolicy) -> Self {
Self {
path_suffix,
reducer,
}
}
fn matches<T: AsRef<str>>(&self, path: &[T]) -> bool {
!self.path_suffix.is_empty()
&& path.len() >= self.path_suffix.len()
&& path[path.len() - self.path_suffix.len()..]
.iter()
.zip(self.path_suffix)
.all(|(component, expected)| component.as_ref() == *expected)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct MergePolicies {
pub merge_key_source: MergeKeySource,
pub nested_merge_key_source: MergeKeySource,
#[serde(default)]
pub nested_insertion: NestedInsertionPolicy,
pub scalar: ScalarMergePolicy,
#[serde(skip)]
pub scalar_reducer_rules: &'static [ScalarReducerRule],
pub list: ListMergePolicy,
#[serde(default)]
pub one_sided_removal: OneSidedRemovalPolicy,
pub block: BlockMergePolicy,
pub boolean: BooleanMergePolicy,
pub divergent_block: DivergentBlockPolicy,
#[serde(skip)]
pub divergent_block_rules: &'static [(&'static str, DivergentBlockPolicy)],
pub named_container: NamedContainerPolicy,
#[serde(default)]
pub edit_wins_over_remove: bool,
}
impl MergePolicies {
pub fn scalar_reducer_rule_for_path<T: AsRef<str>>(
&self,
path: &[T],
) -> Option<&'static ScalarReducerRule> {
let mut best = None;
for rule in self.scalar_reducer_rules {
if rule.matches(path)
&& best.is_none_or(|current: &ScalarReducerRule| {
rule.path_suffix.len() > current.path_suffix.len()
}) {
best = Some(rule);
}
}
best
}
pub fn divergent_block_policy_for_key(&self, key: &str) -> DivergentBlockPolicy {
self.divergent_block_rules
.iter()
.find_map(|(policy_key, policy)| (*policy_key == key).then_some(*policy))
.unwrap_or(self.divergent_block)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DivergentBlockPolicy {
LastWriter,
BooleanOr,
Union,
#[default]
Recurse,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictPolicy {
#[default]
Rename,
MergeLeaf,
LastWriter,
BooleanOr,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum DedupPolicy {
#[default]
None,
PerEntryOnly,
CrossFileOnly,
CrossFileAndPerEntry,
}
impl DedupPolicy {
pub const fn cross_file_safe(self) -> bool {
matches!(
self,
DedupPolicy::CrossFileOnly | DedupPolicy::CrossFileAndPerEntry
)
}
pub const fn per_entry_safe(self) -> bool {
matches!(
self,
DedupPolicy::PerEntryOnly | DedupPolicy::CrossFileAndPerEntry
)
}
pub(crate) const fn with_per_entry(self) -> Self {
match self {
DedupPolicy::None => DedupPolicy::PerEntryOnly,
DedupPolicy::CrossFileOnly => DedupPolicy::CrossFileAndPerEntry,
other => other,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum ContentLoadPolicy {
#[default]
PerPath,
DefinitionModule(DefinitionModulePolicy),
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct DefinitionModulePolicy {
pub definition_key: DefinitionKeyPolicy,
pub file_order: DefinitionFileOrder,
pub duplicate_definitions: DuplicateDefinitionPolicy,
pub output_path: &'static GamePath,
pub namespace_prefix: &'static GamePath,
pub output_mode: DefinitionModuleOutput,
pub policy_version: u32,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum DefinitionModuleOutput {
Overlay,
ReplaceNamespace,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum DefinitionKeyPolicy {
AssignmentKey,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum DefinitionFileOrder {
NormalizedPathAscending,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum DuplicateDefinitionPolicy {
LaterDefinitionWins,
PreserveAll,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ContentFamilyCapabilities {
pub semantic_complete: bool,
pub graph_ready: bool,
pub merge_ready: bool,
pub dedup_policy: DedupPolicy,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ContentFamilyScopePolicy {
pub root_scope: MaybeScope,
pub from_alias: Option<ScopeType>,
pub dynamic_scope: bool,
}
impl Default for ContentFamilyScopePolicy {
fn default() -> Self {
Self {
root_scope: MaybeScope::Unknown,
from_alias: None,
dynamic_scope: false,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ModuleNameRule {
Static(&'static str),
Tail {
fallback: &'static str,
},
FallbackParent,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum MergeKeySource {
#[default]
AssignmentKey,
FieldValue(&'static str),
ContainerChildKey,
ContainerChildFieldValue {
containers: &'static [&'static str],
child_key_field: &'static str,
child_types: &'static [&'static str],
},
ChildFieldValue {
child_key_field: &'static str,
child_types: &'static [&'static str],
},
LeafPath,
}
impl MergeKeySource {
pub const fn nested(self) -> Self {
match self {
MergeKeySource::ContainerChildFieldValue {
child_key_field,
child_types,
..
} => MergeKeySource::ChildFieldValue {
child_key_field,
child_types,
},
MergeKeySource::ChildFieldValue { .. } => self,
_ => MergeKeySource::AssignmentKey,
}
}
}
#[derive(Serialize)]
struct ContainerChildFieldValueSerde<'a> {
containers: &'a [&'a str],
child_key_field: &'a str,
child_types: &'a [&'a str],
}
#[derive(Deserialize)]
struct ContainerChildFieldValueOwned {
containers: Vec<String>,
child_key_field: String,
#[serde(default)]
child_types: Vec<String>,
}
#[derive(Serialize)]
struct ChildFieldValueSerde<'a> {
child_key_field: &'a str,
child_types: &'a [&'a str],
}
#[derive(Deserialize)]
struct ChildFieldValueOwned {
child_key_field: String,
#[serde(default)]
child_types: Vec<String>,
}
impl Serialize for MergeKeySource {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
MergeKeySource::AssignmentKey => serializer.serialize_str("assignment_key"),
MergeKeySource::FieldValue(field) => {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("field_value", field)?;
map.end()
}
MergeKeySource::ContainerChildKey => serializer.serialize_str("container_child_key"),
MergeKeySource::ContainerChildFieldValue {
containers,
child_key_field,
child_types,
} => {
use serde::ser::SerializeMap;
let spec = ContainerChildFieldValueSerde {
containers,
child_key_field,
child_types,
};
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("container_child_field_value", &spec)?;
map.end()
}
MergeKeySource::ChildFieldValue {
child_key_field,
child_types,
} => {
use serde::ser::SerializeMap;
let spec = ChildFieldValueSerde {
child_key_field,
child_types,
};
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("child_field_value", &spec)?;
map.end()
}
MergeKeySource::LeafPath => serializer.serialize_str("leaf_path"),
}
}
}
impl<'de> Deserialize<'de> for MergeKeySource {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de;
struct MergeKeySourceVisitor;
impl<'de> de::Visitor<'de> for MergeKeySourceVisitor {
type Value = MergeKeySource;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "a MergeKeySource string or map")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<MergeKeySource, E> {
match v {
"assignment_key" => Ok(MergeKeySource::AssignmentKey),
"container_child_key" => Ok(MergeKeySource::ContainerChildKey),
"leaf_path" => Ok(MergeKeySource::LeafPath),
_ => Err(E::unknown_variant(
v,
&["assignment_key", "container_child_key", "leaf_path"],
)),
}
}
fn visit_map<A: de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<MergeKeySource, A::Error> {
let key: String = map.next_key()?.ok_or_else(|| {
de::Error::custom("expected field_value or container_child_field_value key")
})?;
match key.as_str() {
"field_value" => {
let value: String = map.next_value()?;
let leaked: &'static str = Box::leak(value.into_boxed_str());
Ok(MergeKeySource::FieldValue(leaked))
}
"container_child_field_value" => {
let spec: ContainerChildFieldValueOwned = map.next_value()?;
let containers = spec
.containers
.into_iter()
.map(|container| Box::leak(container.into_boxed_str()) as &'static str)
.collect::<Vec<_>>();
let containers = Box::leak(containers.into_boxed_slice());
let child_key_field = Box::leak(spec.child_key_field.into_boxed_str());
let child_types = spec
.child_types
.into_iter()
.map(|child_type| {
Box::leak(child_type.into_boxed_str()) as &'static str
})
.collect::<Vec<_>>();
let child_types = Box::leak(child_types.into_boxed_slice());
Ok(MergeKeySource::ContainerChildFieldValue {
containers,
child_key_field,
child_types,
})
}
"child_field_value" => {
let spec: ChildFieldValueOwned = map.next_value()?;
let child_key_field = Box::leak(spec.child_key_field.into_boxed_str());
let child_types = spec
.child_types
.into_iter()
.map(|child_type| {
Box::leak(child_type.into_boxed_str()) as &'static str
})
.collect::<Vec<_>>();
let child_types = Box::leak(child_types.into_boxed_slice());
Ok(MergeKeySource::ChildFieldValue {
child_key_field,
child_types,
})
}
_ => Err(de::Error::unknown_field(
&key,
&[
"field_value",
"container_child_field_value",
"child_field_value",
],
)),
}
}
}
deserializer.deserialize_any(MergeKeySourceVisitor)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ContentFamilyPathMatcher {
Prefix(&'static GamePath),
Exact(&'static GamePath),
}
impl ContentFamilyPathMatcher {
pub fn matches(&self, path: &GamePath) -> bool {
match self {
Self::Prefix(directory) => path.strip_prefix(directory).is_some(),
Self::Exact(file) => path == *file,
}
}
}
pub(crate) fn table_game_path(text: &str) -> &GamePath {
match GamePath::new(text) {
Ok(path) => path,
Err(error) => panic!("static EU4 rule table: {error}"),
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ContentFamilyDescriptor {
pub id: ContentFamilyId,
pub matcher: ContentFamilyPathMatcher,
pub script_file_kind: ScriptFileKind,
pub module_name_rule: ModuleNameRule,
pub load_policy: ContentLoadPolicy,
pub scope_policy: ContentFamilyScopePolicy,
pub capabilities: ContentFamilyCapabilities,
pub merge_key_source: Option<MergeKeySource>,
pub conflict_policy: ConflictPolicy,
pub merge_policies: MergePolicies,
}
#[derive(Clone, Debug)]
pub struct ContentFamilyDescriptorBuilder {
id: ContentFamilyId,
matcher: ContentFamilyPathMatcher,
script_file_kind: ScriptFileKind,
module_name_rule: ModuleNameRule,
load_policy: ContentLoadPolicy,
scope_policy: ContentFamilyScopePolicy,
capabilities: ContentFamilyCapabilities,
merge_key_source: Option<MergeKeySource>,
conflict_policy: ConflictPolicy,
merge_policies: MergePolicies,
}
impl ContentFamilyDescriptorBuilder {
pub fn kind(mut self, kind: ScriptFileKind) -> Self {
self.script_file_kind = kind;
self
}
pub fn module_name(mut self, rule: ModuleNameRule) -> Self {
self.module_name_rule = rule;
self
}
pub fn load_policy(mut self, policy: ContentLoadPolicy) -> Self {
self.load_policy = policy;
self
}
pub fn scope(mut self, policy: ContentFamilyScopePolicy) -> Self {
self.scope_policy = policy;
self
}
pub fn capabilities(mut self, caps: ContentFamilyCapabilities) -> Self {
self.capabilities = caps;
self
}
pub fn per_entry_dedup_safe(mut self) -> Self {
self.capabilities.dedup_policy = self.capabilities.dedup_policy.with_per_entry();
self
}
pub fn merge_key(mut self, source: MergeKeySource) -> Self {
self.merge_key_source = Some(source);
self.merge_policies.merge_key_source = source;
self.merge_policies.nested_merge_key_source = source.nested();
self
}
pub fn nested_merge_key(mut self, source: MergeKeySource) -> Self {
self.merge_policies.nested_merge_key_source = source;
self
}
pub fn nested_insertion_policy(mut self, policy: NestedInsertionPolicy) -> Self {
self.merge_policies.nested_insertion = policy;
self
}
pub fn conflict_policy(mut self, policy: ConflictPolicy) -> Self {
self.conflict_policy = policy;
self
}
pub fn merge_policies(mut self, policies: MergePolicies) -> Self {
self.merge_policies = policies;
self
}
pub fn scalar_policy(mut self, policy: ScalarMergePolicy) -> Self {
self.merge_policies.scalar = policy;
self
}
pub fn scalar_reducer_rules(mut self, rules: &'static [ScalarReducerRule]) -> Self {
self.merge_policies.scalar_reducer_rules = rules;
self
}
pub fn list_policy(mut self, policy: ListMergePolicy) -> Self {
self.merge_policies.list = policy;
self
}
pub fn one_sided_removal_policy(mut self, policy: OneSidedRemovalPolicy) -> Self {
self.merge_policies.one_sided_removal = policy;
self
}
pub fn edit_wins_over_remove(mut self) -> Self {
self.merge_policies.edit_wins_over_remove = true;
self
}
pub fn block_policy(mut self, policy: BlockMergePolicy) -> Self {
self.merge_policies.block = policy;
self
}
pub fn boolean_policy(mut self, policy: BooleanMergePolicy) -> Self {
self.merge_policies.boolean = policy;
self
}
pub fn divergent_block_policy(mut self, policy: DivergentBlockPolicy) -> Self {
self.merge_policies.divergent_block = policy;
self
}
pub fn divergent_block_rules(
mut self,
policies: &'static [(&'static str, DivergentBlockPolicy)],
) -> Self {
self.merge_policies.divergent_block_rules = policies;
self
}
pub fn build(self) -> ContentFamilyDescriptor {
ContentFamilyDescriptor {
id: self.id,
matcher: self.matcher,
script_file_kind: self.script_file_kind,
module_name_rule: self.module_name_rule,
load_policy: self.load_policy,
scope_policy: self.scope_policy,
capabilities: self.capabilities,
merge_key_source: self.merge_key_source,
conflict_policy: self.conflict_policy,
merge_policies: self.merge_policies,
}
}
}
impl ContentFamilyDescriptor {
pub fn supports_verified_empty_file_base(&self) -> bool {
self.load_policy == ContentLoadPolicy::PerPath
&& self.capabilities.merge_ready
&& self.merge_key_source.is_some()
}
pub fn prefix(id: &'static str, directory: &'static str) -> ContentFamilyDescriptorBuilder {
ContentFamilyDescriptorBuilder {
id: ContentFamilyId::new(id),
matcher: ContentFamilyPathMatcher::Prefix(table_game_path(directory)),
script_file_kind: ScriptFileKind::new("other"),
module_name_rule: ModuleNameRule::FallbackParent,
load_policy: ContentLoadPolicy::PerPath,
scope_policy: ContentFamilyScopePolicy {
root_scope: MaybeScope::Unknown,
from_alias: None,
dynamic_scope: false,
},
capabilities: ContentFamilyCapabilities {
semantic_complete: false,
graph_ready: false,
merge_ready: false,
dedup_policy: DedupPolicy::None,
},
merge_key_source: None,
conflict_policy: ConflictPolicy::Rename,
merge_policies: MergePolicies {
merge_key_source: MergeKeySource::AssignmentKey,
nested_merge_key_source: MergeKeySource::AssignmentKey,
nested_insertion: NestedInsertionPolicy::MatchByKey,
scalar: ScalarMergePolicy::Conflict,
scalar_reducer_rules: &[],
list: ListMergePolicy::Union,
one_sided_removal: OneSidedRemovalPolicy::Remove,
block: BlockMergePolicy::Recursive,
boolean: BooleanMergePolicy::Or,
divergent_block: DivergentBlockPolicy::Recurse,
divergent_block_rules: &[],
named_container: NamedContainerPolicy::Conflict,
edit_wins_over_remove: false,
},
}
}
pub fn exact(id: &'static str, exact_path: &'static str) -> ContentFamilyDescriptorBuilder {
ContentFamilyDescriptorBuilder {
id: ContentFamilyId::new(id),
matcher: ContentFamilyPathMatcher::Exact(table_game_path(exact_path)),
script_file_kind: ScriptFileKind::new("other"),
module_name_rule: ModuleNameRule::FallbackParent,
load_policy: ContentLoadPolicy::PerPath,
scope_policy: ContentFamilyScopePolicy {
root_scope: MaybeScope::Unknown,
from_alias: None,
dynamic_scope: false,
},
capabilities: ContentFamilyCapabilities {
semantic_complete: false,
graph_ready: false,
merge_ready: false,
dedup_policy: DedupPolicy::None,
},
merge_key_source: None,
conflict_policy: ConflictPolicy::Rename,
merge_policies: MergePolicies {
merge_key_source: MergeKeySource::AssignmentKey,
nested_merge_key_source: MergeKeySource::AssignmentKey,
nested_insertion: NestedInsertionPolicy::MatchByKey,
scalar: ScalarMergePolicy::Conflict,
scalar_reducer_rules: &[],
list: ListMergePolicy::Union,
one_sided_removal: OneSidedRemovalPolicy::Remove,
block: BlockMergePolicy::Recursive,
boolean: BooleanMergePolicy::Or,
divergent_block: DivergentBlockPolicy::Recurse,
divergent_block_rules: &[],
named_container: NamedContainerPolicy::Conflict,
edit_wins_over_remove: false,
},
}
}
}
pub fn module_name_for_descriptor(
relative: &GamePath,
descriptor: &ContentFamilyDescriptor,
) -> String {
match descriptor.module_name_rule {
ModuleNameRule::Static(value) => value.to_string(),
ModuleNameRule::Tail { fallback } => module_with_tail(relative, descriptor, fallback),
ModuleNameRule::FallbackParent => {
fallback_module_name(&relative.iter().collect::<Vec<_>>())
}
}
}
fn module_with_tail(
relative: &GamePath,
descriptor: &ContentFamilyDescriptor,
fallback: &str,
) -> String {
let mut name = fallback.to_string();
let ContentFamilyPathMatcher::Prefix(directory) = descriptor.matcher else {
return name;
};
let Some(below) = relative.strip_prefix(directory) else {
return name;
};
let parts = below.iter().collect::<Vec<_>>();
for part in &parts[..parts.len().saturating_sub(1)] {
name.push('.');
name.push_str(part);
}
name
}
fn fallback_module_name(parts: &[&str]) -> String {
if parts.len() <= 1 {
return "other".to_string();
}
parts[..parts.len() - 1].join(".")
}
#[cfg(test)]
mod dedup_policy_tests {
use super::DedupPolicy;
#[test]
fn dedup_policy_maps_to_old_boolean_pairs() {
assert_eq!(
(
DedupPolicy::None.cross_file_safe(),
DedupPolicy::None.per_entry_safe()
),
(false, false)
);
assert_eq!(
(
DedupPolicy::PerEntryOnly.cross_file_safe(),
DedupPolicy::PerEntryOnly.per_entry_safe()
),
(false, true)
);
assert_eq!(
(
DedupPolicy::CrossFileOnly.cross_file_safe(),
DedupPolicy::CrossFileOnly.per_entry_safe()
),
(true, false)
);
assert_eq!(
(
DedupPolicy::CrossFileAndPerEntry.cross_file_safe(),
DedupPolicy::CrossFileAndPerEntry.per_entry_safe()
),
(true, true)
);
assert_eq!(
DedupPolicy::None.with_per_entry(),
DedupPolicy::PerEntryOnly
);
assert_eq!(
DedupPolicy::CrossFileOnly.with_per_entry(),
DedupPolicy::CrossFileAndPerEntry
);
assert_eq!(
DedupPolicy::PerEntryOnly.with_per_entry(),
DedupPolicy::PerEntryOnly
);
assert_eq!(
DedupPolicy::CrossFileAndPerEntry.with_per_entry(),
DedupPolicy::CrossFileAndPerEntry
);
}
}
#[cfg(test)]
mod tests {
use super::{
BooleanMergePolicy, ContentFamilyDescriptor, ContentFamilyId, MergePolicies, ScriptFileKind,
};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
#[test]
fn content_family_id_new_round_trips_str() {
assert_eq!(ContentFamilyId::new("foo").as_str(), "foo");
}
#[test]
fn cwt_type_equality_is_string_based() {
assert_eq!(ScriptFileKind::new("events"), ScriptFileKind::new("events"));
}
#[test]
fn content_family_id_hashes_match_for_equal_strings() {
let left = ContentFamilyId::new(Arc::<str>::from("family"));
let right = ContentFamilyId::new(Arc::<str>::from("family"));
let mut left_hasher = DefaultHasher::new();
left.hash(&mut left_hasher);
let mut right_hasher = DefaultHasher::new();
right.hash(&mut right_hasher);
assert_eq!(left_hasher.finish(), right_hasher.finish());
}
#[test]
fn boolean_merge_defaults_to_or_across_revisions() {
assert_eq!(MergePolicies::default().boolean, BooleanMergePolicy::Or);
assert_eq!(
ContentFamilyDescriptor::prefix("test", "common/test")
.build()
.merge_policies
.boolean,
BooleanMergePolicy::Or
);
}
}
#[cfg(test)]
mod scalar_reducer_tests {
use super::{MergePolicies, ScalarMergePolicy, ScalarReducerRule};
const RULES: &[ScalarReducerRule] = &[
ScalarReducerRule::new(&[], ScalarMergePolicy::Avg),
ScalarReducerRule::new(&["value"], ScalarMergePolicy::Sum),
ScalarReducerRule::new(&["special", "value"], ScalarMergePolicy::Max),
];
#[test]
fn numeric_reducers_are_strict_and_deterministic() {
assert_eq!(
ScalarMergePolicy::Max.reduce_numeric_pair("50", "35"),
Some("50".to_string())
);
assert_eq!(
ScalarMergePolicy::Avg.reduce_numeric_pair(".2", ".1"),
Some(".15".to_string())
);
assert_eq!(
ScalarMergePolicy::Sum.reduce_numeric_pair("2.5", "1.25"),
Some("3.75".to_string())
);
assert_eq!(
ScalarMergePolicy::Min.reduce_numeric_pair("-2", "3"),
Some("-2".to_string())
);
assert_eq!(
ScalarMergePolicy::Avg.reduce_numeric_pair("1444.11.11", "1445.1.1"),
None
);
assert_eq!(
ScalarMergePolicy::Avg.reduce_numeric_pair("identifier", ".1"),
None
);
assert_eq!(
ScalarMergePolicy::LastWriter.reduce_numeric_pair("1", "2"),
None
);
assert_eq!(
ScalarMergePolicy::Avg.reduce_numeric_values([".2", ".1", ".3"]),
Some(".2".to_string())
);
assert_eq!(
ScalarMergePolicy::Sum.reduce_numeric_values(["1", "2", "3"]),
Some("6".to_string())
);
assert_eq!(
ScalarMergePolicy::Avg.reduce_numeric_values(std::iter::empty::<&str>()),
None
);
}
#[test]
fn scalar_reducer_rules_use_the_longest_matching_suffix() {
let policies = MergePolicies {
scalar_reducer_rules: RULES,
..Default::default()
};
let generic = policies
.scalar_reducer_rule_for_path(&["root", "value"])
.expect("generic suffix");
let specific = policies
.scalar_reducer_rule_for_path(&["root", "special", "value"])
.expect("specific suffix");
assert_eq!(generic.reducer, ScalarMergePolicy::Sum);
assert_eq!(specific.reducer, ScalarMergePolicy::Max);
assert!(
policies
.scalar_reducer_rule_for_path(&["root", "technology"])
.is_none()
);
}
}
#[cfg(test)]
mod content_load_policy_tests {
use super::{
ContentFamilyDescriptor, ContentLoadPolicy, DefinitionFileOrder, DefinitionKeyPolicy,
DefinitionModuleOutput, DefinitionModulePolicy, DuplicateDefinitionPolicy,
};
use crate::model::GamePath;
fn module_policy() -> DefinitionModulePolicy {
DefinitionModulePolicy {
definition_key: DefinitionKeyPolicy::AssignmentKey,
file_order: DefinitionFileOrder::NormalizedPathAscending,
duplicate_definitions: DuplicateDefinitionPolicy::LaterDefinitionWins,
output_path: GamePath::new("common/governments/00_foch_governments.txt")
.expect("valid game path"),
namespace_prefix: GamePath::new("common/governments").expect("valid game path"),
output_mode: DefinitionModuleOutput::ReplaceNamespace,
policy_version: 1,
}
}
#[test]
fn load_policy_defaults_to_per_path() {
assert_eq!(ContentLoadPolicy::default(), ContentLoadPolicy::PerPath);
assert_eq!(
ContentFamilyDescriptor::prefix("test", "common/test")
.build()
.load_policy,
ContentLoadPolicy::PerPath
);
}
#[test]
fn descriptor_records_the_complete_definition_module_policy() {
let module_policy = module_policy();
let descriptor = ContentFamilyDescriptor::prefix("test", "common/test")
.load_policy(ContentLoadPolicy::DefinitionModule(module_policy))
.build();
assert_eq!(
descriptor.load_policy,
ContentLoadPolicy::DefinitionModule(module_policy)
);
assert_eq!(
module_policy.definition_key,
DefinitionKeyPolicy::AssignmentKey
);
assert_eq!(
module_policy.file_order,
DefinitionFileOrder::NormalizedPathAscending
);
assert_eq!(
module_policy.duplicate_definitions,
DuplicateDefinitionPolicy::LaterDefinitionWins
);
assert_eq!(
module_policy.output_path.as_str(),
"common/governments/00_foch_governments.txt"
);
assert_eq!(
module_policy.namespace_prefix.as_str(),
"common/governments"
);
assert_eq!(
module_policy.output_mode,
DefinitionModuleOutput::ReplaceNamespace
);
assert_eq!(module_policy.policy_version, 1);
}
}