use super::file_filter::FileFilter;
use super::{
InputScriptCache, LoadedModSnapshot,
mod_snapshot::{build_transient_mod_snapshot, load_or_build_mod_snapshot},
};
use crate::game::eu4::Eu4;
use crate::game::eu4::base::snapshot::{
BaseSnapshotCurrentValidation, InstalledBaseSnapshot, InstalledBaseSnapshotIdentity,
base_game_mod_id, detect_game_version, installed_base_snapshot_identity,
load_installed_base_snapshot_from_identity, resolve_game_root, resolve_game_root_and_version,
};
use crate::game::eu4::content::load_rules::load_rules_for_version;
use crate::game::eu4::content::{
ContentFamilyDescriptor, ContentLoadPolicy, module_name_for_descriptor,
};
use crate::game::eu4::script::documents::classify_document_family;
use crate::input::config::Config;
use crate::input::request::{InputRequest, InputSource};
use crate::model::{
DocumentFamily, GamePath, GamePathBuf, GamePathError, MergePlanContributor, MergeUnitId,
ModCandidate, ProductInputManifest, ProductInputMod,
};
use crate::playset::ParseErrorKind;
use crate::playset::descriptor::{load_descriptor, load_launcher_descriptor};
use crate::playset::single_path_component;
use crate::playset::steam::{
SteamWorkshopCatalog, WorkshopInstallIdentity, steam_workshop_mod_path,
};
use crate::playset::{Playset, PlaysetEntry};
use crate::project::{Project, ProjectConfig, ProjectImportKind, ProjectMod};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::error::Error;
use std::ffi::OsStr;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InputResolveErrorKind {
PlaylistFormat,
Io,
}
#[derive(Clone, Debug)]
pub struct InputResolveError {
pub kind: InputResolveErrorKind,
pub path: PathBuf,
pub message: String,
}
impl fmt::Display for InputResolveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.path.display(), self.message)
}
}
impl Error for InputResolveError {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InputTargetRole {
Game,
Mod,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InputTarget {
pub path: PathBuf,
pub role: InputTargetRole,
pub mod_id: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolvedInputMod {
pub mod_id: String,
pub display_name: Option<String>,
pub steam_id: Option<String>,
pub root_path: Option<PathBuf>,
pub descriptor_error: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InputResolveSummary {
pub source_path: PathBuf,
pub game: Eu4,
pub game_root: Option<PathBuf>,
pub mods: Vec<ResolvedInputMod>,
}
#[derive(Clone, Debug)]
pub(crate) struct ResolvedInputContributor {
pub mod_id: String,
pub root_path: PathBuf,
pub relative_path: GamePathBuf,
pub precedence: usize,
pub is_base_game: bool,
pub is_synthetic_base: bool,
pub parse_ok_hint: Option<bool>,
pub mod_hash: Option<String>,
}
impl ResolvedInputContributor {
pub(crate) fn absolute_path(&self) -> PathBuf {
self.relative_path.to_path(&self.root_path)
}
pub(crate) fn is_planned_as(&self, planned: &MergePlanContributor) -> bool {
self.mod_id == planned.mod_id
&& self.precedence == planned.precedence
&& self.is_base_game == planned.is_base_game
}
}
#[derive(Clone, Debug)]
pub(crate) struct ResolvedInput {
pub playlist_path: PathBuf,
pub playlist: Playset,
pub game_version: Option<String>,
pub mods: Vec<ModCandidate>,
pub installed_base_snapshot: Option<InstalledBaseSnapshot>,
pub cache_game_version: Option<String>,
pub mod_snapshots: Vec<Option<LoadedModSnapshot>>,
pub script_cache: InputScriptCache,
pub file_inventory: BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>>,
pub verified_absent_base_paths: BTreeSet<GamePathBuf>,
pub requested_retained_paths: Option<BTreeSet<GamePathBuf>>,
pub effective_retained_paths: Option<BTreeSet<GamePathBuf>>,
}
impl ResolvedInput {
pub(crate) fn snapshot_parse_issues(
&self,
mod_id: &str,
path: &GamePath,
) -> Vec<&crate::model::ParseIssue> {
self.mods
.iter()
.zip(&self.mod_snapshots)
.filter(|(candidate, _)| candidate.mod_id == mod_id)
.filter_map(|(_, snapshot)| snapshot.as_ref())
.flat_map(|snapshot| &snapshot.semantic_index.parse_issues)
.filter(|issue| issue.path.as_game_path() == path)
.collect()
}
}
#[derive(Clone, Debug)]
pub(crate) struct InputInventory {
pub playlist_path: PathBuf,
pub playlist: Playset,
pub mods: Vec<ModCandidate>,
pub base_game_root: Option<PathBuf>,
pub mod_cache_game_version: Option<String>,
pub base_snapshot_identity: Option<InstalledBaseSnapshotIdentity>,
base_snapshot_current_validation: BaseSnapshotCurrentValidation,
pub cache_game_version: Option<String>,
pub snapshot_filter: FileFilter,
pub mod_hashes: Vec<Option<String>>,
pub product_input_manifest: Option<ProductInputManifest>,
pub requested_retained_paths: Option<BTreeSet<GamePathBuf>>,
}
impl InputInventory {
pub(crate) fn defer_base_snapshot_current_validation(&mut self) {
self.base_snapshot_current_validation = BaseSnapshotCurrentValidation::Deferred;
}
}
#[derive(Clone, Debug)]
struct LoadedInputSource {
source_path: PathBuf,
source_root: PathBuf,
playlist: Playset,
config: Config,
}
fn load_input_source(request: &InputRequest) -> Result<LoadedInputSource, InputResolveError> {
match &request.source {
InputSource::DlcLoad(path) => {
load_dlc_load_source(path, &request.config, request.preloaded_playset.as_ref())
}
InputSource::Manifest(path) => load_manifest_source(path, &request.config),
}
}
fn load_dlc_load_source(
path: &Path,
config: &Config,
preloaded_playset: Option<&Playset>,
) -> Result<LoadedInputSource, InputResolveError> {
let playlist = match preloaded_playset {
Some(playset) => playset.clone(),
None => Playset::from_dlc_load(path).map_err(input_error_from_playset_parse)?,
};
Ok(LoadedInputSource {
source_path: path.to_path_buf(),
source_root: source_root_for(path),
playlist,
config: config.clone(),
})
}
fn load_manifest_source(
path: &Path,
config: &Config,
) -> Result<LoadedInputSource, InputResolveError> {
let manifest = Project::load_from_path(path).map_err(|err| InputResolveError {
kind: InputResolveErrorKind::PlaylistFormat,
path: path.to_path_buf(),
message: err.to_string(),
})?;
let project = manifest.project.as_ref().ok_or_else(|| InputResolveError {
kind: InputResolveErrorKind::PlaylistFormat,
path: path.to_path_buf(),
message: "foch.toml does not contain a [project] table".to_string(),
})?;
let source_root = source_root_for(path);
let mut effective_config = config.clone();
apply_project_config_overrides(&mut effective_config, project, &source_root, path)?;
let playlist = playset_from_project_config(project, &source_root, path)?;
Ok(LoadedInputSource {
source_path: path.to_path_buf(),
source_root,
playlist,
config: effective_config,
})
}
fn input_error_from_playset_parse(err: crate::playset::ParseError) -> InputResolveError {
InputResolveError {
kind: if matches!(err.kind, ParseErrorKind::Format) {
InputResolveErrorKind::PlaylistFormat
} else {
InputResolveErrorKind::Io
},
path: err.path.clone(),
message: match err.kind {
ParseErrorKind::Format => err.message,
ParseErrorKind::Io | ParseErrorKind::InvalidReplacePath => {
format!("failed to read Playset: {err}")
}
},
}
}
fn apply_project_config_overrides(
config: &mut Config,
project: &ProjectConfig,
source_root: &Path,
manifest_path: &Path,
) -> Result<(), InputResolveError> {
if let Some(path) = project.paradox_data_path.as_ref() {
config.paradox_data_path = Some(resolve_manifest_path(source_root, path));
}
if let Some(path) = project.game_path.as_ref() {
let game = project.game.as_ref().ok_or_else(|| InputResolveError {
kind: InputResolveErrorKind::PlaylistFormat,
path: manifest_path.to_path_buf(),
message: "[project].game_path requires [project].game".to_string(),
})?;
config.game_path.insert(
game.key().to_string(),
resolve_manifest_path(source_root, path),
);
}
Ok(())
}
fn playset_from_project_config(
project: &ProjectConfig,
source_root: &Path,
manifest_path: &Path,
) -> Result<Playset, InputResolveError> {
let mut game = project.game;
let mut mods = Vec::new();
let mut next_position = 0usize;
for import in &project.imports {
match import.kind {
ProjectImportKind::DlcLoad => {
let import_path = resolve_manifest_path(source_root, &import.path);
let imported =
Playset::from_dlc_load(&import_path).map_err(input_error_from_playset_parse)?;
let import_root = source_root_for(&import_path);
if game.is_none() {
game = Some(imported.game);
}
for mut entry in imported.mods {
if entry.root_path.is_none()
&& let Some(steam_id) =
entry.steam_id.as_deref().and_then(single_path_component)
{
entry.root_path = resolve_mod_from_ugc_descriptor(&import_root, steam_id)?;
}
entry.position = Some(next_position);
next_position += 1;
mods.push(entry);
}
}
}
}
for manifest_mod in &project.mods {
if !manifest_mod.enabled {
continue;
}
let entry = playset_entry_from_project_mod(
manifest_mod,
source_root,
next_position,
manifest_path,
)?;
next_position += 1;
remove_duplicate_entries(&mut mods, &entry);
mods.push(entry);
}
let game = game.unwrap_or(Eu4);
Ok(Playset {
game,
name: manifest_path
.file_stem()
.and_then(|stem| stem.to_str())
.filter(|stem| !stem.is_empty())
.unwrap_or("input")
.to_string(),
mods,
})
}
fn playset_entry_from_project_mod(
manifest_mod: &ProjectMod,
source_root: &Path,
next_position: usize,
manifest_path: &Path,
) -> Result<PlaysetEntry, InputResolveError> {
let root_path = manifest_mod
.path
.as_ref()
.map(|path| resolve_manifest_path(source_root, path));
let steam_id = manifest_mod
.steam_id
.as_ref()
.map(|steam_id| steam_id.trim().to_string())
.filter(|steam_id| !steam_id.is_empty());
let id = manifest_mod
.id
.as_ref()
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty());
if let Some(steam_id) = steam_id.as_deref()
&& single_path_component(steam_id).is_none()
{
return Err(InputResolveError {
kind: InputResolveErrorKind::PlaylistFormat,
path: manifest_path.to_path_buf(),
message: format!(
"[[project.mods]] steam_id `{steam_id}` must be a single plain path component"
),
});
}
if root_path.is_none() && steam_id.is_none() {
return Err(InputResolveError {
kind: InputResolveErrorKind::PlaylistFormat,
path: manifest_path.to_path_buf(),
message: "[[project.mods]] entries require at least one of path or steam_id"
.to_string(),
});
}
let display_name = id
.clone()
.or_else(|| steam_id.as_ref().map(|steam_id| format!("ugc_{steam_id}")));
Ok(PlaysetEntry {
id,
display_name,
enabled: true,
position: Some(manifest_mod.position.unwrap_or(next_position)),
steam_id,
root_path,
workshop_identity: manifest_mod.workshop_identity.clone(),
})
}
fn remove_duplicate_entries(entries: &mut Vec<PlaysetEntry>, explicit: &PlaysetEntry) {
let keys = playlist_entry_identity_keys(explicit);
if keys.is_empty() {
return;
}
entries.retain(|entry| {
playlist_entry_identity_keys(entry)
.iter()
.all(|key| !keys.contains(key))
});
}
#[derive(Debug, Eq, Hash, PartialEq)]
enum PlaylistEntryIdentity<'a> {
Steam(&'a str),
Id(&'a str),
Root(&'a Path),
}
fn playlist_entry_identity_keys(entry: &PlaysetEntry) -> HashSet<PlaylistEntryIdentity<'_>> {
let mut keys = HashSet::new();
if let Some(steam_id) = entry
.steam_id
.as_deref()
.filter(|value| !value.trim().is_empty())
{
keys.insert(PlaylistEntryIdentity::Steam(steam_id));
}
if let Some(id) = entry.id.as_deref().filter(|value| !value.trim().is_empty()) {
keys.insert(PlaylistEntryIdentity::Id(id));
}
if let Some(root) = entry.root_path.as_deref() {
keys.insert(PlaylistEntryIdentity::Root(root));
}
keys
}
fn source_root_for(path: &Path) -> PathBuf {
path.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf)
}
fn resolve_manifest_path(source_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
source_root.join(path)
}
}
pub(crate) fn build_input_inventory(
request: &InputRequest,
include_game_base: bool,
) -> Result<InputInventory, InputResolveError> {
build_input_inventory_for_paths(request, include_game_base, None)
}
pub fn resolve_input_summary(
request: &InputRequest,
) -> Result<InputResolveSummary, InputResolveError> {
let loaded = load_input_source(request)?;
let mut mods =
build_mod_candidates_metadata(&loaded.source_root, &loaded.config, &loaded.playlist)?;
for mod_item in &mut mods {
load_mod_candidate_descriptor(mod_item)?;
}
let game_root = resolve_game_root(&loaded.config, &loaded.playlist.game);
Ok(InputResolveSummary {
source_path: loaded.source_path,
game: loaded.playlist.game,
game_root,
mods: mods
.into_iter()
.map(|mod_item| ResolvedInputMod {
mod_id: mod_item.mod_id,
display_name: mod_item.entry.display_name,
steam_id: mod_item.entry.steam_id,
root_path: mod_item.root_path,
descriptor_error: mod_item.descriptor_error,
})
.collect(),
})
}
pub fn resolve_input_targets(
request: &InputRequest,
include_game_root: bool,
) -> Result<Vec<InputTarget>, InputResolveError> {
let summary = resolve_input_summary(request)?;
let mut targets = Vec::new();
if include_game_root
&& let Some(path) = summary.game_root
&& path.is_dir()
{
targets.push(InputTarget {
path,
role: InputTargetRole::Game,
mod_id: None,
});
}
for mod_item in summary.mods {
if let Some(path) = mod_item.root_path.filter(|path| path.is_dir()) {
targets.push(InputTarget {
path,
role: InputTargetRole::Mod,
mod_id: Some(mod_item.mod_id),
});
}
}
Ok(targets)
}
pub fn resolve_product_input_manifest(
request: &InputRequest,
) -> Result<ProductInputManifest, InputResolveError> {
let loaded = load_input_source(request)?;
let mut entries = loaded.playlist.mods.clone();
entries.sort_by_key(|entry| entry.position.unwrap_or(usize::MAX));
let mods = entries
.iter()
.enumerate()
.map(|(index, entry)| {
let mod_id = mod_id_for_entry(entry);
let identity = entry
.workshop_identity
.clone()
.ok_or_else(|| InputResolveError {
kind: InputResolveErrorKind::Io,
path: loaded.source_path.clone(),
message: format!("product input {mod_id} has no trusted Workshop ACF identity"),
})?;
let root =
resolve_mod_root(&loaded.source_root, &loaded.config, &loaded.playlist, entry)?;
validate_workshop_lease(
&mod_id,
entry.steam_id.as_deref(),
root.as_deref(),
&identity,
)
.map_err(|error| InputResolveError {
kind: InputResolveErrorKind::Io,
path: root.unwrap_or_else(|| loaded.source_path.clone()),
message: error.to_string(),
})?;
Ok(ProductInputMod {
mod_id,
precedence: index + 1,
workshop_identity: identity,
})
})
.collect::<Result<Vec<_>, InputResolveError>>()?;
Ok(ProductInputManifest::new(mods))
}
fn retained_definition_modules(
game: &Eu4,
requested_paths: &BTreeSet<GamePathBuf>,
) -> BTreeMap<MergeUnitId, u32> {
let profile = game;
requested_paths
.iter()
.filter_map(|path| {
let descriptor = profile.classify_content_family(path)?;
let ContentLoadPolicy::DefinitionModule(policy) = descriptor.load_policy else {
return None;
};
Some((
MergeUnitId {
family_id: descriptor.id.as_str().to_string(),
module_name: module_name_for_descriptor(path, descriptor),
},
policy.policy_version,
))
})
.collect()
}
fn expand_retained_paths_for_game<'a>(
game: &Eu4,
game_version: Option<&str>,
requested_paths: Option<&BTreeSet<GamePathBuf>>,
available_paths: impl IntoIterator<Item = &'a GamePath>,
) -> Result<Option<BTreeSet<GamePathBuf>>, String> {
let Some(requested_paths) = requested_paths else {
return Ok(None);
};
let mut effective = requested_paths.clone();
let selected_modules = retained_definition_modules(game, requested_paths);
let rules = game_version.and_then(load_rules_for_version);
let mut selected_databases: BTreeSet<&str> = BTreeSet::new();
if let Some(rules) = rules {
for path in requested_paths {
if let Some(database) = rules.database_for(path)? {
selected_databases.insert(database);
}
}
}
let profile = game;
for game_path in available_paths {
if let Some(database) = rules
.map(|rules| rules.database_for(game_path))
.transpose()?
.flatten()
{
if selected_databases.contains(database) {
effective.insert(game_path.to_owned());
}
continue;
}
let Some(descriptor) = profile.classify_content_family(game_path) else {
continue;
};
let ContentLoadPolicy::DefinitionModule(policy) = descriptor.load_policy else {
continue;
};
if rules
.map(|rules| rules.database_for(policy.output_path))
.transpose()?
.flatten()
.is_some()
{
continue;
}
let module = MergeUnitId {
family_id: descriptor.id.as_str().to_string(),
module_name: module_name_for_descriptor(game_path, descriptor),
};
if selected_modules.contains_key(&module) {
effective.insert(game_path.to_owned());
}
}
Ok(Some(effective))
}
pub(crate) fn build_input_inventory_for_paths(
request: &InputRequest,
include_game_base: bool,
retained_paths: Option<&BTreeSet<GamePathBuf>>,
) -> Result<InputInventory, InputResolveError> {
let loaded = load_input_source(request)?;
let LoadedInputSource {
source_path,
source_root,
playlist,
config,
} = loaded;
let snapshot_filter = match FileFilter::new(playlist.game, &config.extra_ignore_patterns) {
Ok(filter) => filter,
Err(message) => {
tracing::warn!(target: "crate::input::resolve", message, "falling back to filter without extra ignore globs");
FileFilter::for_game(playlist.game)
}
};
let mods = build_mod_candidates_metadata(&source_root, &config, &playlist)?;
let unavailable: Vec<String> = mods
.iter()
.filter(|item| item.root_path.as_ref().is_none_or(|root| !root.is_dir()))
.map(|item| match &item.root_path {
Some(root) => format!("{} ({})", item.mod_id, root.display()),
None => item.mod_id.clone(),
})
.collect();
if !unavailable.is_empty() {
return Err(InputResolveError {
kind: InputResolveErrorKind::Io,
path: source_path.clone(),
message: format!(
"unavailable enabled mod inputs: {}; install them or explicitly disable/remove them from the playset",
unavailable.join(", ")
),
});
}
let optional_game_root = resolve_game_root(&config, &playlist.game);
let (base_game_root, mod_cache_game_version) = if include_game_base {
let (game_root, game_version) = resolve_game_root_and_version(&config, &playlist.game)
.map_err(|message| InputResolveError {
kind: InputResolveErrorKind::Io,
path: source_path.clone(),
message,
})?;
(Some(game_root), Some(game_version))
} else {
(
None,
optional_game_root
.as_ref()
.and_then(|game_root| detect_game_version(game_root)),
)
};
if let Some(expected) = request.expected_game_root.as_ref() {
let actual = base_game_root.as_ref().or(optional_game_root.as_ref());
match actual {
Some(actual) if actual == expected => {}
Some(actual) => {
return Err(InputResolveError {
kind: InputResolveErrorKind::Io,
path: source_path.clone(),
message: format!(
"inspected EU4 game root changed: expected {}, resolved {}",
expected.display(),
actual.display()
),
});
}
None => {
return Err(InputResolveError {
kind: InputResolveErrorKind::Io,
path: source_path.clone(),
message: format!(
"inspected EU4 game root is no longer available: {}",
expected.display()
),
});
}
}
}
let base_snapshot_identity = if let (Some(game_root), Some(game_version)) =
(base_game_root.as_ref(), mod_cache_game_version.as_ref())
{
if let Some(lease) = request.base_snapshot_lease.as_ref() {
Some(lease.clone())
} else {
Some(
installed_base_snapshot_identity(playlist.game.key(), game_version)
.map_err(|message| InputResolveError {
kind: InputResolveErrorKind::Io,
path: source_path.clone(),
message,
})?
.ok_or_else(|| InputResolveError {
kind: InputResolveErrorKind::Io,
path: source_path.clone(),
message: missing_base_data_message(&playlist.game, game_version, game_root),
})?,
)
}
} else {
None
};
if request.base_snapshot_lease.is_some() && base_snapshot_identity.is_none() {
return Err(InputResolveError {
kind: InputResolveErrorKind::Io,
path: source_path.clone(),
message:
"an exact base snapshot lease was supplied, but game-base resolution is disabled"
.to_string(),
});
}
if let Some(expected) = request.expected_base_snapshot_identity.as_deref() {
let Some(actual) = base_snapshot_identity.as_ref() else {
return Err(InputResolveError {
kind: InputResolveErrorKind::Io,
path: source_path.clone(),
message: format!(
"expected base snapshot identity {expected}, but game-base resolution is disabled"
),
});
};
if actual.as_label() != expected {
return Err(InputResolveError {
kind: InputResolveErrorKind::Io,
path: source_path.clone(),
message: format!(
"installed base snapshot identity mismatch: expected {expected}, found {actual}"
),
});
}
}
let cache_game_version = mod_cache_game_version.as_ref().map(|version| {
let mut identity = format!("{} {version}", playlist.game.key());
if let Some(base_snapshot_identity) = base_snapshot_identity.as_ref() {
identity.push_str(" base=");
identity.push_str(&base_snapshot_identity.as_label());
}
identity
});
for mod_item in &mods {
validate_workshop_input(mod_item).map_err(|error| InputResolveError {
kind: InputResolveErrorKind::Io,
path: mod_item
.root_path
.clone()
.unwrap_or_else(|| source_path.clone()),
message: format!(
"failed to validate Workshop ACF identity for {}: {error}",
mod_item.mod_id
),
})?;
}
let mod_hashes = mods
.iter()
.map(|mod_item| {
semantic_snapshot_key(&playlist.game, &config.extra_ignore_patterns, mod_item)
})
.collect::<Vec<_>>();
let product_input_manifest = mods
.iter()
.enumerate()
.map(|(index, mod_item)| {
mod_item
.workshop_identity
.clone()
.map(|workshop_identity| ProductInputMod {
mod_id: mod_item.mod_id.clone(),
precedence: index + 1,
workshop_identity,
})
})
.collect::<Option<Vec<_>>>()
.map(ProductInputManifest::new);
Ok(InputInventory {
playlist_path: source_path,
playlist,
mods,
base_game_root,
mod_cache_game_version,
base_snapshot_identity,
base_snapshot_current_validation: if request.base_snapshot_lease.is_some() {
BaseSnapshotCurrentValidation::Deferred
} else {
BaseSnapshotCurrentValidation::Immediate
},
cache_game_version,
snapshot_filter,
mod_hashes,
product_input_manifest,
requested_retained_paths: retained_paths.cloned(),
})
}
pub(crate) fn resolve_input(
request: &InputRequest,
include_game_base: bool,
) -> Result<ResolvedInput, InputResolveError> {
let inventory = build_input_inventory(request, include_game_base)?;
resolve_input_from_inventory(inventory)
}
pub(crate) fn resolve_input_from_inventory(
inventory: InputInventory,
) -> Result<ResolvedInput, InputResolveError> {
let InputInventory {
playlist_path,
playlist,
mut mods,
base_game_root,
mod_cache_game_version,
base_snapshot_identity,
base_snapshot_current_validation,
cache_game_version,
snapshot_filter,
mod_hashes,
product_input_manifest: _,
requested_retained_paths,
} = inventory;
let installed_base_snapshot = match (
base_game_root.as_ref(),
mod_cache_game_version.as_ref(),
base_snapshot_identity.as_ref(),
) {
(Some(_game_root), Some(game_version), Some(identity)) => Some(
load_installed_base_snapshot_from_identity(
playlist.game.key(),
game_version,
identity,
base_snapshot_current_validation,
)
.map_err(|message| InputResolveError {
kind: InputResolveErrorKind::Io,
path: playlist_path.clone(),
message,
})?,
),
(Some(game_root), Some(game_version), None) => {
return Err(InputResolveError {
kind: InputResolveErrorKind::Io,
path: playlist_path.clone(),
message: missing_base_data_message(&playlist.game, game_version, game_root),
});
}
_ => None,
};
let mut mod_snapshots: Vec<Option<LoadedModSnapshot>> = mods
.iter()
.enumerate()
.map(|(idx, mod_item)| {
load_or_build_mod_snapshot(
playlist.game.key(),
mod_item,
&snapshot_filter,
mod_hashes.get(idx).and_then(|hash| hash.as_deref()),
)
.map_err(|error| InputResolveError {
kind: InputResolveErrorKind::Io,
path: mod_item
.root_path
.clone()
.unwrap_or_else(|| playlist_path.clone()),
message: format!(
"failed to load semantic snapshot for {}: {error}",
mod_item.mod_id
),
})
})
.collect::<Result<_, _>>()?;
for mod_item in &mut mods {
load_mod_candidate_descriptor(mod_item)?;
}
for (mod_item, snapshot) in mods.iter_mut().zip(mod_snapshots.iter()) {
if let Some(snapshot) = snapshot {
mod_item.files = snapshot.inventory_paths.clone();
}
}
let mut available_paths = mods
.iter()
.flat_map(|mod_item| mod_item.files.iter())
.map(GamePathBuf::as_game_path)
.collect::<Vec<_>>();
if let Some(base_snapshot) = installed_base_snapshot.as_ref() {
available_paths.extend(
base_snapshot
.snapshot
.inventory_paths
.iter()
.map(GamePathBuf::as_game_path),
);
}
let effective_retained_paths = expand_retained_paths_for_game(
&playlist.game,
mod_cache_game_version.as_deref(),
requested_retained_paths.as_ref(),
available_paths,
)
.map_err(|message| InputResolveError {
kind: InputResolveErrorKind::Io,
path: playlist_path.clone(),
message,
})?;
if let Some(effective_retained_paths) = effective_retained_paths.as_ref() {
for mod_item in &mut mods {
mod_item
.files
.retain(|relative| effective_retained_paths.contains(relative));
}
for (mod_item, snapshot) in mods.iter().zip(mod_snapshots.iter_mut()) {
let Some(full_snapshot) = snapshot.as_ref() else {
continue;
};
if mod_item.files == full_snapshot.inventory_paths {
continue;
}
*snapshot = build_transient_mod_snapshot(mod_item, &snapshot_filter, full_snapshot)
.map_err(|error| InputResolveError {
kind: InputResolveErrorKind::Io,
path: mod_item
.root_path
.clone()
.unwrap_or_else(|| playlist_path.clone()),
message: format!(
"failed to build retained semantic snapshot for {}: {error}",
mod_item.mod_id
),
})?;
}
}
let mut file_inventory = build_file_inventory(
&playlist,
&mods,
&mod_snapshots,
base_game_root.as_ref(),
installed_base_snapshot.as_ref(),
&mod_hashes,
effective_retained_paths.as_ref(),
);
let verified_absent_base_paths = verify_absent_semantic_bases(
&playlist,
base_game_root.as_deref(),
installed_base_snapshot.is_some(),
&file_inventory,
)?;
inject_synthetic_bases(&mut file_inventory);
let script_cache = InputScriptCache::from_parts(
&mods,
&mod_snapshots,
installed_base_snapshot.as_ref(),
base_game_root.as_deref(),
)
.map_err(|message| InputResolveError {
kind: InputResolveErrorKind::Io,
path: playlist_path.clone(),
message,
})?;
Ok(ResolvedInput {
playlist_path,
playlist,
game_version: mod_cache_game_version,
mods,
installed_base_snapshot,
cache_game_version,
mod_snapshots,
script_cache,
file_inventory,
verified_absent_base_paths,
requested_retained_paths,
effective_retained_paths,
})
}
fn verify_absent_semantic_bases(
playlist: &Playset,
base_game_root: Option<&Path>,
base_snapshot_loaded: bool,
file_inventory: &BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>>,
) -> Result<BTreeSet<GamePathBuf>, InputResolveError> {
let Some(root) = base_game_root else {
return Ok(BTreeSet::new());
};
let profile = &playlist.game;
if !base_snapshot_loaded {
return Ok(BTreeSet::new());
}
let mut absent = BTreeSet::new();
for (relative, contributors) in file_inventory {
let descriptor = profile.classify_content_family(relative);
if classify_document_family(relative) != Some(DocumentFamily::Clausewitz)
|| !descriptor.is_some_and(ContentFamilyDescriptor::supports_verified_empty_file_base)
|| contributors
.iter()
.any(|contributor| contributor.is_base_game)
|| contributors
.iter()
.filter(|contributor| !contributor.is_base_game)
.count() < 2
{
continue;
}
let absolute = relative.to_path(root);
match fs::symlink_metadata(&absolute) {
Ok(_) => {
return Err(InputResolveError {
kind: InputResolveErrorKind::Io,
path: absolute,
message: format!(
"installed base snapshot omits the existing semantic merge base {relative}; rebuild and reinstall base data before merging"
),
});
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
absent.insert(relative.clone());
}
Err(error) => {
return Err(InputResolveError {
kind: InputResolveErrorKind::Io,
path: absolute,
message: format!(
"failed to verify whether semantic merge base {relative} exists: {error}"
),
});
}
}
}
Ok(absent)
}
fn missing_base_data_message(game: &Eu4, game_version: &str, game_root: &Path) -> String {
format!(
"missing installed base data for {} {}; run `foch data install {} --game-version auto` or `foch data build {} --from-game-path {} --game-version auto --install`, or use --no-game-base",
game.key(),
game_version,
game.key(),
game.key(),
game_root.display()
)
}
pub(crate) fn build_mod_candidates_metadata(
source_root: &Path,
config: &Config,
playlist: &Playset,
) -> Result<Vec<ModCandidate>, InputResolveError> {
let mut entries = playlist.mods.clone();
entries.sort_by_key(|entry| entry.position.unwrap_or(usize::MAX));
entries
.into_iter()
.filter(|entry| entry.enabled)
.map(|entry| {
let mod_id = mod_id_for_entry(&entry);
let root_path = resolve_mod_root(source_root, config, playlist, &entry)?;
let descriptor_path = root_path.as_ref().map(|path| path.join("descriptor.mod"));
let workshop_identity = entry.workshop_identity.clone();
Ok(ModCandidate {
entry,
mod_id,
root_path,
descriptor_path,
descriptor: None,
workshop_identity,
descriptor_error: None,
files: Vec::new(),
})
})
.collect()
}
fn load_mod_candidate_descriptor(mod_item: &mut ModCandidate) -> Result<(), InputResolveError> {
let (descriptor, descriptor_error) = match mod_item.descriptor_path.as_ref() {
Some(path) if path.exists() => match load_descriptor(path) {
Ok(descriptor) => (Some(descriptor), None),
Err(error) if error.kind == ParseErrorKind::InvalidReplacePath => {
return Err(InputResolveError {
kind: InputResolveErrorKind::Io,
path: path.clone(),
message: format!(
"mod {} descriptor {}: {}",
mod_item.mod_id,
path.display(),
error.message
),
});
}
Err(error) => (None, Some(error.to_string())),
},
Some(path) => (None, Some(format!("{} does not exist", path.display()))),
None => (None, None),
};
mod_item.descriptor = descriptor;
mod_item.descriptor_error = descriptor_error;
Ok(())
}
fn mod_id_for_entry(entry: &PlaysetEntry) -> String {
entry
.steam_id
.clone()
.filter(|value| !value.trim().is_empty())
.or_else(|| entry.id.clone().filter(|value| !value.trim().is_empty()))
.unwrap_or_else(|| "<missing-steam-id>".to_string())
}
fn validate_workshop_input(mod_item: &ModCandidate) -> Result<(), io::Error> {
let Some(expected) = mod_item.workshop_identity.as_ref() else {
return Ok(());
};
validate_workshop_lease(
&mod_item.mod_id,
mod_item.entry.steam_id.as_deref(),
mod_item.root_path.as_deref(),
expected,
)
}
fn validate_workshop_lease(
mod_id: &str,
steam_id: Option<&str>,
root: Option<&Path>,
expected: &WorkshopInstallIdentity,
) -> Result<(), io::Error> {
let root = root.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("Workshop mod {mod_id} has no content root"),
)
})?;
if steam_id != Some(expected.workshop_id.as_str()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"Workshop identity {} does not match steam_id for {}",
expected.workshop_id, mod_id
),
));
}
let content_root = root.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("Workshop root has no content parent: {}", root.display()),
)
})?;
let workshop_root = content_root
.parent()
.and_then(Path::parent)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("Workshop root has no ACF parent: {}", root.display()),
)
})?;
let manifest_path = workshop_root.join(format!("appworkshop_{}.acf", expected.app_id));
let catalog = SteamWorkshopCatalog::from_override(
expected.app_id,
content_root.to_path_buf(),
manifest_path,
)
.map_err(|error| io::Error::other(error.to_string()))?;
let observed = catalog
.require_item(&expected.workshop_id)
.map_err(|error| io::Error::other(error.to_string()))?;
let observed_identity = observed
.identity()
.map_err(|error| io::Error::other(error.to_string()))?;
let canonical_root = fs::canonicalize(root)?;
if observed_identity != *expected || observed.content_path != canonical_root {
return Err(io::Error::other(format!(
"Workshop ACF identity changed for {}: expected {:?}, observed {:?}",
mod_id, expected, observed_identity
)));
}
Ok(())
}
fn semantic_snapshot_key(
game: &Eu4,
extra_ignore_patterns: &[String],
mod_item: &ModCandidate,
) -> Option<String> {
let identity: &WorkshopInstallIdentity = mod_item.workshop_identity.as_ref()?;
let mut hasher = blake3::Hasher::new();
update_cache_key_field(&mut hasher, "foch-workshop-semantic-snapshot-v2");
update_cache_key_field(&mut hasher, game.key());
let mut ignore_patterns = extra_ignore_patterns.to_vec();
ignore_patterns.sort();
ignore_patterns.dedup();
hasher.update(&(ignore_patterns.len() as u64).to_le_bytes());
for pattern in ignore_patterns {
update_cache_key_field(&mut hasher, &pattern);
}
update_cache_key_field(&mut hasher, &mod_item.mod_id);
hasher.update(&identity.app_id.to_le_bytes());
update_cache_key_field(&mut hasher, identity.workshop_id.as_str());
update_cache_key_field(&mut hasher, identity.manifest_id.as_str());
Some(hasher.finalize().to_hex().to_string())
}
fn update_cache_key_field(hasher: &mut blake3::Hasher, value: &str) {
hasher.update(&(value.len() as u64).to_le_bytes());
hasher.update(value.as_bytes());
}
fn resolve_mod_root(
source_root: &Path,
config: &Config,
playlist: &Playset,
entry: &PlaysetEntry,
) -> Result<Option<PathBuf>, InputResolveError> {
if let Some(path) = entry.root_path.as_ref() {
return Ok(Some(path.clone()));
}
let mut candidates = Vec::new();
if let Some(steam_id) = entry.steam_id.as_deref().and_then(single_path_component) {
candidates.push(source_root.join(steam_id));
candidates.push(source_root.join(format!("mod_{steam_id}")));
if let Some(root) = resolve_mod_from_ugc_descriptor(source_root, steam_id)? {
candidates.push(root);
}
candidates.push(source_root.join("mod").join(steam_id));
candidates.push(source_root.join("mod").join(format!("ugc_{steam_id}")));
if let Some(path) = config.paradox_data_path.as_ref() {
for game_data_dir in paradox_game_data_dirs(path, &playlist.game) {
if let Some(root) = resolve_mod_from_ugc_descriptor(&game_data_dir, steam_id)? {
candidates.push(root);
}
candidates.push(game_data_dir.join("mod").join(steam_id));
candidates.push(game_data_dir.join("mod").join(format!("ugc_{steam_id}")));
}
}
if let Some(steam_root) = config.steam_root_path.as_ref() {
for app_id in playlist.game.steam_app_ids() {
if let Some(path) = steam_workshop_mod_path(steam_root, *app_id, steam_id) {
candidates.push(path);
}
candidates.push(
steam_root
.join("steamapps")
.join("workshop")
.join("content")
.join(app_id.to_string())
.join(steam_id),
);
}
}
}
if let Some(name) = entry.display_name.as_deref() {
for name in [name.to_string(), name.replace(' ', "_")] {
if single_path_component(&name).is_some() {
candidates.push(source_root.join(name));
}
}
}
Ok(dedup_candidates(candidates)
.into_iter()
.find(|candidate| candidate.is_dir()))
}
pub(crate) fn dedup_candidates(candidates: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
candidates
.into_iter()
.filter(|candidate| seen.insert(candidate.clone()))
.collect()
}
fn paradox_game_data_dirs(base: &Path, game: &Eu4) -> Vec<PathBuf> {
let mut dirs = vec![base.to_path_buf()];
if let Some(game_dir_name) = game.paradox_data_dir_name() {
dirs.push(base.join(game_dir_name));
}
dedup_candidates(dirs)
}
fn resolve_mod_from_ugc_descriptor(
game_data_dir: &Path,
steam_id: &str,
) -> Result<Option<PathBuf>, InputResolveError> {
let metadata = game_data_dir
.join("mod")
.join(format!("ugc_{steam_id}.mod"));
match fs::symlink_metadata(&metadata) {
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(input_error_from_playset_parse(
crate::playset::ParseError::io(metadata, err),
));
}
Ok(_) => {}
}
let Some(path) = load_launcher_descriptor(&metadata)
.map_err(input_error_from_playset_parse)?
.path
else {
return Ok(None);
};
Ok(descriptor_path_candidates(game_data_dir, &path)
.into_iter()
.find(|candidate| candidate.is_dir()))
}
fn descriptor_path_candidates(game_data_dir: &Path, path: &Path) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if path.is_absolute() {
candidates.push(path.to_path_buf());
}
candidates.push(game_data_dir.join(path));
candidates.push(game_data_dir.join("mod").join(path));
dedup_candidates(candidates)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum InventoryOwner<'a> {
BaseGame,
Mod(&'a str),
}
#[derive(Debug)]
pub(crate) struct UnportableInventoryPath {
pub mod_id: Option<String>,
pub root: PathBuf,
pub physical: PathBuf,
pub source: GamePathError,
}
impl fmt::Display for UnportableInventoryPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.mod_id {
Some(mod_id) => write!(f, "mod {mod_id}")?,
None => f.write_str("base game")?,
}
write!(
f,
": {} under root {} has no portable game path: {}",
self.physical.display(),
self.root.display(),
self.source.kind
)
}
}
impl Error for UnportableInventoryPath {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
pub(crate) fn collect_relative_files(
root: &Path,
filter: &FileFilter,
owner: InventoryOwner<'_>,
) -> io::Result<Vec<GamePathBuf>> {
let loadable_roots = filter.game().loadable_content_roots();
let entries = WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|entry| {
if entry.depth() != 1 {
return true;
}
let Some(loadable_roots) = loadable_roots else {
return true;
};
entry.file_name().to_str().is_some_and(|name| {
loadable_roots
.iter()
.any(|root_name| name.eq_ignore_ascii_case(root_name))
})
})
.map(walk_entry);
collect_relative_files_from_entries(root, filter, owner, entries)
}
pub(crate) fn collect_relative_files_within(
root: &Path,
within: &[&[&str]],
filter: &FileFilter,
owner: InventoryOwner<'_>,
) -> io::Result<Vec<GamePathBuf>> {
collect_relative_files_from_entries(root, filter, owner, walk_within(root, within))
}
pub(crate) fn walk_within<'a>(
root: &'a Path,
within: &'a [&'a [&'a str]],
) -> impl Iterator<Item = io::Result<walkdir::DirEntry>> + 'a {
WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(move |entry| {
let Ok(relative) = entry.path().strip_prefix(root) else {
return false;
};
within.iter().any(|directory| {
relative
.components()
.zip(directory.iter())
.all(|(component, name)| component.as_os_str() == OsStr::new(name))
})
})
.map(walk_entry)
}
fn walk_entry(entry: walkdir::Result<walkdir::DirEntry>) -> io::Result<walkdir::DirEntry> {
entry.map_err(|error| {
let kind = error
.io_error()
.map_or(io::ErrorKind::Other, io::Error::kind);
io::Error::new(kind, error.to_string())
})
}
fn collect_relative_files_from_entries(
root: &Path,
filter: &FileFilter,
owner: InventoryOwner<'_>,
entries: impl IntoIterator<Item = io::Result<walkdir::DirEntry>>,
) -> io::Result<Vec<GamePathBuf>> {
let mut files = Vec::new();
for entry in entries {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.file_name() == Some(OsStr::new("descriptor.mod")) {
continue;
}
let relative = inventory_path(owner, root, path)?;
if filter.accepts(&relative) {
files.push(relative);
}
}
files.sort();
Ok(files)
}
fn inventory_path(
owner: InventoryOwner<'_>,
root: &Path,
physical: &Path,
) -> io::Result<GamePathBuf> {
GamePathBuf::from_physical(root, physical).map_err(|source| {
io::Error::new(
io::ErrorKind::InvalidData,
UnportableInventoryPath {
mod_id: match owner {
InventoryOwner::BaseGame => None,
InventoryOwner::Mod(mod_id) => Some(mod_id.to_string()),
},
root: root.to_path_buf(),
physical: physical.to_path_buf(),
source,
},
)
})
}
pub(crate) fn build_file_inventory(
playlist: &Playset,
mods: &[ModCandidate],
mod_snapshots: &[Option<LoadedModSnapshot>],
base_game_root: Option<&PathBuf>,
installed_base_snapshot: Option<&InstalledBaseSnapshot>,
mod_hashes: &[Option<String>],
retained_paths: Option<&BTreeSet<GamePathBuf>>,
) -> BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>> {
let mut inventory = BTreeMap::new();
let mut precedence = 0;
if let (Some(root), Some(snapshot)) = (base_game_root, installed_base_snapshot) {
let mod_id = base_game_mod_id(playlist.game.key());
let document_lookup = snapshot.snapshot.document_lookup();
for relative in &snapshot.snapshot.inventory_paths {
if retained_paths.is_some_and(|paths| !paths.contains(relative)) {
continue;
}
let document = document_lookup.get(&relative.as_game_path());
inventory
.entry(relative.clone())
.or_insert_with(Vec::new)
.push(ResolvedInputContributor {
mod_id: mod_id.clone(),
root_path: root.clone(),
relative_path: relative.clone(),
precedence,
is_base_game: true,
is_synthetic_base: false,
parse_ok_hint: document.map(|(_family, parse_ok)| *parse_ok),
mod_hash: None,
});
}
precedence += 1;
}
for (idx, mod_item) in mods.iter().enumerate() {
let Some(root) = mod_item.root_path.as_ref() else {
continue;
};
let parse_hints = mod_snapshots
.get(idx)
.and_then(|snapshot| snapshot.as_ref())
.map(|snapshot| &snapshot.document_parse_hints);
let mod_hash = mod_snapshots
.get(idx)
.and_then(|snapshot| snapshot.as_ref())
.and_then(|snapshot| snapshot.mod_hash.clone())
.or_else(|| mod_hashes.get(idx).cloned().flatten());
for relative in &mod_item.files {
let parse_ok_hint = parse_hints.and_then(|hints| hints.get(relative).copied());
inventory
.entry(relative.clone())
.or_insert_with(Vec::new)
.push(ResolvedInputContributor {
mod_id: mod_item.mod_id.clone(),
root_path: root.clone(),
relative_path: relative.clone(),
precedence,
is_base_game: false,
is_synthetic_base: false,
parse_ok_hint,
mod_hash: mod_hash.clone(),
});
}
precedence += 1;
}
inventory
}
pub(crate) fn inject_synthetic_bases(
file_inventory: &mut BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>>,
) {
for contributors in file_inventory.values_mut() {
if contributors.iter().any(|c| c.is_base_game) {
continue;
}
let non_base_count = contributors.iter().filter(|c| !c.is_base_game).count();
if non_base_count < 2 {
continue;
}
let Some(seed) = contributors
.iter()
.filter(|c| !c.is_base_game)
.min_by(|a, b| {
a.precedence
.cmp(&b.precedence)
.then_with(|| a.mod_id.cmp(&b.mod_id))
})
else {
continue;
};
let mut synthetic = seed.clone();
synthetic.is_synthetic_base = true;
synthetic.is_base_game = false;
synthetic.precedence = 0;
contributors.insert(0, synthetic);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use crate::model::GamePathErrorKind;
use crate::playset::steam::SteamId;
use std::fs;
use tempfile::TempDir;
fn descriptor_path_value(path: &Path) -> String {
crate::playset::descriptor::descriptor_path_text(path)
.expect("a fixture directory has descriptor text")
}
fn write_descriptor(root: &Path, name: &str, steam_id: Option<&str>) {
fs::create_dir_all(root).expect("create descriptor root");
let mut body = format!(
"name=\"{name}\"\npath=\"{}\"\n",
descriptor_path_value(root)
);
if let Some(steam_id) = steam_id {
body.push_str(&format!("remote_file_id=\"{steam_id}\"\n"));
}
fs::write(root.join("descriptor.mod"), body).expect("write descriptor");
}
fn write_dlc_load(paradox_dir: &Path, mods: &[(&str, &Path)]) {
fs::create_dir_all(paradox_dir.join("mod")).expect("create launcher mod dir");
let entries = mods
.iter()
.map(|(steam_id, _root)| format!("mod/ugc_{steam_id}.mod"))
.collect::<Vec<_>>();
let payload =
serde_json::json!({ "enabled_mods": entries, "disabled_dlcs": Vec::<String>::new() });
fs::write(
paradox_dir.join("dlc_load.json"),
serde_json::to_string_pretty(&payload).expect("serialize dlc_load"),
)
.expect("write dlc_load");
for (steam_id, root) in mods {
fs::write(
paradox_dir.join("mod").join(format!("ugc_{steam_id}.mod")),
format!(
"name=\"ugc_{steam_id}\"\npath=\"{}\"\nremote_file_id=\"{steam_id}\"\n",
descriptor_path_value(root)
),
)
.expect("write launcher descriptor");
}
}
fn request_for_manifest(path: &Path) -> InputRequest {
InputRequest::from_manifest_path(path.to_path_buf(), Config::default())
}
fn game_path(text: &str) -> GamePathBuf {
GamePathBuf::parse(text).expect("valid game path")
}
#[cfg(unix)]
fn unportable(error: &io::Error) -> &UnportableInventoryPath {
error
.get_ref()
.and_then(|source| source.downcast_ref::<UnportableInventoryPath>())
.unwrap_or_else(|| panic!("expected an unportable inventory path, got {error}"))
}
#[cfg(unix)]
fn write_backslash_alias_mod(root: &Path) -> PathBuf {
fs::create_dir_all(root.join("common").join("a")).expect("create nested directory");
fs::write(
root.join("common").join("a").join("b.txt"),
"nested = yes\n",
)
.expect("write nested file");
let literal = root.join("common").join(r"a\b.txt");
fs::write(&literal, "literal = yes\n").expect("write literal-backslash file");
literal
}
#[cfg(unix)]
#[test]
fn a_literal_backslash_name_fails_the_inventory_instead_of_aliasing_a_nested_file() {
let temp = TempDir::new().expect("tempdir");
let root = temp.path().join("mod");
let literal = write_backslash_alias_mod(&root);
let error = collect_relative_files(
&root,
&FileFilter::for_game(Eu4),
InventoryOwner::Mod("1001"),
)
.expect_err("a literal backslash has no portable game path");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
let diagnostic = unportable(&error);
assert_eq!(diagnostic.mod_id.as_deref(), Some("1001"));
assert_eq!(diagnostic.root, root);
assert_eq!(diagnostic.physical, literal);
assert_eq!(
diagnostic.source.kind,
GamePathErrorKind::ReservedCharacter {
component: r"a\b.txt".to_string(),
character: '\\',
}
);
let message = error.to_string();
assert!(message.starts_with("mod 1001: "), "{message}");
assert!(
message.contains(&literal.display().to_string()),
"{message}"
);
assert!(message.contains(&root.display().to_string()), "{message}");
}
#[cfg(unix)]
#[test]
fn a_literal_backslash_name_reaches_the_user_as_an_input_error_for_its_mod() {
let temp = TempDir::new().expect("tempdir");
let mod_root = temp.path().join("alias_mod");
write_descriptor(&mod_root, "Alias Mod", None);
write_backslash_alias_mod(&mod_root);
let manifest_path = temp.path().join("foch.toml");
fs::write(
&manifest_path,
r#"
[project]
game = "eu4"
[[project.mods]]
id = "alias_id"
path = "alias_mod"
"#,
)
.expect("write manifest");
let error = resolve_input(&request_for_manifest(&manifest_path), false)
.expect_err("an unportable mod file must fail input resolution");
assert_eq!(error.kind, InputResolveErrorKind::Io);
assert_eq!(error.path, mod_root);
assert!(error.message.contains("mod alias_id: "), "{error}");
assert!(
error
.message
.contains(&format!("under root {} has", mod_root.display())),
"{error}"
);
assert!(error.message.contains(r"a\b.txt"), "{error}");
assert!(error.message.contains("no portable game path"), "{error}");
}
#[test]
fn one_game_path_shipped_by_two_mods_is_one_entry_with_both_contributors_in_playset_order() {
let temp = TempDir::new().expect("tempdir");
let mods: [(&str, &str); 2] = [("zeta", "zeta = yes\n"), ("alpha", "alpha = yes\n")];
for (mod_id, content) in mods {
let root = temp.path().join(mod_id);
write_descriptor(&root, mod_id, None);
fs::create_dir_all(root.join("common").join("ideas")).expect("create ideas");
fs::write(root.join("common").join("ideas").join("x.txt"), content)
.expect("write idea file");
}
let manifest_path = temp.path().join("foch.toml");
fs::write(
&manifest_path,
r#"
[project]
game = "eu4"
[[project.mods]]
id = "zeta"
path = "zeta"
[[project.mods]]
id = "alpha"
path = "alpha"
"#,
)
.expect("write manifest");
let input = resolve_input(&request_for_manifest(&manifest_path), false)
.expect("two mods sharing a game path resolve");
let key = game_path("common/ideas/x.txt");
assert_eq!(input.file_inventory.keys().collect::<Vec<_>>(), vec![&key]);
let contributors = &input.file_inventory[&key];
assert_eq!(
contributors
.iter()
.map(|contributor| (
contributor.mod_id.as_str(),
contributor.precedence,
contributor.is_synthetic_base
))
.collect::<Vec<_>>(),
vec![("zeta", 0, true), ("zeta", 0, false), ("alpha", 1, false)],
"without a base game the first mod also seeds a synthetic base"
);
for contributor in contributors {
assert_eq!(contributor.relative_path, key);
let expected = mods
.iter()
.find(|(mod_id, _)| *mod_id == contributor.mod_id)
.map(|(_, content)| *content);
assert_eq!(
fs::read_to_string(contributor.absolute_path())
.ok()
.as_deref(),
expected,
"{} reads its own mod's file",
contributor.mod_id
);
}
}
#[cfg(unix)]
#[test]
fn distinct_non_utf8_names_are_rejected_instead_of_sharing_one_game_path() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let root = Path::new("/base-game");
let diagnostics = [b"\xff.txt".to_vec(), b"\xfe.txt".to_vec()].map(|name| {
let physical = root.join("common").join(OsString::from_vec(name));
let error = inventory_path(InventoryOwner::BaseGame, root, &physical)
.expect_err("a non-UTF-8 name has no portable game path");
let diagnostic = unportable(&error);
assert_eq!(diagnostic.mod_id, None);
assert_eq!(diagnostic.root, root);
assert_eq!(diagnostic.source.kind, GamePathErrorKind::NonUtf8);
assert!(error.to_string().starts_with("base game: "), "{error}");
diagnostic.physical.clone()
});
assert_ne!(diagnostics[0], diagnostics[1]);
}
#[test]
fn manifest_rejects_a_steam_id_that_is_not_one_path_component() {
let temp = TempDir::new().expect("tempdir");
let manifest_path = temp.path().join("foch.toml");
for bad in ["..", "../../x", "a/b"] {
fs::write(
&manifest_path,
format!("[project]\ngame = \"eu4\"\n\n[[project.mods]]\nsteam_id = \"{bad}\"\n"),
)
.expect("write manifest");
let err = resolve_input_summary(&request_for_manifest(&manifest_path))
.expect_err("a traversing steam id must fail");
assert_eq!(err.path, manifest_path, "{err}");
assert!(err.message.contains("single plain path component"), "{err}");
}
}
#[test]
fn a_display_name_that_leaves_the_data_directory_is_not_a_mod_root() {
let temp = TempDir::new().expect("tempdir");
let paradox_dir = temp.path().join("Europa Universalis IV");
fs::create_dir_all(paradox_dir.join("mod")).expect("create launcher mod dir");
fs::write(
paradox_dir.join("dlc_load.json"),
r#"{"enabled_mods":["mod/local.mod"],"disabled_dlcs":[]}"#,
)
.expect("write dlc_load");
for name in ["..", "../Europa Universalis IV", "mod/.."] {
fs::write(
paradox_dir.join("mod").join("local.mod"),
format!("name=\"{name}\"\n"),
)
.expect("write local descriptor");
let summary = resolve_input_summary(&InputRequest::from_playset_path(
paradox_dir.join("dlc_load.json"),
Config::default(),
))
.expect("resolve summary");
assert_eq!(summary.mods.len(), 1);
assert_eq!(summary.mods[0].root_path, None, "name {name:?}");
}
}
#[test]
fn a_relative_descriptor_path_is_tried_under_the_data_directory_and_its_mod_directory() {
let game_data_dir = Path::new("/paradox/Europa Universalis IV");
assert_eq!(
descriptor_path_candidates(game_data_dir, Path::new("mod/local_mod")),
vec![
game_data_dir.join("mod/local_mod"),
game_data_dir.join("mod").join("mod/local_mod"),
]
);
let absolute = std::env::temp_dir().join("workshop").join("1001");
assert_eq!(
descriptor_path_candidates(game_data_dir, &absolute),
vec![absolute],
"an absolute path is one candidate, not re-read under the data directory"
);
}
#[cfg(unix)]
#[test]
fn a_foreign_separator_in_a_descriptor_path_stays_part_of_the_name() {
let temp = TempDir::new().expect("tempdir");
let game_data_dir = temp.path().join("Europa Universalis IV");
fs::create_dir_all(game_data_dir.join("mod").join("local_mod")).expect("nested mod");
fs::write(
game_data_dir.join("mod").join("ugc_1001.mod"),
"name=\"Local\"\npath=\"mod\\local_mod\"\n",
)
.expect("write launcher descriptor");
assert_eq!(
resolve_mod_from_ugc_descriptor(&game_data_dir, "1001").expect("readable descriptor"),
None,
"the nested directory is not what the descriptor names"
);
let literal = game_data_dir.join(r"mod\local_mod");
fs::create_dir(&literal).expect("literal-backslash directory");
assert_eq!(
resolve_mod_from_ugc_descriptor(&game_data_dir, "1001").expect("readable descriptor"),
Some(literal)
);
}
#[cfg(unix)]
#[test]
fn physical_candidates_are_distinct_unless_the_host_parses_them_alike() {
let nested = PathBuf::from("/mods/a/b");
let literal = PathBuf::from(r"/mods/a\b");
assert_eq!(
dedup_candidates(vec![
nested.clone(),
literal.clone(),
nested.clone(),
PathBuf::from("/mods//a/b"),
]),
vec![nested.clone(), literal.clone()]
);
let entry = |root: &Path| PlaysetEntry {
root_path: Some(root.to_path_buf()),
..PlaysetEntry::default()
};
let mut entries = vec![entry(&literal)];
remove_duplicate_entries(&mut entries, &entry(&nested));
assert_eq!(entries.len(), 1, "distinct roots are distinct mods");
remove_duplicate_entries(&mut entries, &entry(&literal));
assert!(entries.is_empty(), "the same root is the same mod");
}
#[test]
fn relative_file_inventory_propagates_walk_errors() {
let filter = FileFilter::for_game(Eu4);
let injected_error = io::Error::new(
io::ErrorKind::PermissionDenied,
"injected directory traversal failure",
);
let error = collect_relative_files_from_entries(
Path::new("/synthetic-mod-root"),
&filter,
InventoryOwner::Mod("mod-a"),
[Err(injected_error)],
)
.expect_err("directory traversal errors must fail the inventory");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(error.to_string(), "injected directory traversal failure");
}
#[test]
fn semantic_snapshot_key_binds_acf_and_behavior_but_not_root_or_inventory() {
let identity = WorkshopInstallIdentity {
app_id: 236_850,
workshop_id: SteamId::new(1_001),
manifest_id: SteamId::new(2_001),
};
let candidate = |mod_id: &str, root: &str, files: Vec<&str>| ModCandidate {
entry: PlaysetEntry::default(),
mod_id: mod_id.to_string(),
root_path: Some(PathBuf::from(root)),
descriptor_path: None,
descriptor: None,
workshop_identity: Some(identity.clone()),
descriptor_error: None,
files: files.into_iter().map(game_path).collect(),
};
let first = candidate(
"mod-a",
"/first/workshop/root",
vec!["common/scripted_effects/shared.txt"],
);
let moved = candidate(
"mod-a",
"/second/workshop/root",
vec!["events/new.txt", "gfx/large.dds"],
);
let base_patterns = vec!["**/.DS_Store".to_string(), "*.bak".to_string()];
let reordered_patterns = vec![
"*.bak".to_string(),
"**/.DS_Store".to_string(),
"*.bak".to_string(),
];
assert_eq!(
semantic_snapshot_key(&Eu4, &base_patterns, &first),
semantic_snapshot_key(&Eu4, &reordered_patterns, &moved),
"root and discovered file-set changes must not precede an ACF-keyed lookup"
);
assert_ne!(
semantic_snapshot_key(&Eu4, &base_patterns, &first),
semantic_snapshot_key(
&Eu4,
&base_patterns,
&candidate("mod-b", "/first/workshop/root", Vec::new()),
)
);
assert_ne!(
semantic_snapshot_key(&Eu4, &base_patterns, &first),
semantic_snapshot_key(&Eu4, &["different/**".to_string()], &first,)
);
let mut updated_acf = first.clone();
updated_acf
.workshop_identity
.as_mut()
.expect("Workshop identity")
.manifest_id = SteamId::new(2_002);
assert_ne!(
semantic_snapshot_key(&Eu4, &base_patterns, &first),
semantic_snapshot_key(&Eu4, &base_patterns, &updated_acf)
);
}
#[cfg(unix)]
#[test]
fn relative_file_inventory_prunes_non_loadable_subtrees_before_descent() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().expect("tempdir");
let root = temp.path().join("mod");
let loadable = root.join("common/countries");
let poison = root.join("irrelevant/blocked");
fs::create_dir_all(&loadable).expect("create loadable root");
fs::create_dir_all(&poison).expect("create poison root");
fs::write(loadable.join("A.txt"), "AAA = { }\n").expect("write loadable file");
fs::write(poison.join("large.bin"), b"must not be visited").expect("write poison file");
fs::set_permissions(root.join("irrelevant"), fs::Permissions::from_mode(0o000))
.expect("poison irrelevant subtree");
let result = collect_relative_files(
&root,
&FileFilter::for_game(Eu4),
InventoryOwner::Mod("mod-a"),
);
fs::set_permissions(root.join("irrelevant"), fs::Permissions::from_mode(0o700))
.expect("restore irrelevant subtree");
assert_eq!(
result.expect("non-loadable subtree must be pruned"),
vec![game_path("common/countries/A.txt")]
);
}
#[test]
fn relative_file_inventory_is_in_byte_order_not_component_order() {
let temp = TempDir::new().expect("tempdir");
let root = temp.path().join("mod");
fs::create_dir_all(root.join("common").join("a")).expect("create nested directory");
fs::write(root.join("common").join("a").join("b.txt"), "b = { }\n")
.expect("write nested file");
fs::write(root.join("common").join("a-b.txt"), "a = { }\n").expect("write dashed file");
let files = collect_relative_files(
&root,
&FileFilter::for_game(Eu4),
InventoryOwner::Mod("mod-a"),
)
.expect("collect relative files");
assert_eq!(
files,
vec![game_path("common/a-b.txt"), game_path("common/a/b.txt")]
);
}
#[cfg(unix)]
#[test]
fn unportable_names_outside_loadable_roots_are_pruned_before_conversion() {
let temp = TempDir::new().expect("tempdir");
let root = temp.path().join("mod");
fs::create_dir_all(root.join("common")).expect("create loadable root");
fs::create_dir_all(root.join("irrelevant")).expect("create non-loadable root");
fs::write(root.join("common").join("kept.txt"), "k = { }\n").expect("write kept file");
fs::write(root.join(r"common\foo.txt"), "f = { }\n").expect("write root-level name");
fs::write(root.join("irrelevant").join(r"x\y.txt"), "x = { }\n")
.expect("write non-loadable name");
let files = collect_relative_files(
&root,
&FileFilter::for_game(Eu4),
InventoryOwner::Mod("mod-a"),
)
.expect("pruned names are never converted");
assert_eq!(files, vec![game_path("common/kept.txt")]);
}
#[cfg(unix)]
#[test]
fn an_unportable_name_under_a_loadable_root_fails_even_when_a_glob_would_ignore_it() {
let temp = TempDir::new().expect("tempdir");
let root = temp.path().join("mod");
fs::create_dir_all(root.join("common")).expect("create loadable root");
fs::write(root.join("common").join(r"a\b.bak"), "b = { }\n").expect("write ignored name");
let filter = FileFilter::new(Eu4, &["*.bak".to_string()]).expect("valid glob");
let error = collect_relative_files(&root, &filter, InventoryOwner::Mod("mod-a"))
.expect_err("an unportable name has no portable game path");
let diagnostic = unportable(&error);
assert_eq!(diagnostic.physical, root.join("common").join(r"a\b.bak"));
assert_eq!(
diagnostic.source.kind,
GamePathErrorKind::ReservedCharacter {
component: r"a\b.bak".to_string(),
character: '\\',
}
);
}
#[test]
fn manifest_summary_imports_dlc_load_and_appends_path_mod() {
let temp = TempDir::new().expect("tempdir");
let game_root = temp.path().join("game-root");
let paradox_dir = temp.path().join("Europa Universalis IV");
let imported_root = temp.path().join("imported_mod");
let local_root = temp.path().join("local_patch");
fs::create_dir_all(&game_root).expect("create game root");
write_descriptor(&imported_root, "Imported Mod", Some("1001"));
write_descriptor(&local_root, "Local Patch", None);
write_dlc_load(¶dox_dir, &[("1001", imported_root.as_path())]);
let manifest_path = temp.path().join("foch.toml");
fs::write(
&manifest_path,
r#"
[project]
game = "eu4"
game_path = "game-root"
[[project.imports]]
kind = "dlc_load"
path = "Europa Universalis IV/dlc_load.json"
[[project.mods]]
id = "local_patch"
path = "local_patch"
"#,
)
.expect("write manifest");
let request = request_for_manifest(&manifest_path);
let summary = resolve_input_summary(&request).expect("resolve manifest");
assert_eq!(summary.game, Eu4);
assert_eq!(summary.game_root.as_deref(), Some(game_root.as_path()));
assert_eq!(summary.mods.len(), 2);
assert_eq!(summary.mods[0].mod_id, "1001");
assert_eq!(
summary.mods[0].root_path.as_deref(),
Some(imported_root.as_path())
);
assert_eq!(summary.mods[1].mod_id, "local_patch");
assert_eq!(
summary.mods[1].root_path.as_deref(),
Some(local_root.as_path())
);
let targets = resolve_input_targets(&request, true).expect("resolve targets");
assert_eq!(targets.len(), 3);
assert!(
targets
.iter()
.any(|target| target.role == InputTargetRole::Game)
);
assert!(targets.iter().any(|target| target.path == imported_root));
assert!(targets.iter().any(|target| target.path == local_root));
}
#[cfg(unix)]
#[test]
fn input_summary_does_not_walk_mod_content() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().expect("tempdir");
let mod_root = temp.path().join("local_mod");
write_descriptor(&mod_root, "Local Mod", None);
let poison = mod_root.join("events/blocked");
fs::create_dir_all(&poison).expect("create poison content");
fs::write(poison.join("event.txt"), "namespace = poison\n").expect("write poison content");
fs::set_permissions(mod_root.join("events"), fs::Permissions::from_mode(0o000))
.expect("poison loadable subtree");
let manifest_path = temp.path().join("foch.toml");
fs::write(
&manifest_path,
r#"
[project]
game = "eu4"
[[project.mods]]
id = "local_mod"
path = "local_mod"
"#,
)
.expect("write manifest");
let result = resolve_input_summary(&request_for_manifest(&manifest_path));
fs::set_permissions(mod_root.join("events"), fs::Permissions::from_mode(0o700))
.expect("restore content subtree");
let summary = result.expect("summary must be metadata-only");
assert_eq!(summary.mods.len(), 1);
assert_eq!(summary.mods[0].mod_id, "local_mod");
}
#[test]
fn manifest_explicit_mod_overrides_imported_duplicate() {
let temp = TempDir::new().expect("tempdir");
let paradox_dir = temp.path().join("Europa Universalis IV");
let imported_root = temp.path().join("imported_mod");
let override_root = temp.path().join("override_mod");
write_descriptor(&imported_root, "Imported Mod", Some("1001"));
write_descriptor(&override_root, "Override Mod", Some("1001"));
write_dlc_load(¶dox_dir, &[("1001", imported_root.as_path())]);
let manifest_path = temp.path().join("foch.toml");
fs::write(
&manifest_path,
r#"
[project]
game = "eu4"
[[project.imports]]
kind = "dlc_load"
path = "Europa Universalis IV/dlc_load.json"
[[project.mods]]
id = "override"
steam_id = "1001"
path = "override_mod"
"#,
)
.expect("write manifest");
let summary =
resolve_input_summary(&request_for_manifest(&manifest_path)).expect("resolve manifest");
assert_eq!(summary.mods.len(), 1);
assert_eq!(summary.mods[0].mod_id, "1001");
assert_eq!(
summary.mods[0].root_path.as_deref(),
Some(override_root.as_path())
);
assert_eq!(summary.mods[0].display_name.as_deref(), Some("override"));
}
#[test]
fn manifest_rejects_mod_without_path_or_steam_id() {
let temp = TempDir::new().expect("tempdir");
let manifest_path = temp.path().join("foch.toml");
fs::write(
&manifest_path,
r#"
[project]
game = "eu4"
[[project.mods]]
id = "broken"
"#,
)
.expect("write manifest");
let err = resolve_input_summary(&request_for_manifest(&manifest_path))
.expect_err("invalid manifest mod must fail");
assert!(
err.message.contains("at least one of path or steam_id"),
"error was: {err}"
);
}
#[test]
fn manifest_explicit_missing_path_does_not_fall_back_to_id_directory() {
let temp = TempDir::new().expect("tempdir");
let fallback_root = temp.path().join("local_patch");
write_descriptor(&fallback_root, "Fallback Root", None);
let manifest_path = temp.path().join("foch.toml");
fs::write(
&manifest_path,
r#"
[project]
game = "eu4"
[[project.mods]]
id = "local_patch"
path = "missing-local-patch"
"#,
)
.expect("write manifest");
let summary =
resolve_input_summary(&request_for_manifest(&manifest_path)).expect("resolve manifest");
assert_eq!(summary.mods.len(), 1);
let expected_root = temp.path().join("missing-local-patch");
assert_eq!(
summary.mods[0].root_path.as_deref(),
Some(expected_root.as_path())
);
let targets =
resolve_input_targets(&request_for_manifest(&manifest_path), false).expect("targets");
assert!(targets.is_empty());
}
#[test]
fn input_product_input_manifest_reads_acf_not_mod_content() {
let temp = TempDir::new().expect("tempdir");
let workshop_root = temp.path().join("steamapps/workshop");
let content_root = workshop_root.join("content/236850");
let mod_root = content_root.join("1001");
let content_file = mod_root.join("gfx/sentinel.dds");
fs::create_dir_all(content_file.parent().expect("sentinel parent"))
.expect("create Workshop item");
fs::write(&content_file, b"first payload").expect("write sentinel");
fs::write(
workshop_root.join("appworkshop_236850.acf"),
r#""AppWorkshop"
{
"appid" "236850"
"WorkshopItemsInstalled"
{
"1001"
{
"size" "13"
"timeupdated" "1780000000"
"manifest" "2001"
}
}
}"#,
)
.expect("write Workshop ACF");
let manifest_path = temp.path().join("foch.toml");
fs::write(
&manifest_path,
r#"
[project]
game = "eu4"
[[project.mods]]
id = "mod-a"
steam_id = "1001"
path = "steamapps/workshop/content/236850/1001"
workshop_identity = { app_id = 236850, workshop_id = "1001", manifest_id = "2001" }
"#,
)
.expect("write manifest");
let request = request_for_manifest(&manifest_path);
let first = resolve_product_input_manifest(&request).expect("first manifest");
fs::write(&content_file, b"changed bytes").expect("mutate content without changing ACF");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&content_file, fs::Permissions::from_mode(0o000))
.expect("make sentinel unreadable");
}
let second = resolve_product_input_manifest(&request).expect("second manifest");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&content_file, fs::Permissions::from_mode(0o600))
.expect("restore sentinel permissions");
}
assert_eq!(first.mods[0].mod_id, "1001");
assert_eq!(first.mods[0].precedence, 1);
assert_eq!(first.mods[0].workshop_identity.manifest_id.as_str(), "2001");
assert!(first.digest_is_valid());
assert_eq!(first, second);
}
#[test]
fn retained_governments_path_expands_to_the_complete_definition_module() {
let temp = TempDir::new().expect("tempdir");
let mod_root = temp.path().join("governments_mod");
write_descriptor(&mod_root, "Governments Mod", None);
let governments = mod_root.join("common/governments");
fs::create_dir_all(&governments).expect("create governments directory");
fs::write(
governments.join("00_governments.txt"),
"early_government = { basic_reform = early_reform }\n",
)
.expect("write early government");
fs::write(
governments.join("zzz_governments.txt"),
"late_government = { basic_reform = late_reform }\n",
)
.expect("write late government");
let unrelated = mod_root.join("common/scripted_effects");
fs::create_dir_all(&unrelated).expect("create unrelated directory");
fs::write(unrelated.join("effect.txt"), "effect = { }\n").expect("write unrelated file");
let manifest_path = temp.path().join("foch.toml");
fs::write(
&manifest_path,
r#"
[project]
game = "eu4"
[[project.mods]]
id = "governments_mod"
path = "governments_mod"
"#,
)
.expect("write manifest");
let requested = BTreeSet::from([game_path("common/governments/00_governments.txt")]);
let inventory = build_input_inventory_for_paths(
&request_for_manifest(&manifest_path),
false,
Some(&requested),
)
.expect("build retained input inventory");
assert!(inventory.mods[0].files.is_empty());
assert!(inventory.mods[0].descriptor.is_none());
assert!(inventory.mods[0].descriptor_error.is_none());
assert_eq!(inventory.requested_retained_paths, Some(requested));
let input = resolve_input_from_inventory(inventory).expect("resolve input");
assert_eq!(
input.mods[0]
.descriptor
.as_ref()
.map(|descriptor| descriptor.name.as_str()),
Some("Governments Mod")
);
let retained_files = input.mods[0].files.iter().cloned().collect::<BTreeSet<_>>();
assert_eq!(
retained_files,
BTreeSet::from([
game_path("common/governments/00_governments.txt"),
game_path("common/governments/zzz_governments.txt"),
])
);
assert_eq!(input.effective_retained_paths, Some(retained_files));
}
#[test]
fn retained_database_inputs_use_the_resolved_game_version_and_keep_playset_order() {
let temp: TempDir = TempDir::new().unwrap();
let game_root: PathBuf = temp.path().join("game-root");
fs::create_dir_all(&game_root).unwrap();
fs::write(game_root.join("version.txt"), "1.37.5\n").unwrap();
for (mod_id, directory) in [
("mod-z", "common/static_modifiers"),
("mod-a", "common/event_modifiers"),
] {
let root: PathBuf = temp.path().join(mod_id);
write_descriptor(&root, mod_id, None);
fs::create_dir_all(root.join(directory)).unwrap();
fs::write(root.join(directory).join("entry.txt"), "test = { }\n").unwrap();
}
let manifest: PathBuf = temp.path().join("foch.toml");
fs::write(
&manifest,
r#"
[project]
game = "eu4"
game_path = "game-root"
[[project.mods]]
id = "mod-z"
path = "mod-z"
[[project.mods]]
id = "mod-a"
path = "mod-a"
"#,
)
.unwrap();
let requested: BTreeSet<GamePathBuf> =
BTreeSet::from([game_path("common/static_modifiers/entry.txt")]);
let inventory = build_input_inventory_for_paths(
&request_for_manifest(&manifest),
false,
Some(&requested),
)
.unwrap();
let input = resolve_input_from_inventory(inventory).unwrap();
assert_eq!(input.game_version.as_deref(), Some("1.37.5"));
assert_eq!(input.cache_game_version.as_deref(), Some("eu4 1.37.5"));
assert_eq!(input.requested_retained_paths, Some(requested));
assert_eq!(
input.effective_retained_paths,
Some(BTreeSet::from([
game_path("common/static_modifiers/entry.txt"),
game_path("common/event_modifiers/entry.txt"),
]))
);
assert_eq!(input.mods[0].mod_id, "mod-z");
assert_eq!(input.mods[1].mod_id, "mod-a");
assert_eq!(
input.file_inventory[&game_path("common/static_modifiers/entry.txt")][0].precedence,
0
);
assert_eq!(
input.file_inventory[&game_path("common/event_modifiers/entry.txt")][0].precedence,
1
);
}
#[test]
fn retained_database_selection_includes_other_directories_and_respects_file_filters() {
let requested: BTreeSet<GamePathBuf> =
BTreeSet::from([game_path("common/static_modifiers/mod.txt")]);
let available: [GamePathBuf; 5] = [
"common/static_modifiers/base.txt",
"common/static_modifiers/notes.gui",
"common/event_modifiers/base.txt",
"common/event_modifiers/notes.gui",
"common/policies/unrelated.txt",
]
.map(game_path);
let expanded: Option<BTreeSet<GamePathBuf>> = expand_retained_paths_for_game(
&Eu4,
Some("1.37.5"),
Some(&requested),
available.iter().map(GamePathBuf::as_game_path),
)
.unwrap();
assert_eq!(
expanded,
Some(BTreeSet::from([
game_path("common/static_modifiers/mod.txt"),
game_path("common/static_modifiers/base.txt"),
game_path("common/event_modifiers/base.txt"),
]))
);
}
fn write_manifest_mod(temp: &Path, mod_id: &str, descriptor: &str) -> (PathBuf, PathBuf) {
let root = temp.join(mod_id);
fs::create_dir_all(root.join("events")).expect("create mod root");
fs::write(root.join("events").join("a.txt"), "namespace = a\n").expect("write event");
fs::write(root.join("descriptor.mod"), descriptor).expect("write descriptor");
let manifest = temp.join("foch.toml");
fs::write(
&manifest,
format!(
"[project]\ngame = \"eu4\"\n\n[[project.mods]]\nid = \"{mod_id}\"\npath = \"{mod_id}\"\n"
),
)
.expect("write manifest");
(manifest, root.join("descriptor.mod"))
}
#[test]
fn an_invalid_replace_path_stops_input_resolution_naming_the_mod_and_value() {
let temp = TempDir::new().expect("tempdir");
let (manifest, descriptor) = write_manifest_mod(
temp.path(),
"bad_mod",
"name=\"Bad\"\nreplace_path=\"common/../events\"\n",
);
let request = request_for_manifest(&manifest);
for error in [
resolve_input(&request, false).expect_err("resolution must stop"),
resolve_input_summary(&request).expect_err("the summary reads descriptors too"),
] {
assert_eq!(error.kind, InputResolveErrorKind::Io, "{error}");
assert_eq!(error.path, descriptor, "{error}");
for expected in [
"mod bad_mod".to_string(),
descriptor.display().to_string(),
r#""common/../events""#.to_string(),
"`..`".to_string(),
] {
assert!(
error.message.contains(&expected),
"{expected} not in {error}"
);
}
}
}
#[test]
fn a_launcher_descriptor_with_an_invalid_replace_path_still_locates_its_mod() {
let temp = TempDir::new().expect("tempdir");
let paradox_dir = temp.path().join("Europa Universalis IV");
let mod_root = temp.path().join("workshop_mod");
let bad = "replace_path=\"C:/events\"\n";
fs::create_dir_all(mod_root.join("events")).expect("create mod root");
fs::write(mod_root.join("events").join("a.txt"), "namespace = a\n").expect("write event");
fs::write(
mod_root.join("descriptor.mod"),
format!("name=\"Bad\"\nremote_file_id=\"1001\"\n{bad}"),
)
.expect("write mod descriptor");
write_dlc_load(¶dox_dir, &[("1001", mod_root.as_path())]);
let launcher = paradox_dir.join("mod").join("ugc_1001.mod");
let mut body = fs::read_to_string(&launcher).expect("read launcher descriptor");
body.push_str(bad);
fs::write(&launcher, body).expect("write launcher descriptor");
let error = resolve_input(
&InputRequest::from_playset_path(paradox_dir.join("dlc_load.json"), Config::default()),
false,
)
.expect_err("the mod's own replace_path stops resolution");
assert_eq!(error.path, mod_root.join("descriptor.mod"), "{error}");
assert!(error.message.contains("mod 1001"), "{error}");
assert!(error.message.contains(r#""C:/events""#), "{error}");
assert!(!error.message.contains("unavailable"), "{error}");
}
#[test]
fn retained_non_module_path_stays_exact() {
let available = BTreeSet::from([
game_path("common/countries/France.txt"),
game_path("common/countries/England.txt"),
]);
let requested = BTreeSet::from([game_path("common/countries/France.txt")]);
let effective = expand_retained_paths_for_game(
&Eu4,
None,
Some(&requested),
available.iter().map(GamePathBuf::as_game_path),
)
.unwrap();
assert_eq!(effective, Some(requested));
}
#[test]
fn retained_module_expansion_includes_available_basegame_siblings() {
let requested = BTreeSet::from([game_path("common/governments/mod_override.txt")]);
let available = BTreeSet::from([
game_path("common/governments/00_vanilla.txt"),
game_path("common/governments/mod_override.txt"),
game_path("common/scripted_effects/unrelated.txt"),
]);
let effective = expand_retained_paths_for_game(
&Eu4,
None,
Some(&requested),
available.iter().map(GamePathBuf::as_game_path),
)
.unwrap();
assert_eq!(
effective,
Some(BTreeSet::from([
game_path("common/governments/00_vanilla.txt"),
game_path("common/governments/mod_override.txt"),
]))
);
}
fn make_contributor(
mod_id: &str,
precedence: usize,
is_base_game: bool,
) -> ResolvedInputContributor {
ResolvedInputContributor {
mod_id: mod_id.to_string(),
root_path: PathBuf::from(format!("/mods/{mod_id}")),
relative_path: game_path("common/file.txt"),
precedence,
is_base_game,
is_synthetic_base: false,
parse_ok_hint: None,
mod_hash: Some(format!("hash-{mod_id}")),
}
}
fn two_mod_inventory(
relative_path: &str,
) -> BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>> {
let relative_path = game_path(relative_path);
let contributor = |mod_id: &str, precedence: usize| ResolvedInputContributor {
relative_path: relative_path.clone(),
..make_contributor(mod_id, precedence, false)
};
BTreeMap::from([(
relative_path.clone(),
vec![contributor("mod_a", 1), contributor("mod_b", 2)],
)])
}
fn eu4_test_playlist() -> Playset {
Playset {
game: Eu4,
name: "semantic-base-proof".to_string(),
mods: Vec::new(),
}
}
#[test]
fn absent_semantic_base_requires_a_loaded_snapshot_and_supported_file_family() {
let temp = TempDir::new().expect("tempdir");
let playlist = eu4_test_playlist();
let defines_path = "common/defines/es_defines.lua";
let defines_inventory = two_mod_inventory(defines_path);
let absent =
verify_absent_semantic_bases(&playlist, Some(temp.path()), true, &defines_inventory)
.expect("verify absent defines base");
assert_eq!(absent, BTreeSet::from([game_path(defines_path)]));
let without_snapshot =
verify_absent_semantic_bases(&playlist, Some(temp.path()), false, &defines_inventory)
.expect("skip absence proof without a base snapshot");
assert!(without_snapshot.is_empty());
let events_inventory = two_mod_inventory("events/test.txt");
let events =
verify_absent_semantic_bases(&playlist, Some(temp.path()), true, &events_inventory)
.expect("verify absent event base");
assert_eq!(events, BTreeSet::from([game_path("events/test.txt")]));
let gfx_path = "interface/000_expanded_mod_family.gfx";
let gfx = verify_absent_semantic_bases(
&playlist,
Some(temp.path()),
true,
&two_mod_inventory(gfx_path),
)
.expect("verify absent GFX base");
assert_eq!(gfx, BTreeSet::from([game_path(gfx_path)]));
let module_inventory = two_mod_inventory("common/governments/test.txt");
let module =
verify_absent_semantic_bases(&playlist, Some(temp.path()), true, &module_inventory)
.expect("aggregate definition module stays required");
assert!(module.is_empty());
let binary = verify_absent_semantic_bases(
&playlist,
Some(temp.path()),
true,
&two_mod_inventory("gfx/picture.dds"),
)
.expect("binary assets do not need semantic base verification");
assert!(binary.is_empty());
}
#[test]
fn live_semantic_base_rejects_a_filtered_snapshot_gap() {
let temp = TempDir::new().expect("tempdir");
let relative_path = "common/defines/es_defines.lua";
let absolute_path = temp.path().join(relative_path);
fs::create_dir_all(absolute_path.parent().expect("defines parent"))
.expect("create defines parent");
fs::write(&absolute_path, "NDefines.NGame.TEST = 1\n").expect("write live base");
let error = verify_absent_semantic_bases(
&eu4_test_playlist(),
Some(temp.path()),
true,
&two_mod_inventory(relative_path),
)
.expect_err("filtered snapshot must not prove live base absence");
assert_eq!(error.path, absolute_path);
assert!(
error
.message
.contains("omits the existing semantic merge base")
);
}
#[test]
fn inject_synthetic_bases_no_base_two_mods_creates_synthetic() {
let mut inventory: BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>> = BTreeMap::new();
inventory.insert(
game_path("common/file.txt"),
vec![
make_contributor("mod_b", 5, false),
make_contributor("mod_a", 3, false),
],
);
inject_synthetic_bases(&mut inventory);
let contribs = &inventory[GamePath::new("common/file.txt").expect("valid game path")];
assert_eq!(contribs.len(), 3, "synthetic base should be added");
let synth = &contribs[0];
assert!(synth.is_synthetic_base);
assert!(!synth.is_base_game);
assert_eq!(synth.precedence, 0);
assert_eq!(synth.mod_id, "mod_a", "lowest-precedence mod is mod_a (3)");
assert!(!contribs[1].is_synthetic_base);
assert!(!contribs[2].is_synthetic_base);
assert_eq!(contribs[1].mod_id, "mod_b");
assert_eq!(contribs[2].mod_id, "mod_a");
assert_eq!(contribs[2].precedence, 3);
}
#[test]
fn inject_synthetic_bases_with_real_base_skipped() {
let mut inventory: BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>> = BTreeMap::new();
inventory.insert(
game_path("common/file.txt"),
vec![
make_contributor("base:eu4", 0, true),
make_contributor("mod_a", 1, false),
make_contributor("mod_b", 2, false),
],
);
inject_synthetic_bases(&mut inventory);
let contribs = &inventory[GamePath::new("common/file.txt").expect("valid game path")];
assert_eq!(contribs.len(), 3, "no synthetic base should be added");
assert!(!contribs.iter().any(|c| c.is_synthetic_base));
}
#[test]
fn inject_synthetic_bases_single_mod_skipped() {
let mut inventory: BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>> = BTreeMap::new();
inventory.insert(
game_path("common/file.txt"),
vec![make_contributor("mod_a", 1, false)],
);
inject_synthetic_bases(&mut inventory);
let contribs = &inventory[GamePath::new("common/file.txt").expect("valid game path")];
assert_eq!(contribs.len(), 1, "no synthetic base for single mod");
assert!(!contribs[0].is_synthetic_base);
}
#[test]
fn inject_synthetic_bases_tie_breaks_on_mod_id() {
let mut inventory: BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>> = BTreeMap::new();
inventory.insert(
game_path("common/file.txt"),
vec![
make_contributor("mod_z", 2, false),
make_contributor("mod_a", 2, false),
],
);
inject_synthetic_bases(&mut inventory);
let contribs = &inventory[GamePath::new("common/file.txt").expect("valid game path")];
assert_eq!(contribs.len(), 3);
assert!(contribs[0].is_synthetic_base);
assert_eq!(
contribs[0].mod_id, "mod_a",
"tie on precedence resolved by mod_id lex"
);
}
}