use crate::game::eu4::content::MergeKeySource;
use crate::game::eu4::script::ParsedScriptFile;
use crate::game::eu4::script::parser::{AstStatement, AstValue};
use crate::model::{GamePath, StaleVanillaTargetDescriptor};
use super::super::patch::ClausewitzPatch;
const MISSING_PATH_NOTE: &str = "vanilla snapshot for this file does not contain the target path; this remove-style patch may be cross-version drift, dependency-targeted, or intentionally guarded";
const MISSING_KEY_NOTE: &str = "vanilla snapshot contains the parent path but not the target key; this remove-style patch may be cross-version drift, dependency-targeted, or intentionally guarded";
pub(crate) fn detect_stale_vanilla_targets(
patches: &[ClausewitzPatch],
file_path: &GamePath,
mod_id: &str,
mod_version: &str,
vanilla: Option<&ParsedScriptFile>,
merge_key_source: MergeKeySource,
) -> Vec<StaleVanillaTargetDescriptor> {
let Some(vanilla) = vanilla else {
return Vec::new();
};
patches
.iter()
.filter_map(|patch| {
let target = stale_target(patch)?;
let parent = statements_at_path(&vanilla.ast.statements, target.path, merge_key_source);
let note = match (parent, target.key) {
(None, _) => MISSING_PATH_NOTE,
(Some(_), None) => return None,
(Some(statements), Some(key))
if contains_key(statements, key, target.path.len(), merge_key_source) =>
{
return None;
}
(Some(_), Some(_)) => MISSING_KEY_NOTE,
};
Some(StaleVanillaTargetDescriptor {
mod_id: mod_id.to_string(),
mod_version: mod_version.to_string(),
file_path: file_path.to_owned(),
patch_kind: target.kind.to_string(),
target_path: target.path.to_vec(),
target_key: target.key.map(str::to_string),
note: Some(note.to_string()),
})
})
.collect()
}
struct StaleTarget<'a> {
kind: &'static str,
path: &'a [String],
key: Option<&'a str>,
}
fn stale_target(patch: &ClausewitzPatch) -> Option<StaleTarget<'_>> {
match patch {
ClausewitzPatch::RemoveNode { path, key, .. } => Some(StaleTarget {
kind: "RemoveNode",
path,
key: Some(key),
}),
ClausewitzPatch::RemoveListItem { path, key, .. } => Some(StaleTarget {
kind: "RemoveListItem",
path,
key: Some(key),
}),
ClausewitzPatch::RemoveBlockItem { path, .. } => Some(StaleTarget {
kind: "RemoveBlockItem",
path,
key: None,
}),
ClausewitzPatch::Rename { path, old_key, .. } => Some(StaleTarget {
kind: "Rename",
path,
key: Some(old_key),
}),
_ => None,
}
}
fn statements_at_path<'a>(
root: &'a [AstStatement],
path: &[String],
merge_key_source: MergeKeySource,
) -> Option<&'a [AstStatement]> {
let mut current = root;
for (depth, segment) in path.iter().enumerate() {
let statement = current
.iter()
.find(|stmt| statement_matches_key(stmt, segment, depth, merge_key_source))?;
current = block_items(statement)?;
}
Some(current)
}
fn contains_key(
statements: &[AstStatement],
key: &str,
parent_depth: usize,
merge_key_source: MergeKeySource,
) -> bool {
statements
.iter()
.any(|stmt| statement_matches_key(stmt, key, parent_depth, merge_key_source))
}
fn statement_matches_key(
stmt: &AstStatement,
key: &str,
depth: usize,
merge_key_source: MergeKeySource,
) -> bool {
match merge_key_source {
MergeKeySource::FieldValue(field) if depth == 0 => {
field_value(stmt, field).is_some_and(|value| value == key)
}
MergeKeySource::ContainerChildFieldValue { containers, .. } if depth == 0 => {
assignment_key(stmt).is_some_and(|candidate| {
containers.contains(&candidate) && containers.contains(&key)
})
}
MergeKeySource::ContainerChildFieldValue {
child_key_field,
child_types,
..
} if depth == 1 => container_child_field_value_key(stmt, child_key_field, child_types)
.is_some_and(|candidate| candidate == key),
_ => assignment_key(stmt).is_some_and(|candidate| candidate == key),
}
}
fn assignment_key(stmt: &AstStatement) -> Option<&str> {
match stmt {
AstStatement::Assignment { key, .. } => Some(key),
_ => None,
}
}
fn field_value(stmt: &AstStatement, field: &str) -> Option<String> {
let AstStatement::Assignment {
value: AstValue::Block { items, .. },
..
} = stmt
else {
return None;
};
scalar_assignment_value(items, field)
}
fn container_child_field_value_key(
stmt: &AstStatement,
child_key_field: &str,
child_types: &[&str],
) -> Option<String> {
let AstStatement::Assignment { key, value, .. } = stmt else {
return None;
};
if (child_types.is_empty() || child_types.contains(&key.as_str()))
&& let AstValue::Block { items, .. } = value
&& let Some(field_value) = scalar_assignment_value(items, child_key_field)
{
return Some(format!("{key}:{field_value}"));
}
Some(key.clone())
}
fn scalar_assignment_value(items: &[AstStatement], expected_key: &str) -> Option<String> {
items.iter().find_map(|item| match item {
AstStatement::Assignment {
key,
value: AstValue::Scalar { value, .. },
..
} if key == expected_key => Some(value.as_text()),
_ => None,
})
}
fn block_items(stmt: &AstStatement) -> Option<&[AstStatement]> {
match stmt {
AstStatement::Assignment {
value: AstValue::Block { items, .. },
..
} => Some(items),
_ => None,
}
}
#[cfg(test)]
mod tests {
use crate::game::eu4::content::ScriptFileKind;
use crate::game::eu4::script::parser::parse_clausewitz_content;
use super::*;
const FILE_PATH: &str = "common/test/foo.txt";
fn parsed(source: &str) -> ParsedScriptFile {
let path = crate::model::GamePathBuf::parse(FILE_PATH).expect("valid game path");
let parsed = parse_clausewitz_content(&path, source);
ParsedScriptFile {
mod_id: "__game__".to_string(),
path: None,
relative_path: path.clone(),
content_family: None,
file_kind: ScriptFileKind::new("other"),
module_name: "test".to_string(),
ast: parsed.ast,
source: source.to_string(),
parse_issues: Vec::new(),
parse_cache_hit: false,
}
}
fn first_statement(source: &str) -> AstStatement {
parsed(source).ast.statements.remove(0)
}
#[test]
fn remove_present_vanilla_path_emits_no_descriptor() {
let vanilla = parsed("present = yes\n");
let patches = vec![ClausewitzPatch::RemoveNode {
path: Vec::new(),
key: "present".to_string(),
removed: first_statement("present = yes\n"),
}];
let findings = detect_stale_vanilla_targets(
&patches,
crate::model::GamePath::new(FILE_PATH).expect("valid game path"),
"mod-a",
"1.0.0",
Some(&vanilla),
MergeKeySource::AssignmentKey,
);
assert!(findings.is_empty());
}
#[test]
fn remove_absent_vanilla_path_emits_descriptor() {
let vanilla = parsed("present = { child = yes }\n");
let patches = vec![ClausewitzPatch::RemoveNode {
path: vec!["absent".to_string()],
key: "child".to_string(),
removed: first_statement("child = yes\n"),
}];
let findings = detect_stale_vanilla_targets(
&patches,
crate::model::GamePath::new(FILE_PATH).expect("valid game path"),
"mod-a",
"1.34.0",
Some(&vanilla),
MergeKeySource::AssignmentKey,
);
assert_eq!(findings.len(), 1);
let finding = &findings[0];
assert_eq!(finding.mod_id, "mod-a");
assert_eq!(finding.mod_version, "1.34.0");
assert_eq!(finding.file_path.as_str(), FILE_PATH);
assert_eq!(finding.patch_kind, "RemoveNode");
assert_eq!(finding.target_path, vec!["absent"]);
assert_eq!(finding.target_key.as_deref(), Some("child"));
assert!(
finding
.note
.as_deref()
.is_some_and(|note| note.contains("target path"))
);
}
#[test]
fn additive_patch_to_absent_path_emits_no_descriptor() {
let vanilla = parsed("present = yes\n");
let patches = vec![ClausewitzPatch::InsertNode {
path: Vec::new(),
key: "absent".to_string(),
statement: first_statement("absent = yes\n"),
}];
let findings = detect_stale_vanilla_targets(
&patches,
crate::model::GamePath::new(FILE_PATH).expect("valid game path"),
"mod-a",
"1.0.0",
Some(&vanilla),
MergeKeySource::AssignmentKey,
);
assert!(findings.is_empty());
}
}