foch 0.0.2

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

use crate::game::eu4::script::parser::{AstStatement, AstValue, ScalarValue, Span, SpanRange};

use crate::game::eu4::script::emit::{EmitOptions, emit_clausewitz_statements_with_options};
use crate::merge::conflict_view::{CandidateView, ConflictView};
use crate::merge::error::MergeError;
use crate::model::GamePath;

use super::patch::ClausewitzPatch;
use super::patch_merge::{PatchAddress, PatchConflict};

const MAX_SUMMARY_CHARS: usize = 80;
const MAX_CHILD_PREVIEW_ENTRIES: usize = 3;

pub(crate) fn build_conflict_view(
	file_path: &GamePath,
	address: &PatchAddress,
	conflict: &PatchConflict,
	conflict_id: String,
	mod_display_names: &HashMap<String, String>,
	vanilla_snippet: Option<String>,
	emit_options: &EmitOptions,
) -> Result<ConflictView, MergeError> {
	let candidates = conflict
		.patches
		.iter()
		.map(|patch| {
			Ok(CandidateView {
				mod_id: patch.mod_id.clone(),
				mod_display_name: mod_display_names
					.get(&patch.mod_id)
					.filter(|name| !name.trim().is_empty())
					.cloned()
					.unwrap_or_else(|| patch.mod_id.clone()),
				precedence: patch.precedence,
				change_summary: concise_patch_summary(&patch.patch),
				candidate_rendered: render_patch(&patch.patch, emit_options)?,
			})
		})
		.collect::<Result<Vec<_>, MergeError>>()?;

	Ok(ConflictView {
		file_path: file_path.to_owned(),
		address_path: address.path.clone(),
		address_key: address.key.clone(),
		conflict_id,
		reason: conflict.reason.clone(),
		vanilla_snippet,
		candidates,
	})
}

pub(crate) fn build_decision_conflict_view(
	file_path: &GamePath,
	address: &PatchAddress,
	conflict: &PatchConflict,
	conflict_id: String,
	mod_display_names: &HashMap<String, String>,
) -> ConflictView {
	let candidates = conflict
		.patches
		.iter()
		.map(|patch| CandidateView {
			mod_id: patch.mod_id.clone(),
			mod_display_name: mod_display_names
				.get(&patch.mod_id)
				.filter(|name| !name.trim().is_empty())
				.cloned()
				.unwrap_or_else(|| patch.mod_id.clone()),
			precedence: patch.precedence,
			change_summary: Vec::new(),
			candidate_rendered: String::new(),
		})
		.collect();

	ConflictView {
		file_path: file_path.to_owned(),
		address_path: address.path.clone(),
		address_key: address.key.clone(),
		conflict_id,
		reason: conflict.reason.clone(),
		vanilla_snippet: None,
		candidates,
	}
}

fn render_patch(patch: &ClausewitzPatch, emit_options: &EmitOptions) -> Result<String, MergeError> {
	match patch {
		ClausewitzPatch::SetValue { key, new_value, .. } => {
			emit_statement(&assignment(key, new_value.clone()), emit_options)
		}
		ClausewitzPatch::ReplaceBlock { new_statement, .. } => {
			emit_statement(new_statement, emit_options)
		}
		ClausewitzPatch::InsertNode { statement, .. } => emit_statement(statement, emit_options),
		ClausewitzPatch::RemoveNode { .. } => Ok("(removed)".to_string()),
		ClausewitzPatch::AppendListItem { value, .. }
		| ClausewitzPatch::AppendBlockItem { value, .. } => {
			render_prefixed_value("(appends)", value, emit_options)
		}
		ClausewitzPatch::RemoveListItem { value, .. }
		| ClausewitzPatch::RemoveBlockItem { value, .. } => {
			render_prefixed_value("(removes)", value, emit_options)
		}
		ClausewitzPatch::Rename {
			old_key, new_key, ..
		} => Ok(format!("(renames \"{old_key}\" -> \"{new_key}\")")),
	}
}

fn emit_statement(
	statement: &AstStatement,
	emit_options: &EmitOptions,
) -> Result<String, MergeError> {
	emit_clausewitz_statements_with_options(std::slice::from_ref(statement), emit_options)
}

fn render_prefixed_value(
	prefix: &str,
	value: &AstValue,
	emit_options: &EmitOptions,
) -> Result<String, MergeError> {
	let rendered = emit_clausewitz_statements_with_options(
		&[AstStatement::Item {
			value: value.clone(),
			span: synthetic_span(),
		}],
		emit_options,
	)?;
	Ok(format!("{prefix} {}", rendered.trim_end()))
}

fn assignment(key: &str, value: AstValue) -> AstStatement {
	AstStatement::Assignment {
		key: key.to_string(),
		key_span: synthetic_span(),
		value,
		span: synthetic_span(),
	}
}

fn synthetic_span() -> SpanRange {
	SpanRange {
		start: Span {
			line: 0,
			column: 0,
			offset: 0,
		},
		end: Span {
			line: 0,
			column: 0,
			offset: 0,
		},
	}
}

fn concise_patch_summary(patch: &ClausewitzPatch) -> Vec<String> {
	match patch {
		ClausewitzPatch::SetValue {
			key,
			old_value,
			new_value,
			..
		} => vec![format!(
			"set \"{key}\": {} → {}",
			value_summary(old_value),
			value_summary(new_value)
		)],
		ClausewitzPatch::RemoveNode { key, removed, .. } => remove_node_summary(key, removed),
		ClausewitzPatch::InsertNode { key, statement, .. } => insert_node_summary(key, statement),
		ClausewitzPatch::ReplaceBlock {
			key, new_statement, ..
		} => block_patch_summary(format!("replace block \"{key}\""), new_statement, '+'),
		ClausewitzPatch::AppendListItem { key, value, .. } => {
			vec![format!(
				"append to list \"{key}\": {}",
				value_summary(value)
			)]
		}
		ClausewitzPatch::RemoveListItem { key, value, .. } => {
			vec![format!(
				"remove from list \"{key}\": {}",
				value_summary(value)
			)]
		}
		ClausewitzPatch::AppendBlockItem { value, .. } => {
			vec![format!("append item: {}", value_summary(value))]
		}
		ClausewitzPatch::RemoveBlockItem { value, .. } => {
			vec![format!("remove item: {}", value_summary(value))]
		}
		ClausewitzPatch::Rename {
			old_key, new_key, ..
		} => vec![format!("rename \"{old_key}\" → \"{new_key}\"")],
	}
}

fn remove_node_summary(key: &str, removed: &AstStatement) -> Vec<String> {
	let mut lines = vec![format!(
		"remove \"{key}\" (was: {})",
		statement_value_summary(removed)
	)];
	if let AstStatement::Assignment {
		value: AstValue::Block { items, .. },
		..
	} = removed
	{
		lines.extend(child_preview_lines(items, '-'));
	}
	lines
}

fn insert_node_summary(key: &str, statement: &AstStatement) -> Vec<String> {
	if statement_block_items(statement).is_some() {
		return block_patch_summary(format!("insert \"{key}\""), statement, '+');
	}
	vec![format!(
		"insert \"{key}\" = {}",
		statement_value_summary(statement)
	)]
}

fn block_patch_summary(prefix: String, statement: &AstStatement, marker: char) -> Vec<String> {
	let mut lines = vec![format!(
		"{prefix} ({} entries)",
		statement_entry_count(statement)
	)];
	if let Some(items) = statement_block_items(statement) {
		lines.extend(child_preview_lines(items, marker));
	}
	lines
}

fn statement_block_items(statement: &AstStatement) -> Option<&[AstStatement]> {
	match statement {
		AstStatement::Assignment {
			value: AstValue::Block { items, .. },
			..
		}
		| AstStatement::Item {
			value: AstValue::Block { items, .. },
			..
		} => Some(items),
		AstStatement::Assignment { .. }
		| AstStatement::Item { .. }
		| AstStatement::Comment { .. } => None,
	}
}

fn child_preview_lines(items: &[AstStatement], marker: char) -> Vec<String> {
	let entries: Vec<String> = items
		.iter()
		.filter_map(|statement| child_preview_line(statement, marker))
		.collect();
	let mut lines: Vec<String> = entries
		.iter()
		.take(MAX_CHILD_PREVIEW_ENTRIES)
		.cloned()
		.collect();
	let remaining = entries.len().saturating_sub(MAX_CHILD_PREVIEW_ENTRIES);
	if remaining > 0 {
		lines.push(format!("  {marker} … ({remaining} more)"));
	}
	lines
}

fn child_preview_line(statement: &AstStatement, marker: char) -> Option<String> {
	match statement {
		AstStatement::Assignment { key, value, .. } => {
			Some(format!("  {marker} {key} = {}", value_summary(value)))
		}
		AstStatement::Item { value, .. } => Some(format!("  {marker} {}", value_summary(value))),
		AstStatement::Comment { .. } => None,
	}
}

fn statement_value_summary(statement: &AstStatement) -> String {
	match statement {
		AstStatement::Assignment { value, .. } | AstStatement::Item { value, .. } => {
			value_summary(value)
		}
		AstStatement::Comment { text, .. } => format!("# {}", sanitize_summary(text)),
	}
}

fn statement_entry_count(statement: &AstStatement) -> usize {
	match statement {
		AstStatement::Assignment { value, .. } | AstStatement::Item { value, .. } => {
			value_entry_count(value)
		}
		AstStatement::Comment { .. } => 0,
	}
}

fn value_entry_count(value: &AstValue) -> usize {
	match value {
		AstValue::Block { items, .. } => items.len(),
		AstValue::Scalar { .. } => 1,
	}
}

fn value_summary(value: &AstValue) -> String {
	match value {
		AstValue::Scalar { value, .. } => {
			truncate_summary(&sanitize_summary(&scalar_summary(value)))
		}
		AstValue::Block { items, .. } => format!("{{ {} entries }}", items.len()),
	}
}

fn scalar_summary(value: &ScalarValue) -> String {
	match value {
		ScalarValue::Identifier(value) | ScalarValue::Number(value) => value.clone(),
		ScalarValue::String(value) => format!("\"{value}\""),
		ScalarValue::Bool(value) => {
			if *value {
				"yes".to_string()
			} else {
				"no".to_string()
			}
		}
	}
}

fn sanitize_summary(value: &str) -> String {
	let mut out = String::new();
	for c in value.chars() {
		match c {
			'\n' => out.push_str("\\n"),
			'\r' => out.push_str("\\r"),
			'\t' => out.push_str("\\t"),
			c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
			c => out.push(c),
		}
	}
	out
}

fn truncate_summary(value: &str) -> String {
	let mut chars = value.chars();
	let truncated: String = chars.by_ref().take(MAX_SUMMARY_CHARS).collect();
	if chars.next().is_some() {
		format!("{truncated}…")
	} else {
		truncated
	}
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn string_summary_preserves_clausewitz_escape_text() {
		let value = AstValue::Scalar {
			value: ScalarValue::String(r#"gfx\\interface\\icon.dds: \"label\""#.to_string()),
			span: synthetic_span(),
		};

		assert_eq!(
			value_summary(&value),
			r#""gfx\\interface\\icon.dds: \"label\"""#
		);
	}
}