mod cultures;
mod fingerprint;
mod gui;
mod repairs;
mod transform;
pub use cultures::{CultureConfig, CultureRenameEntry, CultureRepairEntry};
pub use fingerprint::compute_playset_fingerprint;
pub use gui::{GuiConfig, GuiMode, GuiTextOverride, GuiVisibilityOverride};
pub(crate) use repairs::repairs_identity;
pub use repairs::{SourceRepairEntry, proposed_repair_entry, render_repairs_toml};
pub use transform::SourceEdit;
use crate::game::eu4::Eu4;
use crate::model::{GamePath, GamePathBuf};
use crate::playset::steam::WorkshopInstallIdentity;
use globset::{Candidate, GlobBuilder, GlobMatcher};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::error::Error;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
pub const DEFAULT_EMIT_INDENT: &str = "\t";
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct Project {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<ProjectConfig>,
#[serde(default)]
pub overrides: Vec<DepOverride>,
#[serde(default)]
pub resolutions: Vec<ResolutionEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub emit: Option<EmitConfig>,
#[serde(default, skip_serializing_if = "CultureConfig::is_empty")]
pub cultures: CultureConfig,
#[serde(default, skip_serializing_if = "GuiConfig::is_empty")]
pub gui: GuiConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repairs: Vec<SourceRepairEntry>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EmitConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub indent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drop_unread_decimals: Option<bool>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawProject {
#[serde(default)]
project: Option<ProjectConfig>,
#[serde(default)]
overrides: Vec<DepOverride>,
#[serde(default)]
resolutions: Vec<ResolutionEntry>,
#[serde(default)]
emit: Option<EmitConfig>,
#[serde(default)]
cultures: CultureConfig,
#[serde(default)]
gui: GuiConfig,
#[serde(default)]
repairs: Vec<SourceRepairEntry>,
}
impl<'de> Deserialize<'de> for Project {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawProject::deserialize(deserializer)?;
ResolutionMap::from_entries(&raw.resolutions).map_err(serde::de::Error::custom)?;
raw.cultures.validate().map_err(serde::de::Error::custom)?;
raw.gui.validate().map_err(serde::de::Error::custom)?;
repairs::validate_repairs(&raw.repairs).map_err(serde::de::Error::custom)?;
Ok(Self {
project: raw.project,
overrides: raw.overrides,
resolutions: raw.resolutions,
emit: raw.emit,
cultures: raw.cultures,
gui: raw.gui,
repairs: raw.repairs,
})
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub game: Option<Eu4>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub game_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paradox_data_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub imports: Vec<ProjectImport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mods: Vec<ProjectMod>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectImport {
pub kind: ProjectImportKind,
pub path: PathBuf,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectImportKind {
DlcLoad,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectMod {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steam_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workshop_identity: Option<WorkshopInstallIdentity>,
#[serde(default = "default_project_mod_enabled")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<usize>,
}
fn default_project_mod_enabled() -> bool {
true
}
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct DepOverride {
#[serde(rename = "mod", alias = "mod_id")]
pub mod_id: String,
#[serde(rename = "dep", alias = "dep_id")]
pub dep_id: String,
#[serde(default)]
pub note: Option<String>,
}
impl DepOverride {
pub fn new(mod_id: impl Into<String>, dep_id: impl Into<String>) -> Self {
Self {
mod_id: mod_id.into(),
dep_id: dep_id.into(),
note: None,
}
}
pub fn matches(&self, mod_id: &str, dep_id: &str) -> bool {
self.mod_id == mod_id && self.dep_id == dep_id
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ResolutionEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<GamePathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conflict_id: Option<String>,
#[serde(rename = "mod", default, skip_serializing_if = "Option::is_none")]
pub mod_id: Option<String>,
#[serde(rename = "match", default, skip_serializing_if = "Option::is_none")]
pub r#match: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefer_mod: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefer_candidate: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_file: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keep_existing: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority_boost: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handler: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<ResolutionPolicy>,
}
#[derive(Debug, Clone, Default)]
pub struct ResolutionMap {
pub by_file: BTreeMap<GamePathBuf, ResolutionDecision>,
pub by_conflict_id: BTreeMap<String, ResolutionDecision>,
pub mod_priority_boost: BTreeMap<String, i32>,
pub pattern_rules: Vec<PatternRule>,
}
impl PartialEq for ResolutionMap {
fn eq(&self, other: &Self) -> bool {
self.by_file == other.by_file
&& self.by_conflict_id == other.by_conflict_id
&& self.mod_priority_boost == other.mod_priority_boost
&& self.pattern_rules.len() == other.pattern_rules.len()
&& self
.pattern_rules
.iter()
.zip(other.pattern_rules.iter())
.all(|(a, b)| a.dsl == b.dsl && a.decision == b.decision)
}
}
impl Eq for ResolutionMap {}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum ResolutionDecision {
PreferMod(String),
PreferCandidate(usize),
UseFile(PathBuf),
UseLiveFile(PathBuf),
KeepExisting,
Handler(String),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResolutionPolicy {
CwtSuggested,
}
#[derive(Clone, Debug)]
pub struct PatternRule {
pub dsl: String,
pub file_matcher: Matcher,
pub leaf_matcher: Option<Matcher>,
pub decision: ResolutionDecision,
}
impl PatternRule {
pub fn matches(&self, file: &GamePath, leaf_address: &str) -> bool {
if !self.file_matcher.is_match(file.as_str()) {
return false;
}
match &self.leaf_matcher {
None => true,
Some(matcher) => !leaf_address.is_empty() && matcher.is_match(leaf_address),
}
}
}
#[derive(Clone, Debug)]
pub enum Matcher {
Glob(GlobMatcher),
Regex(Regex),
}
impl Matcher {
pub fn is_match(&self, value: &str) -> bool {
match self {
Self::Glob(matcher) => matcher.is_match_candidate(&Candidate::from_bytes(value)),
Self::Regex(regex) => regex.is_match(value),
}
}
}
pub fn parse_match_dsl(input: &str) -> Result<(Matcher, Option<Matcher>), ConfigError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ConfigError::new("match pattern cannot be empty"));
}
let (file_side, addr_side) = match trimmed.split_once("::") {
Some((file, addr)) => {
let addr = addr.trim();
(file.trim(), if addr.is_empty() { None } else { Some(addr) })
}
None => (trimmed, None),
};
if file_side.is_empty() {
return Err(ConfigError::new(
"match pattern file side cannot be empty (use `**` to match everything)",
));
}
let file_matcher = parse_pattern_side(file_side)?;
let leaf_matcher = match addr_side {
Some(side) => Some(parse_pattern_side(side)?),
None => None,
};
Ok((file_matcher, leaf_matcher))
}
fn parse_pattern_side(side: &str) -> Result<Matcher, ConfigError> {
if let Some(re) = side.strip_prefix("re:") {
let re = re.trim();
if re.is_empty() {
return Err(ConfigError::new(
"regex pattern side cannot be empty after `re:` prefix",
));
}
Regex::new(re)
.map(Matcher::Regex)
.map_err(|err| ConfigError::new(format!("invalid regex `{re}`: {err}")))
} else {
GlobBuilder::new(side)
.backslash_escape(true)
.build()
.map(|glob| Matcher::Glob(glob.compile_matcher()))
.map_err(|err| ConfigError::new(format!("invalid glob `{side}`: {err}")))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigError {
message: String,
}
impl ConfigError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
fn resolution_entry(index: usize, message: impl Into<String>) -> Self {
Self::new(format!(
"invalid [[resolutions]] entry {}: {}",
index + 1,
message.into()
))
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl Error for ConfigError {}
impl ResolutionMap {
pub fn from_entries(entries: &[ResolutionEntry]) -> Result<Self, ConfigError> {
let mut map = Self::default();
for (index, entry) in entries.iter().enumerate() {
entry.validate(index)?;
if let Some(mod_id) = &entry.mod_id {
let priority_boost = entry
.priority_boost
.expect("validated mod resolution has priority_boost");
map.mod_priority_boost
.insert(mod_id.clone(), priority_boost);
continue;
}
if entry.policy.is_some() {
if let Some(dsl) = &entry.r#match {
let (_, leaf_matcher) = parse_match_dsl(dsl)
.map_err(|err| ConfigError::resolution_entry(index, err.message()))?;
if leaf_matcher.is_some() {
return Err(ConfigError::resolution_entry(
index,
"policy action does not support address-constrained match selectors",
));
}
}
continue;
}
let decision = entry
.decision_action()
.expect("validated non-policy resolution has a decision");
if let Some(file) = &entry.file {
map.by_file.insert(file.clone(), decision);
} else if let Some(conflict_id) = &entry.conflict_id {
map.by_conflict_id.insert(conflict_id.clone(), decision);
} else if let Some(dsl) = &entry.r#match {
let (file_matcher, leaf_matcher) = parse_match_dsl(dsl)
.map_err(|err| ConfigError::resolution_entry(index, err.message()))?;
map.pattern_rules.push(PatternRule {
dsl: dsl.clone(),
file_matcher,
leaf_matcher,
decision,
});
}
}
Ok(map)
}
pub fn lookup(
&self,
file: &GamePath,
conflict_id: &str,
leaf_address: &str,
) -> Option<&ResolutionDecision> {
if let Some(decision) = self.by_conflict_id.get(conflict_id) {
return Some(decision);
}
if let Some(decision) = self.by_file.get(file) {
return Some(decision);
}
self.pattern_rules
.iter()
.find(|rule| rule.matches(file, leaf_address))
.map(|rule| &rule.decision)
}
}
impl ResolutionEntry {
fn validate(&self, index: usize) -> Result<(), ConfigError> {
if self.selector_count() != 1 {
return Err(ConfigError::resolution_entry(
index,
"exactly one selector (file, conflict_id, mod, match) must be set",
));
}
if matches!(self.keep_existing, Some(false)) {
return Err(ConfigError::resolution_entry(
index,
"keep_existing must be true when set",
));
}
if self.mod_id.is_some() {
if self.priority_boost.is_none() {
return Err(ConfigError::resolution_entry(
index,
"mod selector requires priority_boost action",
));
}
if self.all_action_count() != 1 {
return Err(ConfigError::resolution_entry(
index,
"priority_boost cannot be combined with prefer_mod, prefer_candidate, use_file, keep_existing, handler, or policy",
));
}
return Ok(());
}
if self.priority_boost.is_some() {
return Err(ConfigError::resolution_entry(
index,
"priority_boost requires mod selector",
));
}
if self.handler.is_some() && self.r#match.is_none() {
return Err(ConfigError::resolution_entry(
index,
"handler action requires match selector",
));
}
if self.policy.is_some() && !(self.file.is_some() || self.r#match.is_some()) {
return Err(ConfigError::resolution_entry(
index,
"policy action requires file selector or match selector",
));
}
if let Some(candidate) = self.prefer_candidate {
if self.conflict_id.is_none() {
return Err(ConfigError::resolution_entry(
index,
"prefer_candidate requires an exact conflict_id selector",
));
}
if candidate == 0 {
return Err(ConfigError::resolution_entry(
index,
"prefer_candidate is one-based and must be at least 1",
));
}
}
if self.all_action_count() != 1 {
return Err(ConfigError::resolution_entry(
index,
"exactly one action (prefer_mod, prefer_candidate, use_file, keep_existing, handler, policy) must be set",
));
}
if self.keep_existing.is_some() && self.file.is_none() {
return Err(ConfigError::resolution_entry(
index,
"keep_existing action requires file selector",
));
}
Ok(())
}
fn selector_count(&self) -> usize {
option_count(&self.file)
+ option_count(&self.conflict_id)
+ option_count(&self.mod_id)
+ option_count(&self.r#match)
}
fn standard_action_count(&self) -> usize {
option_count(&self.prefer_mod)
+ option_count(&self.prefer_candidate)
+ option_count(&self.use_file)
+ option_count(&self.keep_existing)
+ option_count(&self.handler)
+ option_count(&self.policy)
}
fn all_action_count(&self) -> usize {
self.standard_action_count() + option_count(&self.priority_boost)
}
fn decision_action(&self) -> Option<ResolutionDecision> {
if let Some(prefer_candidate) = self.prefer_candidate {
Some(ResolutionDecision::PreferCandidate(prefer_candidate - 1))
} else if let Some(prefer_mod) = &self.prefer_mod {
Some(ResolutionDecision::PreferMod(prefer_mod.clone()))
} else if let Some(use_file) = &self.use_file {
Some(ResolutionDecision::UseFile(use_file.clone()))
} else if self.keep_existing.is_some() {
Some(ResolutionDecision::KeepExisting)
} else {
self.handler
.as_ref()
.map(|handler| ResolutionDecision::Handler(handler.clone()))
}
}
}
pub fn compute_conflict_id(file_path: &GamePath, addr_path: &str, addr_key: &str) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(file_path.as_str().as_bytes());
hasher.update(b"\0");
hasher.update(addr_path.as_bytes());
hasher.update(b"\0");
hasher.update(addr_key.as_bytes());
let hash = hasher.finalize();
let hex = hash.to_hex();
hex.as_str()[..8].to_owned()
}
fn option_count<T>(option: &Option<T>) -> usize {
if option.is_some() { 1 } else { 0 }
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DepOverrideSource {
Config,
Cli,
}
impl fmt::Display for DepOverrideSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Config => f.write_str("config"),
Self::Cli => f.write_str("cli"),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct AppliedDepOverride {
pub mod_id: String,
pub dep_id: String,
pub source: DepOverrideSource,
}
impl AppliedDepOverride {
pub fn config(dep_override: &DepOverride) -> Self {
Self {
mod_id: dep_override.mod_id.clone(),
dep_id: dep_override.dep_id.clone(),
source: DepOverrideSource::Config,
}
}
pub fn cli(mod_id: impl Into<String>, dep_id: impl Into<String>) -> Self {
Self {
mod_id: mod_id.into(),
dep_id: dep_id.into(),
source: DepOverrideSource::Cli,
}
}
}
impl From<&AppliedDepOverride> for DepOverride {
fn from(value: &AppliedDepOverride) -> Self {
Self::new(value.mod_id.clone(), value.dep_id.clone())
}
}
#[derive(Debug)]
pub enum FochConfigLoadError {
Io {
path: PathBuf,
source: std::io::Error,
},
Parse {
path: PathBuf,
source: toml::de::Error,
},
}
impl fmt::Display for FochConfigLoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => {
write!(f, "failed to read foch config {}: {source}", path.display())
}
Self::Parse { path, source } => {
write!(
f,
"failed to parse foch config {}: {source}",
path.display()
)
}
}
}
}
impl Error for FochConfigLoadError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source),
}
}
}
impl Project {
pub fn from_toml_str(content: &str) -> Result<Self, toml::de::Error> {
toml::from_str(content)
}
pub fn emit_indent(&self) -> &str {
self.emit
.as_ref()
.and_then(|emit| emit.indent.as_deref())
.unwrap_or(DEFAULT_EMIT_INDENT)
}
pub fn emit_drops_unread_decimals(&self) -> bool {
self.emit
.as_ref()
.and_then(|emit| emit.drop_unread_decimals)
.unwrap_or(true)
}
pub fn load(playset_root: &Path) -> Self {
Self::try_load(playset_root).unwrap_or_default()
}
pub fn try_load(playset_root: &Path) -> Result<Self, FochConfigLoadError> {
Self::try_load_from_paths(Self::search_paths(playset_root))
}
pub fn load_from_path(path: &Path) -> Result<Self, FochConfigLoadError> {
Self::load_file(path)
}
fn try_load_from_paths(paths: Vec<PathBuf>) -> Result<Self, FochConfigLoadError> {
let mut merged = Self::default();
for path in paths {
if path.is_file() {
let config = Self::load_file(&path)?;
merged.overrides.extend(config.overrides);
merged.resolutions.extend(config.resolutions);
merged.cultures.renames.extend(config.cultures.renames);
merged.cultures.repairs.extend(config.cultures.repairs);
if config.gui.mode.is_some() {
merged.gui.mode = config.gui.mode;
}
merged.gui.text.extend(config.gui.text);
merged.gui.visibility.extend(config.gui.visibility);
merged
.gui
.validate()
.map_err(|error| FochConfigLoadError::Parse {
path: path.clone(),
source: serde::de::Error::custom(error),
})?;
merged
.cultures
.validate()
.map_err(|error| FochConfigLoadError::Parse {
path: path.clone(),
source: serde::de::Error::custom(error),
})?;
merged.repairs.extend(config.repairs);
repairs::validate_repairs(&merged.repairs).map_err(|error| {
FochConfigLoadError::Parse {
path: path.clone(),
source: serde::de::Error::custom(error),
}
})?;
if config.project.is_some() {
merged.project = config.project;
}
if config.emit.is_some() {
merged.emit = config.emit;
}
}
}
Ok(merged)
}
fn load_file(path: &Path) -> Result<Self, FochConfigLoadError> {
let content = fs::read_to_string(path).map_err(|source| FochConfigLoadError::Io {
path: path.to_path_buf(),
source,
})?;
if content.trim().is_empty() {
return Ok(Self::default());
}
let mut config =
Self::from_toml_str(&content).map_err(|source| FochConfigLoadError::Parse {
path: path.to_path_buf(),
source,
})?;
config.anchor_relative_use_files(path)?;
Ok(config)
}
fn anchor_relative_use_files(&mut self, config_path: &Path) -> Result<(), FochConfigLoadError> {
if !self
.resolutions
.iter()
.any(|entry| entry.use_file.as_deref().is_some_and(Path::is_relative))
{
return Ok(());
}
let config_path =
std::path::absolute(config_path).map_err(|source| FochConfigLoadError::Io {
path: config_path.to_path_buf(),
source,
})?;
let config_dir = config_path.parent().unwrap_or(Path::new(""));
for entry in &mut self.resolutions {
if let Some(use_file) = entry.use_file.as_mut()
&& use_file.is_relative()
{
*use_file = config_dir.join(&*use_file);
}
}
Ok(())
}
fn search_paths(playset_root: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
let mut seen = HashSet::new();
if let Ok(cwd) = std::env::current_dir() {
push_unique_path(&mut paths, &mut seen, cwd.join("foch.toml"));
}
push_unique_path(&mut paths, &mut seen, playset_root.join("foch.toml"));
if let Some(home) = dirs::home_dir() {
push_unique_path(
&mut paths,
&mut seen,
home.join(".config").join("foch").join("foch.toml"),
);
}
paths
}
}
fn push_unique_path(paths: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>, path: PathBuf) {
if seen.insert(path.clone()) {
paths.push(path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn game_path(text: &str) -> &GamePath {
GamePath::new(text).expect("valid game path")
}
fn game_path_buf(text: &str) -> GamePathBuf {
GamePathBuf::parse(text).expect("valid game path")
}
#[test]
fn parses_valid_toml_with_multiple_overrides() {
let config = Project::from_toml_str(
r#"
[[overrides]]
mod = "3378403419"
dep = "1999055990"
note = "not a git parent"
[[overrides]]
mod = "abc"
dep = "def"
"#,
)
.expect("parse config");
assert_eq!(config.overrides.len(), 2);
assert_eq!(config.overrides[0].mod_id, "3378403419");
assert_eq!(config.overrides[0].dep_id, "1999055990");
assert_eq!(
config.overrides[0].note.as_deref(),
Some("not a git parent")
);
assert_eq!(config.overrides[1], DepOverride::new("abc", "def"));
}
#[test]
fn parses_emit_indent_config() {
let config = Project::from_toml_str(
r#"
[emit]
indent = " "
"#,
)
.expect("parse config");
assert_eq!(
config.emit,
Some(EmitConfig {
indent: Some(" ".to_string()),
drop_unread_decimals: None,
})
);
assert_eq!(config.emit_indent(), " ");
assert!(config.emit_drops_unread_decimals());
}
#[test]
fn unread_decimals_are_dropped_unless_the_project_keeps_them() {
assert!(Project::default().emit_drops_unread_decimals());
let config = Project::from_toml_str(
r#"
[emit]
drop_unread_decimals = false
"#,
)
.expect("parse config");
assert!(!config.emit_drops_unread_decimals());
assert_eq!(config.emit_indent(), DEFAULT_EMIT_INDENT);
}
#[test]
fn culture_config_accepts_source_bound_renames_and_repairs() {
let source = format!(
r#"
[[cultures.renames]]
from = "old_culture"
to = "new_culture"
mod = "culture_mod"
file = "common/cultures/test.txt"
sha256 = "{digest}"
[[cultures.repairs]]
mod = "culture_mod"
file = "common/cultures/test.txt"
sha256 = "{digest}"
edits = [{{ start = 1, end = 3, expected = "é", replacement = "e" }}]
"#,
digest = "a".repeat(64),
);
let parsed = Project::from_toml_str(&source).expect("reviewed culture inputs");
let encoded = toml::to_string(&parsed).expect("serialize reviewed inputs");
assert!(encoded.contains("old_culture"));
assert!(encoded.contains("expected = \"é\""));
assert_eq!(Project::from_toml_str(&encoded).unwrap(), parsed);
}
#[test]
fn culture_config_is_retained_and_validated_across_project_sources() {
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("first.toml");
let second = temp.path().join("second.toml");
let source = format!(
"[[cultures.renames]]\nfrom='old_culture'\nto='new_culture'\nmod='mod-a'\nfile='common/cultures/test.txt'\nsha256='{}'\n",
"a".repeat(64)
);
std::fs::write(&first, &source).unwrap();
std::fs::write(
&second,
source
.replace("new_culture", "other_culture")
.replace("mod-a", "mod-b"),
)
.unwrap();
let merged = Project::try_load_from_paths(vec![first.clone()]).unwrap();
assert_eq!(merged.cultures.renames.len(), 1);
assert!(Project::try_load_from_paths(vec![first, second]).is_err());
}
#[test]
fn culture_config_rejects_invalid_bindings_and_mapping_conflicts() {
let valid = format!(
"[[cultures.renames]]\nfrom = 'old_culture'\nto = 'new_culture'\nmod = 'culture_mod'\nfile = 'common/cultures/test.txt'\nsha256 = '{}'\n",
"a".repeat(64),
);
for invalid in [
valid.replace("old_culture", "new_culture"),
valid.replace("test.txt", "../test.txt"),
valid.replace("common/cultures", "events"),
valid.replace("test.txt", "nested/test.txt"),
valid.replace("test.txt", "*.txt"),
valid.replace(&"a".repeat(64), &"A".repeat(64)),
valid.replace("mod = 'culture_mod'", "mod = ''"),
format!("{valid}unknown = true\n"),
format!("{valid}{valid}"),
format!("{valid}{}", valid.replace("new_culture", "other_culture")),
format!("{valid}{}", valid.replace("old_culture", "other_culture")),
] {
assert!(
Project::from_toml_str(&invalid).is_err(),
"accepted {invalid}"
);
}
}
#[test]
fn culture_config_identity_binds_decisions_but_not_entry_order() {
let entry = |id: &str| CultureRenameEntry {
from: format!("old_{id}"),
to: format!("new_{id}"),
mod_id: id.into(),
file: "common/cultures/test.txt".into(),
sha256: "a".repeat(64),
};
let mut config = CultureConfig {
renames: vec![entry("one"), entry("two")],
repairs: vec![CultureRepairEntry {
mod_id: "one".into(),
file: "common/cultures/test.txt".into(),
sha256: "a".repeat(64),
edits: vec![SourceEdit {
start: 1,
end: 1,
expected: String::new(),
replacement: "}".into(),
}],
}],
};
config.validate().unwrap();
let identity = config.identity();
config.renames.reverse();
assert_eq!(config.identity(), identity);
config.repairs[0].edits[0].replacement = "{".into();
assert_ne!(config.identity(), identity);
config.repairs[0].edits[0].replacement = "}".into();
config.renames[0].to = "different_culture".into();
assert_ne!(config.identity(), identity);
}
#[test]
fn culture_config_rejects_overlapping_or_inconsistent_repairs() {
let source = |edits: &str| {
format!(
"[[cultures.repairs]]\nmod = 'culture_mod'\nfile = 'common/cultures/test.txt'\nsha256 = '{}'\nedits = [{edits}]\n",
"a".repeat(64),
)
};
for edits in [
"",
"{ start = 3, end = 1, expected = '', replacement = '}' }",
"{ start = 1, end = 2, expected = 'é', replacement = 'e' }",
"{ start = 1, end = 3, expected = 'ab', replacement = '' }, { start = 2, end = 4, expected = 'bc', replacement = '' }",
"{ start = 1, end = 1, expected = '', replacement = '}' }, { start = 1, end = 1, expected = '', replacement = '}' }",
] {
assert!(
Project::from_toml_str(&source(edits)).is_err(),
"accepted {edits}"
);
}
}
#[test]
fn parses_project_manifest_config() {
let config = Project::from_toml_str(
r#"
[project]
game = "eu4"
game_path = "/games/eu4"
paradox_data_path = "/paradox/eu4"
[[project.imports]]
kind = "dlc_load"
path = "/paradox/eu4/dlc_load.json"
[[project.mods]]
id = "local_patch"
path = "../mods/local_patch"
[[project.mods]]
steam_id = "2164202838"
workshop_identity = { app_id = 236850, workshop_id = "2164202838", manifest_id = "9007199254740993" }
enabled = false
position = 4
"#,
)
.expect("parse project manifest");
let project = config.project.expect("project config");
assert_eq!(project.game, Some(Eu4));
assert_eq!(project.imports.len(), 1);
assert_eq!(project.imports[0].kind, ProjectImportKind::DlcLoad);
assert_eq!(project.mods.len(), 2);
assert_eq!(project.mods[0].id.as_deref(), Some("local_patch"));
assert!(project.mods[0].enabled);
assert_eq!(project.mods[1].steam_id.as_deref(), Some("2164202838"));
assert_eq!(
project.mods[1]
.workshop_identity
.as_ref()
.expect("Workshop identity")
.manifest_id
.as_str(),
"9007199254740993"
);
assert!(!project.mods[1].enabled);
assert_eq!(project.mods[1].position, Some(4));
}
#[test]
fn missing_emit_config_uses_default_indent() {
let config = Project::from_toml_str(
r#"
[[overrides]]
mod = "abc"
dep = "def"
"#,
)
.expect("parse config");
assert_eq!(config.emit, None);
assert_eq!(config.emit_indent(), DEFAULT_EMIT_INDENT);
}
#[test]
fn rejects_override_missing_dep_field() {
let err = Project::from_toml_str(
r#"
[[overrides]]
mod = "3378403419"
"#,
)
.expect_err("missing dep must be invalid");
assert!(err.to_string().contains("dep"), "error was: {err}");
}
#[test]
fn parses_valid_toml_with_resolution_variants() {
let config = Project::from_toml_str(
r#"
[[overrides]]
mod = "abc"
dep = "def"
[[resolutions]]
file = "events/PirateEvents.txt"
prefer_mod = "1234567890"
[[resolutions]]
file = "events/ManualEvents.txt"
use_file = "manual/PirateEvents.txt"
[[resolutions]]
file = "events/ExistingEvents.txt"
keep_existing = true
[[resolutions]]
conflict_id = "ab12cd34"
prefer_mod = "9876543210"
[[resolutions]]
conflict_id = "exact-node"
prefer_candidate = 2
[[resolutions]]
mod = "1234567890"
priority_boost = 100
[[resolutions]]
file = "common/estates_preload/test.txt"
policy = "cwt_suggested"
"#,
)
.expect("parse config");
assert_eq!(config.overrides, vec![DepOverride::new("abc", "def")]);
assert_eq!(config.resolutions.len(), 7);
let map = ResolutionMap::from_entries(&config.resolutions).expect("build resolution map");
assert_eq!(
map.by_file.get(game_path("events/PirateEvents.txt")),
Some(&ResolutionDecision::PreferMod("1234567890".to_owned()))
);
assert_eq!(
map.by_file.get(game_path("events/ManualEvents.txt")),
Some(&ResolutionDecision::UseFile(PathBuf::from(
"manual/PirateEvents.txt"
)))
);
assert_eq!(
map.by_file.get(game_path("events/ExistingEvents.txt")),
Some(&ResolutionDecision::KeepExisting)
);
assert_eq!(
map.by_conflict_id.get("ab12cd34"),
Some(&ResolutionDecision::PreferMod("9876543210".to_owned()))
);
assert_eq!(
map.by_conflict_id.get("exact-node"),
Some(&ResolutionDecision::PreferCandidate(1))
);
assert_eq!(map.mod_priority_boost.get("1234567890"), Some(&100));
assert_eq!(
config.resolutions[6].policy,
Some(ResolutionPolicy::CwtSuggested)
);
assert_eq!(map.by_file.len(), 3);
assert!(
!map.by_file
.contains_key(game_path("common/estates_preload/test.txt"))
);
}
#[test]
fn lookup_iteration_is_deterministic_across_processes() {
let entries = Project::from_toml_str(
r#"
[[resolutions]]
file = "events/zulu.txt"
prefer_mod = "zulu-mod"
[[resolutions]]
conflict_id = "ff00aa11"
prefer_mod = "late-conflict-mod"
[[resolutions]]
mod = "7777777777"
priority_boost = 70
[[resolutions]]
file = "events/alpha.txt"
use_file = "manual/alpha.txt"
[[resolutions]]
conflict_id = "0011aaff"
use_file = "manual/conflict.txt"
[[resolutions]]
mod = "1111111111"
priority_boost = 10
"#,
)
.expect("parse config")
.resolutions;
let first = ResolutionMap::from_entries(&entries).expect("build first map");
let second = ResolutionMap::from_entries(&entries).expect("build second map");
let collect_by_file = |map: &ResolutionMap| {
map.by_file
.iter()
.map(|(path, decision)| (path.clone(), decision.clone()))
.collect::<Vec<_>>()
};
let by_file = collect_by_file(&first);
assert_eq!(by_file, collect_by_file(&second));
assert_eq!(
by_file,
vec![
(
game_path_buf("events/alpha.txt"),
ResolutionDecision::UseFile(PathBuf::from("manual/alpha.txt")),
),
(
game_path_buf("events/zulu.txt"),
ResolutionDecision::PreferMod("zulu-mod".to_string()),
),
]
);
let collect_by_conflict_id = |map: &ResolutionMap| {
map.by_conflict_id
.iter()
.map(|(conflict_id, decision)| (conflict_id.clone(), decision.clone()))
.collect::<Vec<_>>()
};
let by_conflict_id = collect_by_conflict_id(&first);
assert_eq!(by_conflict_id, collect_by_conflict_id(&second));
assert_eq!(
by_conflict_id,
vec![
(
"0011aaff".to_string(),
ResolutionDecision::UseFile(PathBuf::from("manual/conflict.txt")),
),
(
"ff00aa11".to_string(),
ResolutionDecision::PreferMod("late-conflict-mod".to_string()),
),
]
);
let collect_mod_priority_boost = |map: &ResolutionMap| {
map.mod_priority_boost
.iter()
.map(|(mod_id, boost)| (mod_id.clone(), *boost))
.collect::<Vec<_>>()
};
let mod_priority_boost = collect_mod_priority_boost(&first);
assert_eq!(mod_priority_boost, collect_mod_priority_boost(&second));
assert_eq!(
mod_priority_boost,
vec![
("1111111111".to_string(), 10),
("7777777777".to_string(), 70),
]
);
}
#[test]
fn rejects_invalid_resolution_entries() {
let cases = [
(
"missing selector",
r#"
[[resolutions]]
prefer_mod = "123"
"#,
"exactly one selector",
),
(
"missing action",
r#"
[[resolutions]]
file = "events/PirateEvents.txt"
"#,
"exactly one action",
),
(
"multiple selectors",
r#"
[[resolutions]]
file = "events/PirateEvents.txt"
conflict_id = "ab12cd34"
prefer_mod = "123"
"#,
"exactly one selector",
),
(
"multiple actions",
r#"
[[resolutions]]
file = "events/PirateEvents.txt"
prefer_mod = "123"
use_file = "manual/PirateEvents.txt"
"#,
"exactly one action",
),
(
"candidate selection without exact conflict",
r#"
[[resolutions]]
file = "events/PirateEvents.txt"
prefer_candidate = 1
"#,
"prefer_candidate requires an exact conflict_id",
),
(
"zero candidate selection",
r#"
[[resolutions]]
conflict_id = "ab12cd34"
prefer_candidate = 0
"#,
"prefer_candidate is one-based",
),
(
"mod without priority boost",
r#"
[[resolutions]]
mod = "123"
prefer_mod = "123"
"#,
"mod selector requires priority_boost",
),
(
"keep existing without file",
r#"
[[resolutions]]
conflict_id = "ab12cd34"
keep_existing = true
"#,
"keep_existing action requires file selector",
),
(
"policy without file or match selector",
r#"
[[resolutions]]
conflict_id = "ab12cd34"
policy = "cwt_suggested"
"#,
"policy action requires file selector or match selector",
),
(
"policy with address-constrained match",
r#"
[[resolutions]]
match = "common/estates_preload/**::modifier"
policy = "cwt_suggested"
"#,
"does not support address-constrained match selectors",
),
];
for (name, content, expected) in cases {
let err = Project::from_toml_str(content).expect_err(name);
let message = err.to_string();
assert!(
message.contains(expected),
"{name} error should contain {expected:?}, error was: {message}"
);
}
}
#[test]
fn compute_conflict_id_is_stable_and_input_sensitive() {
let base = compute_conflict_id(game_path("events/PirateEvents.txt"), "root/event", "id");
assert_eq!(base.len(), 8);
assert!(base.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(
base,
compute_conflict_id(game_path("events/PirateEvents.txt"), "root/event", "id")
);
assert_ne!(
base,
compute_conflict_id(game_path("events/OtherEvents.txt"), "root/event", "id")
);
assert_ne!(
base,
compute_conflict_id(game_path("events/PirateEvents.txt"), "root/other", "id")
);
assert_ne!(
base,
compute_conflict_id(game_path("events/PirateEvents.txt"), "root/event", "other")
);
}
#[test]
fn compute_conflict_id_keeps_the_ids_persisted_before_paths_were_typed() {
assert_eq!(
compute_conflict_id(game_path("events/PirateEvents.txt"), "root/event", "id"),
"2d6e0b85"
);
}
#[test]
fn a_resolution_file_that_is_not_a_game_path_is_a_config_error() {
for (file, rejected) in [
(r#""events\\PirateEvents.txt""#, r"events\PirateEvents.txt"),
(
r#""../events/PirateEvents.txt""#,
"../events/PirateEvents.txt",
),
(r#""/events/PirateEvents.txt""#, "/events/PirateEvents.txt"),
] {
let error = Project::from_toml_str(&format!(
"[[resolutions]]\nfile = {file}\nprefer_mod = \"x\"\n"
))
.expect_err(file)
.to_string();
assert!(
error.contains(&format!("invalid game path `{rejected}`")),
"{file}: {error}"
);
}
}
#[test]
fn a_use_file_keeps_its_host_text() {
let config = Project::from_toml_str(
r#"
[[resolutions]]
file = "events/PirateEvents.txt"
use_file = "manual\\Pirate Events.txt"
"#,
)
.expect("parse config");
assert_eq!(
config.resolutions[0].use_file.as_deref(),
Some(Path::new(r"manual\Pirate Events.txt"))
);
}
#[test]
fn resolution_map_lookup_prefers_conflict_id_over_file() {
let config = Project::from_toml_str(
r#"
[[resolutions]]
file = "events/PirateEvents.txt"
prefer_mod = "file-mod"
[[resolutions]]
conflict_id = "ab12cd34"
prefer_mod = "conflict-mod"
"#,
)
.expect("parse config");
let map = ResolutionMap::from_entries(&config.resolutions).expect("build resolution map");
let conflict_decision = ResolutionDecision::PreferMod("conflict-mod".to_owned());
let file_decision = ResolutionDecision::PreferMod("file-mod".to_owned());
assert_eq!(
map.lookup(game_path("events/PirateEvents.txt"), "ab12cd34", ""),
Some(&conflict_decision)
);
assert_eq!(
map.lookup(game_path("events/PirateEvents.txt"), "unknown", ""),
Some(&file_decision)
);
assert_eq!(
map.lookup(game_path("events/OtherEvents.txt"), "unknown", ""),
None
);
}
#[test]
fn load_merges_multiple_existing_sources() {
let temp = TempDir::new().expect("temp dir");
let first = temp.path().join("first.toml");
let second = temp.path().join("second.toml");
fs::write(&first, "[[overrides]]\nmod = \"a\"\ndep = \"b\"\n").expect("write first");
fs::write(&second, "[[overrides]]\nmod = \"c\"\ndep = \"d\"\n").expect("write second");
let config = Project::try_load_from_paths(vec![first, second]).expect("load configs");
assert_eq!(
config.overrides,
vec![DepOverride::new("a", "b"), DepOverride::new("c", "d")]
);
}
#[test]
fn a_relative_use_file_is_anchored_at_its_declaring_config() {
let temp = TempDir::new().expect("temp dir");
let project_dir = temp.path().join("project");
let user_dir = temp.path().join("user");
fs::create_dir_all(&project_dir).expect("create project dir");
fs::create_dir_all(&user_dir).expect("create user dir");
let absolute = temp.path().join("shared").join("Absolute.txt");
let first = project_dir.join("foch.toml");
let second = user_dir.join("foch.toml");
fs::write(
&first,
"[[resolutions]]\nfile = \"events/PirateEvents.txt\"\nuse_file = \"resolutions/PirateEvents.txt\"\n",
)
.expect("write project config");
let absolute_text = toml_edit::value(absolute.to_str().expect("UTF-8 temp path"));
fs::write(
&second,
format!(
"[[resolutions]]\nfile = \"events/A.txt\"\nuse_file = \"A.txt\"\n\n[[resolutions]]\nfile = \"events/B.txt\"\nuse_file = {absolute_text}\n"
),
)
.expect("write user config");
let config = Project::try_load_from_paths(vec![first, second]).expect("load configs");
let use_files = config
.resolutions
.iter()
.map(|entry| entry.use_file.clone().expect("use_file entry"))
.collect::<Vec<_>>();
assert_eq!(
use_files,
vec![
std::path::absolute(&project_dir)
.unwrap()
.join("resolutions/PirateEvents.txt"),
std::path::absolute(&user_dir).unwrap().join("A.txt"),
absolute,
]
);
}
#[test]
fn search_paths_excludes_engine_config_toml() {
let temp = TempDir::new().expect("temp dir");
let paths = Project::search_paths(temp.path());
for path in &paths {
assert!(
!path.ends_with("config.toml"),
"Project 不应扫描 config.toml 文件名(归 input::Config 所有);命中:{:?}",
path
);
}
}
#[test]
fn pattern_dsl_pure_glob_matches_file_only() {
let (file_matcher, leaf_matcher) = parse_match_dsl("common/ideas/**").expect("parse glob");
assert!(file_matcher.is_match("common/ideas/foo.txt"));
assert!(file_matcher.is_match("common/ideas/sub/bar.txt"));
assert!(!file_matcher.is_match("events/foo.txt"));
assert!(leaf_matcher.is_none());
}
#[test]
fn pattern_dsl_uses_portable_glob_escapes_on_every_host() {
let (file_matcher, leaf_matcher): (Matcher, Option<Matcher>) =
parse_match_dsl(r"common/ideas/literal\*.txt").expect("parse glob");
assert!(file_matcher.is_match("common/ideas/literal*.txt"));
assert!(!file_matcher.is_match("common/ideas/literal_other.txt"));
assert!(leaf_matcher.is_none());
}
#[test]
fn pattern_dsl_glob_address_side() {
let (file_matcher, leaf_matcher) =
parse_match_dsl("common/ideas/**::xx_idea_*").expect("parse mixed");
assert!(file_matcher.is_match("common/ideas/national.txt"));
let leaf = leaf_matcher.expect("address side present");
assert!(leaf.is_match("xx_idea_pirates"));
assert!(!leaf.is_match("yy_idea_pirates"));
}
#[test]
fn pattern_dsl_pure_regex() {
let (file_matcher, leaf_matcher) =
parse_match_dsl("re:^events/.*\\.txt$").expect("parse regex");
assert!(file_matcher.is_match("events/PirateEvents.txt"));
assert!(!file_matcher.is_match("events/PirateEvents.json"));
assert!(leaf_matcher.is_none());
}
#[test]
fn pattern_dsl_mixed_glob_file_regex_address() {
let (file_matcher, leaf_matcher) =
parse_match_dsl("common/**::re:^test\\..*").expect("parse mixed");
assert!(file_matcher.is_match("common/ideas/anything.txt"));
let leaf = leaf_matcher.expect("address side present");
assert!(leaf.is_match("test.123"));
assert!(!leaf.is_match("other.123"));
}
#[test]
fn pattern_dsl_empty_address_side_treated_as_no_constraint() {
let (file_matcher, leaf_matcher) =
parse_match_dsl("events/**::").expect("parse trailing colons");
assert!(file_matcher.is_match("events/foo.txt"));
assert!(leaf_matcher.is_none());
}
#[test]
fn pattern_dsl_rejects_empty_input() {
assert!(parse_match_dsl("").is_err());
assert!(parse_match_dsl(" ").is_err());
}
#[test]
fn pattern_dsl_rejects_empty_file_side() {
let err = parse_match_dsl("::xx_idea_*").expect_err("empty file side rejected");
assert!(err.to_string().contains("file side"));
}
#[test]
fn pattern_dsl_rejects_empty_regex() {
let err = parse_match_dsl("re:").expect_err("empty regex rejected");
assert!(err.to_string().contains("regex"));
}
#[test]
fn pattern_dsl_rejects_invalid_regex() {
let err = parse_match_dsl("re:[unterminated").expect_err("bad regex rejected");
assert!(err.to_string().contains("invalid regex"));
}
#[test]
fn match_selector_with_handler_action_round_trips() {
let config = Project::from_toml_str(
r#"
[[resolutions]]
match = "common/ideas/**::xx_idea_*"
handler = "last_writer"
"#,
)
.expect("parse match+handler");
let map = ResolutionMap::from_entries(&config.resolutions).expect("build map");
assert_eq!(map.pattern_rules.len(), 1);
let rule = &map.pattern_rules[0];
assert_eq!(rule.dsl, "common/ideas/**::xx_idea_*");
assert_eq!(
rule.decision,
ResolutionDecision::Handler("last_writer".to_string())
);
assert!(rule.matches(game_path("common/ideas/national.txt"), "xx_idea_pirates"));
assert!(!rule.matches(game_path("common/ideas/national.txt"), "yy_idea_pirates"));
assert!(!rule.matches(game_path("events/foo.txt"), "xx_idea_pirates"));
}
#[test]
fn match_selector_supports_prefer_mod_action_too() {
let config = Project::from_toml_str(
r#"
[[resolutions]]
match = "common/ideas/**"
prefer_mod = "ideas-mod"
"#,
)
.expect("parse match+prefer_mod");
let map = ResolutionMap::from_entries(&config.resolutions).expect("build map");
assert_eq!(map.pattern_rules.len(), 1);
assert_eq!(
map.pattern_rules[0].decision,
ResolutionDecision::PreferMod("ideas-mod".to_string())
);
}
#[test]
fn match_selector_supports_cwt_suggested_policy() {
let config = Project::from_toml_str(
r#"
[[resolutions]]
match = "common/estates_preload/**"
policy = "cwt_suggested"
"#,
)
.expect("parse match+policy");
assert_eq!(
config.resolutions[0].policy,
Some(ResolutionPolicy::CwtSuggested)
);
let map = ResolutionMap::from_entries(&config.resolutions).expect("build map");
assert!(map.pattern_rules.is_empty());
assert_eq!(
map.lookup(game_path("common/estates_preload/test.txt"), "no-id", ""),
None
);
}
#[test]
fn a_policy_with_an_address_constrained_match_is_a_config_error() {
let error = Project::from_toml_str(
r#"
[[resolutions]]
match = "common/estates_preload/**::estate_*"
policy = "cwt_suggested"
"#,
)
.expect_err("a policy match cannot name an address");
assert!(
error
.to_string()
.contains("policy action does not support address-constrained match selectors"),
"{error}"
);
}
#[test]
fn a_policy_with_an_invalid_match_is_a_config_error() {
let error = Project::from_toml_str(
r#"
[[resolutions]]
match = "re:("
policy = "cwt_suggested"
"#,
)
.expect_err("the match pattern is still compiled");
assert!(error.to_string().contains("invalid regex"), "{error}");
}
#[test]
fn rejects_match_combined_with_other_selectors() {
let cases = [
r#"
[[resolutions]]
match = "common/**"
file = "common/foo.txt"
prefer_mod = "x"
"#,
r#"
[[resolutions]]
match = "common/**"
conflict_id = "ab12cd34"
prefer_mod = "x"
"#,
];
for content in cases {
let err =
Project::from_toml_str(content).expect_err("multi-selector should be rejected");
assert!(
err.to_string().contains("exactly one selector"),
"expected multi-selector error, got {err}"
);
}
}
#[test]
fn rejects_handler_without_match_selector() {
let err = Project::from_toml_str(
r#"
[[resolutions]]
file = "common/foo.txt"
handler = "last_writer"
"#,
)
.expect_err("handler+file should be rejected");
assert!(
err.to_string().contains("handler action requires match"),
"got: {err}"
);
}
#[test]
fn rejects_handler_combined_with_other_actions() {
let err = Project::from_toml_str(
r#"
[[resolutions]]
match = "common/**"
handler = "last_writer"
prefer_mod = "x"
"#,
)
.expect_err("handler+prefer_mod should be rejected");
assert!(err.to_string().contains("exactly one action"), "got: {err}");
}
#[test]
fn rejects_invalid_match_dsl() {
let err = Project::from_toml_str(
r#"
[[resolutions]]
match = "re:[unterminated"
handler = "last_writer"
"#,
)
.expect_err("invalid regex must surface");
assert!(err.to_string().contains("invalid regex"), "got: {err}");
}
#[test]
fn lookup_precedence_conflict_id_beats_pattern() {
let config = Project::from_toml_str(
r#"
[[resolutions]]
match = "**"
handler = "last_writer"
[[resolutions]]
conflict_id = "abc12345"
prefer_mod = "specific-mod"
"#,
)
.expect("parse");
let map = ResolutionMap::from_entries(&config.resolutions).expect("build map");
assert_eq!(
map.lookup(game_path("anything.txt"), "abc12345", "any/leaf"),
Some(&ResolutionDecision::PreferMod("specific-mod".to_string()))
);
}
#[test]
fn lookup_precedence_file_beats_pattern() {
let config = Project::from_toml_str(
r#"
[[resolutions]]
match = "**"
handler = "last_writer"
[[resolutions]]
file = "events/foo.txt"
prefer_mod = "file-mod"
"#,
)
.expect("parse");
let map = ResolutionMap::from_entries(&config.resolutions).expect("build map");
assert_eq!(
map.lookup(game_path("events/foo.txt"), "no-id", "any/leaf"),
Some(&ResolutionDecision::PreferMod("file-mod".to_string()))
);
assert_eq!(
map.lookup(game_path("events/bar.txt"), "no-id", "any/leaf"),
Some(&ResolutionDecision::Handler("last_writer".to_string()))
);
}
#[test]
fn file_and_match_policy_entries_validate_and_add_no_decisions() {
let config = Project::from_toml_str(
r#"
[[resolutions]]
match = "common/estates_preload/**"
policy = "cwt_suggested"
[[resolutions]]
file = "common/estates_preload/exact.txt"
policy = "cwt_suggested"
"#,
)
.expect("parse");
let map = ResolutionMap::from_entries(&config.resolutions).expect("build map");
assert_eq!(map, ResolutionMap::default());
}
#[test]
fn lookup_pattern_rules_evaluated_in_declaration_order() {
let config = Project::from_toml_str(
r#"
[[resolutions]]
match = "common/ideas/**::xx_idea_*"
handler = "last_writer"
[[resolutions]]
match = "common/ideas/**"
handler = "defer"
"#,
)
.expect("parse");
let map = ResolutionMap::from_entries(&config.resolutions).expect("build map");
assert_eq!(
map.lookup(
game_path("common/ideas/national.txt"),
"no-id",
"xx_idea_pirates"
),
Some(&ResolutionDecision::Handler("last_writer".to_string()))
);
assert_eq!(
map.lookup(
game_path("common/ideas/national.txt"),
"no-id",
"yy_idea_pirates"
),
Some(&ResolutionDecision::Handler("defer".to_string()))
);
}
#[test]
fn lookup_pattern_with_leaf_matcher_skipped_when_address_empty() {
let config = Project::from_toml_str(
r#"
[[resolutions]]
match = "common/**::xx_*"
handler = "last_writer"
"#,
)
.expect("parse");
let map = ResolutionMap::from_entries(&config.resolutions).expect("build map");
assert_eq!(map.lookup(game_path("common/foo.txt"), "no-id", ""), None);
}
}