Compare commits
71 Commits
a5999f28eb
...
feature/tu
| Author | SHA1 | Date | |
|---|---|---|---|
|
bdeebfee4f
|
|||
|
7e20cd370c
|
|||
|
ea50f4d59b
|
|||
|
49f38cb21a
|
|||
|
e70c6e6901
|
|||
|
93676901c0
|
|||
|
2322cd00d2
|
|||
|
d746e830fd
|
|||
|
ac7b07ee3d
|
|||
|
3f91386763
|
|||
|
560562cc25
|
|||
|
f404f597c1
|
|||
|
b3126d1798
|
|||
|
bdd5d849eb
|
|||
|
ddf76602be
|
|||
|
4a152f07da
|
|||
|
afc3f68f36
|
|||
|
fcc65f68bb
|
|||
|
03a127f24b
|
|||
|
ed9e23ed3b
|
|||
|
3949723303
|
|||
|
281327d69c
|
|||
|
9e3bdeacc6
|
|||
|
aacc9795d9
|
|||
|
22c27a2491
|
|||
|
132f784d58
|
|||
|
9df1ec77ef
|
|||
|
e0fd8aa8ea
|
|||
|
0e72675965
|
|||
|
44bca33a17
|
|||
|
87e862c601
|
|||
|
6c634824a8
|
|||
|
b6b3759446
|
|||
|
fa93cf9a6b
|
|||
|
eae0207b0f
|
|||
|
52e48be57f
|
|||
|
defc4a5721
|
|||
|
55f9e3f6d6
|
|||
|
74df0d1cc1
|
|||
|
41e261bb15
|
|||
|
cb022dd5bf
|
|||
|
2b81393fc9
|
|||
|
c8fdf0bc23
|
|||
|
1eb9341d93
|
|||
|
1199d40b31
|
|||
|
6a60e29fd7
|
|||
|
96dda41c46
|
|||
|
295c9bd8c3
|
|||
|
d806b331db
|
|||
|
257ff66af8
|
|||
|
16ed5f9a46
|
|||
|
22c5c7ee91
|
|||
|
b354eedcef
|
|||
|
20e3e304c0
|
|||
|
0b49999bc3
|
|||
|
90c6b59914
|
|||
|
9d9ee1d229
|
|||
|
a78e517163
|
|||
|
8487bafa57
|
|||
|
6612a52e8c
|
|||
|
5cc4d2bab2
|
|||
|
6c5b212d1c
|
|||
|
3da2a4a252
|
|||
|
e8cf4b32f5
|
|||
|
4783c6d20e
|
|||
|
c81178567a
|
|||
|
b6efa0a818
|
|||
|
d263d487b1
|
|||
|
11bd268445
|
|||
|
dc41f93ecb
|
|||
|
7e6de5c73c
|
2361
Cargo.lock
generated
2361
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,21 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
clap = { version = "4.5.60", features = ["derive"] }
|
||||
crossterm = "0.29.0"
|
||||
env_logger = "0.11.9"
|
||||
globset = "0.4.18"
|
||||
libloot = "0.29.0"
|
||||
log = "0.4.29"
|
||||
quick-xml = { version = "0.39.2", features = ["serde-types", "serialize"] }
|
||||
ratatui = "0.30.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
sevenz-rust2 = { version = "0.20.2" }
|
||||
thiserror = "2.0.18"
|
||||
toml = "1.0.3"
|
||||
unrar = "0.5.8"
|
||||
ureq = { version = "3.2.0", features = ["json"] }
|
||||
url = "2.5.8"
|
||||
walkdir = "2.5.0"
|
||||
zip = "8.2.0"
|
||||
|
||||
11
src/actions.rs
Normal file
11
src/actions.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod activate;
|
||||
mod download;
|
||||
mod include;
|
||||
mod install;
|
||||
mod load_order;
|
||||
|
||||
pub use activate::{ActivationError, activate_instance};
|
||||
pub use download::handle_nxm;
|
||||
pub use include::insert_mod_to_instance;
|
||||
pub use install::{resolve_files_for_install, ResolveFileResult};
|
||||
pub use load_order::{LoadOrderError, create_loadorder};
|
||||
171
src/actions/activate.rs
Normal file
171
src/actions/activate.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use log::{debug, trace};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::types::{Game, Link, ModdedInstance, RootConfig};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::{fs, io, os::unix, path::Path};
|
||||
|
||||
/// Create the symlinks for a instance in a given directory
|
||||
pub fn activate_instance(
|
||||
root_config: &RootConfig,
|
||||
instance: &ModdedInstance,
|
||||
target: impl AsRef<Path>,
|
||||
) -> Result<(), ActivationError> {
|
||||
let game = root_config
|
||||
.game_by_id(instance.game_id())
|
||||
.ok_or(ActivationError::GameNotFound)?;
|
||||
|
||||
check_target_valid(&target)?;
|
||||
|
||||
let resolved_links = resolve_links(root_config, instance, &game)?;
|
||||
|
||||
resolved_links
|
||||
.iter()
|
||||
.try_for_each(|link| apply_link(link, &target))
|
||||
.map_err(ActivationError::Linking)?;
|
||||
create_plugins_txt(instance, target.as_ref()).map_err(ActivationError::PluginsTXT)?;
|
||||
|
||||
debug!("Finished activating instance");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_links(
|
||||
root_config: &RootConfig,
|
||||
instance: &ModdedInstance,
|
||||
game: &Game,
|
||||
) -> Result<Vec<Link>, ActivationError> {
|
||||
let game_links = game
|
||||
.export_links()
|
||||
.map_err(ActivationError::ExportGameLinks)?;
|
||||
let mod_links = resolve_link_for_instance(root_config, instance)?;
|
||||
let overrides: Vec<Link> = instance.game_file_overrides().to_owned();
|
||||
|
||||
let mut map: HashMap<PathBuf, PathBuf> = HashMap::new();
|
||||
|
||||
for link in game_links.into_iter().chain(mod_links).chain(overrides) {
|
||||
map.insert(link.dst().to_owned(), link.src().to_owned());
|
||||
}
|
||||
|
||||
let final_links: Vec<Link> = map
|
||||
.into_iter()
|
||||
.map(|(dst, src)| Link::new(src, dst))
|
||||
.collect();
|
||||
|
||||
Ok(final_links)
|
||||
}
|
||||
|
||||
fn resolve_link_for_instance(
|
||||
root_config: &RootConfig,
|
||||
instance: &ModdedInstance,
|
||||
) -> Result<Vec<Link>, ActivationError> {
|
||||
debug!("Resolving links for instance");
|
||||
|
||||
let mut links: Vec<Link> = Vec::new();
|
||||
|
||||
for installed_mod in instance.mods() {
|
||||
let mod_config = root_config
|
||||
.mod_by_id(installed_mod.mod_id())
|
||||
.ok_or(ActivationError::ModNotFound)?;
|
||||
let mod_source_root = root_config.mod_location().join(mod_config.path());
|
||||
|
||||
for link in installed_mod.files() {
|
||||
let link_target = mod_source_root.join(link.src());
|
||||
links.push(Link::new(link_target, link.dst()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
fn apply_link(link: &Link, target: impl AsRef<Path>) -> Result<(), io::Error> {
|
||||
let link_target = &link.src();
|
||||
let link_name = target.as_ref().join(link.dst());
|
||||
|
||||
link_file(link_target, &link_name)
|
||||
}
|
||||
|
||||
fn create_plugins_txt(
|
||||
instance: &ModdedInstance,
|
||||
target: impl AsRef<Path>,
|
||||
) -> Result<(), io::Error> {
|
||||
debug!("Generating Plugins.txt");
|
||||
let mut file = fs::File::create(target.as_ref().join("Plugins.txt"))?;
|
||||
|
||||
writeln!(file, "# Auto generated. DO NOT EDIT MANUALLY!")?;
|
||||
|
||||
for plugin in instance.load_order() {
|
||||
writeln!(file, "*{}", plugin)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn link_file(target: &Path, link_name: &Path) -> Result<(), io::Error> {
|
||||
if let Some(parent) = link_name.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
create_symlink_for_file(target, link_name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_symlink_for_file(target: &Path, link_name: &Path) -> io::Result<()> {
|
||||
trace!(
|
||||
"Creating symlink at {} with target {}",
|
||||
link_name.to_string_lossy(),
|
||||
target.to_string_lossy()
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unix::fs::symlink(target, link_name)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::os::windows::fs::symlink_file(target, link_name)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_target_valid(target: impl AsRef<Path>) -> Result<(), ActivationError> {
|
||||
match fs::read_dir(&target) {
|
||||
Ok(entries) => {
|
||||
if entries.count() == 0 {
|
||||
debug!(
|
||||
"Target {} is a valid target",
|
||||
&target.as_ref().to_string_lossy()
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ActivationError::TargetNotEmpty)
|
||||
}
|
||||
}
|
||||
Err(e) => Err(ActivationError::TargetNotValid(e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ActivationError {
|
||||
#[error("Could not find game in instance config")]
|
||||
GameNotFound,
|
||||
|
||||
#[error("Could not find mod in instance config")]
|
||||
ModNotFound,
|
||||
|
||||
#[error("The target is not empty")]
|
||||
TargetNotEmpty,
|
||||
|
||||
#[error("The target is not valid")]
|
||||
TargetNotValid(io::Error),
|
||||
|
||||
#[error("Failed to create symlink")]
|
||||
Linking(io::Error),
|
||||
|
||||
#[error("Failed to create Plugins.txt")]
|
||||
PluginsTXT(io::Error),
|
||||
|
||||
#[error("Failed to export files in game installation")]
|
||||
ExportGameLinks(io::Error),
|
||||
}
|
||||
44
src/actions/download.rs
Normal file
44
src/actions/download.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use anyhow::anyhow;
|
||||
use log::error;
|
||||
|
||||
use crate::{
|
||||
nexus::{NXMUrl, download_nxm},
|
||||
types::{ModConfig, RootConfig},
|
||||
unpacker::unpack,
|
||||
};
|
||||
|
||||
/// Handles a nexus mod url. Downloads, unpacks and adds the mod to the config
|
||||
pub fn handle_nxm(root_config: &mut RootConfig, raw_url: &str) -> anyhow::Result<()> {
|
||||
let Some(dl_location) = root_config.download_location() else {
|
||||
return Err(anyhow!("No download location set"));
|
||||
};
|
||||
|
||||
let Some(api_key) = root_config.nexus_api_key() else {
|
||||
return Err(anyhow!("No API key provided"));
|
||||
};
|
||||
|
||||
let Some(nxm_url) = NXMUrl::parse_url(raw_url) else {
|
||||
return Err(anyhow!("Failed to parse URL"));
|
||||
};
|
||||
|
||||
let (dl_file, mod_info) = download_nxm(api_key, &nxm_url, dl_location)?;
|
||||
|
||||
let mod_id = format!("{}-{}", mod_info.generate_id(), nxm_url.file);
|
||||
if root_config.game_by_id(&mod_id).is_some() {
|
||||
error!(
|
||||
"Generated mod id already exists. Pleas install downloaded mod manually. Downloaded at {}",
|
||||
&dl_file.to_string_lossy()
|
||||
);
|
||||
return Err(anyhow!("Mod with generated id already exists"));
|
||||
}
|
||||
|
||||
let extract_to = root_config.mod_location().join(&mod_id);
|
||||
unpack(dl_file, extract_to)?;
|
||||
|
||||
let file_id: u64 = nxm_url.file.parse()?;
|
||||
let new_mod = ModConfig::from_mod_info(&mod_id, &mod_id, &mod_info, file_id);
|
||||
|
||||
root_config.add_mod(&new_mod);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
70
src/actions/include.rs
Normal file
70
src/actions/include.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use log::warn;
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use crate::{
|
||||
file_conflict_solver::ConflictSolver,
|
||||
types::{InstalledMod, ModConfig, ModFile, ModdedInstance},
|
||||
};
|
||||
|
||||
pub fn insert_mod_to_instance(
|
||||
instance: &mut ModdedInstance,
|
||||
from_mod: &ModConfig,
|
||||
files_to_add: &[ModFile],
|
||||
priority: isize,
|
||||
) -> Option<FileConflict> {
|
||||
let mut solver = ConflictSolver::new();
|
||||
|
||||
let mut installed_files: Vec<(ModFile, &InstalledMod)> = Vec::new();
|
||||
for installed_mod in instance.mods() {
|
||||
for link in installed_mod.files() {
|
||||
let recreated_mod_file = ModFile::new(link.src(), link.dst(), 0);
|
||||
installed_files.push((recreated_mod_file, installed_mod));
|
||||
}
|
||||
}
|
||||
|
||||
for (file, from_mod) in &installed_files {
|
||||
if let Some(conflict) = solver.add_file(file, from_mod) {
|
||||
warn!("File conflict on already added file: {:?}", conflict);
|
||||
}
|
||||
}
|
||||
|
||||
let new_mod = InstalledMod::new(from_mod.id(), priority);
|
||||
for file in files_to_add {
|
||||
if let Some(conflict) = solver.add_file(file, &new_mod) {
|
||||
return Some(FileConflict {
|
||||
lhs_mod_id: conflict.lhs_mod.mod_id().to_owned(),
|
||||
rhs_mod_id: conflict.rhs_mod.mod_id().to_owned(),
|
||||
path: conflict.rhs_file.dst().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let new_link_tree = solver.export_files();
|
||||
|
||||
let mut map: HashMap<String, InstalledMod> = HashMap::new();
|
||||
|
||||
for (file, from_mod) in new_link_tree {
|
||||
match map.get_mut(from_mod.mod_id()) {
|
||||
Some(existing) => {
|
||||
existing.add_file(file);
|
||||
}
|
||||
None => {
|
||||
let mut new_mod = InstalledMod::new(from_mod.mod_id(), from_mod.priority());
|
||||
new_mod.add_file(file);
|
||||
map.insert(new_mod.mod_id().to_owned(), new_mod);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (_, installed_mod) in map {
|
||||
instance.update_or_create_mod(&installed_mod);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub struct FileConflict {
|
||||
pub lhs_mod_id: String,
|
||||
pub rhs_mod_id: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
181
src/actions/install.rs
Normal file
181
src/actions/install.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
|
||||
use crate::{
|
||||
fomod,
|
||||
types::{ModConfig, ModFile, RootConfig},
|
||||
utils::{resolve_case_insensitive, walk_all_files},
|
||||
};
|
||||
|
||||
pub fn resolve_files_for_install(
|
||||
root_config: &RootConfig,
|
||||
mod_to_install: &ModConfig,
|
||||
) -> anyhow::Result<ResolveFileResult> {
|
||||
let mod_location = root_config.mod_location().join(mod_to_install.path());
|
||||
|
||||
let result = match determain_mod_kind(mod_to_install, &mod_location)? {
|
||||
ModKind::Fomod(xml_path) => {
|
||||
let module_config = fomod::Config::load_from_file(xml_path)?;
|
||||
ResolveFileResult::Fomod(module_config)
|
||||
}
|
||||
ModKind::EmbeddedData(_data_path) => {
|
||||
ResolveFileResult::Files(install_from_dir(mod_to_install, mod_location)?)
|
||||
}
|
||||
ModKind::Root => ResolveFileResult::Files(install_root(mod_to_install, mod_location)?),
|
||||
ModKind::Unkown => {
|
||||
ResolveFileResult::Files(install_from_dir_to_data(mod_to_install, mod_location)?)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn determain_mod_kind(
|
||||
mod_config: &ModConfig,
|
||||
mod_location: impl AsRef<Path>,
|
||||
) -> Result<ModKind, io::Error> {
|
||||
if mod_config.is_root_mod() {
|
||||
return Ok(ModKind::Root);
|
||||
}
|
||||
|
||||
// Check for moduleconfig.xml
|
||||
let module_config_path = resolve_case_insensitive(&mod_location, "fomod/ModuleConfig.xml")?;
|
||||
|
||||
if let Some(path) = module_config_path {
|
||||
return Ok(ModKind::Fomod(path));
|
||||
};
|
||||
|
||||
match resolve_case_insensitive(&mod_location, "data")? {
|
||||
Some(data_path) => Ok(ModKind::EmbeddedData(data_path)),
|
||||
None => Ok(ModKind::Unkown),
|
||||
}
|
||||
}
|
||||
|
||||
// fn install_fomod(
|
||||
// instance: &ModdedInstance,
|
||||
// module_config_path: impl AsRef<Path>,
|
||||
// mod_root: impl AsRef<Path>,
|
||||
// ) -> anyhow::Result<Vec<ModFile>> {
|
||||
// debug!("Running FOmod installer");
|
||||
// let module_config = fomod::Config::load_from_file(module_config_path)?;
|
||||
//
|
||||
// let active_plugins: Vec<_> = instance
|
||||
// .active_plugins()
|
||||
// .map(|e| e.to_string_lossy())
|
||||
// .map(|e| e.to_string())
|
||||
// .collect();
|
||||
//
|
||||
// trace!("Current loded plugins: {:?}", active_plugins);
|
||||
//
|
||||
// let mut installer = FomodInstaller::new(&module_config, &active_plugins);
|
||||
// let mut selection: Option<Vec<usize>> = None;
|
||||
// while let Some(prompt) = installer.run_step(selection.as_deref()) {
|
||||
// selection = Some(install_prompt::prompt(prompt));
|
||||
// }
|
||||
// let files = installer.finalize();
|
||||
//
|
||||
// let mod_files: Vec<_> = files
|
||||
// .iter()
|
||||
// .map(|f| ModFile::from_installer(f.clone(), &mod_root))
|
||||
// .collect::<Result<Vec<_>, _>>()?
|
||||
// .into_iter()
|
||||
// .flatten()
|
||||
// .collect();
|
||||
// Ok(mod_files)
|
||||
// }
|
||||
|
||||
fn install_from_dir(
|
||||
mod_config: &ModConfig,
|
||||
mod_location: impl AsRef<Path>,
|
||||
) -> anyhow::Result<Vec<ModFile>> {
|
||||
let glob_filter = create_glob_filter(mod_config.ignore())?;
|
||||
|
||||
let files: Vec<_> = walk_all_files(&mod_location)?
|
||||
.map(|entry| entry.path().strip_prefix(&mod_location).unwrap().to_owned())
|
||||
.filter(|rel_path| !glob_filter.is_match(rel_path))
|
||||
.filter(|rel_path| should_be_included(rel_path))
|
||||
.map(|rel_path| ModFile::new(&rel_path, &rel_path, 0))
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn install_root(
|
||||
mod_config: &ModConfig,
|
||||
mod_location: impl AsRef<Path>,
|
||||
) -> anyhow::Result<Vec<ModFile>> {
|
||||
let glob_filter = create_glob_filter(mod_config.ignore())?;
|
||||
|
||||
let files: Vec<_> = walk_all_files(&mod_location)?
|
||||
.map(|entry| entry.path().strip_prefix(&mod_location).unwrap().to_owned())
|
||||
.filter(|rel_path| !glob_filter.is_match(rel_path))
|
||||
.map(|rel_path| ModFile::new(&rel_path, &rel_path, 0))
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn install_from_dir_to_data(
|
||||
mod_config: &ModConfig,
|
||||
path: impl AsRef<Path>,
|
||||
) -> anyhow::Result<Vec<ModFile>> {
|
||||
let glob_filter = create_glob_filter(mod_config.ignore())?;
|
||||
let data = PathBuf::from("Data");
|
||||
let files: Vec<ModFile> = walk_all_files(&path)?
|
||||
.map(|entry| entry.path().strip_prefix(&path).unwrap().to_owned())
|
||||
.filter(|rel_path| !glob_filter.is_match(rel_path))
|
||||
.filter(|rel_path| should_be_included(rel_path))
|
||||
.map(|rel_path| ModFile::new(&rel_path, data.join(&rel_path), 0))
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn create_glob_filter(rules: &[String]) -> anyhow::Result<GlobSet> {
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
|
||||
for p in rules {
|
||||
builder.add(Glob::new(p)?);
|
||||
}
|
||||
|
||||
let set = builder.build()?;
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
enum ModKind {
|
||||
Fomod(PathBuf),
|
||||
EmbeddedData(PathBuf),
|
||||
Root,
|
||||
Unkown,
|
||||
}
|
||||
|
||||
pub enum ResolveFileResult {
|
||||
Files(Vec<ModFile>),
|
||||
Fomod(fomod::Config),
|
||||
}
|
||||
|
||||
fn should_be_included(path: impl AsRef<Path>) -> bool {
|
||||
matches!(
|
||||
path.as_ref().extension().and_then(|e| e.to_str()),
|
||||
Some(
|
||||
"esp"
|
||||
| "esm"
|
||||
| "esl"
|
||||
| "bsa"
|
||||
| "ba2"
|
||||
| "bsl"
|
||||
| "ini"
|
||||
| "pex"
|
||||
| "psc"
|
||||
| "strings"
|
||||
| "ilstrings"
|
||||
| "dlstrings"
|
||||
| "dll"
|
||||
| "swf"
|
||||
)
|
||||
)
|
||||
}
|
||||
95
src/actions/load_order.rs
Normal file
95
src/actions/load_order.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use libloot::{
|
||||
Game, GameType,
|
||||
error::{GameHandleCreationError, LoadPluginsError, SortPluginsError},
|
||||
};
|
||||
use log::trace;
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use thiserror::Error;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::{
|
||||
types::{self, ModdedInstance, RootConfig},
|
||||
utils::is_plugin_file,
|
||||
};
|
||||
|
||||
pub fn create_loadorder(
|
||||
root_config: &RootConfig,
|
||||
game: &types::Game,
|
||||
instance: &ModdedInstance,
|
||||
) -> Result<Vec<String>, LoadOrderError> {
|
||||
let mut loot_game = Game::with_local_path(
|
||||
GameType::SkyrimSE,
|
||||
game.install_location(),
|
||||
&game.install_location().join(PathBuf::from("appdata")),
|
||||
)?;
|
||||
|
||||
// Add plugins files from the game install
|
||||
let install_plugins: Vec<PathBuf> = WalkDir::new(game.install_location().join("Data"))
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if is_plugin_file(path) {
|
||||
Ok(Some(path.to_path_buf()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
.filter_map(|r| r.transpose())
|
||||
.collect::<Result<_, io::Error>>()?;
|
||||
|
||||
// The loaded_plugins function requires &[&Path]
|
||||
let refs: Vec<&Path> = install_plugins.iter().map(|e| e.as_path()).collect();
|
||||
|
||||
trace!("Loading {} plugins to game", refs.len());
|
||||
loot_game.load_plugins(&refs)?;
|
||||
|
||||
// Add plugins from the instance
|
||||
let instance_plugins: Vec<_> = instance
|
||||
.mods()
|
||||
.iter()
|
||||
.flat_map(|installed_mod| {
|
||||
let mod_config = root_config.mod_by_id(installed_mod.mod_id()).unwrap();
|
||||
let mod_source_root = root_config.mod_location().join(mod_config.path());
|
||||
|
||||
installed_mod
|
||||
.files()
|
||||
.iter()
|
||||
.filter(|f| is_plugin_file(f.dst()))
|
||||
.map(move |link| mod_source_root.join(link.src()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let refs: Vec<_> = instance_plugins.iter().map(|e| e.as_path()).collect();
|
||||
|
||||
trace!("Loading {} plugins to game", refs.len());
|
||||
|
||||
loot_game.load_plugins(&refs)?;
|
||||
|
||||
// Genrate load order
|
||||
let all_plugins = loot_game.loaded_plugins();
|
||||
let plugins_names: Vec<&str> = all_plugins.iter().map(|e| e.name()).collect();
|
||||
|
||||
let sorted = loot_game.sort_plugins(&plugins_names)?;
|
||||
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum LoadOrderError {
|
||||
#[error("Failed to read game directory")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("Failed to create libloot game")]
|
||||
Creating(#[from] GameHandleCreationError),
|
||||
|
||||
#[error("Failed to add plugins to libloot")]
|
||||
Loading(#[from] LoadPluginsError),
|
||||
|
||||
#[error("libloot failed to sort plugins")]
|
||||
Sorting(#[from] SortPluginsError),
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
use log::trace;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs::{self, read_to_string},
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
conflict_resolver::ConflictSolver,
|
||||
fomod::{FileType, FileTypeEnum},
|
||||
utils::walk_files_recursive,
|
||||
};
|
||||
|
||||
/// A link between a file from a mod and a destination in a ModdedInstance
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(from = "(PathBuf, PathBuf)", into = "(PathBuf,PathBuf)")]
|
||||
pub struct Link {
|
||||
pub src: PathBuf,
|
||||
pub dst: PathBuf,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
pub fn new(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
src: src.as_ref().to_owned(),
|
||||
dst: dst.as_ref().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_mod_file(file: &ModFile) -> Self {
|
||||
Self::new(&file.source, &file.dest)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(PathBuf, PathBuf)> for Link {
|
||||
fn from(value: (PathBuf, PathBuf)) -> Self {
|
||||
Self {
|
||||
src: value.0,
|
||||
dst: value.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Link> for (PathBuf, PathBuf) {
|
||||
fn from(value: Link) -> Self {
|
||||
(value.src, value.dst)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct RootConfig {
|
||||
/// Available games
|
||||
pub games: Vec<Game>,
|
||||
|
||||
/// Where all mods are stored
|
||||
pub mod_location: PathBuf,
|
||||
|
||||
#[serde(default)]
|
||||
pub instances: Vec<InstancePointer>,
|
||||
|
||||
/// All available mods
|
||||
#[serde(default)]
|
||||
pub mods: Vec<ModConfig>,
|
||||
}
|
||||
|
||||
impl RootConfig {
|
||||
pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self, ConfigReadWriteError> {
|
||||
trace!(
|
||||
"Loading RootConfig from file: {}",
|
||||
path.as_ref().to_string_lossy()
|
||||
);
|
||||
|
||||
let data = read_to_string(path)?;
|
||||
let config = toml::from_str(&data)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_mod_location(&self, mod_config: &ModConfig) -> PathBuf {
|
||||
self.mod_location.join(mod_config.path.clone())
|
||||
}
|
||||
|
||||
pub fn get_mod_by_id(&self, id: &str) -> Option<ModConfig> {
|
||||
self.mods.iter().find(|e| e.id == id).cloned()
|
||||
}
|
||||
|
||||
pub fn load_instance_by_id(&self, id: &str) -> Result<ModdedInstance, ConfigReadWriteError> {
|
||||
let conf = self
|
||||
.get_instance_config(id)
|
||||
.ok_or(ConfigReadWriteError::IDNotFound)?;
|
||||
|
||||
ModdedInstance::load_from_file(&conf.path)
|
||||
}
|
||||
|
||||
pub fn get_instance_config(&self, id: &str) -> Option<&InstancePointer> {
|
||||
self.instances.iter().find(|e| e.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Available game
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct Game {
|
||||
pub install_location: PathBuf,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub fn export_links(&self) -> Result<Vec<Link>, io::Error> {
|
||||
let links: Vec<Link> = walk_files_recursive(&self.install_location)
|
||||
.unwrap()
|
||||
.map(|file| file.path())
|
||||
.map(|path| Link::new(&path, path.strip_prefix(&self.install_location).unwrap()))
|
||||
.collect();
|
||||
Ok(links)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct InstancePointer {
|
||||
pub id: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Config for an available mod
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct ModConfig {
|
||||
/// ID of the mod
|
||||
pub id: String,
|
||||
|
||||
/// Relative to the mod_location from root config
|
||||
pub path: PathBuf,
|
||||
|
||||
/// If the files should be included on the root
|
||||
#[serde(default)]
|
||||
root_mod: bool,
|
||||
|
||||
/// Globs of what files to ignore
|
||||
#[serde(default)]
|
||||
ignore: Vec<String>,
|
||||
}
|
||||
|
||||
impl ModConfig {
|
||||
pub fn new(id: &str, source: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
id: id.to_owned(),
|
||||
path: source.as_ref().to_owned(),
|
||||
root_mod: false,
|
||||
ignore: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_root_mod(&self) -> bool {
|
||||
self.root_mod
|
||||
}
|
||||
|
||||
pub fn ignore(&self) -> &[String] {
|
||||
&self.ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// An modded game with all plugins and files
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct ModdedInstance {
|
||||
pub name: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub mods: Vec<InstalledMod>,
|
||||
|
||||
#[serde(default)]
|
||||
pub load_order: Vec<String>,
|
||||
}
|
||||
|
||||
impl ModdedInstance {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
mods: Vec::new(),
|
||||
load_order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self, ConfigReadWriteError> {
|
||||
trace!(
|
||||
"Loading ModdedInstance from file: {}",
|
||||
path.as_ref().to_string_lossy()
|
||||
);
|
||||
|
||||
let data = read_to_string(path)?;
|
||||
let config = toml::from_str(&data)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save_to_file(&self, path: impl AsRef<Path>) -> Result<(), ConfigReadWriteError> {
|
||||
trace!(
|
||||
"Saving ModdedInstance to: {}",
|
||||
path.as_ref().to_string_lossy()
|
||||
);
|
||||
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
let mut file = fs::File::create(path)?;
|
||||
write!(file, "{}", content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_mod(&mut self, from_mod: &ModConfig, priority: isize, files: &[ModFile]) {
|
||||
trace!("Adding mod to instance");
|
||||
|
||||
let mut new_mod = InstalledMod::new(from_mod, priority);
|
||||
let mut solver = ConflictSolver::new();
|
||||
|
||||
// Add all the files form the instance. Unchecked because it is already checked.
|
||||
let mut already_installed_files: Vec<(ModFile, &InstalledMod)> = Vec::new();
|
||||
|
||||
for installed_mod in &self.mods {
|
||||
for link in &installed_mod.files {
|
||||
already_installed_files
|
||||
.push((ModFile::new(&link.src, &link.dst, 1), installed_mod));
|
||||
}
|
||||
}
|
||||
|
||||
trace!("Adding already present files to confict solver");
|
||||
for (present_file, present_mod) in &already_installed_files {
|
||||
solver.add_file_unchecked(present_file, present_mod);
|
||||
}
|
||||
|
||||
trace!("Adding file from mod to confict solver");
|
||||
// Now add the new files and check for conflicts
|
||||
for file in files {
|
||||
if let Some(conflict) = solver.add_file(file, &new_mod) {
|
||||
// FIXME: Find a way to display conflict to user
|
||||
println!("{:?}", conflict);
|
||||
panic!("Conflict")
|
||||
}
|
||||
}
|
||||
|
||||
trace!("No conflicts where found");
|
||||
// No conflicts. Add files.
|
||||
for file in files {
|
||||
new_mod.add_file(file);
|
||||
}
|
||||
|
||||
self.mods.push(new_mod);
|
||||
}
|
||||
|
||||
pub fn set_load_order(&mut self, order: Vec<String>) {
|
||||
self.load_order = order;
|
||||
}
|
||||
|
||||
pub fn load_order(&self) -> &[String] {
|
||||
&self.load_order
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct InstalledMod {
|
||||
id: String,
|
||||
files: Vec<Link>,
|
||||
priority: isize,
|
||||
}
|
||||
|
||||
impl InstalledMod {
|
||||
pub fn new(from_mod: &ModConfig, priority: isize) -> Self {
|
||||
Self {
|
||||
id: from_mod.id.clone(),
|
||||
files: Vec::new(),
|
||||
priority,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_file(&mut self, file: &ModFile) {
|
||||
self.files.push(Link::from_mod_file(file));
|
||||
}
|
||||
|
||||
/// Get the id of the mod
|
||||
pub fn mod_id(&self) -> String {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
/// The priority over other mods. Only used when 2 files conflict.
|
||||
pub fn priority(&self) -> isize {
|
||||
self.priority
|
||||
}
|
||||
|
||||
/// The selected files
|
||||
pub fn files(&self) -> &[Link] {
|
||||
&self.files
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct ModFile {
|
||||
/// Relative path in the mod
|
||||
source: PathBuf,
|
||||
|
||||
/// Relative path on where to install file in game dir
|
||||
dest: PathBuf,
|
||||
|
||||
/// Internal priority inside the mod itself. In case the mod overwrites internal files.
|
||||
internal_priority: isize,
|
||||
}
|
||||
|
||||
impl ModFile {
|
||||
pub fn new(src: impl AsRef<Path>, dst: impl AsRef<Path>, prio: isize) -> Self {
|
||||
Self {
|
||||
source: src.as_ref().to_owned(),
|
||||
dest: dst.as_ref().to_owned(),
|
||||
internal_priority: prio,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_installer(file: FileType) -> Self {
|
||||
let dest: PathBuf = file.destination.unwrap_or_default().into();
|
||||
|
||||
ModFile {
|
||||
source: file.source.into(),
|
||||
dest: dest.to_owned(),
|
||||
internal_priority: file.priority.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_installer(
|
||||
entry: FileTypeEnum,
|
||||
source: impl AsRef<Path>,
|
||||
) -> Result<Vec<Self>, std::io::Error> {
|
||||
match entry {
|
||||
FileTypeEnum::File(file_type) => Ok(vec![Self::new_from_installer(file_type)]),
|
||||
FileTypeEnum::Folder(dir_type) => {
|
||||
let source_root = source.as_ref().join(&dir_type.source);
|
||||
let priority = dir_type.priority.unwrap_or(0);
|
||||
let dest_base: PathBuf =
|
||||
Path::new("Data").join(PathBuf::from(dir_type.destination.unwrap_or_default()));
|
||||
|
||||
Ok(walk_files_recursive(&source_root)?
|
||||
.map(|file| Self {
|
||||
internal_priority: priority,
|
||||
source: file.path().strip_prefix(&source).unwrap().to_owned(),
|
||||
dest: dest_base.join(file.path().strip_prefix(&source_root).unwrap()),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the realtive path this file should be installed
|
||||
#[inline]
|
||||
pub fn destination(&self) -> PathBuf {
|
||||
self.dest.clone()
|
||||
}
|
||||
|
||||
/// Get the iternal priority. Only used when 2 files conflict.
|
||||
#[inline]
|
||||
pub fn internal_priority(&self) -> isize {
|
||||
self.internal_priority
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ConfigReadWriteError {
|
||||
#[error("IO failure")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("Failed to deserialize toml")]
|
||||
Deserialize(#[from] toml::de::Error),
|
||||
|
||||
#[error("Failed to serialize to toml")]
|
||||
Serialize(#[from] toml::ser::Error),
|
||||
|
||||
#[error("The provided ID could not be found")]
|
||||
IDNotFound,
|
||||
}
|
||||
@@ -15,6 +15,9 @@ pub struct Args {
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
Activate { instance: String, target: PathBuf },
|
||||
Add { instance: String, mod_id: String },
|
||||
Include { instance: String, mod_id: String },
|
||||
LoadOrder { instance: String },
|
||||
ApiCheck,
|
||||
Download { url: String },
|
||||
Tui,
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use log::debug;
|
||||
|
||||
use crate::basic_types::{InstalledMod, ModFile};
|
||||
|
||||
pub struct ConflictSolver<'a> {
|
||||
files: HashMap<PathBuf, (&'a ModFile, &'a InstalledMod)>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Conflict<'a> {
|
||||
rhs_mod: &'a InstalledMod,
|
||||
lhs_mod: &'a InstalledMod,
|
||||
rhs_file: &'a ModFile,
|
||||
lhs_file: &'a ModFile,
|
||||
}
|
||||
|
||||
impl<'a> ConflictSolver<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
files: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_file_unchecked(&mut self, file: &'a ModFile, from_mod: &'a InstalledMod) {
|
||||
self.files.insert(file.destination(), (file, from_mod));
|
||||
}
|
||||
|
||||
pub fn add_file(
|
||||
&mut self,
|
||||
file: &'a ModFile,
|
||||
from_mod: &'a InstalledMod,
|
||||
) -> Option<Conflict<'a>> {
|
||||
let path = &file.destination();
|
||||
match self.files.get(path) {
|
||||
Some((current_file, current_file_mod)) => {
|
||||
debug!(
|
||||
"Trying to resolve file conflict between at {}",
|
||||
path.to_string_lossy()
|
||||
);
|
||||
|
||||
if from_mod == *current_file_mod {
|
||||
// File from the same mod
|
||||
// Check internal priority
|
||||
if file.internal_priority() > current_file.internal_priority() {
|
||||
self.files.insert(path.to_owned(), (file, from_mod));
|
||||
debug!("Conflict resolved. Internal priority");
|
||||
return None;
|
||||
}
|
||||
|
||||
if file.internal_priority() == current_file.internal_priority() {
|
||||
// Same prio. We got a conflict.
|
||||
|
||||
return Some(Conflict {
|
||||
rhs_mod: from_mod,
|
||||
lhs_mod: current_file_mod,
|
||||
rhs_file: file,
|
||||
lhs_file: current_file,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if from_mod.priority() > current_file_mod.priority() {
|
||||
self.files.insert(path.to_owned(), (file, from_mod));
|
||||
debug!("Conflict resolved. Mod priority");
|
||||
return None;
|
||||
}
|
||||
|
||||
if from_mod.priority() == current_file_mod.priority() {
|
||||
// Different mod but priority the same. We got a conflict.
|
||||
return Some(Conflict {
|
||||
rhs_mod: from_mod,
|
||||
lhs_mod: current_file_mod,
|
||||
rhs_file: file,
|
||||
lhs_file: current_file,
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.files.insert(path.to_owned(), (file, from_mod));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
219
src/file_conflict_solver.rs
Normal file
219
src/file_conflict_solver.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use log::debug;
|
||||
|
||||
use crate::types::{InstalledMod, ModFile};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Conflict<'a> {
|
||||
pub rhs_mod: &'a InstalledMod,
|
||||
pub lhs_mod: &'a InstalledMod,
|
||||
pub rhs_file: &'a ModFile,
|
||||
pub lhs_file: &'a ModFile,
|
||||
}
|
||||
|
||||
pub struct ConflictSolver<'a> {
|
||||
files: HashMap<PathBuf, (&'a ModFile, &'a InstalledMod)>,
|
||||
}
|
||||
|
||||
impl<'a> ConflictSolver<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
files: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_file(
|
||||
&mut self,
|
||||
file: &'a ModFile,
|
||||
from_mod: &'a InstalledMod,
|
||||
) -> Option<Conflict<'a>> {
|
||||
let path = &file.dst().to_owned();
|
||||
match self.files.get(path) {
|
||||
Some((current_file, current_file_mod)) => {
|
||||
debug!(
|
||||
"Trying to resolve file conflict between at {}",
|
||||
path.to_string_lossy()
|
||||
);
|
||||
|
||||
if from_mod == *current_file_mod {
|
||||
// File from the same mod
|
||||
// Check internal priority
|
||||
if file.internal_priority() > current_file.internal_priority() {
|
||||
self.files.insert(path.to_owned(), (file, from_mod));
|
||||
debug!("Conflict resolved. Internal priority");
|
||||
return None;
|
||||
}
|
||||
|
||||
if file.internal_priority() == current_file.internal_priority() {
|
||||
// Same prio. We got a conflict.
|
||||
|
||||
return Some(Conflict {
|
||||
rhs_mod: from_mod,
|
||||
lhs_mod: current_file_mod,
|
||||
rhs_file: file,
|
||||
lhs_file: current_file,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if from_mod.priority() > current_file_mod.priority() {
|
||||
self.files.insert(path.to_owned(), (file, from_mod));
|
||||
debug!("Conflict resolved. Mod priority");
|
||||
return None;
|
||||
}
|
||||
|
||||
if from_mod.priority() == current_file_mod.priority() {
|
||||
// Different mod but priority the same. We got a conflict.
|
||||
return Some(Conflict {
|
||||
rhs_mod: from_mod,
|
||||
lhs_mod: current_file_mod,
|
||||
rhs_file: file,
|
||||
lhs_file: current_file,
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.files.insert(path.to_owned(), (file, from_mod));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn export_files(&self) -> Vec<(&'a ModFile, &'a InstalledMod)> {
|
||||
self.files.iter().map(|e| e.1.to_owned()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_conflict() {
|
||||
let mut solver = ConflictSolver::new();
|
||||
|
||||
let mod1 = InstalledMod::new("mod1", 0);
|
||||
let m1f1 = ModFile::new("Data/Plugin1.esp", "Data/Plugin1.esp", 0);
|
||||
|
||||
let mod2 = InstalledMod::new("mod2", 0);
|
||||
let m2f1 = ModFile::new("Data/Plugin2.esp", "Data/Plugin2.esp", 0);
|
||||
|
||||
assert!(solver.add_file(&m1f1, &mod1).is_none());
|
||||
assert!(solver.add_file(&m2f1, &mod2).is_none());
|
||||
|
||||
let export = solver.export_files();
|
||||
assert_eq!(export.len(), 2);
|
||||
assert!(
|
||||
export
|
||||
.iter()
|
||||
.find(|e| e.1 == &mod1 && e.0 == &m1f1)
|
||||
.is_some(),
|
||||
"Missing mod1 file1"
|
||||
);
|
||||
assert!(
|
||||
export
|
||||
.iter()
|
||||
.find(|e| e.1 == &mod2 && e.0 == &m2f1)
|
||||
.is_some(),
|
||||
"Missing mod2 file1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_same_mod_solved() {
|
||||
let mut solver = ConflictSolver::new();
|
||||
|
||||
let mod1 = InstalledMod::new("mod1", 0);
|
||||
let m1f1 = ModFile::new("Data/Plugin1.esp", "Data/Plugin.esp", 0);
|
||||
let m1f2 = ModFile::new("Data/Plugin_alt.esp", "Data/Plugin.esp", 1);
|
||||
|
||||
assert!(solver.add_file(&m1f1, &mod1).is_none());
|
||||
assert!(solver.add_file(&m1f2, &mod1).is_none());
|
||||
|
||||
let export = solver.export_files();
|
||||
assert_eq!(export.len(), 1);
|
||||
assert!(
|
||||
export
|
||||
.iter()
|
||||
.find(|e| e.1 == &mod1 && e.0 == &m1f2)
|
||||
.is_some(),
|
||||
"Missing mod1 file2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_diff_mod_solved() {
|
||||
let mut solver = ConflictSolver::new();
|
||||
|
||||
let mod1 = InstalledMod::new("mod1", 0);
|
||||
let m1f1 = ModFile::new("Data/Plugin1.esp", "Data/Plugin.esp", 0);
|
||||
|
||||
let mod2 = InstalledMod::new("mod2", 1);
|
||||
let m2f1 = ModFile::new("Data/Plugin.esp", "Data/Plugin.esp", 0);
|
||||
|
||||
assert!(solver.add_file(&m1f1, &mod1).is_none());
|
||||
assert!(solver.add_file(&m2f1, &mod2).is_none());
|
||||
|
||||
let export = solver.export_files();
|
||||
assert_eq!(export.len(), 1);
|
||||
assert!(
|
||||
export
|
||||
.iter()
|
||||
.find(|e| e.1 == &mod2 && e.0 == &m2f1)
|
||||
.is_some(),
|
||||
"Missing mod2 file1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_same_mod() {
|
||||
let mut solver = ConflictSolver::new();
|
||||
|
||||
let mod1 = InstalledMod::new("mod1", 0);
|
||||
let m1f1 = ModFile::new("Data/Plugin1.esp", "Data/Plugin.esp", 0);
|
||||
let m1f2 = ModFile::new("Data/Plugin_alt.esp", "Data/Plugin.esp", 0);
|
||||
|
||||
assert!(solver.add_file(&m1f1, &mod1).is_none());
|
||||
let conflict = solver.add_file(&m1f2, &mod1);
|
||||
|
||||
assert!(conflict.is_some());
|
||||
let unwraped = conflict.expect("Aserted before");
|
||||
|
||||
assert!(
|
||||
unwraped.rhs_mod == unwraped.lhs_mod,
|
||||
"Not same mod in conflict"
|
||||
);
|
||||
assert!(unwraped.rhs_file != unwraped.lhs_file, "Files are the same");
|
||||
assert!(
|
||||
unwraped.rhs_file == &m1f1 || unwraped.rhs_file == &m1f2,
|
||||
"One file not found in conflict "
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_diff_mod() {
|
||||
let mut solver = ConflictSolver::new();
|
||||
|
||||
let mod1 = InstalledMod::new("mod1", 0);
|
||||
let m1f1 = ModFile::new("Data/Plugin1.esp", "Data/Plugin.esp", 0);
|
||||
|
||||
let mod2 = InstalledMod::new("mod2", 0);
|
||||
let m2f1 = ModFile::new("Data/Plugin.esp", "Data/Plugin.esp", 0);
|
||||
|
||||
assert!(solver.add_file(&m1f1, &mod1).is_none());
|
||||
let conflict = solver.add_file(&m2f1, &mod2);
|
||||
|
||||
assert!(conflict.is_some());
|
||||
let unwraped = conflict.expect("Aserted before");
|
||||
|
||||
assert!(unwraped.rhs_mod != unwraped.lhs_mod, "Same mod in conflict");
|
||||
assert!(unwraped.rhs_file != unwraped.lhs_file, "Files are the same");
|
||||
assert!(
|
||||
unwraped.rhs_file == &m1f1 || unwraped.lhs_file == &m1f1,
|
||||
"One file not found in conflict "
|
||||
);
|
||||
assert_eq!(unwraped.rhs_file.dst(), "Data/Plugin.esp");
|
||||
}
|
||||
}
|
||||
19
src/fomod.rs
19
src/fomod.rs
@@ -4,6 +4,7 @@
|
||||
|
||||
use std::{fs, io, path::Path};
|
||||
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -55,6 +56,11 @@ pub struct Config {
|
||||
|
||||
impl Config {
|
||||
pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self, FOModError> {
|
||||
debug!(
|
||||
"Loading FOmod config from {}",
|
||||
path.as_ref().to_string_lossy()
|
||||
);
|
||||
|
||||
let data = fs::read_to_string(path)?;
|
||||
let config = quick_xml::de::from_str(&data)?;
|
||||
|
||||
@@ -112,6 +118,7 @@ pub enum PluginTypeDescriptorEnum {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct DependencyPluginType {
|
||||
#[serde(rename = "defaultType")]
|
||||
pub default_type: PluginType,
|
||||
pub patterns: DependencyPatternList,
|
||||
}
|
||||
@@ -123,7 +130,7 @@ pub struct DependencyPatternList {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct DependencyPattern {
|
||||
pub dependencies: CompositeDependency,
|
||||
pub dependencies: Vec<CompositeDependency>,
|
||||
#[serde(rename = "type")]
|
||||
pub typ: PluginType,
|
||||
}
|
||||
@@ -142,7 +149,7 @@ pub struct InstallStep {
|
||||
#[serde(rename = "@name")]
|
||||
pub name: String,
|
||||
|
||||
pub visible: Option<CompositeDependency>,
|
||||
pub visible: Option<ModuleDependency>,
|
||||
|
||||
#[serde(rename = "optionalFileGroups")]
|
||||
pub optional_file_groups: GroupList,
|
||||
@@ -151,6 +158,7 @@ pub struct InstallStep {
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ModuleDependency {
|
||||
#[serde(rename = "@operator")]
|
||||
#[serde(default)]
|
||||
pub operator: DependencyOperator,
|
||||
#[serde(rename = "$value")]
|
||||
pub list: Vec<CompositeDependency>,
|
||||
@@ -207,8 +215,11 @@ pub enum DependencyState {
|
||||
Missing,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash,
|
||||
)]
|
||||
pub enum DependencyOperator {
|
||||
#[default]
|
||||
And,
|
||||
Or,
|
||||
}
|
||||
@@ -337,7 +348,7 @@ pub struct ConditionalInstallPatternList {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ConditionalInstallPattern {
|
||||
pub dependencies: CompositeDependency,
|
||||
pub dependencies: ModuleDependency,
|
||||
pub files: FileList,
|
||||
}
|
||||
|
||||
|
||||
11
src/lib.rs
Normal file
11
src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod cli;
|
||||
pub mod file_conflict_solver;
|
||||
pub mod fomod;
|
||||
pub mod install_prompt;
|
||||
pub mod mod_config_installer;
|
||||
pub mod nexus;
|
||||
pub mod types;
|
||||
pub mod unpacker;
|
||||
pub mod utils;
|
||||
pub mod actions;
|
||||
pub mod tui;
|
||||
@@ -1,75 +0,0 @@
|
||||
use log::debug;
|
||||
|
||||
use crate::{
|
||||
basic_types::{Game, Link, ModdedInstance, RootConfig},
|
||||
utils::walk_files_recursive,
|
||||
};
|
||||
use std::io::Write;
|
||||
use std::{fs, io, os::unix, path::Path};
|
||||
|
||||
pub fn link_instance_to_target(
|
||||
root_config: &RootConfig,
|
||||
instance: &ModdedInstance,
|
||||
target: impl AsRef<Path>,
|
||||
) -> Result<(), io::Error> {
|
||||
debug!("Linking instance to {}", target.as_ref().to_string_lossy());
|
||||
for installed_mod in &instance.mods {
|
||||
let mod_config = root_config.get_mod_by_id(&installed_mod.mod_id()).unwrap();
|
||||
let mod_source_root = root_config.get_mod_location(&mod_config);
|
||||
|
||||
for link in installed_mod.files() {
|
||||
let link_target = mod_source_root.join(&link.src);
|
||||
let link_name = target.as_ref().join(&link.dst);
|
||||
link_file(&link_target, &link_name)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn link_game_to_target(game: &Game, target: impl AsRef<Path>) -> Result<(), io::Error> {
|
||||
for link in game.export_links()? {
|
||||
link_file(&link.src, &target.as_ref().join(&link.dst))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn link_file(target: &Path, link_name: &Path) -> Result<(), io::Error> {
|
||||
if let Some(parent) = link_name.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
create_symlink_for_file(target, link_name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_plugins_txt(
|
||||
instance: &ModdedInstance,
|
||||
target: impl AsRef<Path>,
|
||||
) -> Result<(), io::Error> {
|
||||
debug!("Generating plugins.txt");
|
||||
let mut file = fs::File::create(target.as_ref().join("plugins.txt"))?;
|
||||
|
||||
writeln!(file, "# Auto generated. DO NOT EDIT MANUALLY!")?;
|
||||
|
||||
for plugin in instance.load_order() {
|
||||
writeln!(file, "*{}", plugin)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_symlink_for_file(target: &Path, link_name: &Path) -> io::Result<()> {
|
||||
let absolute_path = fs::canonicalize(target)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unix::fs::symlink(absolute_path, link_name)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::os::windows::fs::symlink_file(target, link_name)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use libloot::{
|
||||
Game, GameType,
|
||||
error::{GameHandleCreationError, LoadPluginsError, SortPluginsError},
|
||||
};
|
||||
use log::trace;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
basic_types::{ModdedInstance, RootConfig},
|
||||
utils::walk_files_recursive,
|
||||
};
|
||||
|
||||
pub struct LoadOrder {
|
||||
game: libloot::Game,
|
||||
target: PathBuf,
|
||||
}
|
||||
|
||||
impl LoadOrder {
|
||||
pub fn new(install_dir: impl AsRef<Path>, game_type: GameType) -> Result<Self, LoadOrderError> {
|
||||
Ok(Self {
|
||||
game: Game::with_local_path(
|
||||
game_type,
|
||||
install_dir.as_ref(),
|
||||
&install_dir.as_ref().join("appdata"),
|
||||
)?,
|
||||
target: install_dir.as_ref().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_plugins_from_instance(
|
||||
&mut self,
|
||||
root_config: &RootConfig,
|
||||
instance: &ModdedInstance,
|
||||
) -> Result<(), LoadOrderError> {
|
||||
for installed_mod in &instance.mods {
|
||||
let mod_config = root_config.get_mod_by_id(&installed_mod.mod_id()).unwrap();
|
||||
let mod_source_root = root_config.get_mod_location(&mod_config);
|
||||
|
||||
let mod_plugins: Vec<PathBuf> = installed_mod
|
||||
.files()
|
||||
.iter()
|
||||
.filter(|f| Self::is_plugin_file(&f.dst))
|
||||
.map(|link| mod_source_root.join(&link.src))
|
||||
.collect();
|
||||
|
||||
let refs: Vec<_> = mod_plugins.iter().map(|e| e.as_path()).collect();
|
||||
trace!("Loading {} plugins to game", refs.len());
|
||||
self.game.load_plugins(&refs)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_plugins_from_install(&mut self) -> Result<(), LoadOrderError> {
|
||||
let plugins: Vec<_> = walk_files_recursive(self.target.join("Data"))?
|
||||
.filter(|f| Self::is_plugin_file(f.path()))
|
||||
.map(|f| f.path())
|
||||
.collect();
|
||||
let refs: Vec<_> = plugins.iter().map(|e| e.as_path()).collect();
|
||||
trace!("Loading {} plugins to game", refs.len());
|
||||
self.game.load_plugins(&refs)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_plugin_file(filename: impl AsRef<Path>) -> bool {
|
||||
filename
|
||||
.as_ref()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext == "esp" || ext == "esm" || ext == "esl")
|
||||
}
|
||||
|
||||
pub fn load_order(&self) -> Result<Vec<String>, LoadOrderError> {
|
||||
trace!("Generating new load order");
|
||||
let all_plugins = self.game.loaded_plugins();
|
||||
let plugins_names: Vec<&str> = all_plugins.iter().map(|e| e.name()).collect();
|
||||
|
||||
let sorted = self.game.sort_plugins(&plugins_names)?;
|
||||
|
||||
Ok(sorted)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum LoadOrderError {
|
||||
#[error("Failed to read game directory")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error("Failed to create libloot game")]
|
||||
Creating(#[from] GameHandleCreationError),
|
||||
|
||||
#[error("Failed to add plugins to libloot")]
|
||||
Loading(#[from] LoadPluginsError),
|
||||
|
||||
#[error("libloot failed to sort plugins")]
|
||||
Sorting(#[from] SortPluginsError),
|
||||
}
|
||||
264
src/main.rs
264
src/main.rs
@@ -1,212 +1,94 @@
|
||||
use anyhow::anyhow;
|
||||
use clap::Parser;
|
||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
use log::debug;
|
||||
use std::{
|
||||
error::Error,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use log::{debug, error, info};
|
||||
use std::{error::Error, path::Path};
|
||||
|
||||
use crate::{
|
||||
basic_types::{ModConfig, ModFile, ModdedInstance, RootConfig},
|
||||
cli::Args,
|
||||
linker::{create_plugins_txt, link_game_to_target, link_instance_to_target},
|
||||
load_order::LoadOrder,
|
||||
use fomod_manager::{
|
||||
actions::{
|
||||
ResolveFileResult, activate_instance, create_loadorder, handle_nxm, insert_mod_to_instance,
|
||||
resolve_files_for_install,
|
||||
},
|
||||
cli::{self, Args},
|
||||
mod_config_installer::FomodInstaller,
|
||||
utils::{resolve_case_insensitive, walk_files_recursive},
|
||||
nexus::NexusAPI,
|
||||
tui,
|
||||
types::RootConfig,
|
||||
};
|
||||
|
||||
mod basic_types;
|
||||
mod cli;
|
||||
mod conflict_resolver;
|
||||
mod fomod;
|
||||
mod install_prompt;
|
||||
mod linker;
|
||||
mod load_order;
|
||||
mod mod_config_installer;
|
||||
mod utils;
|
||||
|
||||
pub fn gen_filelist_for_mod(
|
||||
fn command_activate(
|
||||
root_config: &RootConfig,
|
||||
instance: &ModdedInstance,
|
||||
mod_config: &ModConfig,
|
||||
) -> Result<Vec<ModFile>, Box<dyn Error>> {
|
||||
let mod_location = root_config.get_mod_location(mod_config);
|
||||
instance_id: &str,
|
||||
target: impl AsRef<Path>,
|
||||
) -> anyhow::Result<()> {
|
||||
let instance = root_config.load_instance_by_id(instance_id)?;
|
||||
activate_instance(root_config, &instance, target)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if mod_config.is_root_mod() {
|
||||
return gen_filelist_for_root_mod(mod_config, mod_location);
|
||||
}
|
||||
fn command_add(root_config: &RootConfig, instance_id: &str, mod_id: &str) -> anyhow::Result<()> {
|
||||
let mut instance = root_config.load_instance_by_id(instance_id)?;
|
||||
let mod_to_install = root_config
|
||||
.mod_by_id(mod_id)
|
||||
.ok_or(anyhow!("Can't find mod in config"))?;
|
||||
|
||||
let module_config_path = resolve_case_insensitive(&mod_location, "fomod/ModuleConfig.xml")?;
|
||||
|
||||
let files: Vec<ModFile> = match module_config_path {
|
||||
Some(path) => {
|
||||
debug!("Found a ModuleConfig.xml");
|
||||
gen_filelist_from_fomod(instance, path, &mod_location)?
|
||||
}
|
||||
None => {
|
||||
debug!("Did not find a ModuleConfig.xml");
|
||||
gen_filelist_from_mod_dir(&mod_location)?
|
||||
let files = match resolve_files_for_install(root_config, &mod_to_install)? {
|
||||
ResolveFileResult::Files(mod_files) => mod_files,
|
||||
ResolveFileResult::Fomod(module_config) => {
|
||||
let mod_location = root_config.mod_location().join(mod_to_install.path());
|
||||
let active_plugins: Vec<String> = instance.active_plugins().collect();
|
||||
let mut installer = FomodInstaller::new(&module_config, &active_plugins);
|
||||
let mut selection: Option<Vec<usize>> = None;
|
||||
while let Some(prompt) = installer.run_step(selection.as_deref()) {
|
||||
selection = Some(fomod_manager::install_prompt::prompt(prompt));
|
||||
}
|
||||
installer.finalize(mod_location)?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
match insert_mod_to_instance(&mut instance, &mod_to_install, &files, 0) {
|
||||
None => {
|
||||
instance.save_to_file()?;
|
||||
Ok(())
|
||||
}
|
||||
Some(conflict) => {
|
||||
error!(
|
||||
"File conflict between {} and {} at {}",
|
||||
conflict.lhs_mod_id,
|
||||
conflict.rhs_mod_id,
|
||||
conflict.path.to_string_lossy()
|
||||
);
|
||||
info!("To resolve file conflicts give one mod a higher priority in the config");
|
||||
|
||||
fn create_glob_filter(rules: &[String]) -> Result<GlobSet, Box<dyn Error>> {
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
|
||||
for p in rules {
|
||||
builder.add(Glob::new(p)?);
|
||||
Err(anyhow!("File conflict"))
|
||||
}
|
||||
}
|
||||
|
||||
let set = builder.build()?;
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn gen_filelist_for_root_mod(
|
||||
mod_config: &ModConfig,
|
||||
mod_location: impl AsRef<Path>,
|
||||
) -> Result<Vec<ModFile>, Box<dyn Error>> {
|
||||
let glob_filter = create_glob_filter(mod_config.ignore())?;
|
||||
let files: Vec<_> = walk_files_recursive(&mod_location)?
|
||||
.map(|entry| entry.path())
|
||||
.map(|path| path.strip_prefix(&mod_location).unwrap().to_owned())
|
||||
.filter(|rel_path| !glob_filter.is_match(rel_path))
|
||||
.map(|rel_path| ModFile::new(&rel_path, &rel_path, 0))
|
||||
.collect();
|
||||
fn command_order(root_config: &RootConfig, instance_id: &str) -> anyhow::Result<()> {
|
||||
let mut instance = root_config.load_instance_by_id(instance_id)?;
|
||||
let game = root_config.game_by_id(instance.game_id()).unwrap();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn gen_filelist_from_mod_dir(
|
||||
mod_location: impl AsRef<Path>,
|
||||
) -> Result<Vec<ModFile>, Box<dyn Error>> {
|
||||
// Check for Data dir in root
|
||||
let files = match resolve_case_insensitive(&mod_location, "data")? {
|
||||
Some(data_path) => gen_filelist_from_dir(data_path),
|
||||
None => gen_filelist_from_dir(&mod_location),
|
||||
}?;
|
||||
|
||||
let mod_files: Vec<_> = files
|
||||
.iter()
|
||||
.filter(|e| should_be_included(e))
|
||||
.map(|e| ModFile::new(e, PathBuf::from("Data").join(e), 0))
|
||||
.collect();
|
||||
|
||||
Ok(mod_files)
|
||||
}
|
||||
|
||||
fn should_be_included(path: impl AsRef<Path>) -> bool {
|
||||
matches!(
|
||||
path.as_ref().extension().and_then(|e| e.to_str()),
|
||||
Some(
|
||||
"esp"
|
||||
| "esm"
|
||||
| "esl"
|
||||
| "bsa"
|
||||
| "ba2"
|
||||
| "bsl"
|
||||
| "ini"
|
||||
| "pex"
|
||||
| "psc"
|
||||
| "strings"
|
||||
| "ilstrings"
|
||||
| "dlstrings"
|
||||
| "dll"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn gen_filelist_from_dir(path: impl AsRef<Path>) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||
let files: Vec<_> = walk_files_recursive(&path)?
|
||||
.map(|file| file.path().strip_prefix(&path).unwrap().to_owned())
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn gen_filelist_from_fomod(
|
||||
instance: &ModdedInstance,
|
||||
xml_path: impl AsRef<Path>,
|
||||
mod_location: impl AsRef<Path>,
|
||||
) -> Result<Vec<ModFile>, Box<dyn Error>> {
|
||||
let module_config = fomod::Config::load_from_file(xml_path)?;
|
||||
// TODO: add active plugins from instance config
|
||||
let installer = FomodInstaller::new(module_config, vec![], install_prompt::prompt);
|
||||
let files = installer.run();
|
||||
|
||||
let mod_files: Vec<_> = files
|
||||
.iter()
|
||||
.flat_map(|f| ModFile::from_installer(f.clone(), &mod_location).unwrap())
|
||||
.collect();
|
||||
|
||||
Ok(mod_files)
|
||||
}
|
||||
|
||||
pub fn activate_instance(
|
||||
root_config: &RootConfig,
|
||||
instance_id: &str,
|
||||
target: &Path,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let instance_config = root_config.load_instance_by_id(instance_id)?;
|
||||
|
||||
link_game_to_target(root_config.games.first().unwrap(), target)?;
|
||||
link_instance_to_target(root_config, &instance_config, target)?;
|
||||
create_plugins_txt(&instance_config, target)?;
|
||||
let new_load_order = create_loadorder(root_config, &game, &instance)?;
|
||||
instance.set_load_order(new_load_order);
|
||||
instance.save_to_file()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_mod_to_instance(
|
||||
root_config: &RootConfig,
|
||||
instance_id: &str,
|
||||
mod_id: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let mut instance_config = root_config.load_instance_by_id(instance_id)?.clone();
|
||||
let mod_config = root_config.get_mod_by_id(mod_id).ok_or("Mod not found")?;
|
||||
fn command_download(root_config: &mut RootConfig, raw_url: &str) -> anyhow::Result<()> {
|
||||
handle_nxm(root_config, raw_url)?;
|
||||
|
||||
let new_files = gen_filelist_for_mod(root_config, &instance_config, &mod_config)?;
|
||||
|
||||
instance_config.add_mod(&mod_config, 0, &new_files);
|
||||
|
||||
let path = &root_config
|
||||
.get_instance_config(instance_id)
|
||||
.ok_or("Mod not found")?
|
||||
.path;
|
||||
instance_config.save_to_file(path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn order_plugins(root_config: &RootConfig, instance_id: &str) -> Result<(), Box<dyn Error>> {
|
||||
let mut instance_config = root_config.load_instance_by_id(instance_id)?.clone();
|
||||
|
||||
let mut orderer = LoadOrder::new(
|
||||
&root_config.games.first().unwrap().install_location,
|
||||
libloot::GameType::SkyrimSE,
|
||||
)?;
|
||||
|
||||
orderer.add_plugins_from_install()?;
|
||||
orderer.add_plugins_from_instance(root_config, &instance_config)?;
|
||||
|
||||
let load_order = orderer.load_order()?;
|
||||
|
||||
instance_config.set_load_order(load_order);
|
||||
|
||||
let path = &root_config
|
||||
.get_instance_config(instance_id)
|
||||
.ok_or("Mod not found")?
|
||||
.path;
|
||||
|
||||
instance_config.save_to_file(path)?;
|
||||
root_config.save_to_file()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_logger() {
|
||||
env_logger::builder()
|
||||
.filter_level(log::LevelFilter::max())
|
||||
.filter_level(log::LevelFilter::Off)
|
||||
.format_timestamp(None)
|
||||
.filter_module("ureq_proto::util", log::LevelFilter::Debug)
|
||||
.filter_module("rustls::client::hs", log::LevelFilter::Debug)
|
||||
.init();
|
||||
}
|
||||
|
||||
@@ -215,17 +97,27 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args = Args::parse();
|
||||
|
||||
debug!("Loading config from {:?}", args.config);
|
||||
let root_config = RootConfig::load_from_file(args.config)?;
|
||||
let mut root_config = RootConfig::load_from_file(args.config)?;
|
||||
|
||||
match args.command {
|
||||
cli::Commands::Activate { instance, target } => {
|
||||
activate_instance(&root_config, &instance, &target)?;
|
||||
command_activate(&root_config, &instance, &target)?;
|
||||
}
|
||||
cli::Commands::Add { instance, mod_id } => {
|
||||
add_mod_to_instance(&root_config, &instance, &mod_id)?;
|
||||
cli::Commands::Include { instance, mod_id } => {
|
||||
command_add(&root_config, &instance, &mod_id)?;
|
||||
}
|
||||
cli::Commands::LoadOrder { instance } => {
|
||||
order_plugins(&root_config, &instance)?;
|
||||
command_order(&root_config, &instance)?;
|
||||
}
|
||||
cli::Commands::ApiCheck => {
|
||||
let api = NexusAPI::new(root_config.nexus_api_key().unwrap());
|
||||
api.validate_key()?;
|
||||
}
|
||||
cli::Commands::Download { url } => {
|
||||
command_download(&mut root_config, &url)?;
|
||||
}
|
||||
cli::Commands::Tui => {
|
||||
tui::run(&mut root_config)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
use std::{collections::HashMap, fmt::Display};
|
||||
use std::{collections::HashMap, fmt::Display, io, path::Path};
|
||||
|
||||
use log::{debug, warn};
|
||||
|
||||
use crate::fomod::{
|
||||
CompositeDependency, Config, DependencyOperator, DependencyState, FileList, FileTypeEnum,
|
||||
Group, GroupType, Plugin, PluginTypeDescriptorEnum, PluginTypeEnum,
|
||||
use crate::{
|
||||
fomod::{
|
||||
CompositeDependency, Config, DependencyOperator, DependencyState, FileList, FileTypeEnum,
|
||||
Group, GroupType, ModuleDependency, Plugin, PluginTypeDescriptorEnum, PluginTypeEnum,
|
||||
},
|
||||
types::ModFile,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InstallerState {
|
||||
struct InstallerState {
|
||||
flags: HashMap<String, String>,
|
||||
selected_files: Vec<FileTypeEnum>,
|
||||
}
|
||||
@@ -39,7 +42,7 @@ impl InstallerState {
|
||||
self.selected_files.extend_from_slice(list);
|
||||
}
|
||||
|
||||
pub fn into_file_list(self) -> Vec<FileTypeEnum> {
|
||||
fn into_file_list(self) -> Vec<FileTypeEnum> {
|
||||
self.selected_files
|
||||
}
|
||||
}
|
||||
@@ -101,6 +104,23 @@ fn evaluate_dependency(
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_module_depbendecy(
|
||||
dep: &ModuleDependency,
|
||||
state: &InstallerState,
|
||||
installed_plugins: &[String],
|
||||
) -> bool {
|
||||
let mut evaluated = dep
|
||||
.list
|
||||
.iter()
|
||||
.map(|e| evaluate_dependency(e, state, installed_plugins));
|
||||
|
||||
match dep.operator {
|
||||
DependencyOperator::And => evaluated.all(|r| r),
|
||||
DependencyOperator::Or => evaluated.any(|r| r),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GroupPrompt {
|
||||
pub name: String,
|
||||
pub select_type: GroupType,
|
||||
@@ -130,6 +150,7 @@ impl GroupPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InstallOption {
|
||||
pub name: String,
|
||||
pub option_type: PluginTypeEnum,
|
||||
@@ -160,7 +181,11 @@ fn resolve_plugin_type(
|
||||
PluginTypeDescriptorEnum::PluginType(plugin_type) => plugin_type.name,
|
||||
PluginTypeDescriptorEnum::DependencyType(dependency_plugin_type) => {
|
||||
for dep in &dependency_plugin_type.patterns.pattern {
|
||||
if evaluate_dependency(&dep.dependencies, state, installed_plugins) {
|
||||
if dep
|
||||
.dependencies
|
||||
.iter()
|
||||
.all(|e| evaluate_dependency(e, state, installed_plugins))
|
||||
{
|
||||
return dep.typ.name;
|
||||
}
|
||||
}
|
||||
@@ -171,82 +196,90 @@ fn resolve_plugin_type(
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FomodInstaller {
|
||||
config: Config,
|
||||
installed_plugins: Vec<String>,
|
||||
group_prompt: fn(GroupPrompt) -> Vec<usize>,
|
||||
pub struct FomodInstaller<'a> {
|
||||
state: InstallerState,
|
||||
current_step: (usize, usize),
|
||||
config: &'a Config,
|
||||
installed_plugins: &'a [String],
|
||||
}
|
||||
|
||||
impl FomodInstaller {
|
||||
pub fn new(
|
||||
config: Config,
|
||||
installed_plugins: Vec<String>,
|
||||
group_promt: fn(GroupPrompt) -> Vec<usize>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
installed_plugins,
|
||||
group_prompt: group_promt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(self) -> Vec<FileTypeEnum> {
|
||||
impl<'a> FomodInstaller<'a> {
|
||||
pub fn new(fomod_config: &'a Config, installed_plugins: &'a [String]) -> Self {
|
||||
let mut state = InstallerState::new();
|
||||
|
||||
// Always-installed files first
|
||||
if let Some(required) = &self.config.required_install_files {
|
||||
if let Some(required) = &fomod_config.required_install_files {
|
||||
state.add_files(required);
|
||||
}
|
||||
|
||||
if let Some(install_steps) = &self.config.install_steps {
|
||||
let steps = &install_steps.install_step;
|
||||
Self {
|
||||
state,
|
||||
current_step: (0, 0),
|
||||
config: fomod_config,
|
||||
installed_plugins,
|
||||
}
|
||||
}
|
||||
|
||||
for step in steps {
|
||||
// Check if the step should be visible
|
||||
if step
|
||||
.visible
|
||||
.as_ref()
|
||||
.is_some_and(|v| !evaluate_dependency(v, &state, &self.installed_plugins))
|
||||
{
|
||||
// Dependency to show the step not meet. Skipping.
|
||||
continue;
|
||||
}
|
||||
pub fn run_step(&mut self, selection: Option<&[usize]>) -> Option<GroupPrompt> {
|
||||
let Some(install_steps) = &self.config.install_steps else {
|
||||
return None;
|
||||
};
|
||||
|
||||
for group in &step.optional_file_groups.group {
|
||||
// TODO: Skip groups where all plugins are NotUsable
|
||||
let step = install_steps.install_step.get(self.current_step.0)?;
|
||||
|
||||
let prompt = GroupPrompt::new(group, &state, &self.installed_plugins);
|
||||
// Check if the step should be visible
|
||||
if step
|
||||
.visible
|
||||
.as_ref()
|
||||
.is_some_and(|v| !evaluate_module_depbendecy(v, &self.state, self.installed_plugins))
|
||||
{
|
||||
// Dependency to show the step not meet. Skipping.
|
||||
self.current_step = (self.current_step.0 + 1, 0);
|
||||
return self.run_step(selection);
|
||||
}
|
||||
|
||||
let selected_plugins = (self.group_prompt)(prompt);
|
||||
let Some(group) = step.optional_file_groups.group.get(self.current_step.1) else {
|
||||
self.current_step = (self.current_step.0 + 1, 0);
|
||||
return self.run_step(selection);
|
||||
};
|
||||
|
||||
for i in selected_plugins {
|
||||
let plugin = &group.plugins.plugin[i];
|
||||
// TODO: Skip groups where all plugins are NotUsable
|
||||
|
||||
// Add files from selected plugin
|
||||
if let Some(files) = &plugin.files {
|
||||
state.add_files(files);
|
||||
}
|
||||
match selection {
|
||||
Some(selected_plugins) => {
|
||||
for i in selected_plugins {
|
||||
let plugin = &group.plugins.plugin[*i];
|
||||
|
||||
// Set condition flags
|
||||
if let Some(condition_flags) = &plugin.condition_flags {
|
||||
for flag in &condition_flags.flag {
|
||||
state.set_flag(&flag.name, &flag.flag_value);
|
||||
}
|
||||
// Add files from selected plugin
|
||||
if let Some(files) = &plugin.files {
|
||||
self.state.add_files(files);
|
||||
}
|
||||
|
||||
// Set condition flags
|
||||
if let Some(condition_flags) = &plugin.condition_flags {
|
||||
for flag in &condition_flags.flag {
|
||||
self.state.set_flag(&flag.name, &flag.flag_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate conditional file installs based on final flag state
|
||||
if let Some(conditional) = &self.config.conditional_file_installs {
|
||||
for pattern in &conditional.patterns.pattern {
|
||||
if evaluate_dependency(&pattern.dependencies, &state, &self.installed_plugins) {
|
||||
state.add_files(&pattern.files);
|
||||
}
|
||||
// Next step
|
||||
self.current_step = (self.current_step.0, self.current_step.1 + 1);
|
||||
self.run_step(None)
|
||||
}
|
||||
None => Some(GroupPrompt::new(group, &self.state, self.installed_plugins)),
|
||||
}
|
||||
}
|
||||
|
||||
state.into_file_list()
|
||||
pub fn finalize(self, mod_root: impl AsRef<Path>) -> Result<Vec<ModFile>, io::Error> {
|
||||
let files: Vec<_> = self
|
||||
.state
|
||||
.into_file_list()
|
||||
.iter()
|
||||
.map(|f| ModFile::from_installer(f.clone(), &mod_root))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
|
||||
7
src/nexus.rs
Normal file
7
src/nexus.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod api;
|
||||
mod downloader;
|
||||
mod url;
|
||||
|
||||
pub use api::{ModInfo, NexusAPI};
|
||||
pub use downloader::download_nxm;
|
||||
pub use url::NXMUrl;
|
||||
154
src/nexus/api.rs
Normal file
154
src/nexus/api.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use crate::{nexus::NXMUrl, types::GameType};
|
||||
|
||||
const NEXUS_ENDPOINT: &str = "https://api.nexusmods.com";
|
||||
|
||||
pub struct NexusAPI {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
impl NexusAPI {
|
||||
pub fn new(api_key: &str) -> Self {
|
||||
Self {
|
||||
api_key: api_key.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_key(&self) -> anyhow::Result<()> {
|
||||
let _body = ureq::get(format!("{}/v1/users/validate.json", NEXUS_ENDPOINT))
|
||||
.header("apikey", &self.api_key)
|
||||
.header("accept", "application/json")
|
||||
.call()?
|
||||
.body_mut()
|
||||
.read_to_string()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mod_info(&self, game_name: &str, mod_id: &str) -> anyhow::Result<ModInfo> {
|
||||
let mod_info: ModInfo = ureq::get(format!(
|
||||
"{}/v1/games/{}/mods/{}.json",
|
||||
NEXUS_ENDPOINT, game_name, mod_id
|
||||
))
|
||||
.header("apikey", &self.api_key)
|
||||
.header("accept", "application/json")
|
||||
.call()?
|
||||
.body_mut()
|
||||
.read_json()?;
|
||||
|
||||
Ok(mod_info)
|
||||
}
|
||||
|
||||
pub fn generate_download_link_for_file(
|
||||
&self,
|
||||
url: &NXMUrl,
|
||||
) -> anyhow::Result<Vec<DownloadLocation>> {
|
||||
let mut req_url = Url::parse(NEXUS_ENDPOINT)?;
|
||||
|
||||
let path = format!(
|
||||
"/v1/games/{}/mods/{}/files/{}/download_link.json",
|
||||
url.game, url.mod_id, url.file
|
||||
);
|
||||
req_url.set_path(&path);
|
||||
|
||||
req_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("key", &url.key)
|
||||
.append_pair("expires", &url.expires);
|
||||
|
||||
let body: Vec<DownloadLocation> = ureq::get(req_url.to_string())
|
||||
.header("apikey", &self.api_key)
|
||||
.header("accept", "application/json")
|
||||
.call()?
|
||||
.body_mut()
|
||||
.read_json()?;
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DownloadLocation {
|
||||
pub name: String,
|
||||
pub short_name: String,
|
||||
#[serde(rename = "URI")]
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
impl DownloadLocation {
|
||||
pub fn parse_url(&self) -> Result<Url, url::ParseError> {
|
||||
Url::parse(&self.uri)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ModInfo {
|
||||
pub name: String,
|
||||
// pub summary: String,
|
||||
// pub description: String,
|
||||
// pub picture_url: String,
|
||||
// pub mod_downloads: u64,
|
||||
// pub mod_unique_downloads: u64,
|
||||
// pub uid: u64,
|
||||
pub mod_id: u64,
|
||||
// pub game_id: u64,
|
||||
// pub allow_rating: bool,
|
||||
pub domain_name: String,
|
||||
// pub category_id: u64,
|
||||
pub version: String,
|
||||
// pub endorsement_count: u64,
|
||||
// pub created_timestamp: u64,
|
||||
// pub created_time: String,
|
||||
// pub updated_timestamp: u64,
|
||||
// pub updated_time: String,
|
||||
// pub author: String,
|
||||
// pub uploaded_by: String,
|
||||
// pub uploaded_users_profile_url: String,
|
||||
// pub contains_adult_content: bool,
|
||||
// pub status: String,
|
||||
// pub available: bool,
|
||||
// pub user: String /* Complex struct */,
|
||||
// pub endorsement: String /* Complex struct*/ ,
|
||||
}
|
||||
|
||||
impl ModInfo {
|
||||
/// Try to generate a id for a mod based on name, mod_id and version
|
||||
pub fn generate_id(&self) -> String {
|
||||
const MAX_CHARS: usize = 16;
|
||||
const MIN_CHARS: usize = 8;
|
||||
|
||||
let mut short_name = String::new();
|
||||
|
||||
for word in self.name.split_whitespace() {
|
||||
let cleaned: String = word.chars().filter(|c| !c.is_ascii_punctuation()).collect();
|
||||
|
||||
if cleaned.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if short_name.is_empty() {
|
||||
short_name.push_str(&cleaned);
|
||||
} else {
|
||||
short_name.push('_');
|
||||
short_name.push_str(&cleaned);
|
||||
}
|
||||
|
||||
// Ensure at least two words, then stop when >= 8 chars
|
||||
let words = short_name.matches('_').count() + 1;
|
||||
if words >= 2 && short_name.len() >= MIN_CHARS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if short_name.len() > MAX_CHARS {
|
||||
short_name.truncate(MAX_CHARS);
|
||||
}
|
||||
format!("{}-{}", short_name.to_lowercase(), self.mod_id)
|
||||
}
|
||||
|
||||
pub fn get_game_type(&self) -> GameType {
|
||||
GameType::from_nexus_domain(&self.domain_name).unwrap_or(GameType::Unknown)
|
||||
}
|
||||
}
|
||||
66
src/nexus/downloader.rs
Normal file
66
src/nexus/downloader.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use std::{
|
||||
fs::{File, create_dir_all},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use log::info;
|
||||
|
||||
use crate::nexus::{
|
||||
NXMUrl, NexusAPI,
|
||||
api::{DownloadLocation, ModInfo},
|
||||
};
|
||||
|
||||
pub fn download_nxm(
|
||||
api_key: &str,
|
||||
nxm_url: &NXMUrl,
|
||||
target_dir: impl AsRef<Path>,
|
||||
) -> anyhow::Result<(PathBuf, ModInfo)> {
|
||||
let api = NexusAPI::new(api_key);
|
||||
|
||||
let mod_info = api.mod_info(&nxm_url.game, &nxm_url.mod_id)?;
|
||||
let links = api.generate_download_link_for_file(nxm_url)?;
|
||||
let selected_mirror = links.first().unwrap();
|
||||
let url = selected_mirror.parse_url()?;
|
||||
let original_filename = url.path_segments().and_then(|mut e| e.next_back()).unwrap();
|
||||
let filename = gen_filename_for_mod(&mod_info, &nxm_url.file, original_filename);
|
||||
let download_path = target_dir.as_ref().join(&nxm_url.game).join(filename);
|
||||
|
||||
if let Some(parent) = download_path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
download_mod(selected_mirror, &download_path)?;
|
||||
|
||||
Ok((download_path, mod_info))
|
||||
}
|
||||
|
||||
fn download_mod(mod_dl_link: &DownloadLocation, target: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
info!("Downloading file from {}", mod_dl_link.name);
|
||||
|
||||
let (response, body) = ureq::get(mod_dl_link.parse_url()?.as_str())
|
||||
.call()?
|
||||
.into_parts();
|
||||
|
||||
if !response.status.is_success() {
|
||||
return Err(anyhow!("Got status {}", response.status));
|
||||
}
|
||||
|
||||
let mut file = File::create(target)?;
|
||||
let mut reader = body.into_reader();
|
||||
|
||||
io::copy(&mut reader, &mut file)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gen_filename_for_mod(mod_info: &ModInfo, file_id: &str, dl_filename: &str) -> String {
|
||||
let filename_from_url = PathBuf::from(dl_filename);
|
||||
let ext = filename_from_url
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy();
|
||||
|
||||
format!("{}-{}.{}", mod_info.mod_id, file_id, ext)
|
||||
}
|
||||
44
src/nexus/url.rs
Normal file
44
src/nexus/url.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NXMUrl {
|
||||
pub game: String,
|
||||
pub mod_id: String,
|
||||
pub file: String,
|
||||
pub key: String,
|
||||
pub user_id: String,
|
||||
pub expires: String,
|
||||
}
|
||||
|
||||
impl NXMUrl {
|
||||
pub fn parse_url(raw: &str) -> Option<Self> {
|
||||
// Example url
|
||||
//nxm://skyrimspecialedition/mods/266/files/725705?key=xHxcFyx2_i5rhY1YdsSbyA&expires=1773086056&user_id=123456
|
||||
|
||||
let url = Url::parse(raw).ok()?;
|
||||
|
||||
let segs: Vec<&str> = url.path_segments()?.collect();
|
||||
|
||||
let game = url.host_str()?;
|
||||
let mod_id = segs.get(1)?.parse().ok()?;
|
||||
let file_id = segs.get(3)?.parse().ok()?;
|
||||
|
||||
let key = url.query_pairs().find(|(k, _)| k == "key")?.1;
|
||||
let expires = url
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == "expires")?
|
||||
.1
|
||||
.parse()
|
||||
.ok()?;
|
||||
let user_id = url.query_pairs().find(|(k, _)| k == "user_id")?.1;
|
||||
|
||||
Some(Self {
|
||||
game: game.to_owned(),
|
||||
mod_id,
|
||||
file: file_id,
|
||||
key: key.to_string(),
|
||||
user_id: user_id.parse().ok()?,
|
||||
expires,
|
||||
})
|
||||
}
|
||||
}
|
||||
6
src/tui.rs
Normal file
6
src/tui.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod app;
|
||||
mod mod_list;
|
||||
mod status;
|
||||
mod instance;
|
||||
|
||||
pub use app::run;
|
||||
137
src/tui/app.rs
Normal file
137
src/tui/app.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use std::io;
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
|
||||
use log::error;
|
||||
use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
widgets::{StatefulWidget, Widget},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
tui::{
|
||||
instance::{InstanceSelect, InstanceSelectState},
|
||||
mod_list::{ModList, ModListState},
|
||||
status::StatusBar,
|
||||
},
|
||||
types::{ModdedInstance, RootConfig},
|
||||
};
|
||||
|
||||
pub fn run(root_config: &mut RootConfig) -> anyhow::Result<()> {
|
||||
ratatui::run(|terminal| App::new(root_config).run(terminal))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct App<'a> {
|
||||
root_config: &'a mut RootConfig,
|
||||
|
||||
loaded_instance: Option<ModdedInstance>,
|
||||
|
||||
exit: bool,
|
||||
|
||||
mod_list_state: ModListState,
|
||||
selected_instance_state: InstanceSelectState,
|
||||
}
|
||||
|
||||
impl<'a> App<'a> {
|
||||
fn new(root_config: &'a mut RootConfig) -> Self {
|
||||
let mut mod_list_state = ModListState::new();
|
||||
mod_list_state.update_list(root_config, None);
|
||||
Self {
|
||||
root_config,
|
||||
|
||||
loaded_instance: None,
|
||||
|
||||
exit: false,
|
||||
mod_list_state,
|
||||
selected_instance_state: InstanceSelectState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
terminal.clear()?;
|
||||
while !self.exit {
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
self.handle_events()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
frame.render_widget(self, frame.area());
|
||||
}
|
||||
|
||||
fn handle_events(&mut self) -> io::Result<()> {
|
||||
match event::read()? {
|
||||
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
|
||||
self.handle_key_event(key_event)
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => self.exit(),
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.mod_list_state.select_prev();
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.mod_list_state.select_next();
|
||||
}
|
||||
KeyCode::Right | KeyCode::Char('l') => {
|
||||
self.selected_instance_state.next_instance(self.root_config);
|
||||
self.load_instance();
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('h') => {
|
||||
self.selected_instance_state.prev_instance(self.root_config);
|
||||
self.load_instance();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn exit(&mut self) {
|
||||
self.exit = true;
|
||||
}
|
||||
|
||||
fn load_instance(&mut self) {
|
||||
let Some(selected) = self.selected_instance_state.instance() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match self.root_config.load_instance_by_id(selected) {
|
||||
Ok(instance) => {
|
||||
self.loaded_instance = Some(instance);
|
||||
self.mod_list_state
|
||||
.update_list(self.root_config, self.loaded_instance.as_ref());
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to load instance: {err}");
|
||||
self.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for &mut App<'a> {
|
||||
fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
InstanceSelect.render(chunks[0], buf, &mut self.selected_instance_state);
|
||||
ModList.render(chunks[1], buf, &mut self.mod_list_state);
|
||||
StatusBar.render(chunks[2], buf);
|
||||
}
|
||||
}
|
||||
92
src/tui/instance.rs
Normal file
92
src/tui/instance.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
|
||||
};
|
||||
|
||||
use crate::types::RootConfig;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InstanceSelectState {
|
||||
selected: Option<String>,
|
||||
}
|
||||
|
||||
impl InstanceSelectState {
|
||||
pub fn new() -> Self {
|
||||
Self { selected: None }
|
||||
}
|
||||
|
||||
pub fn instance(&self) -> Option<&str> {
|
||||
self.selected.as_deref()
|
||||
}
|
||||
|
||||
pub fn next_instance(&mut self, root_config: &RootConfig) {
|
||||
let mut instances = root_config.instances();
|
||||
instances.sort();
|
||||
|
||||
if instances.is_empty() {
|
||||
self.selected = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let next = match &self.selected {
|
||||
None => instances.first().cloned(),
|
||||
Some(curr) => {
|
||||
let idx = instances.iter().position(|x| x == curr);
|
||||
match idx {
|
||||
Some(i) => {
|
||||
let next_index = (i + 1) % instances.len();
|
||||
instances.get(next_index).cloned()
|
||||
}
|
||||
None => instances.first().cloned(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.selected = next;
|
||||
}
|
||||
|
||||
pub fn prev_instance(&mut self, root_config: &RootConfig) {
|
||||
let mut instances = root_config.instances();
|
||||
instances.sort();
|
||||
|
||||
if instances.is_empty() {
|
||||
self.selected = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let prev = match &self.selected {
|
||||
None => instances.last().cloned(),
|
||||
Some(curr) => {
|
||||
let idx = instances.iter().position(|x| x == curr);
|
||||
match idx {
|
||||
Some(i) => {
|
||||
let prev_index = if i == 0 { instances.len() - 1 } else { i - 1 };
|
||||
instances.get(prev_index).cloned()
|
||||
}
|
||||
None => Some(instances[instances.len() - 1].clone()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.selected = prev;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InstanceSelect;
|
||||
|
||||
impl StatefulWidget for InstanceSelect {
|
||||
type State = InstanceSelectState;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
let list_block = Block::default()
|
||||
.title("Instance")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default());
|
||||
|
||||
Paragraph::new(state.selected.clone().unwrap_or("None".to_owned()))
|
||||
.block(list_block)
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
114
src/tui/mod_list.rs
Normal file
114
src/tui/mod_list.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Block, Borders, Cell, Row, StatefulWidget, Table, TableState},
|
||||
};
|
||||
|
||||
use crate::types::{ModConfig, ModdedInstance, RootConfig};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ModListState {
|
||||
table_state: TableState,
|
||||
items: Vec<ListItem>,
|
||||
}
|
||||
|
||||
impl ModListState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
table_state: TableState::new(),
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_next(&mut self) {
|
||||
self.table_state.select_next();
|
||||
}
|
||||
|
||||
pub fn select_prev(&mut self) {
|
||||
self.table_state.select_previous();
|
||||
}
|
||||
|
||||
pub fn update_list(
|
||||
&mut self,
|
||||
root_config: &RootConfig,
|
||||
loaded_instance: Option<&ModdedInstance>,
|
||||
) {
|
||||
let instance_game_type = loaded_instance
|
||||
.and_then(|e| root_config.game_by_id(e.game_id()))
|
||||
.map(|e| e.game_type());
|
||||
|
||||
let included_ids: Option<HashSet<_>> =
|
||||
loaded_instance.map(|instance| instance.mods().iter().map(|m| m.mod_id()).collect());
|
||||
|
||||
let mut items: Vec<_> = root_config
|
||||
.mods()
|
||||
.iter()
|
||||
.filter(|e| instance_game_type.clone().is_none_or(|gt| e.1.game() == gt))
|
||||
.map(|(id, config)| ListItem {
|
||||
id: id.to_owned(),
|
||||
mod_config: config.clone(),
|
||||
included: included_ids
|
||||
.as_ref()
|
||||
.is_some_and(|set| set.contains(id.as_str())),
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by_key(|item| item.id.clone());
|
||||
|
||||
self.items = items;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ListItem {
|
||||
mod_config: ModConfig,
|
||||
id: String,
|
||||
included: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ModList;
|
||||
|
||||
impl StatefulWidget for ModList {
|
||||
type State = ModListState;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
let block = Block::default()
|
||||
.title("Mod list")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default());
|
||||
|
||||
let rows: Vec<Row> = state
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
Row::new(vec![
|
||||
Cell::from(item.mod_config.name().unwrap_or(&item.id)),
|
||||
Cell::from(item.id.as_str()),
|
||||
Cell::from(if item.included { "" } else { "" }),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
],
|
||||
)
|
||||
.row_highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.block(block);
|
||||
|
||||
StatefulWidget::render(table, area, buf, &mut state.table_state);
|
||||
}
|
||||
}
|
||||
12
src/tui/status.rs
Normal file
12
src/tui/status.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use ratatui::{text::Line, widgets::Widget};
|
||||
|
||||
pub struct StatusBar;
|
||||
|
||||
impl Widget for StatusBar {
|
||||
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
|
||||
where
|
||||
Self: Sized {
|
||||
|
||||
Line::from("Up Down Left right").render(area, buf);
|
||||
}
|
||||
}
|
||||
39
src/types.rs
Normal file
39
src/types.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use thiserror::Error;
|
||||
|
||||
mod game;
|
||||
mod game_type;
|
||||
mod installed_mod;
|
||||
mod link;
|
||||
mod mod_config;
|
||||
mod mod_file;
|
||||
mod modded_instance;
|
||||
mod root_config;
|
||||
mod nexus_id;
|
||||
|
||||
pub use game::*;
|
||||
pub use game_type::GameType;
|
||||
pub use installed_mod::*;
|
||||
pub use link::*;
|
||||
pub use mod_config::*;
|
||||
pub use mod_file::*;
|
||||
pub use modded_instance::*;
|
||||
pub use root_config::*;
|
||||
pub use nexus_id::*;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ConfigReadWriteError {
|
||||
#[error("IO failure")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Failed to deserialize toml")]
|
||||
Deserialize(#[from] toml::de::Error),
|
||||
|
||||
#[error("Failed to serialize to toml")]
|
||||
Serialize(#[from] toml::ser::Error),
|
||||
|
||||
#[error("The provided ID could not be found")]
|
||||
IDNotFound,
|
||||
|
||||
#[error("Could not determine the parent path of the file")]
|
||||
NoParent, //fatty fatty no parents
|
||||
}
|
||||
52
src/types/game.rs
Normal file
52
src/types/game.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
types::{GameType, link::Link},
|
||||
utils::walk_all_files,
|
||||
};
|
||||
|
||||
/// Available game
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Game {
|
||||
path: PathBuf,
|
||||
kind: GameType,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub fn new(path: impl AsRef<Path>, game_type: GameType) -> Self {
|
||||
Self {
|
||||
path: path.as_ref().to_owned(),
|
||||
kind: game_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_links(&self) -> Result<HashSet<Link>, io::Error> {
|
||||
let links: HashSet<Link> = walk_all_files(&self.path)?
|
||||
.map(|entry| {
|
||||
Link::new(
|
||||
entry.path(),
|
||||
entry
|
||||
.path()
|
||||
.strip_prefix(&self.path)
|
||||
.expect("Can't transform path back to relative. Should not happen."),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
pub fn install_location(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn game_type(&self) -> GameType {
|
||||
self.kind.clone()
|
||||
}
|
||||
}
|
||||
186
src/types/game_type.rs
Normal file
186
src/types/game_type.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub enum GameType {
|
||||
Oblivion,
|
||||
Skyrim,
|
||||
Fallout3,
|
||||
FalloutNV,
|
||||
Fallout4,
|
||||
SkyrimSE,
|
||||
Fallout4VR,
|
||||
SkyrimVR,
|
||||
Morrowind,
|
||||
Starfield,
|
||||
OpenMW,
|
||||
OblivionRemastered,
|
||||
Custom(String),
|
||||
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl GameType {
|
||||
pub fn to_libloot_type(self) -> Option<libloot::GameType> {
|
||||
match self {
|
||||
GameType::Oblivion => Some(libloot::GameType::Oblivion),
|
||||
GameType::Skyrim => Some(libloot::GameType::Skyrim),
|
||||
GameType::Fallout3 => Some(libloot::GameType::Fallout3),
|
||||
GameType::FalloutNV => Some(libloot::GameType::FalloutNV),
|
||||
GameType::Fallout4 => Some(libloot::GameType::Fallout4),
|
||||
GameType::SkyrimSE => Some(libloot::GameType::SkyrimSE),
|
||||
GameType::Fallout4VR => Some(libloot::GameType::Fallout4VR),
|
||||
GameType::SkyrimVR => Some(libloot::GameType::SkyrimVR),
|
||||
GameType::Morrowind => Some(libloot::GameType::Morrowind),
|
||||
GameType::Starfield => Some(libloot::GameType::Starfield),
|
||||
GameType::OpenMW => Some(libloot::GameType::OpenMW),
|
||||
GameType::OblivionRemastered => Some(libloot::GameType::OblivionRemastered),
|
||||
GameType::Custom(_) => None,
|
||||
GameType::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_nexus_domain(self) -> Option<String> {
|
||||
match self {
|
||||
GameType::Oblivion => Some("oblivion".to_owned()),
|
||||
GameType::Skyrim => Some("skyrim".to_owned()),
|
||||
GameType::Fallout3 => Some("fallout3".to_owned()),
|
||||
GameType::FalloutNV => Some("newvegas".to_owned()),
|
||||
GameType::Fallout4 => Some("fallout4".to_owned()),
|
||||
GameType::SkyrimSE => Some("skyrimspecialedition".to_owned()),
|
||||
GameType::Fallout4VR => Some("fallout4".to_owned()),
|
||||
GameType::SkyrimVR => Some("skyrimspecialedition".to_owned()),
|
||||
GameType::Morrowind => Some("morrowind".to_owned()),
|
||||
GameType::Starfield => Some("starfield".to_owned()),
|
||||
GameType::OpenMW => Some("morrowind".to_owned()),
|
||||
GameType::OblivionRemastered => Some("oblivionremastered".to_owned()),
|
||||
GameType::Custom(_) => None,
|
||||
GameType::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_nexus_domain(domain: &str) -> Option<Self> {
|
||||
match domain {
|
||||
"oblivion" => Some(GameType::Oblivion),
|
||||
"skyrim" => Some(GameType::Skyrim),
|
||||
"fallout3" => Some(GameType::Fallout3),
|
||||
"newvegas" => Some(GameType::FalloutNV),
|
||||
"fallout4" => Some(GameType::Fallout4),
|
||||
"skyrimspecialedition" => Some(GameType::SkyrimSE),
|
||||
"morrowind" => Some(GameType::Morrowind),
|
||||
"starfield" => Some(GameType::Starfield),
|
||||
"oblivionremastered" => Some(GameType::OblivionRemastered),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for GameType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
GameType::Oblivion => "Oblivion",
|
||||
GameType::Skyrim => "Skyrim",
|
||||
GameType::Fallout3 => "Fallout 3",
|
||||
GameType::FalloutNV => "Fallout New Vegas",
|
||||
GameType::Fallout4 => "Fallout 4",
|
||||
GameType::SkyrimSE => "Skyrim Special Edition",
|
||||
GameType::Fallout4VR => "Fallout 4 VR",
|
||||
GameType::SkyrimVR => "Skyrim VR",
|
||||
GameType::Morrowind => "Morrowind",
|
||||
GameType::Starfield => "Starfield",
|
||||
GameType::OpenMW => "OpenMW",
|
||||
GameType::OblivionRemastered => "Oblivion Remastered",
|
||||
GameType::Custom(name) => name,
|
||||
GameType::Unknown => "Unknown",
|
||||
};
|
||||
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for GameType {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(match s.as_str() {
|
||||
"oblivion" => Self::Oblivion,
|
||||
"skyrim" => Self::Skyrim,
|
||||
"fo3" => Self::Fallout3,
|
||||
"fonv" => Self::FalloutNV,
|
||||
"fo4" => Self::Fallout4,
|
||||
"sse" => Self::SkyrimSE,
|
||||
"fo4vr" => Self::Fallout4VR,
|
||||
"skyrimvr" => Self::SkyrimVR,
|
||||
"morrowind" => Self::Morrowind,
|
||||
"starfield" => Self::Starfield,
|
||||
"openmw" => Self::OpenMW,
|
||||
"oblivionrm" => Self::OblivionRemastered,
|
||||
"unknown" => Self::Unknown,
|
||||
_ => Self::Custom(s),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for GameType {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let s = match self {
|
||||
Self::Custom(s) => s,
|
||||
Self::Oblivion => "oblivion",
|
||||
Self::Skyrim => "skyrim",
|
||||
Self::Fallout3 => "fo3",
|
||||
Self::FalloutNV => "fonv",
|
||||
Self::Fallout4 => "fo4",
|
||||
Self::SkyrimSE => "sse",
|
||||
Self::Fallout4VR => "fo4vr",
|
||||
Self::SkyrimVR => "skyrimvr",
|
||||
Self::Morrowind => "morrowind",
|
||||
Self::Starfield => "starfield",
|
||||
Self::OpenMW => "openmw",
|
||||
Self::OblivionRemastered => "oblivionrm",
|
||||
Self::Unknown => "unknown",
|
||||
};
|
||||
|
||||
serializer.serialize_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize, Serialize, PartialEq, Debug)]
|
||||
struct Wrapper {
|
||||
value: GameType,
|
||||
}
|
||||
|
||||
fn roundtrip(game_type: GameType) {
|
||||
let val = Wrapper { value: game_type };
|
||||
let serialized = toml::to_string(&val).unwrap();
|
||||
let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
|
||||
|
||||
assert_eq!(val, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_back_and_forth_all() {
|
||||
for e in [
|
||||
GameType::Oblivion,
|
||||
GameType::Skyrim,
|
||||
GameType::Fallout3,
|
||||
GameType::FalloutNV,
|
||||
GameType::Fallout4,
|
||||
GameType::SkyrimSE,
|
||||
GameType::Fallout4VR,
|
||||
GameType::SkyrimVR,
|
||||
GameType::Morrowind,
|
||||
GameType::Starfield,
|
||||
GameType::OpenMW,
|
||||
GameType::OblivionRemastered,
|
||||
GameType::Custom("custom".to_owned()),
|
||||
GameType::Unknown,
|
||||
] {
|
||||
roundtrip(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/types/installed_mod.rs
Normal file
52
src/types/installed_mod.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::{collections::HashSet, ffi::OsStr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
types::{link::Link, mod_file::ModFile},
|
||||
utils::is_plugin_file,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct InstalledMod {
|
||||
id: String,
|
||||
files: HashSet<Link>,
|
||||
priority: isize,
|
||||
}
|
||||
|
||||
impl InstalledMod {
|
||||
pub fn new(root_mod_id: &str, priority: isize) -> Self {
|
||||
Self {
|
||||
id: root_mod_id.to_owned(),
|
||||
files: HashSet::new(),
|
||||
priority,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_file(&mut self, file: &ModFile) {
|
||||
self.files.insert(Link::from_mod_file(file));
|
||||
}
|
||||
|
||||
/// Get the id of the mod
|
||||
pub fn mod_id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// The priority over other mods. Only used when 2 files conflict.
|
||||
pub fn priority(&self) -> isize {
|
||||
self.priority
|
||||
}
|
||||
|
||||
/// The selected files
|
||||
pub fn files(&self) -> &HashSet<Link> {
|
||||
&self.files
|
||||
}
|
||||
|
||||
pub fn active_plugins(&self) -> impl Iterator<Item = &OsStr> {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|e| is_plugin_file(e.dst()))
|
||||
.map(|e| e.dst())
|
||||
.flat_map(|e| e.file_name())
|
||||
}
|
||||
}
|
||||
93
src/types/link.rs
Normal file
93
src/types/link.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use serde::{
|
||||
Deserialize, Deserializer, Serialize, Serializer,
|
||||
de::{self, Visitor},
|
||||
};
|
||||
use std::{
|
||||
fmt::{self, Debug},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::types::mod_file::ModFile;
|
||||
|
||||
/// A link between a file from a mod and a destination in a ModdedInstance
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Link {
|
||||
src: PathBuf,
|
||||
dst: PathBuf,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
pub fn new(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
src: src.as_ref().to_owned(),
|
||||
dst: dst.as_ref().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_mod_file(file: &ModFile) -> Self {
|
||||
Self::new(file.src(), file.dst())
|
||||
}
|
||||
|
||||
pub fn src(&self) -> &Path {
|
||||
&self.src
|
||||
}
|
||||
|
||||
pub fn dst(&self) -> &Path {
|
||||
&self.dst
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Link {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
if self.src == self.dst {
|
||||
serializer.serialize_str(&self.src.to_string_lossy())
|
||||
} else {
|
||||
serializer.serialize_str(&format!(
|
||||
"{} -> {}",
|
||||
self.src.to_string_lossy(),
|
||||
self.dst.to_string_lossy()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Link {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct LinkVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for LinkVisitor {
|
||||
type Value = Link;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str(r#"a string like "src -> dst" or "path" if they are the same"#)
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, value: &str) -> Result<Link, E> {
|
||||
match value.split_once(" -> ") {
|
||||
Some((src, dst)) => Ok(Link {
|
||||
src: PathBuf::from(src),
|
||||
dst: PathBuf::from(dst),
|
||||
}),
|
||||
None => Ok(Link {
|
||||
src: PathBuf::from(value),
|
||||
dst: PathBuf::from(value),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(LinkVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModFile> for Link {
|
||||
fn from(value: ModFile) -> Self {
|
||||
Self::new(value.src(), value.dst())
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for Link {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Link{{{:?} -> {:?}}}", self.src, self.dst,)
|
||||
}
|
||||
}
|
||||
108
src/types/mod_config.rs
Normal file
108
src/types/mod_config.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
nexus::ModInfo,
|
||||
types::{GameType, NexusID},
|
||||
};
|
||||
|
||||
/// Config for an available mod
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ModConfig {
|
||||
/// ID of the mod
|
||||
#[serde(skip)]
|
||||
id: String,
|
||||
|
||||
/// Relative to the mod_location from root config
|
||||
path: PathBuf,
|
||||
|
||||
/// If the files should be included on the root
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
root_mod: bool,
|
||||
|
||||
/// Globs of what files to ignore
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
ignore: Vec<String>,
|
||||
|
||||
name: Option<String>,
|
||||
|
||||
nexus_id: Option<NexusID>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
game: GameType,
|
||||
}
|
||||
|
||||
impl ModConfig {
|
||||
pub fn new(id: &str, source: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
id: id.to_owned(),
|
||||
path: source.as_ref().to_owned(),
|
||||
root_mod: false,
|
||||
ignore: Vec::new(),
|
||||
name: None,
|
||||
nexus_id: None,
|
||||
game: GameType::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_mod_info(
|
||||
id: &str,
|
||||
source: impl AsRef<Path>,
|
||||
mod_info: &ModInfo,
|
||||
file_id: u64,
|
||||
) -> Self {
|
||||
let mut normal = Self::new(id, source);
|
||||
normal.name = Some(mod_info.name.clone());
|
||||
normal.game = mod_info.get_game_type();
|
||||
normal.nexus_id = Some(NexusID::new(mod_info.mod_id, file_id));
|
||||
|
||||
normal
|
||||
}
|
||||
|
||||
pub fn add_id(mut self, id: &str) -> Self {
|
||||
self.id = id.to_owned();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Get the relative path to the mod from the mod directory
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn is_root_mod(&self) -> bool {
|
||||
self.root_mod
|
||||
}
|
||||
|
||||
pub fn ignore(&self) -> &[String] {
|
||||
&self.ignore
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
|
||||
pub fn nexus_id(&self) -> Option<&NexusID> {
|
||||
self.nexus_id.as_ref()
|
||||
}
|
||||
|
||||
pub fn game(&self) -> GameType {
|
||||
self.game.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!b
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
|
||||
t == &T::default()
|
||||
}
|
||||
80
src/types/mod_file.rs
Normal file
80
src/types/mod_file.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
fomod::{FileType, FileTypeEnum},
|
||||
utils::walk_all_files,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct ModFile {
|
||||
/// Relative path in the mod
|
||||
src: PathBuf,
|
||||
|
||||
/// Relative path on where to install file in game dir
|
||||
dst: PathBuf,
|
||||
|
||||
/// Internal priority inside the mod itself. In case the mod overwrites internal files.
|
||||
internal_priority: isize,
|
||||
}
|
||||
|
||||
impl ModFile {
|
||||
pub fn new(src: impl AsRef<Path>, dst: impl AsRef<Path>, prio: isize) -> Self {
|
||||
Self {
|
||||
src: src.as_ref().to_owned(),
|
||||
dst: dst.as_ref().to_owned(),
|
||||
internal_priority: prio,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_installer(file: FileType) -> Self {
|
||||
let dest: PathBuf = file.destination.unwrap_or_default().into();
|
||||
|
||||
ModFile {
|
||||
src: file.source.into(),
|
||||
dst: dest.to_owned(),
|
||||
internal_priority: file.priority.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_installer(
|
||||
entry: FileTypeEnum,
|
||||
source: impl AsRef<Path>,
|
||||
) -> Result<Vec<Self>, std::io::Error> {
|
||||
match entry {
|
||||
FileTypeEnum::File(file_type) => Ok(vec![Self::new_from_installer(file_type)]),
|
||||
FileTypeEnum::Folder(dir_type) => {
|
||||
let source_root = source.as_ref().join(&dir_type.source);
|
||||
let priority = dir_type.priority.unwrap_or(0);
|
||||
let dest_base: PathBuf =
|
||||
Path::new("Data").join(PathBuf::from(dir_type.destination.unwrap_or_default()));
|
||||
|
||||
let files = walk_all_files(&source_root)?
|
||||
.map(|entry| Self {
|
||||
src: entry.path().strip_prefix(&source).unwrap().to_owned(),
|
||||
dst: dest_base.join(entry.path().strip_prefix(&source_root).unwrap()),
|
||||
internal_priority: priority,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn src(&self) -> &Path {
|
||||
&self.src
|
||||
}
|
||||
|
||||
/// Get the realtive path this file should be installed
|
||||
pub fn dst(&self) -> &Path {
|
||||
&self.dst
|
||||
}
|
||||
|
||||
/// Get the iternal priority. Only used when 2 files conflict.
|
||||
#[inline]
|
||||
pub fn internal_priority(&self) -> isize {
|
||||
self.internal_priority
|
||||
}
|
||||
}
|
||||
163
src/types/modded_instance.rs
Normal file
163
src/types/modded_instance.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use std::{
|
||||
fs::{self, read_to_string},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::{ConfigReadWriteError, installed_mod::InstalledMod, link::Link};
|
||||
|
||||
/// An modded game with all plugins and files
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ModdedInstance {
|
||||
game: String,
|
||||
|
||||
#[serde(default)]
|
||||
mods: Vec<InstalledMod>,
|
||||
|
||||
#[serde(default)]
|
||||
load_order: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
game_file_overrides: Vec<Link>,
|
||||
|
||||
#[serde(skip)]
|
||||
self_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ModdedInstance {
|
||||
pub fn new(
|
||||
game: &str,
|
||||
mods: &[InstalledMod],
|
||||
load_order: &[String],
|
||||
overrides: &[Link],
|
||||
self_path: impl AsRef<Path>,
|
||||
) -> Self {
|
||||
Self {
|
||||
game: game.to_owned(),
|
||||
mods: mods.to_owned(),
|
||||
load_order: load_order.to_owned(),
|
||||
game_file_overrides: overrides.to_owned(),
|
||||
self_path: self_path.as_ref().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self, ConfigReadWriteError> {
|
||||
debug!(
|
||||
"Loading ModdedInstance from file: {}",
|
||||
path.as_ref().to_string_lossy()
|
||||
);
|
||||
|
||||
let data = read_to_string(&path)?;
|
||||
let mut config: Self = toml::from_str(&data)?;
|
||||
|
||||
config.self_path = path.as_ref().to_owned();
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save_to_file(&self) -> Result<(), ConfigReadWriteError> {
|
||||
debug!(
|
||||
"Saving ModdedInstance to: {}",
|
||||
self.self_path.to_string_lossy()
|
||||
);
|
||||
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
let mut file = fs::File::create(&self.self_path)?;
|
||||
write!(file, "{}", content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_load_order(&mut self, order: Vec<String>) {
|
||||
self.load_order = order;
|
||||
}
|
||||
|
||||
pub fn load_order(&self) -> &[String] {
|
||||
&self.load_order
|
||||
}
|
||||
|
||||
pub fn game_file_overrides(&self) -> &[Link] {
|
||||
&self.game_file_overrides
|
||||
}
|
||||
|
||||
pub fn update_or_create_mod(&mut self, installed_mod: &InstalledMod) {
|
||||
match self
|
||||
.mods
|
||||
.iter_mut()
|
||||
.find(|e| e.mod_id() == installed_mod.mod_id())
|
||||
{
|
||||
Some(existing) => {
|
||||
*existing = installed_mod.to_owned();
|
||||
}
|
||||
None => {
|
||||
self.mods.push(installed_mod.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn game_id(&self) -> &str {
|
||||
&self.game
|
||||
}
|
||||
|
||||
pub fn mods(&self) -> &[InstalledMod] {
|
||||
&self.mods
|
||||
}
|
||||
|
||||
pub fn active_plugins(&self) -> impl Iterator<Item = String> {
|
||||
self.mods
|
||||
.iter()
|
||||
.flat_map(|e| e.active_plugins())
|
||||
.map(|e| e.to_string_lossy())
|
||||
.map(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_config() -> ModdedInstance {
|
||||
ModdedInstance::new(
|
||||
"sse",
|
||||
&[InstalledMod::new("mod1", 0), InstalledMod::new("mod1", 0)],
|
||||
&["Plugin1.esp".to_owned(), "Plugin2.esp".to_owned()],
|
||||
&[Link::new("file1.txt", "file2.txt")],
|
||||
"/config/instance.toml",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_members() {
|
||||
let cfg = create_config();
|
||||
|
||||
assert_eq!(cfg.game_file_overrides().len(), 1);
|
||||
assert_eq!(cfg.mods().len(), 2);
|
||||
assert_eq!(cfg.load_order().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mod() {
|
||||
let mut cfg = create_config();
|
||||
|
||||
let new_mod = InstalledMod::new("mod3", 1);
|
||||
|
||||
cfg.update_or_create_mod(&new_mod);
|
||||
|
||||
let mods = cfg.mods();
|
||||
assert_eq!(mods.len(), 3);
|
||||
|
||||
let found_mod = mods.iter().find(|e| e.mod_id() == "mod3");
|
||||
|
||||
assert!(found_mod.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_mod() {
|
||||
let mut cfg = create_config();
|
||||
let new_mod = InstalledMod::new("mod1", 1);
|
||||
|
||||
cfg.update_or_create_mod(&new_mod);
|
||||
}
|
||||
}
|
||||
70
src/types/nexus_id.rs
Normal file
70
src/types/nexus_id.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NexusID {
|
||||
mod_id: u64,
|
||||
file_id: u64,
|
||||
}
|
||||
|
||||
impl NexusID {
|
||||
pub fn new(mod_id: u64, file_id: u64) -> Self {
|
||||
Self { mod_id, file_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for NexusID {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let s = format!("{}:{}", self.mod_id, self.file_id);
|
||||
serializer.serialize_str(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for NexusID {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let mut parts = s.split(':');
|
||||
|
||||
let mod_id = parts
|
||||
.next()
|
||||
.ok_or_else(|| serde::de::Error::custom("missing first value"))
|
||||
.and_then(|p| u64::from_str(p).map_err(serde::de::Error::custom))?;
|
||||
|
||||
let file_id = parts
|
||||
.next()
|
||||
.ok_or_else(|| serde::de::Error::custom("missing second value"))
|
||||
.and_then(|p| u64::from_str(p).map_err(serde::de::Error::custom))?;
|
||||
|
||||
if parts.next().is_some() {
|
||||
return Err(serde::de::Error::custom("too many parts"));
|
||||
}
|
||||
|
||||
Ok(Self { mod_id, file_id })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize, Serialize, PartialEq, Debug)]
|
||||
struct Wrapper {
|
||||
value: NexusID,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let val = Wrapper {
|
||||
value: NexusID::new(1234, 5678),
|
||||
};
|
||||
let serialized = toml::to_string(&val).unwrap();
|
||||
let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(val, deserialized);
|
||||
}
|
||||
}
|
||||
278
src/types/root_config.rs
Normal file
278
src/types/root_config.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{self, read_to_string},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::{ConfigReadWriteError, ModConfig, game::Game, modded_instance::ModdedInstance};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RootConfig {
|
||||
#[serde(default)]
|
||||
games: HashMap<String, Game>,
|
||||
|
||||
mod_location: PathBuf,
|
||||
|
||||
download_location: Option<PathBuf>,
|
||||
|
||||
nexus_api_key: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
instances: HashMap<String, InstancePointer>,
|
||||
|
||||
/// All available mods
|
||||
#[serde(default)]
|
||||
mods: HashMap<String, ModConfig>,
|
||||
|
||||
#[serde(skip)]
|
||||
self_path: PathBuf,
|
||||
|
||||
#[serde(skip)]
|
||||
self_parent: PathBuf,
|
||||
}
|
||||
|
||||
impl RootConfig {
|
||||
pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self, ConfigReadWriteError> {
|
||||
debug!(
|
||||
"Loading RootConfig from file: {}",
|
||||
path.as_ref().to_string_lossy()
|
||||
);
|
||||
|
||||
let data = read_to_string(&path)?;
|
||||
let mut config: Self = toml::from_str(&data)?;
|
||||
|
||||
let absolute = fs::canonicalize(path.as_ref())?;
|
||||
config.self_parent = absolute
|
||||
.parent()
|
||||
.ok_or(ConfigReadWriteError::NoParent)?
|
||||
.to_owned();
|
||||
config.self_path = absolute;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save_to_file(&self) -> Result<(), ConfigReadWriteError> {
|
||||
debug!(
|
||||
"Saving root_config to: {}",
|
||||
self.self_path.to_string_lossy()
|
||||
);
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
let mut file = fs::File::create(&self.self_path)?;
|
||||
write!(file, "{}", content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn game_by_id(&self, id: &str) -> Option<Game> {
|
||||
self.games.get(id).map(|parsed_game| {
|
||||
if parsed_game.install_location().is_relative() {
|
||||
let abs_path = self.self_parent.join(parsed_game.install_location());
|
||||
debug!(
|
||||
"game path for {} is relative. Resolving to {}",
|
||||
id,
|
||||
abs_path.to_string_lossy()
|
||||
);
|
||||
Game::new(abs_path, parsed_game.game_type())
|
||||
} else {
|
||||
parsed_game.clone()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mod_by_id(&self, id: &str) -> Option<ModConfig> {
|
||||
self.mods.get(id).map(|e| e.clone().add_id(id))
|
||||
}
|
||||
|
||||
pub fn mods(&self) -> &HashMap<String, ModConfig> {
|
||||
&self.mods
|
||||
}
|
||||
|
||||
pub fn add_mod(&mut self, new_mod: &ModConfig) {
|
||||
self.mods.insert(new_mod.id().to_owned(), new_mod.clone());
|
||||
}
|
||||
|
||||
pub fn instances(&self) -> Vec<String> {
|
||||
self.instances.keys().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn load_instance_by_id(&self, id: &str) -> Result<ModdedInstance, ConfigReadWriteError> {
|
||||
debug!("Loading instance {}", id);
|
||||
let conf = self
|
||||
.instances
|
||||
.get(id)
|
||||
.ok_or(ConfigReadWriteError::IDNotFound)?;
|
||||
|
||||
if conf.path.is_relative() {
|
||||
let abs_path = self.self_parent.join(&conf.path);
|
||||
debug!(
|
||||
"instance path is relative. Resolving to {}",
|
||||
abs_path.to_string_lossy()
|
||||
);
|
||||
ModdedInstance::load_from_file(abs_path)
|
||||
} else {
|
||||
ModdedInstance::load_from_file(&conf.path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mod_location(&self) -> PathBuf {
|
||||
if self.mod_location.is_relative() {
|
||||
let abs_path = self.self_parent.join(&self.mod_location);
|
||||
debug!(
|
||||
"mod_location path is relative. Resolving to {}",
|
||||
abs_path.to_string_lossy()
|
||||
);
|
||||
abs_path
|
||||
} else {
|
||||
self.mod_location.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nexus_api_key(&self) -> Option<&str> {
|
||||
self.nexus_api_key.as_deref()
|
||||
}
|
||||
|
||||
pub fn download_location(&self) -> Option<PathBuf> {
|
||||
self.download_location.as_ref().map(|e| {
|
||||
if e.is_relative() {
|
||||
let abs_path = self.self_parent.join(e);
|
||||
debug!(
|
||||
"download_location path is relative. Resolving to {}",
|
||||
abs_path.to_string_lossy()
|
||||
);
|
||||
abs_path
|
||||
} else {
|
||||
e.clone()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct InstancePointer {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::types::GameType;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn create_config() -> RootConfig {
|
||||
RootConfig {
|
||||
games: HashMap::from([(
|
||||
"sse".to_owned(),
|
||||
Game::new("/games/sse", GameType::SkyrimSE),
|
||||
)]),
|
||||
mod_location: PathBuf::from("mods"),
|
||||
download_location: Some(PathBuf::from("download")),
|
||||
nexus_api_key: Some("1234".to_owned()),
|
||||
instances: HashMap::from([(
|
||||
"instance1".to_owned(),
|
||||
InstancePointer {
|
||||
path: PathBuf::from("instances/instance1.toml"),
|
||||
},
|
||||
)]),
|
||||
mods: HashMap::from([(
|
||||
"mod1".to_owned(),
|
||||
ModConfig::new("mod1", PathBuf::from("mod1")),
|
||||
)]),
|
||||
self_path: PathBuf::from("/config/root.toml"),
|
||||
self_parent: PathBuf::from("/config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_game() {
|
||||
let cfg = create_config();
|
||||
|
||||
let game = cfg.game_by_id("sse");
|
||||
|
||||
assert!(game.is_some());
|
||||
|
||||
let unwraped = game.expect("Asserted before");
|
||||
assert_eq!(unwraped.install_location(), "/games/sse");
|
||||
assert_eq!(unwraped.game_type(), GameType::SkyrimSE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_missing_game() {
|
||||
let cfg = create_config();
|
||||
|
||||
let game = cfg.game_by_id("starfield");
|
||||
|
||||
assert!(game.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_mod() {
|
||||
let cfg = create_config();
|
||||
|
||||
let found_mod = cfg.mod_by_id("mod1");
|
||||
|
||||
assert!(found_mod.is_some());
|
||||
|
||||
let unwraped = found_mod.expect("Asserted before");
|
||||
assert_eq!(unwraped.path(), "mod1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_missing_mod() {
|
||||
let cfg = create_config();
|
||||
|
||||
let found_mod = cfg.mod_by_id("mod200");
|
||||
|
||||
assert!(found_mod.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_api_key() {
|
||||
let cfg = create_config();
|
||||
|
||||
assert_eq!(cfg.nexus_api_key(), Some("1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_download_location() {
|
||||
let cfg = create_config();
|
||||
|
||||
let dl = cfg.download_location();
|
||||
|
||||
assert!(dl.is_some());
|
||||
|
||||
let unwraped = dl.expect("Asserted before");
|
||||
|
||||
assert!(unwraped.is_absolute(), "Path not absolute");
|
||||
assert_eq!(unwraped, PathBuf::from("/config/download"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_mod_location() {
|
||||
let cfg = create_config();
|
||||
|
||||
let mod_dir = cfg.mod_location();
|
||||
|
||||
assert!(mod_dir.is_absolute(), "Path not absolute");
|
||||
assert_eq!(mod_dir, PathBuf::from("/config/mods"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_mod() {
|
||||
let mut cfg = create_config();
|
||||
|
||||
let new_mod = ModConfig::new("new_mod", "new_mod_path");
|
||||
|
||||
cfg.add_mod(&new_mod);
|
||||
|
||||
let found_mod = cfg.mod_by_id("new_mod");
|
||||
|
||||
assert!(found_mod.is_some());
|
||||
let unwraped = found_mod.expect("Asserted before");
|
||||
|
||||
assert_eq!(unwraped.path(), new_mod.path());
|
||||
}
|
||||
}
|
||||
104
src/unpacker.rs
Normal file
104
src/unpacker.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::{Ok, anyhow};
|
||||
use log::error;
|
||||
use zip::ZipArchive;
|
||||
|
||||
pub fn unpack(archive_path: impl AsRef<Path>, extract_to: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
if fs::exists(&extract_to)? {
|
||||
return Err(anyhow!(
|
||||
"File already exists: {}",
|
||||
extract_to.as_ref().to_string_lossy()
|
||||
));
|
||||
}
|
||||
|
||||
match archive_path.as_ref().extension().and_then(|e| e.to_str()) {
|
||||
Some("7z") => unpack_7z_file(archive_path, &extract_to),
|
||||
Some("zip") => unpack_zip_file(archive_path, &extract_to),
|
||||
Some("rar") => unpack_rar(archive_path, &extract_to),
|
||||
Some(ext) => {
|
||||
error!("Unsupported archive format: {}", ext);
|
||||
Err(anyhow!("Unsupported archive format: {}", ext))
|
||||
}
|
||||
None => {
|
||||
error!(
|
||||
"Failed to determine the file extension for {}",
|
||||
&archive_path.as_ref().to_string_lossy()
|
||||
);
|
||||
Err(anyhow!("Failed to determine file extension"))
|
||||
}
|
||||
}?;
|
||||
|
||||
unnest_dir(extract_to)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unpack_7z_file(path: impl AsRef<Path>, to: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
sevenz_rust2::decompress_file(path, to)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unpack_zip_file(path: impl AsRef<Path>, to: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
let file = fs::File::open(path)?;
|
||||
let mut archive = ZipArchive::new(file)?;
|
||||
archive.extract(to)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unpack_rar(path: impl AsRef<Path>, to: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
let mut archive = unrar::Archive::new(path.as_ref()).open_for_processing()?;
|
||||
|
||||
while let Some(header) = archive.read_header()? {
|
||||
archive = header.extract_with_base(&to)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves a directorys content into the parent if it is the only dir
|
||||
fn unnest_dir(path: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
let path = path.as_ref();
|
||||
|
||||
let Some(nested_dir) = check_nested_dir(path) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
for entry in fs::read_dir(&nested_dir)? {
|
||||
let entry = entry?;
|
||||
let src = entry.path();
|
||||
let dest = path.join(entry.file_name());
|
||||
fs::rename(&src, &dest)?;
|
||||
}
|
||||
|
||||
fs::remove_dir(&nested_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the extracted archive has a single directory in it which contains the mod files
|
||||
fn check_nested_dir(path: impl AsRef<Path>) -> Option<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
|
||||
let entries: Vec<_> = fs::read_dir(path).ok()?.filter_map(|e| e.ok()).collect();
|
||||
|
||||
if entries.len() == 1 {
|
||||
let entry = &entries[0];
|
||||
let entry_path = entry.path();
|
||||
|
||||
if entry_path
|
||||
.file_name()
|
||||
.is_some_and(|e| e == "Data" || e == "data")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if entry_path.is_dir() {
|
||||
return Some(entry_path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
47
src/utils.rs
47
src/utils.rs
@@ -1,36 +1,16 @@
|
||||
use std::{
|
||||
fs::{self, DirEntry},
|
||||
fs::{self},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub fn walk_files_recursive(
|
||||
root: impl AsRef<Path>,
|
||||
) -> std::io::Result<impl Iterator<Item = DirEntry>> {
|
||||
fn visit(dir: &Path, out: &mut Vec<DirEntry>) -> std::io::Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let file_type = entry.file_type()?;
|
||||
|
||||
if file_type.is_dir() {
|
||||
visit(&path, out)?;
|
||||
} else if file_type.is_file() {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
visit(root.as_ref(), &mut files)?;
|
||||
Ok(files.into_iter())
|
||||
}
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub fn path_to_lowercase(path: impl AsRef<Path>) -> PathBuf {
|
||||
PathBuf::from(path.as_ref().to_string_lossy().to_lowercase())
|
||||
}
|
||||
|
||||
/// Searches for a path but ignores case. Returns the first it finds.
|
||||
pub fn resolve_case_insensitive(
|
||||
base: impl AsRef<Path>,
|
||||
rel: impl AsRef<Path>,
|
||||
@@ -63,3 +43,24 @@ pub fn resolve_case_insensitive(
|
||||
|
||||
Ok(Some(current))
|
||||
}
|
||||
|
||||
/// Use walkdir to walk all actual files in a dir
|
||||
/// Returns early if any error occurs
|
||||
pub fn walk_all_files(
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<impl Iterator<Item = walkdir::DirEntry>, walkdir::Error> {
|
||||
let a = WalkDir::new(path)
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, walkdir::Error>>()?
|
||||
.into_iter()
|
||||
.filter(|entry| entry.file_type().is_file());
|
||||
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
pub fn is_plugin_file(filename: impl AsRef<Path>) -> bool {
|
||||
filename
|
||||
.as_ref()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext == "esp" || ext == "esm" || ext == "esl")
|
||||
}
|
||||
|
||||
164
tests/add_mod_test.rs
Normal file
164
tests/add_mod_test.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use std::{collections::HashSet, error::Error, path::PathBuf};
|
||||
|
||||
use fomod_manager::{
|
||||
actions::{insert_mod_to_instance, resolve_files_for_install},
|
||||
types::{Link, RootConfig},
|
||||
};
|
||||
|
||||
fn get_parent() -> PathBuf {
|
||||
PathBuf::from(file!()).parent().unwrap().to_owned()
|
||||
}
|
||||
|
||||
fn load_root() -> RootConfig {
|
||||
RootConfig::load_from_file(get_parent().join("data/root_config_complex.toml")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_plain() -> Result<(), Box<dyn Error>> {
|
||||
let root_config = load_root();
|
||||
let mut instance = root_config.load_instance_by_id("instance_minimal")?;
|
||||
let mod_to_install = root_config
|
||||
.mod_by_id("add_test_plain")
|
||||
.expect("Mod not found");
|
||||
let files_to_add = match resolve_files_for_install(&root_config, &mod_to_install)? {
|
||||
fomod_manager::actions::ResolveFileResult::Files(mod_files) => mod_files,
|
||||
_ => {
|
||||
panic!("Resolved files have wrong type");
|
||||
}
|
||||
};
|
||||
|
||||
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
|
||||
|
||||
let installed_mods = instance.mods();
|
||||
|
||||
assert_eq!(installed_mods.len(), 1);
|
||||
|
||||
let the_mod = installed_mods.first().expect("Asserted before");
|
||||
|
||||
assert_eq!(the_mod.mod_id(), "add_test_plain");
|
||||
assert_eq!(the_mod.priority(), 0);
|
||||
|
||||
let expected_files: HashSet<_> = [Link::new("plugin.esp", "Data/plugin.esp")]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
*the_mod.files(),
|
||||
expected_files,
|
||||
"Installed files missmatch"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_nested() -> Result<(), Box<dyn Error>> {
|
||||
let root_config = load_root();
|
||||
let mut instance = root_config.load_instance_by_id("instance_minimal")?;
|
||||
let mod_to_install = root_config
|
||||
.mod_by_id("add_test_nested")
|
||||
.expect("Mod not found");
|
||||
let files_to_add = match resolve_files_for_install(&root_config, &mod_to_install)? {
|
||||
fomod_manager::actions::ResolveFileResult::Files(mod_files) => mod_files,
|
||||
_ => {
|
||||
panic!("Resolved files have wrong type");
|
||||
}
|
||||
};
|
||||
|
||||
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
|
||||
|
||||
let installed_mods = instance.mods();
|
||||
|
||||
assert_eq!(installed_mods.len(), 1);
|
||||
|
||||
let the_mod = installed_mods.first().expect("Asserted before");
|
||||
|
||||
assert_eq!(the_mod.mod_id(), "add_test_nested");
|
||||
assert_eq!(the_mod.priority(), 0);
|
||||
|
||||
let expected_files: HashSet<_> = [Link::new("Data/plugin.esp", "Data/plugin.esp")]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
*the_mod.files(),
|
||||
expected_files,
|
||||
"Installed files missmatch"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_root() -> Result<(), Box<dyn Error>> {
|
||||
let root_config = load_root();
|
||||
let mut instance = root_config.load_instance_by_id("instance_minimal")?;
|
||||
let mod_to_install = root_config
|
||||
.mod_by_id("add_test_root")
|
||||
.expect("Mod not found");
|
||||
let files_to_add = match resolve_files_for_install(&root_config, &mod_to_install)? {
|
||||
fomod_manager::actions::ResolveFileResult::Files(mod_files) => mod_files,
|
||||
_ => {
|
||||
panic!("Resolved files have wrong type");
|
||||
}
|
||||
};
|
||||
|
||||
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
|
||||
|
||||
let installed_mods = instance.mods();
|
||||
|
||||
assert_eq!(installed_mods.len(), 1, "No mod was added");
|
||||
|
||||
let the_mod = installed_mods.first().expect("Asserted before");
|
||||
|
||||
assert_eq!(the_mod.mod_id(), "add_test_root");
|
||||
assert_eq!(the_mod.priority(), 0);
|
||||
|
||||
let expected_files: HashSet<_> = [Link::new("skse.exe", "skse.exe")].into_iter().collect();
|
||||
|
||||
assert_eq!(
|
||||
*the_mod.files(),
|
||||
expected_files,
|
||||
"Installed files missmatch"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_filter() -> Result<(), Box<dyn Error>> {
|
||||
let root_config = load_root();
|
||||
let mut instance = root_config.load_instance_by_id("instance_minimal")?;
|
||||
let mod_to_install = root_config
|
||||
.mod_by_id("add_test_filter")
|
||||
.expect("Mod not found");
|
||||
let files_to_add = match resolve_files_for_install(&root_config, &mod_to_install)? {
|
||||
fomod_manager::actions::ResolveFileResult::Files(mod_files) => mod_files,
|
||||
_ => {
|
||||
panic!("Resolved files have wrong type");
|
||||
}
|
||||
};
|
||||
|
||||
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
|
||||
|
||||
let installed_mods = instance.mods();
|
||||
|
||||
assert_eq!(installed_mods.len(), 1, "No mod was added");
|
||||
|
||||
let the_mod = installed_mods.first().expect("Asserted before");
|
||||
|
||||
assert_eq!(the_mod.mod_id(), "add_test_filter");
|
||||
assert_eq!(the_mod.priority(), 0);
|
||||
|
||||
let expected_files: HashSet<_> = [Link::new("plugin.esp", "plugin.esp")]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
*the_mod.files(),
|
||||
expected_files,
|
||||
"Installed files missmatch"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
88
tests/data/fomod/moduleconfig/banana.xml
Normal file
88
tests/data/fomod/moduleconfig/banana.xml
Normal file
@@ -0,0 +1,88 @@
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
|
||||
<moduleName>The BANANA Mod</moduleName>
|
||||
<moduleImage path="fomod\images\banana.jpg" />
|
||||
|
||||
<installSteps>
|
||||
<installStep name="THE FIRST OF MANY STEPS">
|
||||
<optionalFileGroups order="Explicit">
|
||||
|
||||
|
||||
<group name="Banana Types" type="SelectAny">
|
||||
<plugins order="Explicit">
|
||||
|
||||
<plugin name="1700s">
|
||||
<description>Bananas from the 1700s were vastly different from what we see on the shelves today!</description>
|
||||
<conditionFlags><flag name="1">1</flag></conditionFlags>
|
||||
<typeDescriptor><type name="Required" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Australian">
|
||||
<description>Nobody knows why, but Australia has some WEIRD bananas.</description>
|
||||
<conditionFlags><flag name="1">1</flag></conditionFlags>
|
||||
<typeDescriptor><type name="Recommended" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Modern Worldwide">
|
||||
<description>Ah, the modern Cavendish banana! How not-sweet it tastes! Do yourself a favor and get one from the 1700s.</description>
|
||||
<conditionFlags><flag name="1">1</flag></conditionFlags>
|
||||
<typeDescriptor><type name="Optional" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Purple Bananas">
|
||||
<description>~~CENSORED~~</description>
|
||||
<conditionFlags><flag name="1">1</flag></conditionFlags>
|
||||
<typeDescriptor><type name="CouldBeUsable" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Brown Bananas">
|
||||
<description>Sorry, but you can't have feces.</description>
|
||||
<conditionFlags><flag name="1">1</flag></conditionFlags>
|
||||
<typeDescriptor><type name="NotUsable" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</group>
|
||||
|
||||
|
||||
<group name="Banana Textures" type="SelectExactlyOne">
|
||||
<plugins order="Explicit">
|
||||
|
||||
<plugin name="Base 1024x1024">
|
||||
<description>These textures are used for anything we didn't downscale/upscale.</description>
|
||||
<conditionFlags><flag name="1">1</flag></conditionFlags>
|
||||
<typeDescriptor><type name="Required" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="2048x2048">
|
||||
<description>2K is a comfortable resolution fot banana textures.</description>
|
||||
<conditionFlags><flag name="1">1</flag></conditionFlags>
|
||||
<typeDescriptor><type name="Recommended" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="4096x4096">
|
||||
<description>4K might be a bit over the top, but hey.</description>
|
||||
<conditionFlags><flag name="1">1</flag></conditionFlags>
|
||||
<typeDescriptor><type name="Optional" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="128x128">
|
||||
<description>Looks awful.</description>
|
||||
<conditionFlags><flag name="1">1</flag></conditionFlags>
|
||||
<typeDescriptor><type name="CouldBeUsable" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="0x0">
|
||||
<description>Just... don't install the mod.</description>
|
||||
<conditionFlags><flag name="1">1</flag></conditionFlags>
|
||||
<typeDescriptor><type name="NotUsable" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</group>
|
||||
|
||||
|
||||
</optionalFileGroups>
|
||||
</installStep>
|
||||
</installSteps>
|
||||
|
||||
</config>
|
||||
10
tests/data/fomod/moduleconfig/example_01.xml
Normal file
10
tests/data/fomod/moduleconfig/example_01.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
|
||||
|
||||
<moduleName>Example Mod</moduleName>
|
||||
|
||||
<requiredInstallFiles>
|
||||
<file source="example.plugin"/>
|
||||
</requiredInstallFiles>
|
||||
|
||||
</config>
|
||||
30
tests/data/fomod/moduleconfig/example_02.xml
Normal file
30
tests/data/fomod/moduleconfig/example_02.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<!-- For this second example, let's make use of dependencies.
|
||||
Before starting the installation, dependencies
|
||||
make sure the things you specify are in place.
|
||||
-->
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
|
||||
|
||||
<moduleName>Example Mod</moduleName>
|
||||
|
||||
<!-- The "And" operator means that all dependencies
|
||||
below this tag must be true for it to be true
|
||||
as well. The other possible value is "Or".
|
||||
-->
|
||||
<moduleDependencies operator="And">
|
||||
<fileDependency file="depend1.plugin" state="Active"/>
|
||||
<dependencies operator="Or">
|
||||
<fileDependency file="depend2v1.plugin" state="Active"/>
|
||||
<fileDependency file="depend2v2.plugin" state="Active"/>
|
||||
</dependencies>
|
||||
</moduleDependencies>
|
||||
|
||||
<!-- Now before installing our lovely and empty
|
||||
data file in requiredInstallFiles,
|
||||
we need to make sure a few other plugins exist
|
||||
-->
|
||||
<requiredInstallFiles>
|
||||
<file source="example.plugin"/>
|
||||
</requiredInstallFiles>
|
||||
|
||||
</config>
|
||||
86
tests/data/fomod/moduleconfig/example_03.xml
Normal file
86
tests/data/fomod/moduleconfig/example_03.xml
Normal file
@@ -0,0 +1,86 @@
|
||||
<!-- On the third example, we'll take a look at using
|
||||
install steps to let users choose what to install
|
||||
-->
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
|
||||
|
||||
<moduleName>Example Mod</moduleName>
|
||||
|
||||
<moduleDependencies operator="And">
|
||||
<fileDependency file="depend1.plugin" state="Active"/>
|
||||
<dependencies operator="Or">
|
||||
<fileDependency file="depend2v1.plugin" state="Active"/>
|
||||
<fileDependency file="depend2v2.plugin" state="Active"/>
|
||||
</dependencies>
|
||||
</moduleDependencies>
|
||||
|
||||
<!-- We'll no longer be using "requiredInstallFiles"
|
||||
since we can now offer a choice between files
|
||||
-->
|
||||
<installSteps order="Explicit">
|
||||
<installStep name="Choose Option">
|
||||
|
||||
<!-- In 99.9% of cases you'll want to set
|
||||
the 'order' attribute in "installSteps",
|
||||
"optionalFileGroups" and "plugins" to
|
||||
'Explicit'
|
||||
-->
|
||||
<optionalFileGroups order="Explicit">
|
||||
|
||||
<!-- This tag collects options into separate
|
||||
groups - useful if you want to have multiple
|
||||
types of choices for the user in a single
|
||||
step
|
||||
-->
|
||||
<group name="Select an option:" type="SelectExactlyOne">
|
||||
<plugins order="Explicit">
|
||||
|
||||
<!-- Each "plugin" tag represents a choice
|
||||
the user can make.
|
||||
-->
|
||||
<plugin name="Option A">
|
||||
|
||||
<description>Select this to install Option A!</description>
|
||||
|
||||
<!-- Optional but recommended
|
||||
-->
|
||||
<image path="fomod/option_a.png"/>
|
||||
|
||||
<!-- The files/folders to install
|
||||
-->
|
||||
<files>
|
||||
<folder source="option_a"/>
|
||||
</files>
|
||||
|
||||
<!-- This describes what type the plugin is.
|
||||
Most likely you'll choose between:
|
||||
- 'Optional'
|
||||
- 'Required'
|
||||
- 'Recommended'
|
||||
-->
|
||||
<typeDescriptor>
|
||||
<type name="Recommended"/>
|
||||
</typeDescriptor>
|
||||
|
||||
</plugin>
|
||||
|
||||
<plugin name="Option B">
|
||||
<description>Select this to install Option B!</description>
|
||||
<image path="fomod/option_b.png"/>
|
||||
<files>
|
||||
<folder source="option_b"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</group>
|
||||
|
||||
</optionalFileGroups>
|
||||
|
||||
</installStep>
|
||||
</installSteps>
|
||||
|
||||
</config>
|
||||
141
tests/data/fomod/moduleconfig/example_04.xml
Normal file
141
tests/data/fomod/moduleconfig/example_04.xml
Normal file
@@ -0,0 +1,141 @@
|
||||
<!-- This time we'll take a look at multiple step
|
||||
installs - flags and visiblity
|
||||
-->
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
|
||||
|
||||
<moduleName>Example Mod</moduleName>
|
||||
|
||||
<moduleDependencies operator="And">
|
||||
<fileDependency file="depend1.plugin" state="Active"/>
|
||||
<dependencies operator="Or">
|
||||
<fileDependency file="depend2v1.plugin" state="Active"/>
|
||||
<fileDependency file="depend2v2.plugin" state="Active"/>
|
||||
</dependencies>
|
||||
</moduleDependencies>
|
||||
|
||||
<installSteps order="Explicit">
|
||||
|
||||
<installStep name="Choose Option">
|
||||
<optionalFileGroups order="Explicit">
|
||||
<group name="Select an option:" type="SelectExactlyOne">
|
||||
<plugins order="Explicit">
|
||||
|
||||
<plugin name="Option A">
|
||||
<description>Select this to install Option A!</description>
|
||||
<image path="fomod/option_a.png"/>
|
||||
<files>
|
||||
<folder source="option_a"/>
|
||||
</files>
|
||||
<!-- conditionFlags and files have interchangeable
|
||||
order and at least one of them needs to be
|
||||
present.
|
||||
|
||||
conditionFlags sets flags whenever this plugin
|
||||
is selected.
|
||||
-->
|
||||
<conditionFlags>
|
||||
<flag name="option_a">selected</flag>
|
||||
</conditionFlags>
|
||||
<typeDescriptor>
|
||||
<type name="Recommended"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Option B">
|
||||
<description>Select this to install Option B!</description>
|
||||
<image path="fomod/option_b.png"/>
|
||||
<files>
|
||||
<folder source="option_b"/>
|
||||
</files>
|
||||
<conditionFlags>
|
||||
<flag name="option_b">selected</flag>
|
||||
</conditionFlags>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</group>
|
||||
</optionalFileGroups>
|
||||
</installStep>
|
||||
|
||||
<installStep name="Choose Texture">
|
||||
<!-- visible is a dependencies network that lets this
|
||||
step appear only when it's conditions are met.
|
||||
|
||||
If they're not met, this step is skipped.
|
||||
-->
|
||||
<visible>
|
||||
<flagDependency flag="option_a" value="selected"/>
|
||||
</visible>
|
||||
<optionalFileGroups order="Explicit">
|
||||
<group name="Select a texture:" type="SelectExactlyOne">
|
||||
<plugins order="Explicit">
|
||||
|
||||
<plugin name="Texture Blue">
|
||||
<description>Select this to install Texture Blue!</description>
|
||||
<image path="fomod/texture_blue.png"/>
|
||||
<files>
|
||||
<folder source="texture_blue_a"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Texture Red">
|
||||
<description>Select this to install Texture Red!</description>
|
||||
<image path="fomod/texture_red.png"/>
|
||||
<files>
|
||||
<folder source="texture_red_a"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</group>
|
||||
</optionalFileGroups>
|
||||
</installStep>
|
||||
|
||||
<installStep name="Choose Texture">
|
||||
<visible>
|
||||
<flagDependency flag="option_b" value="selected"/>
|
||||
</visible>
|
||||
<optionalFileGroups order="Explicit">
|
||||
<group name="Select a texture:" type="SelectExactlyOne">
|
||||
<plugins order="Explicit">
|
||||
|
||||
<plugin name="Texture Blue">
|
||||
<description>Select this to install Texture Blue!</description>
|
||||
<image path="fomod/texture_blue.png"/>
|
||||
<files>
|
||||
<folder source="texture_blue_b"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Texture Red">
|
||||
<description>Select this to install Texture Red!</description>
|
||||
<image path="fomod/texture_red.png"/>
|
||||
<files>
|
||||
<folder source="texture_red_b"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</group>
|
||||
</optionalFileGroups>
|
||||
</installStep>
|
||||
|
||||
</installSteps>
|
||||
|
||||
</config>
|
||||
133
tests/data/fomod/moduleconfig/example_05.xml
Normal file
133
tests/data/fomod/moduleconfig/example_05.xml
Normal file
@@ -0,0 +1,133 @@
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
|
||||
|
||||
<moduleName>Example Mod</moduleName>
|
||||
|
||||
<moduleDependencies operator="And">
|
||||
<fileDependency file="depend1.plugin" state="Active"/>
|
||||
<dependencies operator="Or">
|
||||
<fileDependency file="depend2v1.plugin" state="Active"/>
|
||||
<fileDependency file="depend2v2.plugin" state="Active"/>
|
||||
</dependencies>
|
||||
</moduleDependencies>
|
||||
|
||||
<installSteps order="Explicit">
|
||||
<installStep name="Choose Option">
|
||||
<optionalFileGroups order="Explicit">
|
||||
|
||||
<group name="Select an option:" type="SelectExactlyOne">
|
||||
<plugins order="Explicit">
|
||||
|
||||
<plugin name="Option A">
|
||||
<description>Select this to install Option A!</description>
|
||||
<image path="fomod/option_a.png"/>
|
||||
<conditionFlags>
|
||||
<flag name="option_a">selected</flag>
|
||||
</conditionFlags>
|
||||
<typeDescriptor>
|
||||
<type name="Recommended"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Option B">
|
||||
<description>Select this to install Option B!</description>
|
||||
<image path="fomod/option_b.png"/>
|
||||
<conditionFlags>
|
||||
<flag name="option_b">selected</flag>
|
||||
</conditionFlags>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</group>
|
||||
|
||||
<!-- Since we're installing everything in
|
||||
conditionalFileInstalls we're free to
|
||||
make all selections in a single step.
|
||||
-->
|
||||
<group name="Select a texture:" type="SelectExactlyOne">
|
||||
<plugins order="Explicit">
|
||||
|
||||
<plugin name="Texture Blue">
|
||||
<description>Select this to install Texture Blue!</description>
|
||||
<image path="fomod/texture_blue.png"/>
|
||||
<conditionFlags>
|
||||
<flag name="texture_blue">selected</flag>
|
||||
</conditionFlags>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Texture Red">
|
||||
<description>Select this to install Texture Red!</description>
|
||||
<image path="fomod/texture_red.png"/>
|
||||
<conditionFlags>
|
||||
<flag name="texture_red">selected</flag>
|
||||
</conditionFlags>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</group>
|
||||
|
||||
</optionalFileGroups>
|
||||
</installStep>
|
||||
</installSteps>
|
||||
|
||||
<!-- This is where we're installing everything.
|
||||
-->
|
||||
<conditionalFileInstalls>
|
||||
<patterns>
|
||||
<!-- Each pattern is a piece of the matrix
|
||||
eseentially linking a set of dependencies
|
||||
to a set of files to install.
|
||||
-->
|
||||
<pattern>
|
||||
<dependencies operator="And">
|
||||
<flagDependency flag="option_a" value="selected"/>
|
||||
<flagDependency flag="texture_blue" value="selected"/>
|
||||
</dependencies>
|
||||
<files>
|
||||
<folder source="option_a"/>
|
||||
<folder source="texture_blue_a"/>
|
||||
</files>
|
||||
</pattern>
|
||||
<pattern>
|
||||
<dependencies operator="And">
|
||||
<flagDependency flag="option_a" value="selected"/>
|
||||
<flagDependency flag="texture_red" value="selected"/>
|
||||
</dependencies>
|
||||
<files>
|
||||
<folder source="option_a"/>
|
||||
<folder source="texture_red_a"/>
|
||||
</files>
|
||||
</pattern>
|
||||
<pattern>
|
||||
<dependencies operator="And">
|
||||
<flagDependency flag="option_b" value="selected"/>
|
||||
<flagDependency flag="texture_blue" value="selected"/>
|
||||
</dependencies>
|
||||
<files>
|
||||
<folder source="option_b"/>
|
||||
<folder source="texture_blue_b"/>
|
||||
</files>
|
||||
</pattern>
|
||||
<pattern>
|
||||
<dependencies operator="And">
|
||||
<flagDependency flag="option_b" value="selected"/>
|
||||
<flagDependency flag="texture_red" value="selected"/>
|
||||
</dependencies>
|
||||
<files>
|
||||
<folder source="option_b"/>
|
||||
<folder source="texture_red_b"/>
|
||||
</files>
|
||||
</pattern>
|
||||
</patterns>
|
||||
</conditionalFileInstalls>
|
||||
|
||||
</config>
|
||||
118
tests/data/fomod/moduleconfig/ineed.xml
Normal file
118
tests/data/fomod/moduleconfig/ineed.xml
Normal file
@@ -0,0 +1,118 @@
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
|
||||
<moduleName>iNeed - Food, Water and Sleep - Continued</moduleName>
|
||||
<installSteps order="Explicit">
|
||||
<installStep name="Options">
|
||||
<optionalFileGroups>
|
||||
<group name="0. Core" type="SelectAny">
|
||||
<plugins order="Explicit">
|
||||
<plugin name="Core Files">
|
||||
<description>
|
||||
Contains the main mod.
|
||||
</description>
|
||||
<image path="FOMod\screenshot.jpg"/>
|
||||
<files>
|
||||
<folder source="00 Core" destination="" priority="0"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Required"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</group>
|
||||
<group name="1. Extended" type="SelectExactlyOne">
|
||||
<plugins order="Explicit">
|
||||
<plugin name="Full | Requires AE and Creation Club Fishing">
|
||||
<description>
|
||||
Requires AE and Creation Club Fishing. This option is not compatible with other mods that modify vanilla food, recipes and with 'Survival' mode! Enables snow collection from medium to large exterior snow drifts simply by activating them. Removes magic effect buffs and debuffs from most food and drink items. Removes the Salt Pile ingredient from cooked meat. Rebalances soup recipes by adding a water ingredient requirement and by changing the number of soups produced from 1 to 2. Innkeepers will now sometimes sell soups and Hearthfire foods.
|
||||
</description>
|
||||
<image path="FOMod\extended.jpg"/>
|
||||
<files>
|
||||
<folder source="01 Extended" destination="" priority="1"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
<plugin name="No Food Changes | Requires AE and Creation Club Fishing">
|
||||
<description>
|
||||
Requires AE and Creation Club Fishing. This option is not compatible with other mods that modify vanilla recipes and with 'Survival' mode! Enables snow collection from medium to large exterior snow drifts simply by activating them. Removes the Salt Pile ingredient from cooked meat. Rebalances soup recipes by adding a water ingredient requirement and by changing the number of soups produced from 1 to 2. Innkeepers will now sometimes sell soups and Hearthfire foods.
|
||||
</description>
|
||||
<image path="FOMod\extended.jpg"/>
|
||||
<files>
|
||||
<folder source="02 Extended - No Food" destination="" priority="1"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
<plugin name="No Food / No Recipe Changes | Requires AE and Creation Club Fishing">
|
||||
<description>
|
||||
Requires AE and Creation Club Fishing. Enables snow collection from medium to large exterior snow drifts simply by activating them. This option is compatible with all other mods that change "vanilla" cooking recipes, such as CACO or Cooking Expanded. Install it if such mods are installed, for better compatibility with iNeed.
|
||||
</description>
|
||||
<image path="FOMod\extended.jpg"/>
|
||||
<files>
|
||||
<folder source="03 Extended - No Food - No Recipes" destination="" priority="1"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
<plugin name="None">
|
||||
<description>
|
||||
iNeed - Extended will not be installed.
|
||||
</description>
|
||||
<image path="FOMod\extended.jpg"/>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</group>
|
||||
<group name="2. Compatibility" type="SelectAny">
|
||||
<plugins order="Explicit">
|
||||
<plugin name="Nordic Snow">
|
||||
<description>
|
||||
Select this option if you have any version of iNeed - Extended installed along with Nordic Snow. This patch will match up the snow drift textures with the rest of the landscape modified by Nordic Snow.
|
||||
</description>
|
||||
<image path="FOMod\snow.jpg"/>
|
||||
<files>
|
||||
<folder source="10 Nordic Snow" destination="" priority="1"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
<plugin name="NobleSkyrimMod">
|
||||
<description>
|
||||
Select this option if you have any version of iNeed - Extended installed along with NobleSkyrimMod. This patch will match up the snow drift textures with the rest of the landscape modified by NobleSkyrimMod.
|
||||
</description>
|
||||
<image path="FOMod\snow.jpg"/>
|
||||
<files>
|
||||
<folder source="11 NobleSkyrimMod" destination="" priority="1"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</group>
|
||||
<group name="Optionals" type="SelectAny">
|
||||
<plugins order="Explicit">
|
||||
<plugin name="Dangerous Diseases">
|
||||
<description>
|
||||
All non-transformative diseases will be more unique, harder to cure and progress through 4 deadlier stages at random intervals. See mod description for more information.
|
||||
</description>
|
||||
<image path="FOMod\disease.jpg"/>
|
||||
<files>
|
||||
<folder source="12 Dangerous Diseases Converted" destination="" priority="1"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</group>
|
||||
</optionalFileGroups>
|
||||
</installStep>
|
||||
</installSteps>
|
||||
</config>
|
||||
67
tests/data/fomod/moduleconfig/po3tweaks.xml
Normal file
67
tests/data/fomod/moduleconfig/po3tweaks.xml
Normal file
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
|
||||
<moduleName>powerofthree's Tweaks</moduleName>
|
||||
<requiredInstallFiles>
|
||||
<folder source="Required" destination="" />
|
||||
</requiredInstallFiles>
|
||||
<installSteps order="Explicit">
|
||||
<installStep name="Main">
|
||||
<optionalFileGroups order="Explicit">
|
||||
<group name="DLL" type="SelectExactlyOne">
|
||||
<plugins order="Explicit">
|
||||
<plugin name="SSE v1.6.629+ ("Anniversary Edition")">
|
||||
<description>Select this if you are using Skyrim Anniversary Edition v1.6.629 or higher.</description>
|
||||
<files>
|
||||
<folder source="AE/SKSE/Plugins" destination="SKSE/Plugins" priority="0" />
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<dependencyType>
|
||||
<defaultType name="Optional" />
|
||||
<patterns>
|
||||
<pattern>
|
||||
<dependencies>
|
||||
<gameDependency version="1.6" />
|
||||
</dependencies>
|
||||
<type name="Recommended" />
|
||||
</pattern>
|
||||
<pattern>
|
||||
<dependencies>
|
||||
<gameDependency version="1.5" />
|
||||
</dependencies>
|
||||
<type name="Optional" />
|
||||
</pattern>
|
||||
</patterns>
|
||||
</dependencyType>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
<plugin name="SSE v1.5.97 ("Special Edition")">
|
||||
<description>Select this if you are using Skyrim Special Edition v1.5.97.</description>
|
||||
<files>
|
||||
<folder source="SE/SKSE/Plugins" destination="SKSE/Plugins" priority="0" />
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<dependencyType>
|
||||
<defaultType name="Optional" />
|
||||
<patterns>
|
||||
<pattern>
|
||||
<dependencies>
|
||||
<gameDependency version="1.6" />
|
||||
</dependencies>
|
||||
<type name="Optional" />
|
||||
</pattern>
|
||||
<pattern>
|
||||
<dependencies>
|
||||
<gameDependency version="1.5" />
|
||||
</dependencies>
|
||||
<type name="Recommended" />
|
||||
</pattern>
|
||||
</patterns>
|
||||
</dependencyType>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</group>
|
||||
</optionalFileGroups>
|
||||
</installStep>
|
||||
</installSteps>
|
||||
</config>
|
||||
171
tests/data/fomod/moduleconfig/starui.xml
Normal file
171
tests/data/fomod/moduleconfig/starui.xml
Normal file
@@ -0,0 +1,171 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
|
||||
<moduleName>StarUI Inventory</moduleName>
|
||||
|
||||
<installSteps order="Explicit">
|
||||
|
||||
<installStep name="Select installation options">
|
||||
<optionalFileGroups order="Explicit">
|
||||
<group name="Main files" type="SelectAny">
|
||||
<plugins order="Explicit">
|
||||
|
||||
<plugin name="StarUI Inventory">
|
||||
<description>StarUI Inventory improves all inventory screens for use on a PC. Compact display style. More details in sortable columns. Item category icons. Category as left sidebar. Many quality of life features!</description>
|
||||
<image path="fomod\images\StarUI Inventory Teaser.jpg" />
|
||||
<files />
|
||||
<typeDescriptor><type name="Required" /></typeDescriptor>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</group>
|
||||
|
||||
<group name="Mod Manager" type="SelectExactlyOne">
|
||||
<plugins order='Explicit'>
|
||||
<plugin name="Vortex">
|
||||
<description>Select this if you use Vortex</description>
|
||||
<image path="fomod\images\StarUI Inventory Teaser.jpg" />
|
||||
<conditionFlags>
|
||||
<flag name="flag_vortex">Active</flag>
|
||||
</conditionFlags>
|
||||
<files>
|
||||
<folder source="Interface" destination="Data\Interface" />
|
||||
</files>
|
||||
<typeDescriptor><type name="Optional" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Mod Organizer 2">
|
||||
<description>Select this if you use Mod Organizer 2.</description>
|
||||
<image path="fomod\images\StarUI Inventory Teaser.jpg" />
|
||||
<conditionFlags>
|
||||
<flag name="flag_mo2">Active</flag>
|
||||
</conditionFlags>
|
||||
<files>
|
||||
<folder source="Interface" destination="Interface" />
|
||||
</files>
|
||||
<typeDescriptor><type name="Optional" /></typeDescriptor>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</group>
|
||||
|
||||
|
||||
<group name="FPS (Frames Per Second)" type="SelectExactlyOne">
|
||||
<plugins order='Explicit'>
|
||||
<plugin name="30 FPS - Vanilla">
|
||||
<description>Vanilla interface FPS. As like in the original game.</description>
|
||||
<image path="fomod\images\StarUI Inventory Teaser.jpg" />
|
||||
<conditionFlags>
|
||||
<flag name="flag_30fps">Active</flag>
|
||||
</conditionFlags>
|
||||
<files />
|
||||
<typeDescriptor><type name="Optional" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="60 FPS - Smooth and stable">
|
||||
<description>Doubles the default interface FPS. Smoother and more responsive.</description>
|
||||
<image path="fomod\images\StarUI Inventory Teaser.jpg" />
|
||||
<conditionFlags>
|
||||
<flag name="flag_60fps">Active</flag>
|
||||
</conditionFlags>
|
||||
<files />
|
||||
<typeDescriptor><type name="Recommended" /></typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="120 FPS - High-FPS (may cause crashes)">
|
||||
<description>High-FPS version. This version needs an appropiate monitor to be used. 
|
||||
WARNING: Using 120FPS may cause the game to crash, as the game engine is not programmed for such high interface FPS rates.
|
||||

|
||||
USE AT YOUR OWN RISK.
|
||||
</description>
|
||||
<image path="fomod\images\StarUI Inventory Teaser.jpg" />
|
||||
<conditionFlags>
|
||||
<flag name="flag_120fps">Active</flag>
|
||||
</conditionFlags>
|
||||
<files />
|
||||
<typeDescriptor><type name="Optional" /></typeDescriptor>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</group>
|
||||
|
||||
</optionalFileGroups>
|
||||
|
||||
</installStep>
|
||||
|
||||
<installStep name="README">
|
||||
<optionalFileGroups order="Explicit">
|
||||
<group name="Please read the notes" type="SelectAny">
|
||||
<plugins order="Explicit">
|
||||
<plugin name="Requires Archive Invalidation">
|
||||
<description>You will need to enable Archive Invalidation to load loose files.
|
||||
If you haven't done that yet, see the mod page for detailed instructions.
</description>
|
||||
<files />
|
||||
<typeDescriptor>
|
||||
<type name="Required" />
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="INI: Settings, settings, settings">
|
||||
<description>You can configure many different settings in the file Interface\StarUI Inventory.ini .
|
||||
Every settings is described in the file, so you can easily adapt the whole mod to your liking.
</description>
|
||||
<files />
|
||||
<typeDescriptor>
|
||||
<type name="Required" />
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
<plugin name="Configuration done">
|
||||
<description>Ready for installation.
|
||||
If you are updating, make sure you have a backup of your StarUI Inventory.ini to keep your settings.
</description>
|
||||
<files />
|
||||
<typeDescriptor>
|
||||
<type name="Required" />
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</group>
|
||||
</optionalFileGroups>
|
||||
</installStep>
|
||||
</installSteps>
|
||||
|
||||
<conditionalFileInstalls>
|
||||
<patterns>
|
||||
<pattern>
|
||||
<dependencies operator="And">
|
||||
<flagDependency flag="flag_vortex" value="Active" />
|
||||
<flagDependency flag="flag_30fps" value="Active" />
|
||||
</dependencies>
|
||||
<files>
|
||||
<folder source="Optional\30fps\Interface" destination="Data\Interface" />
|
||||
</files>
|
||||
</pattern>
|
||||
<pattern>
|
||||
<dependencies operator="And">
|
||||
<flagDependency flag="flag_mo2" value="Active" />
|
||||
<flagDependency flag="flag_30fps" value="Active" />
|
||||
</dependencies>
|
||||
<files>
|
||||
<folder source="Optional\30fps\Interface" destination="Interface" />
|
||||
</files>
|
||||
</pattern>
|
||||
|
||||
<pattern>
|
||||
<dependencies operator="And">
|
||||
<flagDependency flag="flag_vortex" value="Active" />
|
||||
<flagDependency flag="flag_120fps" value="Active" />
|
||||
</dependencies>
|
||||
<files>
|
||||
<folder source="Optional\120fps\Interface" destination="Data\Interface" />
|
||||
</files>
|
||||
</pattern>
|
||||
<pattern>
|
||||
<dependencies operator="And">
|
||||
<flagDependency flag="flag_mo2" value="Active" />
|
||||
<flagDependency flag="flag_120fps" value="Active" />
|
||||
</dependencies>
|
||||
<files>
|
||||
<folder source="Optional\120fps\Interface" destination="Interface" />
|
||||
</files>
|
||||
</pattern>
|
||||
</patterns>
|
||||
</conditionalFileInstalls>
|
||||
|
||||
</config>
|
||||
60
tests/data/fomod/moduleconfig/trade_barter.xml
Normal file
60
tests/data/fomod/moduleconfig/trade_barter.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://qconsulting.ca/fo3/ModConfig5.0.xsd">
|
||||
<moduleName>Trade and Barter - Patches</moduleName>
|
||||
<moduleImage path="FOMOD\header.jpg"/>
|
||||
|
||||
<installSteps order="Explicit">
|
||||
<installStep name="Trade and Barter - Patches ESPLite">
|
||||
<optionalFileGroups>
|
||||
<group name="Core" type="SelectAny">
|
||||
<plugins order="Explicit">
|
||||
<plugin name="Beyond Bruma Patch">
|
||||
<description>
|
||||
ESPLite version
|
||||
</description>
|
||||
<files>
|
||||
<folder source="01_bruma" destination="" priority="0"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
<plugin name="YASH Patch">
|
||||
<description>
|
||||
ESPLite version
|
||||
</description>
|
||||
<files>
|
||||
<folder source="02_yash" destination="" priority="0"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
<plugin name="Keld-Nar Patch">
|
||||
<description>
|
||||
ESPLite version
|
||||
</description>
|
||||
<files>
|
||||
<folder source="00_keld" destination="" priority="0"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
<plugin name="Priest Vendors Patch">
|
||||
<description>
|
||||
ESPLite version
|
||||
</description>
|
||||
<files>
|
||||
<folder source="03_vendor" destination="" priority="0"/>
|
||||
</files>
|
||||
<typeDescriptor>
|
||||
<type name="Optional"/>
|
||||
</typeDescriptor>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</group>
|
||||
</optionalFileGroups>
|
||||
</installStep>
|
||||
|
||||
</installSteps>
|
||||
</config>
|
||||
1
tests/data/games/sse/Data/Skyrim.esm
Normal file
1
tests/data/games/sse/Data/Skyrim.esm
Normal file
@@ -0,0 +1 @@
|
||||
Skyrim.esm
|
||||
1
tests/data/games/sse/Data/Update.esm
Normal file
1
tests/data/games/sse/Data/Update.esm
Normal file
@@ -0,0 +1 @@
|
||||
Update.esm
|
||||
1
tests/data/games/sse/SkyrimSE.exe
Normal file
1
tests/data/games/sse/SkyrimSE.exe
Normal file
@@ -0,0 +1 @@
|
||||
SkyrimSE.exe
|
||||
1
tests/data/games/sse/SkyrimSELauncher.exe
Normal file
1
tests/data/games/sse/SkyrimSELauncher.exe
Normal file
@@ -0,0 +1 @@
|
||||
SkyrimSELauncher.exe
|
||||
108
tests/data/instance_complex.toml
Normal file
108
tests/data/instance_complex.toml
Normal file
@@ -0,0 +1,108 @@
|
||||
game = "sse"
|
||||
load_order = [
|
||||
"Skyrim.esm",
|
||||
"Update.esm",
|
||||
"Dawnguard.esm",
|
||||
"HearthFires.esm",
|
||||
"Dragonborn.esm",
|
||||
"ccBGSSSE001-Fish.esm",
|
||||
"ccQDRSSE001-SurvivalMode.esl",
|
||||
"ccBGSSSE037-Curios.esl",
|
||||
"ccBGSSSE025-AdvDSGS.esm",
|
||||
"_ResourcePack.esl",
|
||||
"RaceMenu.esp",
|
||||
"SkyUI_SE.esp",
|
||||
"RaceMenuPlugin.esp",
|
||||
]
|
||||
game_file_overrides = [
|
||||
"skse64_loader.exe -> SkyrimSELauncher.exe"
|
||||
]
|
||||
|
||||
[[mods]]
|
||||
id = "skse"
|
||||
files = [
|
||||
"Data/Scripts/math.pex",
|
||||
"Data/Scripts/form.pex",
|
||||
"Data/Scripts/soulgem.pex",
|
||||
"Data/Scripts/formlist.pex",
|
||||
"Data/Scripts/stringutil.pex",
|
||||
"Data/Scripts/colorcomponent.pex",
|
||||
"Data/Scripts/quest.pex",
|
||||
"Data/Scripts/faction.pex",
|
||||
"Data/Scripts/combatstyle.pex",
|
||||
"Data/Scripts/actorbase.pex",
|
||||
"Data/Scripts/potion.pex",
|
||||
"Data/Scripts/actor.pex",
|
||||
"Data/Scripts/game.pex",
|
||||
"Data/Scripts/armor.pex",
|
||||
"Data/Scripts/headpart.pex",
|
||||
"Data/Scripts/objectreference.pex",
|
||||
"Data/Scripts/weapon.pex",
|
||||
"Data/Scripts/perk.pex",
|
||||
"Data/Scripts/constructibleobject.pex",
|
||||
"Data/Scripts/armoraddon.pex",
|
||||
"Data/Scripts/textureset.pex",
|
||||
"Data/Scripts/scroll.pex",
|
||||
"Data/Scripts/actorvalueinfo.pex",
|
||||
"Data/Scripts/equipslot.pex",
|
||||
"Data/Scripts/art.pex",
|
||||
"Data/Scripts/colorform.pex",
|
||||
"Data/Scripts/weather.pex",
|
||||
"Data/Scripts/gamedata.pex",
|
||||
"Data/Scripts/skse.pex",
|
||||
"Data/Scripts/sound.pex",
|
||||
"Data/Scripts/formtype.pex",
|
||||
"Data/Scripts/spawnertask.pex",
|
||||
"Data/Scripts/netimmerse.pex",
|
||||
"Data/Scripts/ingredient.pex",
|
||||
"Data/Scripts/book.pex",
|
||||
"Data/Scripts/ui.pex",
|
||||
"Data/Scripts/leveleditem.pex",
|
||||
"Data/Scripts/spell.pex",
|
||||
"Data/Scripts/leveledspell.pex",
|
||||
"Data/Scripts/modevent.pex",
|
||||
"Data/Scripts/keyword.pex",
|
||||
"Data/Scripts/activemagiceffect.pex",
|
||||
"Data/Scripts/utility.pex",
|
||||
"Data/Scripts/shout.pex",
|
||||
"Data/Scripts/input.pex",
|
||||
"Data/Scripts/race.pex",
|
||||
"Data/Scripts/sounddescriptor.pex",
|
||||
"Data/Scripts/wornobject.pex",
|
||||
"Data/Scripts/ammo.pex",
|
||||
"Data/Scripts/defaultobjectmanager.pex",
|
||||
"Data/Scripts/camera.pex",
|
||||
"Data/Scripts/apparatus.pex",
|
||||
"skse64_1_6_1170.dll",
|
||||
"Data/Scripts/magiceffect.pex",
|
||||
"Data/Scripts/location.pex",
|
||||
"Data/Scripts/alias.pex",
|
||||
"Data/Scripts/treeobject.pex",
|
||||
"Data/Scripts/leveledactor.pex",
|
||||
"Data/Scripts/enchantment.pex",
|
||||
"Data/Scripts/uicallback.pex",
|
||||
"Data/Scripts/flora.pex",
|
||||
"Data/Scripts/outfit.pex",
|
||||
"Data/Scripts/cell.pex",
|
||||
]
|
||||
priority = 0
|
||||
|
||||
[[mods]]
|
||||
id = "SkyUI-12604-35407"
|
||||
files = [
|
||||
"SkyUI_SE.bsa -> Data/SkyUI_SE.bsa",
|
||||
"SkyUI_SE.esp -> Data/SkyUI_SE.esp",
|
||||
]
|
||||
priority = 0
|
||||
|
||||
[[mods]]
|
||||
id = "racemenu-19080-465102"
|
||||
files = [
|
||||
"RaceMenu.esp -> Data/RaceMenu.esp",
|
||||
"SKSE/Plugins/skee64.ini -> Data/SKSE/Plugins/skee64.ini",
|
||||
"RaceMenu.bsa -> Data/RaceMenu.bsa",
|
||||
"RaceMenuPlugin.esp -> Data/RaceMenuPlugin.esp",
|
||||
"SKSE/Plugins/skee64.dll -> Data/SKSE/Plugins/skee64.dll",
|
||||
]
|
||||
priority = 0
|
||||
|
||||
2
tests/data/instance_minimal.toml
Normal file
2
tests/data/instance_minimal.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
game = "sse"
|
||||
|
||||
1
tests/data/mods/add_test_filter/image.jpg
Normal file
1
tests/data/mods/add_test_filter/image.jpg
Normal file
@@ -0,0 +1 @@
|
||||
image.jpg
|
||||
1
tests/data/mods/add_test_filter/plugin.esp
Normal file
1
tests/data/mods/add_test_filter/plugin.esp
Normal file
@@ -0,0 +1 @@
|
||||
plugin.esp
|
||||
1
tests/data/mods/add_test_filter/readme.txt
Normal file
1
tests/data/mods/add_test_filter/readme.txt
Normal file
@@ -0,0 +1 @@
|
||||
readme.txt
|
||||
1
tests/data/mods/add_test_filter/sub/extra.esp
Normal file
1
tests/data/mods/add_test_filter/sub/extra.esp
Normal file
@@ -0,0 +1 @@
|
||||
extra.esp
|
||||
1
tests/data/mods/add_test_nested/Data/plugin.esp
Normal file
1
tests/data/mods/add_test_nested/Data/plugin.esp
Normal file
@@ -0,0 +1 @@
|
||||
plugin.esp-add_test_2
|
||||
1
tests/data/mods/add_test_plain/plugin.esp
Normal file
1
tests/data/mods/add_test_plain/plugin.esp
Normal file
@@ -0,0 +1 @@
|
||||
plugin.esp-add_test_1
|
||||
1
tests/data/mods/add_test_root/skse.exe
Normal file
1
tests/data/mods/add_test_root/skse.exe
Normal file
@@ -0,0 +1 @@
|
||||
skse.exe
|
||||
47
tests/data/root_config_complex.toml
Normal file
47
tests/data/root_config_complex.toml
Normal file
@@ -0,0 +1,47 @@
|
||||
mod_location = "mods"
|
||||
download_location = "downloads"
|
||||
nexus_api_key = "1234"
|
||||
|
||||
[games.example_game]
|
||||
path = "/home/user/games/sse"
|
||||
kind = "sse"
|
||||
|
||||
[games.sse]
|
||||
path = "games/sse"
|
||||
kind = "unkown"
|
||||
|
||||
[instances.example1]
|
||||
path = "example1.toml"
|
||||
|
||||
[instances.example2]
|
||||
path = "/home/user/example2.toml"
|
||||
|
||||
[instances.instance_minimal]
|
||||
path = "instance_minimal.toml"
|
||||
|
||||
[instances.instance_complex]
|
||||
path = "instance_complex.toml"
|
||||
|
||||
[mods.mod1]
|
||||
path = "/home/user/mods/mod1"
|
||||
|
||||
[mods."mod2"]
|
||||
path = "mod2"
|
||||
|
||||
[mods.mod3]
|
||||
path = "mod3"
|
||||
|
||||
[mods.add_test_plain]
|
||||
path = "add_test_plain"
|
||||
|
||||
[mods.add_test_nested]
|
||||
path = "add_test_nested"
|
||||
|
||||
[mods.add_test_root]
|
||||
path = "add_test_root"
|
||||
root_mod = true
|
||||
|
||||
[mods.add_test_filter]
|
||||
path = "add_test_filter"
|
||||
ignore = [ "*.txt", "image.jpg", "sub/*" ]
|
||||
root_mod = true
|
||||
1
tests/data/root_config_minimal.toml
Normal file
1
tests/data/root_config_minimal.toml
Normal file
@@ -0,0 +1 @@
|
||||
mod_location = "mods"
|
||||
42
tests/fomod_test.rs
Normal file
42
tests/fomod_test.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use fomod_manager::fomod::{self, FOModError};
|
||||
|
||||
fn get_parent() -> PathBuf {
|
||||
PathBuf::from(file!()).parent().unwrap().to_owned()
|
||||
}
|
||||
|
||||
fn get_xml(filename: &str) -> PathBuf {
|
||||
get_parent().join(format!("data/fomod/moduleconfig/{}", filename))
|
||||
}
|
||||
|
||||
fn err_to_string(e: FOModError) -> String {
|
||||
match e {
|
||||
FOModError::Io(error) => format!("IO: {:?}", error),
|
||||
FOModError::Parse(de_error) => match de_error {
|
||||
quick_xml::DeError::UnexpectedStart(items) => {
|
||||
format!("UnexpectedStart: {}", str::from_utf8(&items).unwrap())
|
||||
}
|
||||
_ => format!("Other: {:?}", de_error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse() {
|
||||
for xml in [
|
||||
"ineed.xml",
|
||||
"trade_barter.xml",
|
||||
"starui.xml",
|
||||
"example_01.xml",
|
||||
"example_02.xml",
|
||||
"example_03.xml",
|
||||
"example_04.xml",
|
||||
"example_05.xml",
|
||||
"banana.xml",
|
||||
"po3tweaks.xml"
|
||||
] {
|
||||
fomod::Config::load_from_file(get_xml(xml))
|
||||
.unwrap_or_else(|e| panic!("Parse for {xml} with {}", err_to_string(e)));
|
||||
}
|
||||
}
|
||||
27
tests/game_test.rs
Normal file
27
tests/game_test.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use fomod_manager::types::RootConfig;
|
||||
|
||||
fn get_parent() -> PathBuf {
|
||||
PathBuf::from(file!()).parent().unwrap().to_owned()
|
||||
}
|
||||
|
||||
fn load_root() -> RootConfig {
|
||||
RootConfig::load_from_file(get_parent().join("data/root_config_complex.toml")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_gamefiles() {
|
||||
let root_config = load_root();
|
||||
|
||||
let game = root_config.game_by_id("sse").expect("No game found");
|
||||
|
||||
let links = game.export_links().expect("Failed to export game links");
|
||||
|
||||
assert!(
|
||||
links.iter().all(|e| e.src().is_absolute()),
|
||||
"Link src is not absolute"
|
||||
);
|
||||
|
||||
assert_eq!(links.len(), 4, "Not all files linked");
|
||||
}
|
||||
53
tests/modded_instance_test.rs
Normal file
53
tests/modded_instance_test.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use fomod_manager::types::{Link, RootConfig};
|
||||
|
||||
fn get_parent() -> PathBuf {
|
||||
PathBuf::from(file!()).parent().unwrap().to_owned()
|
||||
}
|
||||
|
||||
fn load_root() -> RootConfig {
|
||||
RootConfig::load_from_file(get_parent().join("data/root_config_complex.toml")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal() {
|
||||
let root = load_root();
|
||||
|
||||
let inst = root.load_instance_by_id("instance_minimal");
|
||||
|
||||
assert!(inst.is_ok(), "Failed to load instance");
|
||||
|
||||
let unwraped = inst.expect("Asserted before");
|
||||
|
||||
assert_eq!(unwraped.game_id(), "sse");
|
||||
|
||||
assert!(unwraped.mods().is_empty());
|
||||
assert!(unwraped.game_file_overrides().is_empty());
|
||||
assert!(unwraped.load_order().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_complex() {
|
||||
let root = load_root();
|
||||
|
||||
let inst = root.load_instance_by_id("instance_complex");
|
||||
|
||||
assert!(inst.is_ok(), "Failed to load instance");
|
||||
|
||||
let unwraped = inst.expect("Asserted before");
|
||||
|
||||
assert_eq!(unwraped.game_id(), "sse");
|
||||
assert_eq!(unwraped.load_order().len(), 13);
|
||||
assert_eq!(
|
||||
unwraped.game_file_overrides().first().unwrap(),
|
||||
&Link::new("skse64_loader.exe", "SkyrimSELauncher.exe")
|
||||
);
|
||||
|
||||
assert_eq!(unwraped.mods().len(), 3);
|
||||
let test_mod = unwraped.mods().iter().find(|e| e.mod_id() == "SkyUI-12604-35407");
|
||||
assert!(test_mod.is_some());
|
||||
|
||||
assert_eq!(test_mod.unwrap().priority(), 0);
|
||||
assert_eq!(test_mod.unwrap().files().len(), 2);
|
||||
}
|
||||
70
tests/root_config_test.rs
Normal file
70
tests/root_config_test.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use fomod_manager::types::RootConfig;
|
||||
|
||||
fn get_parent() -> PathBuf {
|
||||
PathBuf::from(file!()).parent().unwrap().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal() {
|
||||
let config =
|
||||
RootConfig::load_from_file(get_parent().join("data/root_config_minimal.toml")).unwrap();
|
||||
|
||||
assert!(config.mod_location().ends_with("mods"));
|
||||
assert!(
|
||||
config.download_location().is_none(),
|
||||
"Download location should be None"
|
||||
);
|
||||
assert!(config.nexus_api_key().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_complex() {
|
||||
let config =
|
||||
RootConfig::load_from_file(get_parent().join("data/root_config_complex.toml")).unwrap();
|
||||
|
||||
assert!(config.mod_location().ends_with("mods"));
|
||||
assert!(
|
||||
config
|
||||
.download_location()
|
||||
.is_some_and(|e| e.ends_with("downloads"))
|
||||
);
|
||||
assert_eq!(config.nexus_api_key(), Some("1234"));
|
||||
|
||||
assert!(
|
||||
config
|
||||
.game_by_id("example_game")
|
||||
.is_some_and(|e| e.install_location() == "/home/user/games/sse"),
|
||||
"Installed game wrong path"
|
||||
);
|
||||
|
||||
assert!(
|
||||
config
|
||||
.game_by_id("sse")
|
||||
.is_some_and(|e| e.install_location().is_absolute()),
|
||||
"Relative game path was not resolved to absolute"
|
||||
);
|
||||
|
||||
assert!(
|
||||
config
|
||||
.game_by_id("sse")
|
||||
.is_some_and(|e| e.install_location().ends_with("games/sse")),
|
||||
"Installed game wrong path"
|
||||
);
|
||||
|
||||
assert!(config.game_by_id("starfield").is_none());
|
||||
|
||||
assert!(config.mod_by_id("mod1").is_some());
|
||||
assert!(config.mod_by_id("mod100").is_none());
|
||||
assert!(
|
||||
config
|
||||
.mod_by_id("mod1")
|
||||
.is_some_and(|e| e.path() == "/home/user/mods/mod1")
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.mod_by_id("mod2")
|
||||
.is_some_and(|e| e.path().ends_with("mod2"))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user