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::{bail, Result};
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 add_loaded_dlc_root, add_loaded_mod_root, err, fatal, report, warn_abbreviated, warn_header,
28 will_maybe_log, ErrorKey, Severity,
29};
30use crate::token::Token;
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 let path_ord = if self.idx.is_some() && other.idx.is_some() {
128 self.idx.unwrap().cmp(&other.idx.unwrap())
129 } else {
130 self.path.cmp(&other.path)
131 };
132
133 if path_ord == Ordering::Equal {
135 self.kind.cmp(&other.kind)
136 } else {
137 path_ord
138 }
139 }
140}
141
142pub trait FileHandler<T: Send>: Sync + Send {
144 fn config(&mut self, _config: &Block) {}
146
147 fn subpath(&self) -> PathBuf;
151
152 fn load_file(&self, entry: &FileEntry, parser: &ParserMemory) -> Option<T>;
157
158 fn handle_file(&mut self, entry: &FileEntry, loaded: T);
161
162 fn finalize(&mut self) {}
165}
166
167#[derive(Clone, Debug)]
168pub struct LoadedMod {
169 kind: FileKind,
171
172 #[allow(dead_code)]
174 label: String,
175
176 root: PathBuf,
178
179 replace_paths: Vec<PathBuf>,
181}
182
183impl LoadedMod {
184 fn new_main_mod(root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
185 Self { kind: FileKind::Mod, label: "MOD".to_string(), root, replace_paths }
186 }
187
188 fn new(kind: FileKind, label: String, root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
189 Self { kind, label, root, replace_paths }
190 }
191
192 pub fn root(&self) -> &Path {
193 &self.root
194 }
195
196 pub fn kind(&self) -> FileKind {
197 self.kind
198 }
199
200 pub fn should_replace(&self, path: &Path) -> bool {
201 self.replace_paths.iter().any(|p| p == path)
202 }
203}
204
205#[derive(Debug)]
206pub struct Fileset {
207 vanilla_root: Option<PathBuf>,
209
210 #[cfg(feature = "jomini")]
212 clausewitz_root: Option<PathBuf>,
213
214 #[cfg(feature = "jomini")]
216 jomini_root: Option<PathBuf>,
217
218 the_mod: LoadedMod,
220
221 pub loaded_mods: Vec<LoadedMod>,
223
224 loaded_dlcs: Vec<LoadedMod>,
226
227 config: Option<Block>,
229
230 files: Vec<FileEntry>,
232
233 ordered_files: Vec<FileEntry>,
235
236 filename_tokens: Vec<Token>,
239
240 filenames: TigerHashSet<PathBuf>,
242
243 directories: RwLock<TigerHashSet<PathBuf>>,
245
246 used: RwLock<TigerHashSet<String>>,
248}
249
250impl Fileset {
251 pub fn new(vanilla_dir: Option<&Path>, mod_root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
252 let vanilla_root = if Game::is_jomini() {
253 vanilla_dir.map(|dir| dir.join("game"))
254 } else {
255 vanilla_dir.map(ToOwned::to_owned)
256 };
257 #[cfg(feature = "jomini")]
258 let clausewitz_root = vanilla_dir.map(|dir| dir.join("clausewitz"));
259 #[cfg(feature = "jomini")]
260 let jomini_root = vanilla_dir.map(|dir| dir.join("jomini"));
261
262 Fileset {
263 vanilla_root,
264 #[cfg(feature = "jomini")]
265 clausewitz_root,
266 #[cfg(feature = "jomini")]
267 jomini_root,
268 the_mod: LoadedMod::new_main_mod(mod_root, replace_paths),
269 loaded_mods: Vec::new(),
270 loaded_dlcs: Vec::new(),
271 config: None,
272 files: Vec::new(),
273 ordered_files: Vec::new(),
274 filename_tokens: Vec::new(),
275 filenames: TigerHashSet::default(),
276 directories: RwLock::new(TigerHashSet::default()),
277 used: RwLock::new(TigerHashSet::default()),
278 }
279 }
280
281 pub fn config(
282 &mut self,
283 config: Block,
284 #[allow(unused_variables)] workshop_dir: Option<&Path>,
285 #[allow(unused_variables)] paradox_dir: Option<&Path>,
286 ) -> Result<()> {
287 let config_path = config.loc.fullpath();
288 for block in config.get_field_blocks("load_mod") {
289 let mod_idx;
290 if let Ok(idx) = u8::try_from(self.loaded_mods.len()) {
291 mod_idx = idx;
292 } else {
293 bail!("too many loaded mods, cannot process more");
294 }
295
296 let default_label = || format!("MOD{mod_idx}");
297 let label =
298 block.get_field_value("label").map_or_else(default_label, ToString::to_string);
299
300 if Game::is_ck3() || Game::is_imperator() || Game::is_hoi4() {
301 #[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
302 if let Some(path) = get_modfile(&label, config_path, block, paradox_dir) {
303 let modfile = ModFile::read(&path)?;
304 eprintln!(
305 "Loading secondary mod {label} from: {}{}",
306 modfile.modpath().display(),
307 modfile
308 .display_name()
309 .map_or_else(String::new, |name| format!(" \"{name}\"")),
310 );
311 let kind = FileKind::LoadedMod(mod_idx);
312 let loaded_mod = LoadedMod::new(
313 kind,
314 label.clone(),
315 modfile.modpath().clone(),
316 modfile.replace_paths(),
317 );
318 add_loaded_mod_root(label);
319 self.loaded_mods.push(loaded_mod);
320 } else {
321 bail!("could not load secondary mod from config; missing valid `modfile` or `workshop_id` field");
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 if let Ok(metadata) = ModMetadata::read(&pathdir) {
327 eprintln!(
328 "Loading secondary mod {label} from: {}{}",
329 pathdir.display(),
330 metadata
331 .display_name()
332 .map_or_else(String::new, |name| format!(" \"{name}\"")),
333 );
334 let kind = FileKind::LoadedMod(mod_idx);
335 let loaded_mod =
336 LoadedMod::new(kind, label.clone(), pathdir, metadata.replace_paths());
337 add_loaded_mod_root(label);
338 self.loaded_mods.push(loaded_mod);
339 } else {
340 bail!("does not look like a mod dir: {}", pathdir.display());
341 }
342 } else {
343 bail!("could not load secondary mod from config; missing valid `mod` or `workshop_id` field");
344 }
345 }
346 }
347 self.config = Some(config);
348 Ok(())
349 }
350
351 fn should_replace(&self, path: &Path, kind: FileKind) -> bool {
352 if kind == FileKind::Mod {
353 return false;
354 }
355 if kind < FileKind::Mod && self.the_mod.should_replace(path) {
356 return true;
357 }
358 for loaded_mod in &self.loaded_mods {
359 if kind < loaded_mod.kind && loaded_mod.should_replace(path) {
360 return true;
361 }
362 }
363 false
364 }
365
366 fn scan(&mut self, path: &Path, kind: FileKind) -> Result<(), walkdir::Error> {
367 for entry in WalkDir::new(path) {
368 let entry = entry?;
369 if entry.depth() == 0 || !entry.file_type().is_file() {
370 continue;
371 }
372 let inner_path = entry.path().strip_prefix(path).unwrap();
374 if inner_path.starts_with(".git") {
375 continue;
376 }
377 let inner_dir = inner_path.parent().unwrap_or_else(|| Path::new(""));
378 if self.should_replace(inner_dir, kind) {
379 continue;
380 }
381 self.files.push(FileEntry::new(
382 inner_path.to_path_buf(),
383 kind,
384 entry.path().to_path_buf(),
385 ));
386 }
387 Ok(())
388 }
389
390 pub fn scan_all(&mut self) -> Result<(), FilesError> {
391 #[cfg(feature = "jomini")]
392 if let Some(clausewitz_root) = self.clausewitz_root.clone() {
393 self.scan(&clausewitz_root.clone(), FileKind::Clausewitz).map_err(|e| {
394 FilesError::VanillaUnreadable { path: clausewitz_root.clone(), source: e }
395 })?;
396 }
397 #[cfg(feature = "jomini")]
398 if let Some(jomini_root) = &self.jomini_root.clone() {
399 self.scan(&jomini_root.clone(), FileKind::Jomini).map_err(|e| {
400 FilesError::VanillaUnreadable { path: jomini_root.clone(), source: e }
401 })?;
402 }
403 if let Some(vanilla_root) = &self.vanilla_root.clone() {
404 self.scan(&vanilla_root.clone(), FileKind::Vanilla).map_err(|e| {
405 FilesError::VanillaUnreadable { path: vanilla_root.clone(), source: e }
406 })?;
407 #[cfg(feature = "hoi4")]
408 if Game::is_hoi4() {
409 self.load_dlcs(&vanilla_root.join("integrated_dlc"))?;
410 }
411 self.load_dlcs(&vanilla_root.join("dlc"))?;
412 }
413 for loaded_mod in &self.loaded_mods.clone() {
415 self.scan(loaded_mod.root(), loaded_mod.kind()).map_err(|e| {
416 FilesError::ModUnreadable { path: loaded_mod.root().to_path_buf(), source: e }
417 })?;
418 }
419 #[allow(clippy::unnecessary_to_owned)] self.scan(&self.the_mod.root().to_path_buf(), FileKind::Mod).map_err(|e| {
421 FilesError::ModUnreadable { path: self.the_mod.root().to_path_buf(), source: e }
422 })?;
423 Ok(())
424 }
425
426 pub fn load_dlcs(&mut self, dlc_root: &Path) -> Result<(), FilesError> {
427 for entry in WalkDir::new(dlc_root).max_depth(1).sort_by_file_name().into_iter().flatten() {
428 if entry.depth() == 1 && entry.file_type().is_dir() {
429 let label = entry.file_name().to_string_lossy().to_string();
430 let idx =
431 u8::try_from(self.loaded_dlcs.len()).expect("more than 256 DLCs installed");
432 let dlc = LoadedMod::new(
433 FileKind::Dlc(idx),
434 label.clone(),
435 entry.path().to_path_buf(),
436 Vec::new(),
437 );
438 self.scan(dlc.root(), dlc.kind()).map_err(|e| FilesError::VanillaUnreadable {
439 path: dlc.root().to_path_buf(),
440 source: e,
441 })?;
442 self.loaded_dlcs.push(dlc);
443 add_loaded_dlc_root(label);
444 }
445 }
446 Ok(())
447 }
448
449 pub fn finalize(&mut self) {
450 self.files.sort();
453
454 for entry in self.files.drain(..) {
456 if let Some(prev) = self.ordered_files.last_mut() {
457 if entry.path == prev.path {
458 *prev = entry;
459 } else {
460 self.ordered_files.push(entry);
461 }
462 } else {
463 self.ordered_files.push(entry);
464 }
465 }
466
467 for entry in &mut self.ordered_files {
468 let token = Token::new(&entry.filename().to_string_lossy(), (&*entry).into());
469 self.filename_tokens.push(token);
470 entry.store_in_pathtable();
471 self.filenames.insert(entry.path.clone());
472 }
473 }
474
475 pub fn get_files_under<'a>(&'a self, subpath: &'a Path) -> &'a [FileEntry] {
476 let start = self.ordered_files.partition_point(|entry| entry.path < subpath);
477 let end = start
478 + self.ordered_files[start..].partition_point(|entry| entry.path.starts_with(subpath));
479 &self.ordered_files[start..end]
480 }
481
482 pub fn filter_map_under<F, T>(&self, subpath: &Path, f: F) -> Vec<T>
483 where
484 F: Fn(&FileEntry) -> Option<T> + Sync + Send,
485 T: Send,
486 {
487 self.get_files_under(subpath).par_iter().filter_map(f).collect()
488 }
489
490 pub fn handle<T: Send, H: FileHandler<T>>(&self, handler: &mut H, parser: &ParserMemory) {
491 if let Some(config) = &self.config {
492 handler.config(config);
493 }
494 let subpath = handler.subpath();
495 let entries = self.filter_map_under(&subpath, |entry| {
496 handler.load_file(entry, parser).map(|loaded| (entry.clone(), loaded))
497 });
498 for (entry, loaded) in entries {
499 handler.handle_file(&entry, loaded);
500 }
501 handler.finalize();
502 }
503
504 pub fn mark_used(&self, file: &str) {
505 let file = file.strip_prefix('/').unwrap_or(file);
506 self.used.write().unwrap().insert(file.to_string());
507 }
508
509 pub fn exists(&self, key: &str) -> bool {
510 let key = key.strip_prefix('/').unwrap_or(key);
511 let filepath = if Game::is_hoi4() && key.contains('\\') {
512 PathBuf::from(key.replace('\\', "/"))
513 } else {
514 PathBuf::from(key)
515 };
516 self.filenames.contains(&filepath)
517 }
518
519 pub fn iter_keys(&self) -> impl Iterator<Item = &Token> {
520 self.filename_tokens.iter()
521 }
522
523 pub fn entry_exists(&self, key: &str) -> bool {
524 if self.exists(key) {
526 return true;
527 }
528
529 let dir = key.strip_prefix('/').unwrap_or(key);
531 let dirpath = Path::new(dir);
532
533 if self.directories.read().unwrap().contains(dirpath) {
534 return true;
535 }
536
537 match self.ordered_files.binary_search_by_key(&dirpath, |fe| fe.path.as_path()) {
538 Ok(_) => unreachable!(),
540 Err(idx) => {
541 if self.ordered_files[idx].path.starts_with(dirpath) {
543 self.directories.write().unwrap().insert(dirpath.to_path_buf());
544 return true;
545 }
546 }
547 }
548 false
549 }
550
551 pub fn verify_entry_exists(&self, entry: &str, token: &Token, max_sev: Severity) {
552 self.mark_used(&entry.replace("//", "/"));
553 if !self.entry_exists(entry) {
554 let msg = format!("file or directory {entry} does not exist");
555 report(ErrorKey::MissingFile, Item::File.severity().at_most(max_sev))
556 .msg(msg)
557 .loc(token)
558 .push();
559 }
560 }
561
562 #[cfg(feature = "ck3")] pub fn verify_exists(&self, file: &Token) {
564 self.mark_used(&file.as_str().replace("//", "/"));
565 if !self.exists(file.as_str()) {
566 let msg = "referenced file does not exist";
567 report(ErrorKey::MissingFile, Item::File.severity()).msg(msg).loc(file).push();
568 }
569 }
570
571 pub fn verify_exists_implied(&self, file: &str, t: &Token, max_sev: Severity) {
572 self.mark_used(&file.replace("//", "/"));
573 if !self.exists(file) {
574 let msg = format!("file {file} does not exist");
575 report(ErrorKey::MissingFile, Item::File.severity().at_most(max_sev))
576 .msg(msg)
577 .loc(t)
578 .push();
579 }
580 }
581
582 pub fn verify_exists_implied_crashes(&self, file: &str, t: &Token) {
583 self.mark_used(&file.replace("//", "/"));
584 if !self.exists(file) {
585 let msg = format!("file {file} does not exist");
586 fatal(ErrorKey::Crash).msg(msg).loc(t).push();
587 }
588 }
589
590 pub fn validate(&self, _data: &Everything) {
591 let common_dirs = match Game::game() {
592 #[cfg(feature = "ck3")]
593 Game::Ck3 => crate::ck3::tables::misc::COMMON_DIRS,
594 #[cfg(feature = "vic3")]
595 Game::Vic3 => crate::vic3::tables::misc::COMMON_DIRS,
596 #[cfg(feature = "imperator")]
597 Game::Imperator => crate::imperator::tables::misc::COMMON_DIRS,
598 #[cfg(feature = "hoi4")]
599 Game::Hoi4 => crate::hoi4::tables::misc::COMMON_DIRS,
600 };
601 let common_subdirs_ok = match Game::game() {
602 #[cfg(feature = "ck3")]
603 Game::Ck3 => crate::ck3::tables::misc::COMMON_SUBDIRS_OK,
604 #[cfg(feature = "vic3")]
605 Game::Vic3 => crate::vic3::tables::misc::COMMON_SUBDIRS_OK,
606 #[cfg(feature = "imperator")]
607 Game::Imperator => crate::imperator::tables::misc::COMMON_SUBDIRS_OK,
608 #[cfg(feature = "hoi4")]
609 Game::Hoi4 => crate::hoi4::tables::misc::COMMON_SUBDIRS_OK,
610 };
611 let mut warned: Vec<&Path> = Vec::new();
613 'outer: for entry in &self.ordered_files {
614 if !entry.path.to_string_lossy().ends_with(".txt") {
615 continue;
616 }
617 if entry.path == PathBuf::from("common/achievement_groups.txt") {
618 continue;
619 }
620 #[cfg(feature = "hoi4")]
621 if Game::is_hoi4() {
622 for valid in crate::hoi4::tables::misc::COMMON_FILES {
623 if <&str as AsRef<Path>>::as_ref(valid) == entry.path {
624 continue 'outer;
625 }
626 }
627 }
628 let dirname = entry.path.parent().unwrap();
629 if warned.contains(&dirname) {
630 continue;
631 }
632 if !entry.path.starts_with("common") {
633 let joined = Path::new("common").join(&entry.path);
635 for valid in common_dirs {
636 if joined.starts_with(valid) {
637 let msg = format!("file in unexpected directory {}", dirname.display());
638 let info = format!("did you mean common/{} ?", dirname.display());
639 err(ErrorKey::Filename).msg(msg).info(info).loc(entry).push();
640 warned.push(dirname);
641 continue 'outer;
642 }
643 }
644 continue;
645 }
646
647 for valid in common_subdirs_ok {
648 if entry.path.starts_with(valid) {
649 continue 'outer;
650 }
651 }
652
653 for valid in common_dirs {
654 if <&str as AsRef<Path>>::as_ref(valid) == dirname {
655 continue 'outer;
656 }
657 }
658
659 if entry.path.starts_with("common/scripted_values") {
660 let msg = "file should be in common/script_values/";
661 err(ErrorKey::Filename).msg(msg).loc(entry).push();
662 } else if (Game::is_ck3() || Game::is_imperator())
663 && entry.path.starts_with("common/on_actions")
664 {
665 let msg = "file should be in common/on_action/";
666 err(ErrorKey::Filename).msg(msg).loc(entry).push();
667 } else if (Game::is_vic3() || Game::is_hoi4())
668 && entry.path.starts_with("common/on_action")
669 {
670 let msg = "file should be in common/on_actions/";
671 err(ErrorKey::Filename).msg(msg).loc(entry).push();
672 } else if Game::is_vic3() && entry.path.starts_with("common/modifiers") {
673 let msg = "file should be in common/static_modifiers since 1.7";
674 err(ErrorKey::Filename).msg(msg).loc(entry).push();
675 } else if Game::is_ck3() && entry.path.starts_with("common/vassal_contracts") {
676 let msg = "common/vassal_contracts was replaced with common/subject_contracts/contracts/ in 1.16";
677 err(ErrorKey::Filename).msg(msg).loc(entry).push();
678 } else {
679 let msg = format!("file in unexpected directory `{}`", dirname.display());
680 err(ErrorKey::Filename).msg(msg).loc(entry).push();
681 }
682 warned.push(dirname);
683 }
684 }
685
686 pub fn check_unused_dds(&self, _data: &Everything) {
687 let mut vec = Vec::new();
688 for entry in &self.ordered_files {
689 let pathname = entry.path.to_string_lossy();
690 if entry.path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("dds"))
691 && !entry.path.starts_with("gfx/interface/illustrations/loading_screens")
692 && !self.used.read().unwrap().contains(pathname.as_ref())
693 {
694 vec.push(entry);
695 }
696 }
697 let mut printed_header = false;
698 for entry in vec {
699 if !printed_header && will_maybe_log(entry, ErrorKey::UnusedFile) {
700 warn_header(ErrorKey::UnusedFile, "Unused DDS files:\n");
701 printed_header = true;
702 }
703 warn_abbreviated(entry, ErrorKey::UnusedFile);
704 }
705 if printed_header {
706 warn_header(ErrorKey::UnusedFile, "");
707 }
708 }
709}
710
711#[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
712fn get_modfile(
713 label: &String,
714 config_path: &Path,
715 block: &Block,
716 paradox_dir: Option<&Path>,
717) -> Option<PathBuf> {
718 let mut path: Option<PathBuf> = None;
719 if let Some(modfile) = block.get_field_value("modfile") {
720 let modfile_path = config_path
721 .parent()
722 .unwrap() .join(modfile.as_str());
724 if modfile_path.exists() {
725 path = Some(modfile_path);
726 } else {
727 eprintln!("Could not find mod {label} at: {}", modfile_path.display());
728 }
729 }
730 if path.is_none() {
731 if let Some(workshop_id) = block.get_field_value("workshop_id") {
732 match paradox_dir {
733 Some(p) => path = Some(p.join(format!("mod/ugc_{workshop_id}.mod"))),
734 None => eprintln!("workshop_id defined, but could not find paradox directory"),
735 }
736 }
737 }
738 path
739}
740
741#[cfg(feature = "vic3")]
742fn get_mod(
743 label: &String,
744 config_path: &Path,
745 block: &Block,
746 workshop_dir: Option<&Path>,
747) -> Option<PathBuf> {
748 let mut path: Option<PathBuf> = None;
749 if let Some(modfile) = block.get_field_value("mod") {
750 let mod_path = config_path
751 .parent()
752 .unwrap() .join(modfile.as_str());
754 if mod_path.exists() {
755 path = Some(mod_path);
756 } else {
757 eprintln!("Could not find mod {label} at: {}", mod_path.display());
758 }
759 }
760 if path.is_none() {
761 if let Some(workshop_id) = block.get_field_value("workshop_id") {
762 match workshop_dir {
763 Some(w) => path = Some(w.join(workshop_id.as_str())),
764 None => eprintln!("workshop_id defined, but could not find workshop"),
765 }
766 }
767 }
768 path
769}