Skip to main content

foch/input/
file_filter.rs

1use crate::game::eu4::Eu4;
2use crate::model::GamePath;
3use globset::{Candidate, GlobSet, GlobSetBuilder};
4
5/// Filter applied while walking mod roots and the base game install. Combines
6/// the game's authoritative content-root list (`Eu4::is_loadable_content_path`)
7/// with user-configured extra ignore globs from [`crate::Config`].
8///
9/// Globs are matched (case-insensitive) against the portable `/` text of each
10/// discovered file's [`GamePath`]. The compiled [`GlobSet`] is built once and
11/// reused for every walk to avoid repeated regex compilation.
12#[derive(Clone, Debug)]
13pub struct FileFilter {
14	game: Eu4,
15	extra_ignore: GlobSet,
16	extra_ignore_pattern_count: usize,
17}
18
19impl FileFilter {
20	/// Build a filter for `game` with `extra_patterns` glob strings.
21	///
22	/// Returns `Err` containing the offending pattern and `globset` message if
23	/// any pattern fails to compile.
24	pub fn new(game: Eu4, extra_patterns: &[String]) -> Result<Self, String> {
25		let mut builder = GlobSetBuilder::new();
26		for pattern in extra_patterns {
27			let glob = globset::GlobBuilder::new(pattern)
28				.case_insensitive(true)
29				.literal_separator(false)
30				.backslash_escape(true)
31				.build()
32				.map_err(|err| {
33					format!("failed to parse extra_ignore_patterns pattern \"{pattern}\": {err}")
34				})?;
35			builder.add(glob);
36		}
37		let extra_ignore = builder
38			.build()
39			.map_err(|err| format!("failed to build extra_ignore_patterns GlobSet: {err}"))?;
40		Ok(Self {
41			game,
42			extra_ignore,
43			extra_ignore_pattern_count: extra_patterns.len(),
44		})
45	}
46
47	/// Filter that retains every path the game would load and applies no extra
48	/// ignore patterns. Useful in tests and contexts that don't have a
49	/// [`crate::Config`] handy.
50	pub fn for_game(game: Eu4) -> Self {
51		Self {
52			game,
53			extra_ignore: GlobSet::empty(),
54			extra_ignore_pattern_count: 0,
55		}
56	}
57
58	pub fn game(&self) -> &Eu4 {
59		&self.game
60	}
61
62	/// Returns `true` when the file at `relative` should be retained.
63	pub fn accepts(&self, relative: &GamePath) -> bool {
64		if !self.game.is_loadable_content_path(relative) {
65			return false;
66		}
67		if self.extra_ignore_pattern_count == 0 {
68			return true;
69		}
70		// Match portable UTF-8 bytes without reinterpreting them as a host path.
71		!self
72			.extra_ignore
73			.is_match_candidate(&Candidate::from_bytes(relative.as_str()))
74	}
75}
76
77#[cfg(test)]
78mod tests {
79	use super::*;
80	use crate::model::GamePathBuf;
81
82	fn pf(p: &str) -> GamePathBuf {
83		GamePathBuf::parse(p).expect("valid game path")
84	}
85
86	#[test]
87	fn extra_pattern_bak_matches_top_and_nested() {
88		let filter = FileFilter::new(Eu4, &["*.bak".to_string()]).unwrap();
89		assert!(!filter.accepts(&pf("common/foo.bak")));
90		assert!(!filter.accepts(&pf("common/dir/foo.bak")));
91		assert!(filter.accepts(&pf("common/foo.txt")));
92	}
93
94	#[test]
95	fn extra_pattern_dsstore_matches_top_and_nested() {
96		let filter = FileFilter::new(Eu4, &["**/.DS_Store".to_string()]).unwrap();
97		assert!(!filter.accepts(&pf("common/.DS_Store")));
98		assert!(!filter.accepts(&pf("common/nested/.DS_Store")));
99	}
100
101	#[test]
102	fn extra_pattern_match_is_case_insensitive() {
103		let filter = FileFilter::new(Eu4, &["*.BAK".to_string()]).unwrap();
104		assert!(!filter.accepts(&pf("common/Foo.bak")));
105		assert!(!filter.accepts(&pf("common/foo.BAK")));
106	}
107
108	#[test]
109	fn rejects_invalid_glob() {
110		let err = FileFilter::new(Eu4, &["[".to_string()]).unwrap_err();
111		assert!(err.contains("extra_ignore_patterns"));
112	}
113
114	#[test]
115	fn defers_to_game_root_filter_when_no_extra_patterns() {
116		let filter = FileFilter::for_game(Eu4);
117		assert!(filter.accepts(&pf("common/countries/X.txt")));
118		assert!(!filter.accepts(&pf("README.md")));
119		assert!(!filter.accepts(&pf(".git/HEAD")));
120		assert!(
121			!filter.accepts(&pf("common")),
122			"a root name alone is not content"
123		);
124	}
125
126	#[test]
127	fn loadable_root_matches_the_top_component_ignoring_ascii_case() {
128		let filter = FileFilter::for_game(Eu4);
129		assert!(filter.accepts(&pf("Common/countries/X.txt")));
130		assert!(filter.accepts(&pf("EVENTS/x.txt")));
131		assert!(!filter.accepts(&pf("commonx/countries/X.txt")));
132		assert!(!filter.accepts(&pf("mod/common/countries/X.txt")));
133	}
134
135	#[test]
136	fn extra_pattern_matches_portable_text_across_separators() {
137		// `literal_separator(false)` lets `*` cross `/`, as before the typed path.
138		let filter = FileFilter::new(Eu4, &["common/*.bak".to_string()]).unwrap();
139		assert!(!filter.accepts(&pf("common/nested/deep/foo.bak")));
140		assert!(filter.accepts(&pf("events/nested/foo.bak")));
141	}
142
143	#[test]
144	fn extra_patterns_use_portable_glob_escapes_on_every_host() {
145		let filter: FileFilter =
146			FileFilter::new(Eu4, &[r"common/literal\*.txt".to_string()]).expect("glob");
147		assert!(!filter.accepts(&pf("common/literal*.txt")));
148		assert!(filter.accepts(&pf("common/literal_other.txt")));
149	}
150
151	#[test]
152	fn collect_relative_files_drops_filtered_paths() {
153		use crate::input::resolve::{InventoryOwner, collect_relative_files};
154		use std::fs;
155		let dir = tempfile::tempdir().expect("tempdir");
156		let root = dir.path();
157		fs::create_dir_all(root.join("common/countries")).unwrap();
158		fs::create_dir_all(root.join(".git")).unwrap();
159		fs::create_dir_all(root.join("nested")).unwrap();
160		fs::write(root.join("common/countries/X.txt"), "x").unwrap();
161		fs::write(root.join("common/countries/X.bak"), "x").unwrap();
162		fs::write(root.join("README.md"), "r").unwrap();
163		fs::write(root.join(".git/HEAD"), "g").unwrap();
164		fs::write(root.join("nested/.DS_Store"), "d").unwrap();
165		fs::write(root.join("descriptor.mod"), "name=\"x\"").unwrap();
166
167		let filter =
168			FileFilter::new(Eu4, &["*.bak".to_string(), "**/.DS_Store".to_string()]).unwrap();
169		let files = collect_relative_files(root, &filter, InventoryOwner::Mod("x"))
170			.expect("collect relative files");
171		assert_eq!(files, vec![pf("common/countries/X.txt")]);
172	}
173
174	#[cfg(unix)]
175	#[test]
176	fn collect_relative_files_ignores_file_and_directory_symlinks() {
177		use crate::input::resolve::{InventoryOwner, collect_relative_files};
178		use std::fs;
179		use std::os::unix::fs::symlink;
180
181		let dir = tempfile::tempdir().expect("tempdir");
182		let root = dir.path().join("mod");
183		let outside = dir.path().join("outside");
184		fs::create_dir_all(root.join("common/countries")).expect("create mod files");
185		fs::create_dir_all(outside.join("events")).expect("create outside files");
186		let regular = root.join("common/countries/regular.txt");
187		fs::write(&regular, "regular = yes\n").expect("write regular file");
188		fs::write(outside.join("events/leak.txt"), "leak = yes\n").expect("write outside file");
189		symlink(&regular, root.join("common/countries/linked.txt")).expect("create file symlink");
190		symlink(outside.join("events"), root.join("events")).expect("create directory symlink");
191
192		let files =
193			collect_relative_files(&root, &FileFilter::for_game(Eu4), InventoryOwner::BaseGame)
194				.expect("collect relative files");
195		assert_eq!(files, vec![pf("common/countries/regular.txt")]);
196	}
197}