foch/input/
file_filter.rs1use crate::game::eu4::Eu4;
2use crate::model::GamePath;
3use globset::{Candidate, GlobSet, GlobSetBuilder};
4
5#[derive(Clone, Debug)]
13pub struct FileFilter {
14 game: Eu4,
15 extra_ignore: GlobSet,
16 extra_ignore_pattern_count: usize,
17}
18
19impl FileFilter {
20 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 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 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 !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 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(®ular, "regular = yes\n").expect("write regular file");
188 fs::write(outside.join("events/leak.txt"), "leak = yes\n").expect("write outside file");
189 symlink(®ular, 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}