Skip to main content

tiger_lib/
fileset.rs

1//! Track all the files (vanilla and mods) that are relevant to the current validation.
2
3use std::borrow::ToOwned;
4use std::cmp::Ordering;
5use std::ffi::OsStr;
6use std::fmt::{Display, Formatter};
7use std::path::{Path, PathBuf};
8use std::string::ToString;
9use std::sync::RwLock;
10
11use anyhow::{Result, bail};
12use rayon::prelude::*;
13use walkdir::WalkDir;
14
15use crate::block::Block;
16use crate::everything::{Everything, FilesError};
17use crate::game::Game;
18use crate::helpers::TigerHashSet;
19use crate::item::Item;
20#[cfg(feature = "vic3")]
21use crate::mod_metadata::ModMetadata;
22#[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
23use crate::modfile::ModFile;
24use crate::parse::ParserMemory;
25use crate::pathtable::{PathTable, PathTableIndex};
26use crate::report::{
27    ErrorKey, Severity, add_loaded_dlc_root, add_loaded_mod_root, err, fatal, report,
28};
29use crate::token::Token;
30use crate::util::fix_slashes_for_target_platform;
31
32/// Note that ordering of these enum values matters.
33/// Files later in the order will override files of the same name before them,
34/// and the warnings about duplicates take that into account.
35// TODO: verify the relative order of `Clausewitz` and `Jomini`
36#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub enum FileKind {
38    /// `Internal` is for parsing tiger's own data. The user should not see warnings from this.
39    Internal,
40    /// `Clausewitz` and `Jomini` are directories bundled with the base game.
41    Clausewitz,
42    Jomini,
43    /// The base game files.
44    Vanilla,
45    /// Downloadable content present on the user's system.
46    Dlc(u8),
47    /// Other mods loaded as directed by the config file. 0-based indexing.
48    LoadedMod(u8),
49    /// The mod under scrutiny. Usually, warnings are not emitted unless they touch `Mod` files.
50    Mod,
51}
52
53impl FileKind {
54    pub fn counts_as_vanilla(&self) -> bool {
55        match self {
56            FileKind::Clausewitz | FileKind::Jomini | FileKind::Vanilla | FileKind::Dlc(_) => true,
57            FileKind::Internal | FileKind::LoadedMod(_) | FileKind::Mod => false,
58        }
59    }
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct FileEntry {
64    /// Pathname components below the mod directory or the vanilla game dir
65    /// Must not be empty.
66    path: PathBuf,
67    /// Whether it's a vanilla or mod file
68    kind: FileKind,
69    /// Index into the `PathTable`. Used to initialize `Loc`, which doesn't carry a copy of the pathbuf.
70    /// A `FileEntry` might not have this index, because `FileEntry` needs to be usable before the (ordered)
71    /// path table is created.
72    idx: Option<PathTableIndex>,
73    /// The full filesystem path of this entry. Not used for ordering or equality.
74    fullpath: PathBuf,
75}
76
77impl FileEntry {
78    pub fn new(path: PathBuf, kind: FileKind, fullpath: PathBuf) -> Self {
79        assert!(path.file_name().is_some());
80        Self { path, kind, idx: None, fullpath }
81    }
82
83    pub fn kind(&self) -> FileKind {
84        self.kind
85    }
86
87    pub fn path(&self) -> &Path {
88        &self.path
89    }
90
91    pub fn fullpath(&self) -> &Path {
92        &self.fullpath
93    }
94
95    /// Convenience function
96    /// Won't panic because `FileEntry` with empty filename is not allowed.
97    #[allow(clippy::missing_panics_doc)]
98    pub fn filename(&self) -> &OsStr {
99        self.path.file_name().unwrap()
100    }
101
102    fn store_in_pathtable(&mut self) {
103        assert!(self.idx.is_none());
104        self.idx = Some(PathTable::store(self.path.clone(), self.fullpath.clone()));
105    }
106
107    pub fn path_idx(&self) -> Option<PathTableIndex> {
108        self.idx
109    }
110}
111
112impl Display for FileEntry {
113    fn fmt(&self, fmt: &mut Formatter) -> Result<(), std::fmt::Error> {
114        write!(fmt, "{}", self.path.display())
115    }
116}
117
118impl PartialOrd for FileEntry {
119    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
120        Some(self.cmp(other))
121    }
122}
123
124impl Ord for FileEntry {
125    fn cmp(&self, other: &Self) -> Ordering {
126        // Compare idx if available (for speed), otherwise compare the paths.
127        // TODO: the unnecessary unwrap can be fixed after msrv is high enough to use multiple `let` in one if.
128        #[allow(clippy::unnecessary_unwrap)]
129        let path_ord = if self.idx.is_some() && other.idx.is_some() {
130            self.idx.unwrap().cmp(&other.idx.unwrap())
131        } else {
132            self.path.cmp(&other.path)
133        };
134
135        // For same paths, the later [`FileKind`] wins.
136        if path_ord == Ordering::Equal { self.kind.cmp(&other.kind) } else { path_ord }
137    }
138}
139
140/// A trait for a submodule that can process files.
141pub trait FileHandler<T: Send>: Sync + Send {
142    /// The `FileHandler` can read settings it needs from the ck3-tiger config.
143    fn config(&mut self, _config: &Block) {}
144
145    /// Which files this handler is interested in.
146    /// This is a directory prefix of files it wants to handle,
147    /// relative to the mod or vanilla root.
148    fn subpath(&self) -> PathBuf;
149
150    /// This is called for each matching file, in arbitrary order.
151    /// If a `T` is returned, it will be passed to `handle_file` later.
152    /// Since `load_file` is executed multi-threaded while `handle_file`
153    /// is single-threaded, try to do the heavy work in this function.
154    fn load_file(&self, entry: &FileEntry, parser: &ParserMemory) -> Option<T>;
155
156    /// This is called for each matching file in turn, in lexical order.
157    /// That's the order in which the CK3 game engine loads them too.
158    fn handle_file(&mut self, entry: &FileEntry, loaded: T);
159
160    /// This is called after all files have been handled.
161    /// The `FileHandler` can generate indexes, perform full-data checks, etc.
162    fn finalize(&mut self) {}
163}
164
165#[derive(Clone, Debug)]
166pub struct LoadedMod {
167    /// The `FileKind` to use for file entries from this mod.
168    kind: FileKind,
169
170    /// The tag used for this mod in error messages.
171    #[allow(dead_code)]
172    label: String,
173
174    /// The location of this mod in the filesystem.
175    root: PathBuf,
176
177    /// A list of directories that should not be read from vanilla or previous mods.
178    replace_paths: Vec<PathBuf>,
179}
180
181impl LoadedMod {
182    fn new_main_mod(root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
183        Self { kind: FileKind::Mod, label: "MOD".to_string(), root, replace_paths }
184    }
185
186    fn new(kind: FileKind, label: String, root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
187        Self { kind, label, root, replace_paths }
188    }
189
190    pub fn root(&self) -> &Path {
191        &self.root
192    }
193
194    pub fn kind(&self) -> FileKind {
195        self.kind
196    }
197
198    pub fn should_replace(&self, path: &Path) -> bool {
199        self.replace_paths.iter().any(|p| p == path)
200    }
201}
202
203#[derive(Debug)]
204pub struct Fileset {
205    /// The CK3 game directory.
206    vanilla_root: Option<PathBuf>,
207
208    /// Extra CK3 directory loaded before vanilla.
209    #[cfg(feature = "jomini")]
210    clausewitz_root: Option<PathBuf>,
211
212    /// Extra CK3 directory loaded before vanilla.
213    #[cfg(feature = "jomini")]
214    jomini_root: Option<PathBuf>,
215
216    /// The mod being analyzed.
217    the_mod: LoadedMod,
218
219    /// Other mods to be loaded before `mod`, in order.
220    pub loaded_mods: Vec<LoadedMod>,
221
222    /// DLC directories to be loaded after vanilla, in order.
223    loaded_dlcs: Vec<LoadedMod>,
224
225    /// The ck3-tiger config.
226    config: Option<Block>,
227
228    /// The CK3 and mod files in arbitrary order (will be empty after `finalize`).
229    files: Vec<FileEntry>,
230
231    /// The CK3 and mod files in the order the game would load them.
232    ordered_files: Vec<FileEntry>,
233
234    /// Filename Tokens for the files in `ordered_files`.
235    /// Used for [`Fileset::iter_keys()`].
236    filename_tokens: Vec<Token>,
237
238    /// All filenames from `ordered_files`, for quick lookup.
239    filenames: TigerHashSet<PathBuf>,
240
241    /// All directories that have been looked up, for quick lookup.
242    directories: RwLock<TigerHashSet<PathBuf>>,
243
244    /// Filenames that have been looked up during validation. Used to filter the --unused output.
245    used: RwLock<TigerHashSet<String>>,
246}
247
248impl Fileset {
249    pub fn new(vanilla_dir: Option<&Path>, mod_root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
250        let vanilla_root = if Game::is_jomini() {
251            vanilla_dir.map(|dir| dir.join("game"))
252        } else {
253            vanilla_dir.map(ToOwned::to_owned)
254        };
255        #[cfg(feature = "jomini")]
256        let clausewitz_root = vanilla_dir.map(|dir| dir.join("clausewitz"));
257        #[cfg(feature = "jomini")]
258        let jomini_root = vanilla_dir.map(|dir| dir.join("jomini"));
259
260        Fileset {
261            vanilla_root,
262            #[cfg(feature = "jomini")]
263            clausewitz_root,
264            #[cfg(feature = "jomini")]
265            jomini_root,
266            the_mod: LoadedMod::new_main_mod(mod_root, replace_paths),
267            loaded_mods: Vec::new(),
268            loaded_dlcs: Vec::new(),
269            config: None,
270            files: Vec::new(),
271            ordered_files: Vec::new(),
272            filename_tokens: Vec::new(),
273            filenames: TigerHashSet::default(),
274            directories: RwLock::new(TigerHashSet::default()),
275            used: RwLock::new(TigerHashSet::default()),
276        }
277    }
278
279    pub fn config(
280        &mut self,
281        config: Block,
282        #[allow(unused_variables)] workshop_dir: Option<&Path>,
283        #[allow(unused_variables)] paradox_dir: Option<&Path>,
284    ) -> Result<()> {
285        let config_path = config.loc.fullpath();
286        for block in config.get_field_blocks("load_mod") {
287            let mod_idx;
288            if let Ok(idx) = u8::try_from(self.loaded_mods.len()) {
289                mod_idx = idx;
290            } else {
291                bail!("too many loaded mods, cannot process more");
292            }
293
294            let default_label = || format!("MOD{mod_idx}");
295            let label =
296                block.get_field_value("label").map_or_else(default_label, ToString::to_string);
297
298            if Game::is_ck3() || Game::is_imperator() || Game::is_hoi4() {
299                #[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
300                if let Some(path) = get_modfile(&label, config_path, block, paradox_dir) {
301                    let modfile = ModFile::read(&path)?;
302                    eprintln!(
303                        "Loading secondary mod {label} from: {}{}",
304                        modfile.modpath().display(),
305                        modfile
306                            .display_name()
307                            .map_or_else(String::new, |name| format!(" \"{name}\"")),
308                    );
309                    let kind = FileKind::LoadedMod(mod_idx);
310                    let loaded_mod = LoadedMod::new(
311                        kind,
312                        label.clone(),
313                        modfile.modpath().clone(),
314                        modfile.replace_paths(),
315                    );
316                    add_loaded_mod_root(label);
317                    self.loaded_mods.push(loaded_mod);
318                } else {
319                    bail!(
320                        "could not load secondary mod from config; missing valid `modfile` or `workshop_id` field"
321                    );
322                }
323            } else if Game::is_vic3() {
324                #[cfg(feature = "vic3")]
325                if let Some(pathdir) = get_mod(&label, config_path, block, workshop_dir) {
326                    match ModMetadata::read(&pathdir) {
327                        Ok(metadata) => {
328                            eprintln!(
329                                "Loading secondary mod {label} from: {}{}",
330                                pathdir.display(),
331                                metadata
332                                    .display_name()
333                                    .map_or_else(String::new, |name| format!(" \"{name}\"")),
334                            );
335                            let kind = FileKind::LoadedMod(mod_idx);
336                            let loaded_mod = LoadedMod::new(
337                                kind,
338                                label.clone(),
339                                pathdir,
340                                metadata.replace_paths(),
341                            );
342                            add_loaded_mod_root(label);
343                            self.loaded_mods.push(loaded_mod);
344                        }
345                        Err(e) => {
346                            eprintln!(
347                                "could not load secondary mod {label} from: {}",
348                                pathdir.display()
349                            );
350                            eprintln!("  because: {e}");
351                        }
352                    }
353                } else {
354                    bail!(
355                        "could not load secondary mod from config; missing valid `mod` or `workshop_id` field"
356                    );
357                }
358            }
359        }
360        self.config = Some(config);
361        Ok(())
362    }
363
364    fn should_replace(&self, path: &Path, kind: FileKind) -> bool {
365        if kind == FileKind::Mod {
366            return false;
367        }
368        if kind < FileKind::Mod && self.the_mod.should_replace(path) {
369            return true;
370        }
371        for loaded_mod in &self.loaded_mods {
372            if kind < loaded_mod.kind && loaded_mod.should_replace(path) {
373                return true;
374            }
375        }
376        false
377    }
378
379    fn scan(&mut self, path: &Path, kind: FileKind) -> Result<(), walkdir::Error> {
380        for entry in WalkDir::new(path) {
381            let entry = entry?;
382            if entry.depth() == 0 || !entry.file_type().is_file() {
383                continue;
384            }
385            // unwrap is safe here because WalkDir gives us paths with this prefix.
386            let inner_path = entry.path().strip_prefix(path).unwrap();
387            if inner_path.starts_with(".git") {
388                continue;
389            }
390            let inner_dir = inner_path.parent().unwrap_or_else(|| Path::new(""));
391            if self.should_replace(inner_dir, kind) {
392                continue;
393            }
394            self.files.push(FileEntry::new(
395                inner_path.to_path_buf(),
396                kind,
397                entry.path().to_path_buf(),
398            ));
399        }
400        Ok(())
401    }
402
403    pub fn scan_all(&mut self) -> Result<(), FilesError> {
404        #[cfg(feature = "jomini")]
405        if let Some(clausewitz_root) = self.clausewitz_root.clone() {
406            self.scan(&clausewitz_root.clone(), FileKind::Clausewitz).map_err(|e| {
407                FilesError::VanillaUnreadable { path: clausewitz_root.clone(), source: e }
408            })?;
409        }
410        #[cfg(feature = "jomini")]
411        if let Some(jomini_root) = &self.jomini_root.clone() {
412            self.scan(&jomini_root.clone(), FileKind::Jomini).map_err(|e| {
413                FilesError::VanillaUnreadable { path: jomini_root.clone(), source: e }
414            })?;
415        }
416        if let Some(vanilla_root) = &self.vanilla_root.clone() {
417            self.scan(&vanilla_root.clone(), FileKind::Vanilla).map_err(|e| {
418                FilesError::VanillaUnreadable { path: vanilla_root.clone(), source: e }
419            })?;
420            #[cfg(feature = "hoi4")]
421            if Game::is_hoi4() {
422                self.load_dlcs(&vanilla_root.join("integrated_dlc"))?;
423            }
424            self.load_dlcs(&vanilla_root.join("dlc"))?;
425        }
426        // loaded_mods is cloned here for the borrow checker
427        for loaded_mod in &self.loaded_mods.clone() {
428            self.scan(loaded_mod.root(), loaded_mod.kind()).map_err(|e| {
429                FilesError::ModUnreadable { path: loaded_mod.root().to_path_buf(), source: e }
430            })?;
431        }
432        #[allow(clippy::unnecessary_to_owned)] // borrow checker requires to_path_buf here
433        self.scan(&self.the_mod.root().to_path_buf(), FileKind::Mod).map_err(|e| {
434            FilesError::ModUnreadable { path: self.the_mod.root().to_path_buf(), source: e }
435        })?;
436        Ok(())
437    }
438
439    pub fn load_dlcs(&mut self, dlc_root: &Path) -> Result<(), FilesError> {
440        for entry in WalkDir::new(dlc_root).max_depth(1).sort_by_file_name().into_iter().flatten() {
441            if entry.depth() == 1 && entry.file_type().is_dir() {
442                let label = entry.file_name().to_string_lossy().to_string();
443                let idx =
444                    u8::try_from(self.loaded_dlcs.len()).expect("more than 256 DLCs installed");
445                let dlc = LoadedMod::new(
446                    FileKind::Dlc(idx),
447                    label.clone(),
448                    entry.path().to_path_buf(),
449                    Vec::new(),
450                );
451                self.scan(dlc.root(), dlc.kind()).map_err(|e| FilesError::VanillaUnreadable {
452                    path: dlc.root().to_path_buf(),
453                    source: e,
454                })?;
455                self.loaded_dlcs.push(dlc);
456                add_loaded_dlc_root(label);
457            }
458        }
459        Ok(())
460    }
461
462    pub fn finalize(&mut self) {
463        // This sorts by pathname but where pathnames are equal it places `Mod` entries after `Vanilla` entries
464        // and `LoadedMod` entries between them in order
465        self.files.sort();
466
467        // When there are identical paths, only keep the last entry of them.
468        for entry in self.files.drain(..) {
469            if let Some(prev) = self.ordered_files.last_mut() {
470                if entry.path == prev.path {
471                    *prev = entry;
472                } else {
473                    self.ordered_files.push(entry);
474                }
475            } else {
476                self.ordered_files.push(entry);
477            }
478        }
479
480        for entry in &mut self.ordered_files {
481            let token = Token::new(&entry.filename().to_string_lossy(), (&*entry).into());
482            self.filename_tokens.push(token);
483            entry.store_in_pathtable();
484            self.filenames.insert(entry.path.clone());
485        }
486    }
487
488    pub fn get_files_under<'a>(&'a self, subpath: &'a Path) -> &'a [FileEntry] {
489        let start = self.ordered_files.partition_point(|entry| entry.path < subpath);
490        let end = start
491            + self.ordered_files[start..].partition_point(|entry| entry.path.starts_with(subpath));
492        &self.ordered_files[start..end]
493    }
494
495    pub fn filter_map_under<F, T>(&self, subpath: &Path, f: F) -> Vec<T>
496    where
497        F: Fn(&FileEntry) -> Option<T> + Sync + Send,
498        T: Send,
499    {
500        self.get_files_under(subpath).par_iter().filter_map(f).collect()
501    }
502
503    pub fn handle<T: Send, H: FileHandler<T>>(&self, handler: &mut H, parser: &ParserMemory) {
504        if let Some(config) = &self.config {
505            handler.config(config);
506        }
507        let subpath = handler.subpath();
508        let entries = self.filter_map_under(&subpath, |entry| {
509            handler.load_file(entry, parser).map(|loaded| (entry.clone(), loaded))
510        });
511        for (entry, loaded) in entries {
512            handler.handle_file(&entry, loaded);
513        }
514        handler.finalize();
515    }
516
517    pub fn mark_used(&self, file: &str) {
518        let file = file.strip_prefix('/').unwrap_or(file);
519        self.used.write().unwrap().insert(file.to_string());
520    }
521
522    pub fn exists(&self, key: &str) -> bool {
523        let key = key.strip_prefix('/').unwrap_or(key);
524        let filepath = if Game::is_hoi4() && key.contains('\\') {
525            PathBuf::from(key.replace('\\', "/"))
526        } else {
527            PathBuf::from(key)
528        };
529        self.filenames.contains(&filepath)
530    }
531
532    pub fn iter_keys(&self) -> impl Iterator<Item = &Token> {
533        self.filename_tokens.iter()
534    }
535
536    pub fn entry_exists(&self, key: &str) -> bool {
537        // file exists
538        if self.exists(key) {
539            return true;
540        }
541
542        // directory lookup - check if there are any files within the directory
543        let dir = key.strip_prefix('/').unwrap_or(key);
544        let dirpath = Path::new(dir);
545
546        if self.directories.read().unwrap().contains(dirpath) {
547            return true;
548        }
549
550        match self.ordered_files.binary_search_by_key(&dirpath, |fe| fe.path.as_path()) {
551            // should be handled in `exists` already; something must be wrong
552            Ok(_) => unreachable!(),
553            Err(idx) => {
554                // there exists a file in the given directory
555                if self.ordered_files[idx].path.starts_with(dirpath) {
556                    self.directories.write().unwrap().insert(dirpath.to_path_buf());
557                    return true;
558                }
559            }
560        }
561        false
562    }
563
564    pub fn verify_entry_exists(&self, entry: &str, token: &Token, max_sev: Severity) {
565        self.mark_used(&entry.replace("//", "/"));
566        if !self.entry_exists(entry) {
567            let msg = format!("file or directory {entry} does not exist");
568            report(ErrorKey::MissingFile, Item::File.severity().at_most(max_sev))
569                .msg(msg)
570                .loc(token)
571                .push();
572        }
573    }
574
575    #[cfg(feature = "ck3")] // vic3 happens not to use
576    pub fn verify_exists(&self, file: &Token) {
577        self.mark_used(&file.as_str().replace("//", "/"));
578        if !self.exists(file.as_str()) {
579            let msg = "referenced file does not exist";
580            report(ErrorKey::MissingFile, Item::File.severity()).msg(msg).loc(file).push();
581        }
582    }
583
584    pub fn verify_exists_implied(&self, file: &str, t: &Token, max_sev: Severity) {
585        self.mark_used(&file.replace("//", "/"));
586        if !self.exists(file) {
587            let msg = format!("file {file} does not exist");
588            report(ErrorKey::MissingFile, Item::File.severity().at_most(max_sev))
589                .msg(msg)
590                .loc(t)
591                .push();
592        }
593    }
594
595    pub fn verify_exists_implied_crashes(&self, file: &str, t: &Token) {
596        self.mark_used(&file.replace("//", "/"));
597        if !self.exists(file) {
598            let msg = format!("file {file} does not exist");
599            fatal(ErrorKey::Crash).msg(msg).loc(t).push();
600        }
601    }
602
603    pub fn validate(&self, _data: &Everything) {
604        let common_dirs = match Game::game() {
605            #[cfg(feature = "ck3")]
606            Game::Ck3 => crate::ck3::tables::misc::COMMON_DIRS,
607            #[cfg(feature = "vic3")]
608            Game::Vic3 => crate::vic3::tables::misc::COMMON_DIRS,
609            #[cfg(feature = "imperator")]
610            Game::Imperator => crate::imperator::tables::misc::COMMON_DIRS,
611            #[cfg(feature = "hoi4")]
612            Game::Hoi4 => crate::hoi4::tables::misc::COMMON_DIRS,
613        };
614        let common_subdirs_ok = match Game::game() {
615            #[cfg(feature = "ck3")]
616            Game::Ck3 => crate::ck3::tables::misc::COMMON_SUBDIRS_OK,
617            #[cfg(feature = "vic3")]
618            Game::Vic3 => crate::vic3::tables::misc::COMMON_SUBDIRS_OK,
619            #[cfg(feature = "imperator")]
620            Game::Imperator => crate::imperator::tables::misc::COMMON_SUBDIRS_OK,
621            #[cfg(feature = "hoi4")]
622            Game::Hoi4 => crate::hoi4::tables::misc::COMMON_SUBDIRS_OK,
623        };
624        // Check the files in directories in common/ to make sure they are in known directories
625        let mut warned: Vec<&Path> = Vec::new();
626        'outer: for entry in &self.ordered_files {
627            if !entry.path.to_string_lossy().ends_with(".txt") {
628                continue;
629            }
630            if entry.path == OsStr::new("common/achievement_groups.txt") {
631                continue;
632            }
633            #[cfg(feature = "hoi4")]
634            if Game::is_hoi4() {
635                for valid in crate::hoi4::tables::misc::COMMON_FILES {
636                    if <&str as AsRef<Path>>::as_ref(valid) == entry.path {
637                        continue 'outer;
638                    }
639                }
640            }
641            let dirname = entry.path.parent().unwrap();
642            if warned.contains(&dirname) {
643                continue;
644            }
645            if !entry.path.starts_with("common") {
646                // Check if the modder forgot the common/ part
647                let joined = Path::new("common").join(&entry.path);
648                for valid in common_dirs {
649                    if joined.starts_with(valid) {
650                        let msg = format!("file in unexpected directory {}", dirname.display());
651                        let info = format!("did you mean common/{} ?", dirname.display());
652                        err(ErrorKey::Filename).msg(msg).info(info).loc(entry).push();
653                        warned.push(dirname);
654                        continue 'outer;
655                    }
656                }
657                continue;
658            }
659
660            for valid in common_subdirs_ok {
661                if entry.path.starts_with(valid) {
662                    continue 'outer;
663                }
664            }
665
666            for valid in common_dirs {
667                if <&str as AsRef<Path>>::as_ref(valid) == dirname {
668                    continue 'outer;
669                }
670            }
671
672            if entry.path.starts_with("common/scripted_values") {
673                let msg = "file should be in common/script_values/";
674                err(ErrorKey::Filename).msg(msg).loc(entry).push();
675            } else if (Game::is_ck3() || Game::is_imperator())
676                && entry.path.starts_with("common/on_actions")
677            {
678                let msg = "file should be in common/on_action/";
679                err(ErrorKey::Filename).msg(msg).loc(entry).push();
680            } else if (Game::is_vic3() || Game::is_hoi4())
681                && entry.path.starts_with("common/on_action")
682            {
683                let msg = "file should be in common/on_actions/";
684                err(ErrorKey::Filename).msg(msg).loc(entry).push();
685            } else if Game::is_vic3() && entry.path.starts_with("common/modifiers") {
686                let msg = "file should be in common/static_modifiers since 1.7";
687                err(ErrorKey::Filename).msg(msg).loc(entry).push();
688            } else if Game::is_ck3() && entry.path.starts_with("common/vassal_contracts") {
689                let msg = "common/vassal_contracts was replaced with common/subject_contracts/contracts/ in 1.16";
690                err(ErrorKey::Filename).msg(msg).loc(entry).push();
691            } else {
692                let msg = format!("file in unexpected directory `{}`", dirname.display());
693                err(ErrorKey::Filename).msg(msg).loc(entry).push();
694            }
695            warned.push(dirname);
696        }
697    }
698
699    pub fn check_unused_dds(&self, _data: &Everything) {
700        let mut vec = Vec::new();
701        for entry in &self.ordered_files {
702            let pathname = entry.path.to_string_lossy();
703            if entry.path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("dds"))
704                && !entry.path.starts_with("gfx/interface/illustrations/loading_screens")
705                && !self.used.read().unwrap().contains(pathname.as_ref())
706            {
707                vec.push(entry);
708            }
709        }
710        for entry in vec {
711            report(ErrorKey::UnusedFile, Severity::Untidy)
712                .msg("Unused DDS files")
713                .abbreviated(entry)
714                .push();
715        }
716    }
717}
718
719#[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
720fn get_modfile(
721    label: &String,
722    config_path: &Path,
723    block: &Block,
724    paradox_dir: Option<&Path>,
725) -> Option<PathBuf> {
726    let mut path: Option<PathBuf> = None;
727    if let Some(modfile) = block.get_field_value("modfile") {
728        let modfile_path = fix_slashes_for_target_platform(
729            config_path
730                .parent()
731                .unwrap() // SAFETY: known to be for a file in a directory
732                .join(modfile.as_str()),
733        );
734        if modfile_path.exists() {
735            path = Some(modfile_path);
736        } else {
737            eprintln!("Could not find mod {label} at: {}", modfile_path.display());
738        }
739    }
740    if path.is_none() {
741        if let Some(workshop_id) = block.get_field_value("workshop_id") {
742            match paradox_dir {
743                Some(p) => {
744                    path = Some(fix_slashes_for_target_platform(
745                        p.join(format!("mod/ugc_{workshop_id}.mod")),
746                    ));
747                }
748                None => eprintln!("workshop_id defined, but could not find paradox directory"),
749            }
750        }
751    }
752    path
753}
754
755#[cfg(feature = "vic3")]
756fn get_mod(
757    label: &String,
758    config_path: &Path,
759    block: &Block,
760    workshop_dir: Option<&Path>,
761) -> Option<PathBuf> {
762    let mut path: Option<PathBuf> = None;
763    if let Some(modfile) = block.get_field_value("mod") {
764        let mod_path = fix_slashes_for_target_platform(
765            config_path
766                .parent()
767                .unwrap() // SAFETY: known to be for a file in a directory
768                .join(modfile.as_str()),
769        );
770        if mod_path.exists() {
771            path = Some(mod_path);
772        } else {
773            eprintln!("Could not find mod {label} at: {}", mod_path.display());
774        }
775    }
776    if path.is_none() {
777        if let Some(workshop_id) = block.get_field_value("workshop_id") {
778            match workshop_dir {
779                Some(w) => {
780                    path = Some(fix_slashes_for_target_platform(w.join(workshop_id.as_str())));
781                }
782                None => eprintln!("workshop_id defined, but could not find workshop"),
783            }
784        }
785    }
786    path
787}