1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub enum FileKind {
38 Internal,
40 Clausewitz,
42 Jomini,
43 Vanilla,
45 Dlc(u8),
47 LoadedMod(u8),
49 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 path: PathBuf,
67 kind: FileKind,
69 idx: Option<PathTableIndex>,
73 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 #[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 #[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 if path_ord == Ordering::Equal { self.kind.cmp(&other.kind) } else { path_ord }
137 }
138}
139
140pub trait FileHandler<T: Send>: Sync + Send {
142 fn config(&mut self, _config: &Block) {}
144
145 fn subpath(&self) -> PathBuf;
149
150 fn load_file(&self, entry: &FileEntry, parser: &ParserMemory) -> Option<T>;
155
156 fn handle_file(&mut self, entry: &FileEntry, loaded: T);
159
160 fn finalize(&mut self) {}
163}
164
165#[derive(Clone, Debug)]
166pub struct LoadedMod {
167 kind: FileKind,
169
170 #[allow(dead_code)]
172 label: String,
173
174 root: PathBuf,
176
177 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 vanilla_root: Option<PathBuf>,
207
208 #[cfg(feature = "jomini")]
210 clausewitz_root: Option<PathBuf>,
211
212 #[cfg(feature = "jomini")]
214 jomini_root: Option<PathBuf>,
215
216 the_mod: LoadedMod,
218
219 pub loaded_mods: Vec<LoadedMod>,
221
222 loaded_dlcs: Vec<LoadedMod>,
224
225 config: Option<Block>,
227
228 files: Vec<FileEntry>,
230
231 ordered_files: Vec<FileEntry>,
233
234 filename_tokens: Vec<Token>,
237
238 filenames: TigerHashSet<PathBuf>,
240
241 directories: RwLock<TigerHashSet<PathBuf>>,
243
244 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 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 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)] 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 self.files.sort();
466
467 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 if self.exists(key) {
539 return true;
540 }
541
542 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 Ok(_) => unreachable!(),
553 Err(idx) => {
554 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")] 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 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 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() .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() .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}