foch 0.0.2

EU4 mod analysis and merge toolkit
Documentation
use std::collections::HashMap;
use std::path::Path;

use crate::game::eu4::cwt::merge::classify_conflict_kind;
use crate::game::eu4::script::ParsedScriptFile;
use crate::model::{GamePath, LeafConflictDetail, MergeReportConflictContributor};
use crate::project::{ResolutionMap, compute_conflict_id};

use super::super::{
	StructuralConflictReport, StructuralMergeContext, StructuralMergeFailure,
	StructuralMergeOutput,
	per_entry_noop::drop_per_entry_noop_duplicates,
	stale_detect::{
		collect_dep_misuse_remove_counts, collect_stale_vanilla_targets,
		parse_vanilla_for_stale_detection, vanilla_snippet_for_address,
	},
};
use crate::game::eu4::script::emit::{EmitOptions, emit_clausewitz_statements_with_options};
use crate::input::ResolvedInputContributor;
use crate::merge::address_patch::conflict_view::build_conflict_view;
use crate::merge::address_patch::dag_merge::{
	ReferenceDagMergeComputation, ReferenceDagMergeRequest, ReferenceParsedDagMergeRequest,
	compute_reference_dag_merge, compute_reference_dag_merge_from_parsed,
};
use crate::merge::address_patch::patch_merge::{
	AttributedPatch, PatchConflict, PatchMergeResult, PatchResolution,
};
use crate::merge::error::{MergeError, MergeErrorSubject};
use crate::merge::planning::dag_input::{
	DagMergeInputRequest, merge_ancestor_statements, template_for,
};
use crate::merge::planning::module_view::CrossFileModuleViews;
use crate::merge::resolution::conflict_handler::ConflictHandler;
use crate::merge::resolution::conflict_view::ConflictView;
use crate::merge::structured::observe_merge_trace;

pub(crate) fn merge_structural_file(
	target_path: &GamePath,
	contributors: &[ResolvedInputContributor],
	context: StructuralMergeContext<'_>,
	interactive_handler: Option<&mut (dyn ConflictHandler + '_)>,
	interactive_config_path: Option<&Path>,
) -> Result<StructuralMergeOutput, StructuralMergeFailure> {
	let vanilla =
		parse_vanilla_for_stale_detection(target_path, contributors, context.script_cache)?;
	finish_reference_structural_merge(
		target_path,
		contributors,
		context,
		vanilla,
		interactive_handler,
		interactive_config_path,
		|resolution_map, context| {
			run_reference_structural_file_engine(target_path, contributors, context, resolution_map)
		},
	)
}

pub(crate) fn merge_definition_module(
	target_path: &GamePath,
	views: &CrossFileModuleViews,
	context: StructuralMergeContext<'_>,
	interactive_handler: Option<&mut (dyn ConflictHandler + '_)>,
	interactive_config_path: Option<&Path>,
) -> Result<StructuralMergeOutput, StructuralMergeFailure> {
	finish_reference_structural_merge(
		target_path,
		&views.aggregate_contributors,
		context,
		views.vanilla.clone(),
		interactive_handler,
		interactive_config_path,
		|resolution_map, context| {
			run_reference_definition_module_engine(target_path, views, context, resolution_map)
		},
	)
}

fn finish_reference_structural_merge<F>(
	target_path: &GamePath,
	contributors: &[ResolvedInputContributor],
	context: StructuralMergeContext<'_>,
	vanilla: Option<ParsedScriptFile>,
	mut interactive_handler: Option<&mut (dyn ConflictHandler + '_)>,
	interactive_config_path: Option<&Path>,
	mut run_engine: F,
) -> Result<StructuralMergeOutput, StructuralMergeFailure>
where
	F: FnMut(
		&ResolutionMap,
		&StructuralMergeContext<'_>,
	) -> Result<ReferenceDagMergeComputation, MergeError>,
{
	let mut effective_map = context.resolution_map.clone();
	let mut dag_merge = run_engine(&effective_map, &context)?;

	if !dag_merge.merge_result.conflicts.is_empty()
		&& let (Some(handler), Some(config_path)) =
			(interactive_handler.as_mut(), interactive_config_path)
	{
		let survivor_views = survivor_views(
			target_path,
			&dag_merge.merge_result,
			vanilla.as_ref(),
			context.mod_display_names,
			context.emit_options,
		)?;
		if super::prompt_conflict_views(
			target_path,
			&survivor_views,
			&mut effective_map,
			&mut **handler,
			config_path,
		)? {
			dag_merge = run_engine(&effective_map, &context)?;
		}
	}

	let stale_vanilla_targets = collect_stale_vanilla_targets(
		target_path,
		&dag_merge.mod_patches,
		vanilla.as_ref(),
		context.merge_key_source,
		context.mod_versions,
	);
	let dep_remove_counts = collect_dep_misuse_remove_counts(
		context.dep_misuse_findings,
		contributors,
		&dag_merge.mod_patches,
	);
	if !dag_merge.merge_result.conflicts.is_empty() {
		let conflict_views = survivor_views(
			target_path,
			&dag_merge.merge_result,
			vanilla.as_ref(),
			context.mod_display_names,
			context.emit_options,
		)
		.unwrap_or_else(|error| {
			eprintln!("[merge] conflict candidates not rendered for {target_path}: {error}");
			Vec::new()
		});
		return Err(StructuralMergeFailure::Unresolved(unresolved_report(
			target_path,
			&dag_merge.merge_result,
			context.mod_versions,
			&effective_map,
			conflict_views,
		)));
	}

	let noop_vs_vanilla = vanilla.as_ref().is_some_and(|base| {
		crate::merge::address_patch::patch::ast_statement_lists_semantically_equal(
			&base.ast.statements,
			&dag_merge.merged_statements,
		)
	});
	let merged_statements = dag_merge.merged_statements;
	let (merged_statements, per_entry_noop_skipped_count) = if let Some(base) = vanilla.as_ref() {
		drop_per_entry_noop_duplicates(
			merged_statements,
			&base.ast.statements,
			context.descriptor,
			&base.ast.path,
		)
	} else {
		(merged_statements, 0)
	};
	let definition_participants = dag_merge.definition_participants;
	let definition_provenance = dag_merge.definition_provenance;
	let merge_trace = observe_merge_trace(
		&definition_provenance,
		&definition_participants,
		context.descriptor,
		None,
	)
	.map_err(|message| {
		StructuralMergeFailure::Merge(MergeError::Validation {
			subject: Some(MergeErrorSubject::Game(target_path.to_owned())),
			message,
		})
	})?;
	let merged_statements = if context.provenance {
		super::inject_provenance_comments(
			merged_statements,
			&definition_provenance,
			context.mod_display_names,
		)
	} else {
		merged_statements
	};
	let emit_options = context.emit_options.clone();
	let rendered = emit_clausewitz_statements_with_options(&merged_statements, &emit_options)?;
	let merge_result = dag_merge.merge_result;
	Ok(StructuralMergeOutput {
		rendered,
		dep_remove_counts,
		stale_vanilla_targets,
		handler_resolutions: merge_result.handler_resolutions,
		external_file_resolutions: merge_result.external_file_resolutions,
		keep_existing_paths: merge_result.keep_existing_paths,
		noop_vs_vanilla,
		per_entry_noop_skipped_count,
		definition_provenance,
		merge_trace,
		// The address-patch reference backend does not retain node lineage. It is
		// test-only; product materialization uses the semantic backend above.
		provenance_localisation: Default::default(),
	})
}

fn run_reference_structural_file_engine(
	target_path: &GamePath,
	contributors: &[ResolvedInputContributor],
	context: &StructuralMergeContext<'_>,
	resolution_map: &ResolutionMap,
) -> Result<ReferenceDagMergeComputation, MergeError> {
	let mut handler = super::automatic_conflict_handler(target_path, context, resolution_map);
	let effective_policies = super::effective_merge_policies(context);
	compute_reference_dag_merge(
		ReferenceDagMergeRequest {
			input: DagMergeInputRequest {
				file_path: target_path,
				contributors,
				mod_dag: context.mod_dag,
				ignore_replace_path: context.ignore_replace_path,
				dep_overrides: context.dep_overrides,
				script_cache: Some(context.script_cache),
			},
			merge_key_source: context.merge_key_source,
			policies: &effective_policies,
			game_version: context.cache_game_version,
		},
		&mut handler,
	)
	.map_err(|error| MergeError::Validation {
		subject: Some(MergeErrorSubject::Game(target_path.to_owned())),
		message: format!("address-patch reference DAG merge failed: {error}"),
	})
}

fn run_reference_definition_module_engine(
	target_path: &GamePath,
	views: &CrossFileModuleViews,
	context: &StructuralMergeContext<'_>,
	resolution_map: &ResolutionMap,
) -> Result<ReferenceDagMergeComputation, MergeError> {
	let mut handler = super::automatic_conflict_handler(target_path, context, resolution_map);
	let effective_policies = super::effective_merge_policies(context);
	let base_statements = merge_ancestor_statements(views.vanilla.as_ref());
	compute_reference_dag_merge_from_parsed(ReferenceParsedDagMergeRequest {
		file_dag: &views.file_dag,
		base_statements: &base_statements,
		template: template_for(&views.file_dag, views.vanilla.as_ref(), &views.contributors),
		contributors: &views.contributors,
		merge_key_source: context.merge_key_source,
		policies: &effective_policies,
		handler: &mut handler,
		mod_hashes: None,
		game_version: context.cache_game_version,
	})
	.map_err(|error| MergeError::Validation {
		subject: Some(MergeErrorSubject::Game(target_path.to_owned())),
		message: format!("address-patch reference definition-module DAG merge failed: {error}"),
	})
}

pub(super) fn survivor_views(
	target_path: &GamePath,
	merge_result: &PatchMergeResult,
	vanilla: Option<&ParsedScriptFile>,
	mod_display_names: &HashMap<String, String>,
	emit_options: &EmitOptions,
) -> Result<Vec<ConflictView>, MergeError> {
	let vanilla_lookup = |address| vanilla_snippet_for_address(vanilla, address, emit_options);
	merge_result
		.conflicts
		.iter()
		.filter_map(|resolution| match resolution {
			PatchResolution::Conflict {
				address,
				patches,
				reason,
			} => Some((address, patches, reason)),
			_ => None,
		})
		.map(|(address, patches, reason)| {
			let address_path = address.path.join("/");
			let conflict_id = compute_conflict_id(target_path, &address_path, &address.key);
			build_conflict_view(
				target_path,
				address,
				&PatchConflict {
					patches: patches.clone(),
					reason: reason.clone(),
				},
				conflict_id,
				mod_display_names,
				vanilla_lookup(address),
				emit_options,
			)
		})
		.collect()
}

pub(super) fn unresolved_report(
	target_path: &GamePath,
	merge_result: &PatchMergeResult,
	mod_versions: &HashMap<String, String>,
	resolution_map: &crate::project::ResolutionMap,
	conflict_views: Vec<ConflictView>,
) -> StructuralConflictReport {
	let conflict_keys = merge_result
		.conflicts
		.iter()
		.filter_map(|resolution| match resolution {
			PatchResolution::Conflict {
				address, reason, ..
			} => Some(format!("{}: {}", address.key, reason)),
			_ => None,
		})
		.collect::<Vec<_>>();
	let leaf_conflicts = leaf_conflicts(target_path, &merge_result.conflicts, mod_versions);
	let explicitly_deferred =
		super::all_conflicts_explicitly_deferred(target_path, &leaf_conflicts, resolution_map);
	StructuralConflictReport {
		reason: format!(
			"structural merge has {} unresolved conflict(s): {}",
			conflict_keys.len(),
			conflict_keys.join("; "),
		),
		leaf_conflicts,
		handler_resolutions: merge_result.handler_resolutions.clone(),
		explicitly_deferred,
		conflict_views,
	}
}

fn leaf_conflicts(
	target_path: &GamePath,
	conflicts: &[PatchResolution],
	mod_versions: &HashMap<String, String>,
) -> Vec<LeafConflictDetail> {
	conflicts
		.iter()
		.filter_map(|resolution| match resolution {
			PatchResolution::Conflict {
				address,
				patches,
				reason,
			} => {
				let address_path = address.path.join("/");
				let ast_path = address.path.iter().map(String::as_str).collect::<Vec<_>>();
				Some(LeafConflictDetail {
					address_path: address_path.clone(),
					address_key: address.key.clone(),
					conflict_id: compute_conflict_id(target_path, &address_path, &address.key),
					kind: classify_conflict_kind(target_path, &ast_path, reason),
					contributors: leaf_conflict_contributors(patches, mod_versions),
				})
			}
			_ => None,
		})
		.collect()
}

fn leaf_conflict_contributors(
	patches: &[AttributedPatch],
	mod_versions: &HashMap<String, String>,
) -> Vec<MergeReportConflictContributor> {
	let mut contributors = patches
		.iter()
		.map(|patch| MergeReportConflictContributor {
			mod_id: patch.mod_id.clone(),
			mod_version: mod_versions
				.get(&patch.mod_id)
				.cloned()
				.unwrap_or_else(|| "unknown".to_string()),
			precedence: patch.precedence,
		})
		.collect::<Vec<_>>();
	contributors.sort_by(|left, right| {
		left.precedence
			.cmp(&right.precedence)
			.then_with(|| left.mod_id.cmp(&right.mod_id))
	});
	contributors
		.dedup_by(|left, right| left.mod_id == right.mod_id && left.precedence == right.precedence);
	contributors
}