use std::path::PathBuf;
use super::namespace::{FamilyKeyIndex, KeyContributor, detect_key_conflicts};
use super::patch::{ClausewitzPatch, diff_ast};
use super::patch_apply::{apply_patches, merge_single_mod};
use crate::game::eu4::content::MergeKeySource;
use crate::game::eu4::script::emit::emit_clausewitz_statements;
use crate::game::eu4::script::parser::{AstStatement, AstValue};
use crate::game::eu4::script::{ParsedScriptFile, parse_script_file};
fn workshop_root() -> PathBuf {
let home = std::env::var("HOME").expect("HOME not set");
PathBuf::from(home).join("Library/Application Support/Steam/steamapps/workshop/content/236850")
}
fn mod_root(steam_id: &str) -> PathBuf {
workshop_root().join(steam_id)
}
fn parse_mod_file(steam_id: &str, relative: &str) -> ParsedScriptFile {
let root = mod_root(steam_id);
let file = root.join(relative);
assert!(
file.exists(),
"Workshop file not found: {} (is the mod installed?)",
file.display()
);
let relative = crate::model::GamePath::new(relative).expect("valid game path");
parse_script_file(steam_id, &root, relative)
}
fn count_top_level_blocks(statements: &[AstStatement]) -> usize {
statements
.iter()
.filter(|s| {
matches!(
s,
AstStatement::Assignment {
value: AstValue::Block { .. },
..
}
)
})
.count()
}
fn contains_assignment(statements: &[AstStatement], target_key: &str, target_val: &str) -> bool {
for stmt in statements {
match stmt {
AstStatement::Assignment { key, value, .. } => {
if key == target_key
&& matches!(value, AstValue::Scalar { value: sv, .. } if sv.as_text() == target_val)
{
return true;
}
if let AstValue::Block { items, .. } = value
&& contains_assignment(items, target_key, target_val)
{
return true;
}
}
AstStatement::Item {
value: AstValue::Block { items, .. },
..
} if contains_assignment(items, target_key, target_val) => {
return true;
}
_ => {}
}
}
false
}
fn patch_variant_name(p: &ClausewitzPatch) -> &'static str {
match p {
ClausewitzPatch::SetValue { .. } => "SetValue",
ClausewitzPatch::RemoveNode { .. } => "RemoveNode",
ClausewitzPatch::InsertNode { .. } => "InsertNode",
ClausewitzPatch::AppendListItem { .. } => "AppendListItem",
ClausewitzPatch::RemoveListItem { .. } => "RemoveListItem",
ClausewitzPatch::ReplaceBlock { .. } => "ReplaceBlock",
ClausewitzPatch::AppendBlockItem { .. } => "AppendBlockItem",
ClausewitzPatch::RemoveBlockItem { .. } => "RemoveBlockItem",
ClausewitzPatch::Rename { .. } => "Rename",
}
}
#[test]
#[ignore] fn patch_p3_ee_compatch_scripted_triggers() {
let rel_path = "common/scripted_triggers/missions_expanded_scripted_triggers.txt";
let ee_steam_id = "2164202838";
let compatch_steam_id = "3081508015";
let ee_parsed = parse_mod_file(ee_steam_id, rel_path);
let compatch_parsed = parse_mod_file(compatch_steam_id, rel_path);
let ee_top_blocks = count_top_level_blocks(&ee_parsed.ast.statements);
let compatch_top_blocks = count_top_level_blocks(&compatch_parsed.ast.statements);
println!("EE top-level blocks: {ee_top_blocks}");
println!("Compatch top-level blocks: {compatch_top_blocks}");
assert_eq!(
ee_top_blocks, compatch_top_blocks,
"Compatch should have the same top-level triggers as EE"
);
let patches = diff_ast(&ee_parsed, &compatch_parsed, MergeKeySource::AssignmentKey);
println!("\nPatches produced: {}", patches.len());
for (i, p) in patches.iter().enumerate() {
println!(
" [{i}] {}: path={:?}",
patch_variant_name(p),
match p {
ClausewitzPatch::SetValue { path, key, .. } => format!("{path:?} / {key}"),
ClausewitzPatch::RemoveNode { path, key, .. } => format!("{path:?} / {key}"),
ClausewitzPatch::InsertNode { path, key, .. } => format!("{path:?} / {key}"),
ClausewitzPatch::AppendListItem { path, key, .. } => format!("{path:?} / {key}"),
ClausewitzPatch::RemoveListItem { path, key, .. } => format!("{path:?} / {key}"),
ClausewitzPatch::ReplaceBlock { path, key, .. } => format!("{path:?} / {key}"),
ClausewitzPatch::AppendBlockItem { path, value } =>
format!("{path:?} / item={value:?}"),
ClausewitzPatch::RemoveBlockItem { path, value } =>
format!("{path:?} / item={value:?}"),
ClausewitzPatch::Rename {
path,
old_key,
new_key,
} => format!("{path:?} / {old_key} -> {new_key}"),
}
);
}
assert!(
patches.len() <= 5,
"Expected a very small diff (≤5 patches), got {}. \
The patch system may be duplicating blocks instead of diffing.",
patches.len()
);
let merged = apply_patches(
&ee_parsed.ast.statements,
&patches,
MergeKeySource::AssignmentKey,
);
let merged_top_blocks = count_top_level_blocks(&merged);
println!("\nMerged top-level blocks: {merged_top_blocks}");
assert_eq!(
merged_top_blocks, ee_top_blocks,
"Merged output should have {ee_top_blocks} top-level triggers, not {merged_top_blocks} (doubled?)"
);
assert!(
contains_assignment(&merged, "tag", "ERS"),
"Merged output must contain `tag = ERS` from the compatch"
);
let emitted = emit_clausewitz_statements(&merged).expect("emit_clausewitz_statements failed");
let ee_text =
std::fs::read_to_string(mod_root(ee_steam_id).join(rel_path)).expect("read EE source file");
let ratio = emitted.len() as f64 / ee_text.len() as f64;
println!("\nEE source length: {} chars", ee_text.len());
println!("Emitted length: {} chars", emitted.len());
println!("Ratio: {ratio:.3}");
assert!(
(0.90..=1.10).contains(&ratio),
"Emitted output length should be within ±10% of EE original (ratio={ratio:.3})"
);
println!("\n✅ P3 PASSED: patch system correctly diffs EE×Compatch as a small patch set.");
}
#[test]
#[ignore] fn patch_p3_merge_single_mod_shortcut() {
let rel_path = "common/scripted_triggers/missions_expanded_scripted_triggers.txt";
let ee_parsed = parse_mod_file("2164202838", rel_path);
let compatch_parsed = parse_mod_file("3081508015", rel_path);
let ee_top_blocks = count_top_level_blocks(&ee_parsed.ast.statements);
let merged = merge_single_mod(&ee_parsed, &compatch_parsed, MergeKeySource::AssignmentKey);
let merged_top_blocks = count_top_level_blocks(&merged);
println!("merge_single_mod: {merged_top_blocks} top-level blocks (EE has {ee_top_blocks})");
assert_eq!(merged_top_blocks, ee_top_blocks);
assert!(contains_assignment(&merged, "tag", "ERS"));
println!("✅ merge_single_mod shortcut matches step-by-step result.");
}
#[test]
#[ignore] fn patch_p1_funding_event_modifiers() {
let funding_steam_id = "1678280999";
let simplified_steam_id = "3453420872";
let rel_path = "common/event_modifiers/event_modifiers_rf_tax.txt";
let funding_root = mod_root(funding_steam_id);
let simplified_root = mod_root(simplified_steam_id);
let funding_file = funding_root.join(rel_path);
let simplified_file = simplified_root.join(rel_path);
if !funding_file.exists() || !simplified_file.exists() {
let alternate = find_first_overlapping_file(
funding_steam_id,
simplified_steam_id,
"common/event_modifiers",
);
if let Some(fb) = &alternate {
println!("Primary file not found; using alternate: {fb}");
run_funding_convergence_test(funding_steam_id, simplified_steam_id, fb.as_str());
} else {
panic!(
"No overlapping event_modifiers file found between {} and {}",
funding_steam_id, simplified_steam_id
);
}
return;
}
run_funding_convergence_test(funding_steam_id, simplified_steam_id, rel_path);
}
fn find_first_overlapping_file(
steam_a: &str,
steam_b: &str,
subdir: &str,
) -> Option<crate::model::GamePathBuf> {
let root_a = mod_root(steam_a);
let dir_a = root_a.join(subdir);
let dir_b = mod_root(steam_b).join(subdir);
if !dir_a.exists() || !dir_b.exists() {
return None;
}
for entry in std::fs::read_dir(&dir_a).ok()? {
let entry = entry.ok()?;
if dir_b.join(entry.file_name()).exists() {
return Some(
crate::model::GamePathBuf::from_physical(&root_a, &entry.path())
.expect("workshop file has a game path"),
);
}
}
None
}
fn run_funding_convergence_test(steam_a: &str, steam_b: &str, rel_path: &str) {
let parsed_a = parse_mod_file(steam_a, rel_path);
let parsed_b = parse_mod_file(steam_b, rel_path);
let a_blocks = count_top_level_blocks(&parsed_a.ast.statements);
let b_blocks = count_top_level_blocks(&parsed_b.ast.statements);
println!("Mod A ({steam_a}) top-level blocks: {a_blocks}");
println!("Mod B ({steam_b}) top-level blocks: {b_blocks}");
let patches = diff_ast(&parsed_a, &parsed_b, MergeKeySource::AssignmentKey);
println!("\nPatches produced: {}", patches.len());
let mut counts = std::collections::HashMap::<&str, usize>::new();
for p in &patches {
*counts.entry(patch_variant_name(p)).or_default() += 1;
}
for (variant, count) in &counts {
println!(" {variant}: {count}");
}
let a_keys = extract_assignment_keys(&parsed_a.ast.statements);
let b_keys = extract_assignment_keys(&parsed_b.ast.statements);
let common_keys: Vec<_> = a_keys.iter().filter(|k| b_keys.contains(k)).collect();
println!("\nCommon keys: {}", common_keys.len());
let patched_keys: std::collections::HashSet<String> = patches
.iter()
.filter_map(|p| match p {
ClausewitzPatch::SetValue { path, key, .. }
| ClausewitzPatch::ReplaceBlock { path, key, .. } => {
Some(path.first().cloned().unwrap_or_else(|| key.clone()))
}
_ => None,
})
.collect();
let converged_count = common_keys
.iter()
.filter(|k| !patched_keys.contains(k.as_str()))
.count();
println!("Converged (no patches): {converged_count}");
println!(
"Diverged (patched): {}",
common_keys.len() - converged_count
);
let merged = apply_patches(
&parsed_a.ast.statements,
&patches,
MergeKeySource::AssignmentKey,
);
let merged_blocks = count_top_level_blocks(&merged);
println!("\nMerged top-level blocks: {merged_blocks}");
assert!(
merged_blocks >= a_blocks.min(b_blocks),
"Merged output should have at least as many blocks as the smaller input"
);
let emitted = emit_clausewitz_statements(&merged).expect("emit_clausewitz_statements failed");
let open_braces = emitted.chars().filter(|&c| c == '{').count();
let close_braces = emitted.chars().filter(|&c| c == '}').count();
println!(
"\nEmitted: {} chars, braces: {{ {open_braces} }} {close_braces}",
emitted.len()
);
assert_eq!(
open_braces, close_braces,
"Emitted text has unbalanced braces"
);
let merged_keys = extract_assignment_keys(&merged);
let mut seen = std::collections::HashSet::new();
let mut dups = Vec::new();
for k in &merged_keys {
if !seen.insert(k.as_str()) {
dups.push(k.clone());
}
}
if !dups.is_empty() {
println!("\n⚠ Duplicated keys in merged output: {dups:?}");
}
println!("\n✅ P1 PASSED: Funding × SimplifiedFunding convergence test on {rel_path}");
}
fn extract_assignment_keys(statements: &[AstStatement]) -> Vec<String> {
statements
.iter()
.filter_map(|s| match s {
AstStatement::Assignment {
key,
value: AstValue::Block { .. },
..
} => Some(key.clone()),
_ => None,
})
.collect()
}
#[test]
#[ignore] fn namespace_detects_cross_file_conflicts_in_real_playlist() {
let ee_steam_id = "2164202838";
let compatch_steam_id = "3081508015";
let family = "scripted_triggers";
let subdir = "common/scripted_triggers";
let mut index = FamilyKeyIndex {
family_id: family.to_string(),
entries: std::collections::HashMap::new(),
};
let mods: &[(&str, usize)] = &[
(ee_steam_id, 1), (compatch_steam_id, 2), ];
for &(steam_id, precedence) in mods {
let dir = mod_root(steam_id).join(subdir);
if !dir.exists() {
println!("Skipping {steam_id}: {subdir} not found");
continue;
}
let entries: Vec<_> = std::fs::read_dir(&dir)
.expect("read scripted_triggers dir")
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "txt"))
.collect();
for entry in &entries {
let file_path = entry.path();
let root = mod_root(steam_id);
let relative = crate::model::GamePathBuf::from_physical(&root, &file_path)
.expect("workshop file has a game path");
let parsed = parse_script_file(steam_id, &root, &relative);
let keys = extract_assignment_keys(&parsed.ast.statements);
for key in keys {
index.entries.entry(key).or_default().push(KeyContributor {
mod_id: steam_id.to_string(),
file_path: relative.clone(),
precedence,
is_base_game: false,
});
}
}
println!("Indexed {steam_id}: {} files", entries.len());
}
println!("Total keys in index: {}", index.entries.len());
let conflicts = detect_key_conflicts(&index);
println!("Cross-file key conflicts found: {}", conflicts.len());
for conflict in &conflicts {
let mods_involved: Vec<_> = conflict
.contributors
.iter()
.map(|c| format!("{}@{}", c.mod_id, c.file_path))
.collect();
println!(" key={}: {}", conflict.key, mods_involved.join(", "));
}
if !conflicts.is_empty() {
println!(
"\n✅ Namespace detected {} cross-mod key conflicts as expected.",
conflicts.len()
);
} else {
println!("\nℹ No cross-file conflicts detected (mods may be disjoint in this family).");
}
assert!(
!index.entries.is_empty(),
"Should have indexed at least some scripted_trigger keys"
);
println!("✅ Namespace conflict detection test completed.");
}