use std::collections::{HashMap, HashSet};
use crate::model::GamePath;
use crate::project::compute_conflict_id;
use super::super::patch::AstPath;
use super::address::{patch_kind, patch_raw_address};
use super::{
AttributedPatch, PatchAddress, PatchConflict, PatchMergeResult, PatchMergeStats,
PatchResolution,
};
use crate::merge::address_patch::conflict_view::build_decision_conflict_view;
use crate::merge::conflict_handler::{ConflictDecision, ConflictHandler};
use crate::merge::error::{MergeError, MergeErrorSubject};
use crate::merge::model::ExternalFileResolution;
pub(super) struct CrossKindConflict {
pub(super) address: PatchAddress,
pub(super) patches: Vec<AttributedPatch>,
pub(super) reason: String,
pub(super) split_addresses: Vec<PatchAddress>,
}
pub(super) fn detect_cross_kind_sibling_conflicts(
by_address: &HashMap<PatchAddress, Vec<AttributedPatch>>,
stats: &mut PatchMergeStats,
) -> Vec<CrossKindConflict> {
let mut by_raw: HashMap<(AstPath, String), Vec<&PatchAddress>> = HashMap::new();
for addr in by_address.keys() {
let Some(first) = by_address.get(addr).and_then(|patches| patches.first()) else {
continue;
};
let Some(raw) = patch_raw_address(&first.patch) else {
continue;
};
by_raw.entry(raw).or_default().push(addr);
}
let mut conflicts = Vec::new();
for ((path, key), addrs) in by_raw {
if addrs.len() < 2 {
continue;
}
let mut kinds: HashSet<&'static str> = HashSet::new();
let mut contributors: HashSet<&str> = HashSet::new();
for addr in &addrs {
for patch in by_address.get(*addr).into_iter().flatten() {
kinds.insert(patch_kind(&patch.patch));
contributors.insert(patch.mod_id.as_str());
}
}
if kinds.len() > 1 && contributors.len() > 1 {
let mut combined: Vec<AttributedPatch> = addrs
.iter()
.flat_map(|a| by_address.get(*a).cloned().unwrap_or_default())
.collect();
combined.sort_by(|a, b| {
a.precedence
.cmp(&b.precedence)
.then_with(|| a.mod_id.cmp(&b.mod_id))
});
let mut kind_list: Vec<&str> = kinds.iter().copied().collect();
kind_list.sort_unstable();
stats.conflict_patches += 1;
conflicts.push(CrossKindConflict {
address: PatchAddress {
path: path.clone(),
key: key.clone(),
},
patches: combined,
reason: format!(
"sibling mods produced incompatible patch kinds at the same key: {}",
kind_list.join(", ")
),
split_addresses: addrs.into_iter().cloned().collect(),
});
}
}
conflicts
}
pub(super) fn apply_conflict_decision(
result: &mut PatchMergeResult,
handler: &mut dyn ConflictHandler,
conflict_file: &GamePath,
address: PatchAddress,
patches: Vec<AttributedPatch>,
reason: String,
) -> Result<(), MergeError> {
let conflict_address = conflict_address_label(&address);
let conflict_id = compute_conflict_id(conflict_file, &address.path.join("/"), &address.key);
let conflict = PatchConflict { patches, reason };
let view = build_decision_conflict_view(
conflict_file,
&address,
&conflict,
conflict_id,
&HashMap::new(),
);
match handler.on_conflict(&view) {
ConflictDecision::Defer { record } => {
if let Some(record) = record {
result.handler_resolutions.push(record);
}
result.conflicts.push(PatchResolution::Conflict {
address,
patches: conflict.patches,
reason: conflict.reason,
});
}
ConflictDecision::PickCandidate { candidate, record } => {
let Some(chosen) = conflict.patches.get(candidate).cloned() else {
eprintln!(
"[foch] stale pick for {conflict_file}::{conflict_address}: candidate {candidate} no longer exists; deferring"
);
result.conflicts.push(PatchResolution::Conflict {
address,
patches: conflict.patches,
reason: conflict.reason,
});
return Ok(());
};
result.handler_resolved_count += 1;
if let Some(record) = record {
result.handler_resolutions.push(record);
}
result
.resolved
.push(PatchResolution::Resolved(chosen.patch));
}
ConflictDecision::UseFile(source_path) => {
result.handler_resolved_count += 1;
result.external_file_resolutions.insert(
conflict_file.to_owned(),
ExternalFileResolution::Live(source_path),
);
}
ConflictDecision::UseFrozenFile(source_path) => {
result.handler_resolved_count += 1;
result.external_file_resolutions.insert(
conflict_file.to_owned(),
ExternalFileResolution::Frozen(source_path),
);
}
ConflictDecision::KeepExisting => {
result.handler_resolved_count += 1;
result.keep_existing_paths.insert(conflict_file.to_owned());
}
ConflictDecision::Abort => {
return Err(MergeError::Validation {
subject: Some(MergeErrorSubject::Game(conflict_file.to_owned())),
message: format!(
"conflict handler aborted merge at {conflict_address}: {}",
conflict.reason
),
});
}
}
Ok(())
}
fn conflict_address_label(address: &PatchAddress) -> String {
if address.path.is_empty() {
return address.key.clone();
}
let mut path = address.path.join("/");
if !address.key.is_empty() {
path.push('/');
path.push_str(&address.key);
}
path
}