use crate::game::eu4::content::BlockMergePolicy;
use crate::game::schema::query::{
CompiledRoot, CompiledRuleField, CompiledRuleValue, CwtQuery, RuleContext, SchemaBinding,
};
use crate::model::{ConflictKind, GamePath};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SchemaMergeSuggestion {
pub suggested_identity_source: Option<SchemaMergeIdentity>,
pub suggested_block_policy: Option<BlockMergePolicy>,
pub schema_provenance: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SchemaMergeIdentity {
AssignmentKey,
FieldValue(String),
}
pub fn suggest_for_conflict(
file_path: &GamePath,
ast_path: &[&str],
) -> Option<SchemaMergeSuggestion> {
suggest_for_conflict_with_query(super::rule_engine(), file_path, ast_path)
}
pub fn classify_conflict_kind(
file_path: &GamePath,
ast_path: &[&str],
reason: &str,
) -> Option<ConflictKind> {
classify_conflict_kind_with_query(super::rule_engine(), file_path, ast_path, reason)
}
fn suggest_for_conflict_with_query(
schema: &CwtQuery,
file_path: &GamePath,
ast_path: &[&str],
) -> Option<SchemaMergeSuggestion> {
let SchemaBinding::Bound { type_id, .. } = schema.bind_chain(file_path, ast_path) else {
return None;
};
let definition = schema.root(type_id.as_str())?;
Some(SchemaMergeSuggestion {
suggested_identity_source: Some(match &definition.name_field {
Some(field) => SchemaMergeIdentity::FieldValue(field.clone()),
None => SchemaMergeIdentity::AssignmentKey,
}),
suggested_block_policy: schema
.bind_root(file_path)
.and_then(|root| rule_value_for_path(schema, root, ast_path))
.and_then(block_policy_for_value),
schema_provenance: format!("{}:<{}>", path_namespace(file_path), type_id.as_str()),
})
}
fn classify_conflict_kind_with_query(
schema: &CwtQuery,
file_path: &GamePath,
ast_path: &[&str],
reason: &str,
) -> Option<ConflictKind> {
if reason.contains("deep merge of replaced block has ")
&& !matches!(
schema.root_binding(file_path),
SchemaBinding::Unbound { .. }
) {
return Some(ConflictKind::DeepMergeable);
}
let rule_fields = conflict_rule_fields_for_path(schema, file_path, ast_path);
let has_single_cardinality_match = rule_fields.iter().any(|field| {
field
.attributes
.cardinality
.and_then(|(_, max)| max)
.is_some_and(|max| max <= 1)
});
let has_explicit_multi_cardinality = rule_fields.iter().any(|field| {
field
.attributes
.cardinality
.and_then(|(_, max)| max)
.is_some_and(|max| max > 1)
});
if has_single_cardinality_match && !has_explicit_multi_cardinality {
return Some(ConflictKind::SchemaCardinalityViolation);
}
if reason.starts_with("policy:")
&& ast_path.len() > 1
&& !matches!(
schema.root_binding(file_path),
SchemaBinding::Unbound { .. }
) {
return Some(ConflictKind::DeepMergeable);
}
if (reason.contains("sibling mods inserted divergent statements at the same key")
|| reason.contains("multiple mods replace the same block with different content"))
&& (root_name_field(schema, file_path).is_some()
|| (!rule_fields.is_empty()
&& rule_fields
.iter()
.all(|field| matches!(field.value, CompiledRuleValue::Block(_)))))
{
return Some(ConflictKind::DeepMergeable);
}
None
}
fn conflict_rule_fields_for_path<'schema>(
schema: &'schema CwtQuery,
file_path: &GamePath,
ast_path: &[&str],
) -> Vec<&'schema CompiledRuleField> {
let Some(root) = schema.bind_root(file_path) else {
return Vec::new();
};
let mut matches = fields_for_segments(root, ast_path);
if let [_root_instance, rest @ ..] = ast_path
&& !rest.is_empty()
{
matches.extend(fields_for_segments(root, rest));
}
matches
}
fn fields_for_segments<'schema>(
root: &'schema CompiledRoot,
ast_path: &[&str],
) -> Vec<&'schema CompiledRuleField> {
if ast_path.is_empty() {
return Vec::new();
}
let mut current_rule_sets = vec![root.rules.as_slice()];
current_rule_sets.extend(root.subtypes.iter().map(|subtype| subtype.rules.as_slice()));
let mut last_matches = Vec::new();
for (index, segment) in ast_path.iter().enumerate() {
let mut matches = Vec::new();
for rules in ¤t_rule_sets {
matches.extend(rules.iter().filter(|field| field.key == *segment));
}
if matches.is_empty() {
return Vec::new();
}
last_matches = matches;
if index + 1 == ast_path.len() {
return last_matches;
}
current_rule_sets = last_matches
.iter()
.filter_map(|field| match &field.value {
CompiledRuleValue::Block(fields) => Some(fields.as_slice()),
_ => None,
})
.collect();
if current_rule_sets.is_empty() {
return Vec::new();
}
}
last_matches
}
fn rule_value_for_path<'schema>(
schema: &'schema CwtQuery,
root: &'schema CompiledRoot,
ast_path: &[&str],
) -> Option<&'schema CompiledRuleValue> {
let mut context = RuleContext::RootType(root);
let mut last_value = None;
for (index, segment) in ast_path.iter().enumerate() {
let field_match = schema.bind_field_match(context, segment)?;
let value = field_match.value();
last_value = Some(value);
if index + 1 == ast_path.len() {
break;
}
context = match field_match.alias() {
Some(alias) => RuleContext::AliasRules(alias.rules.as_slice()),
None => {
let CompiledRuleValue::Block(_) = value else {
return None;
};
RuleContext::RuleField(field_match.field())
}
};
}
last_value
}
fn root_name_field<'schema>(
schema: &'schema CwtQuery,
file_path: &GamePath,
) -> Option<&'schema str> {
schema.bind_root(file_path)?.name_field.as_deref()
}
fn block_policy_for_value(value: &CompiledRuleValue) -> Option<BlockMergePolicy> {
Some(match value {
CompiledRuleValue::Block(_) => BlockMergePolicy::Recursive,
CompiledRuleValue::Scalar(_) | CompiledRuleValue::Marker(_) => BlockMergePolicy::Replace,
})
}
fn path_namespace(file_path: &GamePath) -> &str {
let mut components = file_path.iter();
let first = components.next().unwrap_or_else(|| file_path.file_name());
match components.next() {
Some(second) if first == "common" => second,
_ => first,
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn game_path(text: &str) -> &GamePath {
GamePath::new(text).expect("valid game path")
}
use crate::game::schema::CwtSchema;
const EVENT_SCHEMA: &str = r#"
types = {
type[event] = {
path = "game/events"
name_field = "id"
## type_key_filter = country_event
subtype[country] = {}
}
}
event = {
trigger = { alias_name[trigger] = alias_match_left[trigger] }
immediate = { alias_name[effect] = alias_match_left[effect] }
option = { name = scalar }
}
"#;
const BINDING_SCHEMA: &str = r#"
types = {
type[mission] = { path = "game/missions" }
}
mission = {
## cardinality = 0..1
provinces_to_highlight = {
alias_name[trigger] = alias_match_left[trigger]
}
}
"#;
#[test]
fn suggests_field_value_identity_from_name_field() {
let schema = test_schema(EVENT_SCHEMA);
let suggestion =
suggest_for_conflict_with_query(schema.facts(), game_path("events/example.txt"), &[])
.expect("suggestion");
assert_eq!(
suggestion.suggested_identity_source,
Some(SchemaMergeIdentity::FieldValue("id".to_string()))
);
assert_eq!(suggestion.suggested_block_policy, None);
assert_eq!(suggestion.schema_provenance, "events:<event>");
}
#[test]
fn suggests_assignment_key_when_schema_has_no_name_field() {
let schema = test_schema(BINDING_SCHEMA);
let suggestion = suggest_for_conflict_with_query(
schema.facts(),
game_path("missions/example.txt"),
&["my_mission"],
)
.expect("suggestion");
assert_eq!(
suggestion.suggested_identity_source,
Some(SchemaMergeIdentity::AssignmentKey)
);
assert_eq!(suggestion.schema_provenance, "missions:<mission>");
}
#[test]
fn classifies_root_name_field_conflicts_as_deep_mergeable() {
let schema = test_schema(EVENT_SCHEMA);
assert_eq!(
classify_conflict_kind_with_query(
schema.facts(),
game_path("events/example.txt"),
&["country_event"],
"sibling mods inserted divergent statements at the same key"
),
Some(ConflictKind::DeepMergeable)
);
}
#[test]
fn classifies_tree_policy_conflicts_below_bound_roots_as_deep_mergeable() {
let schema = test_schema(EVENT_SCHEMA);
assert_eq!(
classify_conflict_kind_with_query(
schema.facts(),
game_path("events/example.txt"),
&["country_event", "immediate"],
"policy: divergent block revisions remain unresolved"
),
Some(ConflictKind::DeepMergeable)
);
}
#[test]
fn classifies_single_cardinality_fields_as_schema_cardinality_violations() {
let schema = test_schema(BINDING_SCHEMA);
assert_eq!(
classify_conflict_kind_with_query(
schema.facts(),
game_path("missions/example.txt"),
&["provinces_to_highlight"],
"sibling mods inserted divergent statements at the same key"
),
Some(ConflictKind::SchemaCardinalityViolation)
);
}
#[test]
fn classifies_vendor_country_history_cardinality_conflict() {
assert_eq!(
classify_conflict_kind(
game_path("history/countries/TES - Test.txt"),
&["government_rank"],
"sibling mods inserted divergent statements at the same key"
),
Some(ConflictKind::SchemaCardinalityViolation)
);
}
#[test]
fn classifies_vendor_recursive_block_conflict_as_deep_mergeable() {
assert_eq!(
classify_conflict_kind(
game_path("common/government_reforms/test.txt"),
&["test_reform"],
"deep merge of replaced block has 1 unresolved sub-conflict(s)"
),
Some(ConflictKind::DeepMergeable)
);
}
const ALIAS_SCHEMA: &str = r#"
types = {
type[thing] = { path = "game/common/things" }
}
thing = {
alias_name[modifier] = alias_match_left[modifier]
plain_scalar = scalar
}
alias[modifier:upkeep] = float
alias[modifier:slots] = int[0..10]
alias[modifier:breakdown] = {
base = float
}
"#;
#[test]
fn alias_bound_block_fields_are_recursive_rather_than_replaced() {
let schema = test_schema(ALIAS_SCHEMA);
let suggestion = suggest_for_conflict_with_query(
schema.facts(),
game_path("common/things/example.txt"),
&["breakdown"],
)
.expect("suggestion");
assert_eq!(
suggestion.suggested_block_policy,
Some(BlockMergePolicy::Recursive)
);
}
#[test]
fn alias_bound_scalar_fields_keep_the_replace_policy() {
let schema = test_schema(ALIAS_SCHEMA);
let suggestion = suggest_for_conflict_with_query(
schema.facts(),
game_path("common/things/example.txt"),
&["upkeep"],
)
.expect("suggestion");
assert_eq!(
suggestion.suggested_block_policy,
Some(BlockMergePolicy::Replace)
);
}
#[test]
fn returns_none_for_unbound_schema_path() {
let schema = test_schema(EVENT_SCHEMA);
assert!(
suggest_for_conflict_with_query(
schema.facts(),
game_path("events/example.txt"),
&["missing"]
)
.is_none()
);
}
fn test_schema(source: &str) -> CwtSchema {
let root = TempDir::new().expect("create merge schema directory");
fs::write(root.path().join("merge.cwt"), source).expect("write merge schema");
CwtSchema::load_with_cache(root.path(), None).expect("load merge schema")
}
}