foch 0.0.1

EU4 mod analysis and merge toolkit
Documentation
use super::model::{CollisionHotspot, ModSummary, ModulePartition, ModuleReport, SymbolGraph};
use crate::model::{GamePathBuf, MergeTraceEdge, MergeTraceEntry};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

pub fn build_module_report(graph: &SymbolGraph, partition: &ModulePartition) -> ModuleReport {
	let module_of = &partition.module_of;

	let mut module_sizes: BTreeMap<String, usize> = BTreeMap::new();
	for module in module_of.values() {
		*module_sizes.entry(module.clone()).or_insert(0) += 1;
	}

	let mut per_mod: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
	for (node, mods) in &graph.node_mods {
		let Some(module) = module_of.get(node) else {
			continue;
		};
		for mod_id in mods {
			*per_mod
				.entry(mod_id.clone())
				.or_default()
				.entry(module.clone())
				.or_insert(0) += 1;
		}
	}

	let mods = per_mod
		.into_iter()
		.map(|(mod_id, nodes_per_module)| {
			let touched_nodes: usize = nodes_per_module.values().sum();
			let top = nodes_per_module.values().copied().max().unwrap_or(0);
			let top_module_share = if touched_nodes == 0 {
				0.0
			} else {
				top as f64 / touched_nodes as f64
			};
			ModSummary {
				mod_id,
				touched_nodes,
				nodes_per_module,
				top_module_share,
			}
		})
		.collect();

	let mut collision_nodes_by_module: BTreeMap<String, usize> = BTreeMap::new();
	let mut mods_by_module: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
	for (node, mods) in &graph.node_mods {
		if mods.len() < 2 {
			continue;
		}
		let Some(module) = module_of.get(node) else {
			continue;
		};
		*collision_nodes_by_module.entry(module.clone()).or_insert(0) += 1;
		mods_by_module
			.entry(module.clone())
			.or_default()
			.extend(mods.iter().cloned());
	}

	let collision_hotspots = collision_nodes_by_module
		.into_iter()
		.map(|(module, collision_nodes)| CollisionHotspot {
			mods_involved: mods_by_module
				.get(&module)
				.map(|s| s.iter().cloned().collect())
				.unwrap_or_default(),
			module,
			collision_nodes,
		})
		.collect();

	ModuleReport {
		module_count: module_sizes.len(),
		node_count: graph.nodes.len(),
		module_sizes,
		mods,
		collision_hotspots,
		merge_trace_edges: Vec::new(),
	}
}

/// One edge per contributor of each traced definition. `merged_definition`
/// is display text naming the output file and the definition key.
pub fn merge_trace_edges_from_trace(
	trace: &BTreeMap<GamePathBuf, BTreeMap<String, MergeTraceEntry>>,
) -> Vec<MergeTraceEdge> {
	let mut edges = Vec::new();
	for (path, definitions) in trace {
		for (key, entry) in definitions {
			let merged_definition = format!("{path}::{key}");
			for contributor in &entry.contributors {
				edges.push(MergeTraceEdge {
					merged_definition: merged_definition.clone(),
					source_mod: contributor.mod_id.clone(),
					policy: entry.policy,
					decision: entry.decision,
					precedence: contributor.precedence,
					dag_level: contributor.dag_level,
				});
			}
		}
	}
	edges.sort_by(|left, right| {
		left.merged_definition
			.cmp(&right.merged_definition)
			.then_with(|| left.source_mod.cmp(&right.source_mod))
			.then_with(|| left.precedence.cmp(&right.precedence))
			.then_with(|| left.dag_level.cmp(&right.dag_level))
	});
	edges
}

pub fn write_module_report(path: &Path, report: &ModuleReport) -> std::io::Result<()> {
	let json = serde_json::to_string_pretty(report).map_err(std::io::Error::other)?;
	std::fs::write(path, json)
}

#[cfg(test)]
mod tests {
	use super::super::cluster::cluster_modules;
	use super::super::model::{SymbolGraph, SymbolNodeId};
	use super::*;

	#[test]
	fn report_localizes_mods_and_flags_collisions() {
		let mut g = SymbolGraph::default();
		let r1 = SymbolNodeId::new("k", "r1");
		let r2 = SymbolNodeId::new("k", "r2");
		let t1 = SymbolNodeId::new("k", "t1");
		for (n, fam) in [(&r1, "religion"), (&r2, "religion"), (&t1, "trade")] {
			g.set_seed(n, fam);
		}
		g.add_edge(&r1, &r2, 5);
		g.add_mod(&r1, "modA");
		g.add_mod(&r2, "modA");
		g.add_mod(&r2, "modB");
		g.add_mod(&t1, "modB");

		let part = cluster_modules(&g, 20);
		let report = build_module_report(&g, &part);

		let mod_a = report.mods.iter().find(|m| m.mod_id == "modA").unwrap();
		assert_eq!(mod_a.touched_nodes, 2);
		assert!(
			(mod_a.top_module_share - 1.0).abs() < 1e-9,
			"modA is fully localized"
		);

		let hotspot = report
			.collision_hotspots
			.iter()
			.find(|h| h.collision_nodes > 0)
			.expect("expected a collision hotspot");
		assert_eq!(hotspot.collision_nodes, 1);
		assert_eq!(
			hotspot.mods_involved,
			vec!["modA".to_string(), "modB".to_string()]
		);
	}

	#[test]
	fn trace_edges_are_sorted_and_deterministic() {
		use crate::model::{MergeTraceContributor, MergeTraceDecision, MergeTracePolicy};

		let mut trace = BTreeMap::new();
		trace.insert(
			GamePathBuf::parse("common/scripted_effects/test.txt").expect("valid game path"),
			BTreeMap::from([(
				"test_shared_effect".to_string(),
				MergeTraceEntry {
					contributors: vec![
						MergeTraceContributor {
							mod_id: "mod_b".to_string(),
							precedence: 2,
							dag_level: 0,
						},
						MergeTraceContributor {
							mod_id: "mod_a".to_string(),
							precedence: 1,
							dag_level: 0,
						},
					],
					policy: MergeTracePolicy::Union,
					decision: MergeTraceDecision::Unioned,
				},
			)]),
		);

		let first = merge_trace_edges_from_trace(&trace);
		let second = merge_trace_edges_from_trace(&trace);
		assert_eq!(first, second);
		assert_eq!(first.len(), 2);
		assert_eq!(
			first[0].merged_definition,
			"common/scripted_effects/test.txt::test_shared_effect"
		);
		assert_eq!(first[0].source_mod, "mod_a");
		assert_eq!(first[0].policy, MergeTracePolicy::Union);
		assert_eq!(first[0].decision, MergeTraceDecision::Unioned);
	}
}