Compare commits
28 Commits
0e72675965
...
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
|
1536
Cargo.lock
generated
1536
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,19 @@ 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-rust = { version = "0.6.1", default-features = false }
|
||||
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};
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -18,7 +19,7 @@ pub fn activate_instance(
|
||||
|
||||
check_target_valid(&target)?;
|
||||
|
||||
let resolved_links = resolve_links(root_config, instance, game)?;
|
||||
let resolved_links = resolve_links(root_config, instance, &game)?;
|
||||
|
||||
resolved_links
|
||||
.iter()
|
||||
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"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,10 @@ use std::{
|
||||
use thiserror::Error;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::{types::{self, ModdedInstance, RootConfig}, utils::is_plugin_file};
|
||||
use crate::{
|
||||
types::{self, ModdedInstance, RootConfig},
|
||||
utils::is_plugin_file,
|
||||
};
|
||||
|
||||
pub fn create_loadorder(
|
||||
root_config: &RootConfig,
|
||||
@@ -15,9 +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 },
|
||||
Unpack { id: String, path: String },
|
||||
Tui,
|
||||
}
|
||||
|
||||
@@ -118,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,
|
||||
}
|
||||
@@ -129,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,
|
||||
}
|
||||
|
||||
244
src/instance.rs
244
src/instance.rs
@@ -1,244 +0,0 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
use log::{debug, trace, warn};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
file_conflict_solver::ConflictSolver,
|
||||
fomod, install_prompt,
|
||||
mod_config_installer::run_fomod_installer,
|
||||
types::{InstalledMod, ModConfig, ModFile, ModdedInstance, RootConfig},
|
||||
utils::{resolve_case_insensitive, walk_all_files},
|
||||
};
|
||||
|
||||
pub fn insert_mod_to_instance(
|
||||
instance: &mut ModdedInstance,
|
||||
from_mod: &ModConfig,
|
||||
files: &[ModFile],
|
||||
priority: isize,
|
||||
) -> Result<(), InststanceError> {
|
||||
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 {
|
||||
if let Some(conflict) = solver.add_file(file, &new_mod) {
|
||||
return Err(InststanceError::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);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn files_to_install_mod(
|
||||
root_config: &RootConfig,
|
||||
instance: &ModdedInstance,
|
||||
mod_to_install: &ModConfig,
|
||||
) -> anyhow::Result<Vec<ModFile>> {
|
||||
let mod_location = root_config.mod_location().join(mod_to_install.path());
|
||||
|
||||
let files = match determain_mod_kind(mod_to_install, &mod_location)? {
|
||||
ModKind::Fomod(xml_path) => install_fomod(instance, xml_path, &mod_location)?,
|
||||
ModKind::EmbeddedData(_data_path) => install_from_dir(mod_to_install, mod_location)?,
|
||||
ModKind::Root => install_root(mod_to_install, mod_location)?,
|
||||
ModKind::Unkown => install_from_dir_to_data(mod_to_install, mod_location)?,
|
||||
};
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
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 files = run_fomod_installer(module_config, &active_plugins, install_prompt::prompt)
|
||||
.map_err(|_| InststanceError::FomodRunInstaller)?;
|
||||
|
||||
let mod_files: Vec<_> = files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
ModFile::from_installer(f.clone(), &mod_root).map_err(InststanceError::FomodFinalize)
|
||||
})
|
||||
.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,
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum InststanceError {
|
||||
#[error("Two mods write the same file")]
|
||||
FileConflict {
|
||||
lhs_mod_id: String,
|
||||
rhs_mod_id: String,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
#[error("Failed to run fomod installer")]
|
||||
FomodRunInstaller,
|
||||
|
||||
#[error("Failed to handle results of fomod installer")]
|
||||
FomodFinalize(io::Error),
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
pub mod activator;
|
||||
pub mod cli;
|
||||
pub mod file_conflict_solver;
|
||||
pub mod fomod;
|
||||
pub mod install_prompt;
|
||||
pub mod instance;
|
||||
pub mod load_order;
|
||||
pub mod mod_config_installer;
|
||||
pub mod nexus;
|
||||
pub mod types;
|
||||
pub mod unpacker;
|
||||
pub mod utils;
|
||||
pub mod actions;
|
||||
pub mod tui;
|
||||
|
||||
119
src/main.rs
119
src/main.rs
@@ -4,13 +4,15 @@ use log::{debug, error, info};
|
||||
use std::{error::Error, path::Path};
|
||||
|
||||
use fomod_manager::{
|
||||
activator::activate_instance,
|
||||
actions::{
|
||||
ResolveFileResult, activate_instance, create_loadorder, handle_nxm, insert_mod_to_instance,
|
||||
resolve_files_for_install,
|
||||
},
|
||||
cli::{self, Args},
|
||||
instance::{self, files_to_install_mod, insert_mod_to_instance},
|
||||
load_order,
|
||||
nexus::{NexusAPI, download_nxm},
|
||||
mod_config_installer::FomodInstaller,
|
||||
nexus::NexusAPI,
|
||||
tui,
|
||||
types::RootConfig,
|
||||
unpacker::unpack,
|
||||
};
|
||||
|
||||
fn command_activate(
|
||||
@@ -29,40 +31,35 @@ fn command_add(root_config: &RootConfig, instance_id: &str, mod_id: &str) -> any
|
||||
.mod_by_id(mod_id)
|
||||
.ok_or(anyhow!("Can't find mod in config"))?;
|
||||
|
||||
let files = files_to_install_mod(root_config, &instance, &mod_to_install)?;
|
||||
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)?
|
||||
}
|
||||
};
|
||||
|
||||
match insert_mod_to_instance(&mut instance, &mod_to_install, &files, 0) {
|
||||
Ok(_) => {
|
||||
None => {
|
||||
instance.save_to_file()?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
match &err {
|
||||
instance::InststanceError::FileConflict {
|
||||
lhs_mod_id,
|
||||
rhs_mod_id,
|
||||
path,
|
||||
} => {
|
||||
error!(
|
||||
"File conflict between {} and {} at {}",
|
||||
lhs_mod_id,
|
||||
rhs_mod_id,
|
||||
path.to_string_lossy()
|
||||
);
|
||||
info!("To resolve file conflicts give one mod a higher priority in the config");
|
||||
}
|
||||
instance::InststanceError::FomodRunInstaller => {
|
||||
error!("Failed to run FOMod installer");
|
||||
}
|
||||
instance::InststanceError::FomodFinalize(error) => {
|
||||
error!(
|
||||
"FOMod installer finished but failed to finalize result: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
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");
|
||||
|
||||
Err(err.into())
|
||||
Err(anyhow!("File conflict"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,57 +68,15 @@ 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();
|
||||
|
||||
let new_load_order = load_order::create_loadorder(root_config, game, &instance)?;
|
||||
|
||||
let new_load_order = create_loadorder(root_config, &game, &instance)?;
|
||||
instance.set_load_order(new_load_order);
|
||||
|
||||
instance.save_to_file()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_download(root_config: &mut RootConfig, nxm_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 (dl_file, mod_info) = download_nxm(api_key, nxm_url, dl_location)?;
|
||||
|
||||
let mod_id = mod_info.generate_id();
|
||||
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 already exists"));
|
||||
}
|
||||
|
||||
let new_mod = unpack(root_config, &mod_id, dl_file)?;
|
||||
|
||||
root_config.add_mod(&new_mod);
|
||||
|
||||
root_config.save_to_file()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_unpack(
|
||||
root_config: &mut RootConfig,
|
||||
id: &str,
|
||||
file: impl AsRef<Path>,
|
||||
) -> anyhow::Result<()> {
|
||||
if root_config.game_by_id(id).is_some() {
|
||||
error!("Mod already present");
|
||||
return Err(anyhow!("Mod already exists"));
|
||||
}
|
||||
|
||||
let new_mod = unpack(root_config, id, file)?;
|
||||
|
||||
root_config.add_mod(&new_mod);
|
||||
fn command_download(root_config: &mut RootConfig, raw_url: &str) -> anyhow::Result<()> {
|
||||
handle_nxm(root_config, raw_url)?;
|
||||
|
||||
root_config.save_to_file()?;
|
||||
|
||||
@@ -130,7 +85,7 @@ fn command_unpack(
|
||||
|
||||
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)
|
||||
@@ -148,7 +103,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
cli::Commands::Activate { instance, target } => {
|
||||
command_activate(&root_config, &instance, &target)?;
|
||||
}
|
||||
cli::Commands::Add { instance, mod_id } => {
|
||||
cli::Commands::Include { instance, mod_id } => {
|
||||
command_add(&root_config, &instance, &mod_id)?;
|
||||
}
|
||||
cli::Commands::LoadOrder { instance } => {
|
||||
@@ -161,8 +116,8 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
cli::Commands::Download { url } => {
|
||||
command_download(&mut root_config, &url)?;
|
||||
}
|
||||
cli::Commands::Unpack { id, path } => {
|
||||
command_unpack(&mut root_config, &id, path)?;
|
||||
cli::Commands::Tui => {
|
||||
tui::run(&mut root_config)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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, ModuleDependency, Plugin, PluginTypeDescriptorEnum, PluginTypeEnum,
|
||||
use crate::{
|
||||
fomod::{
|
||||
CompositeDependency, Config, DependencyOperator, DependencyState, FileList, FileTypeEnum,
|
||||
Group, GroupType, ModuleDependency, Plugin, PluginTypeDescriptorEnum, PluginTypeEnum,
|
||||
},
|
||||
types::ModFile,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -117,6 +120,7 @@ fn evaluate_module_depbendecy(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GroupPrompt {
|
||||
pub name: String,
|
||||
pub select_type: GroupType,
|
||||
@@ -146,6 +150,7 @@ impl GroupPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InstallOption {
|
||||
pub name: String,
|
||||
pub option_type: PluginTypeEnum,
|
||||
@@ -176,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;
|
||||
}
|
||||
}
|
||||
@@ -187,66 +196,90 @@ fn resolve_plugin_type(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_fomod_installer(
|
||||
fomod_config: Config,
|
||||
installed_plugins: &[String],
|
||||
group_prompt: fn(GroupPrompt) -> Vec<usize>,
|
||||
) -> anyhow::Result<Vec<FileTypeEnum>> {
|
||||
let mut state = InstallerState::new();
|
||||
pub struct FomodInstaller<'a> {
|
||||
state: InstallerState,
|
||||
current_step: (usize, usize),
|
||||
config: &'a Config,
|
||||
installed_plugins: &'a [String],
|
||||
}
|
||||
|
||||
// Always-installed files first
|
||||
if let Some(required) = &fomod_config.required_install_files {
|
||||
state.add_files(required);
|
||||
impl<'a> FomodInstaller<'a> {
|
||||
pub fn new(fomod_config: &'a Config, installed_plugins: &'a [String]) -> Self {
|
||||
let mut state = InstallerState::new();
|
||||
if let Some(required) = &fomod_config.required_install_files {
|
||||
state.add_files(required);
|
||||
}
|
||||
|
||||
Self {
|
||||
state,
|
||||
current_step: (0, 0),
|
||||
config: fomod_config,
|
||||
installed_plugins,
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(install_steps) = fomod_config.install_steps {
|
||||
let steps = &install_steps.install_step;
|
||||
pub fn run_step(&mut self, selection: Option<&[usize]>) -> Option<GroupPrompt> {
|
||||
let Some(install_steps) = &self.config.install_steps else {
|
||||
return None;
|
||||
};
|
||||
|
||||
for step in steps {
|
||||
// Check if the step should be visible
|
||||
if step
|
||||
.visible
|
||||
.as_ref()
|
||||
.is_some_and(|v| !evaluate_module_depbendecy(v, &state, installed_plugins))
|
||||
{
|
||||
// Dependency to show the step not meet. Skipping.
|
||||
continue;
|
||||
}
|
||||
let step = install_steps.install_step.get(self.current_step.0)?;
|
||||
|
||||
for group in &step.optional_file_groups.group {
|
||||
// TODO: Skip groups where all plugins are NotUsable
|
||||
// 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 prompt = GroupPrompt::new(group, &state, installed_plugins);
|
||||
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);
|
||||
};
|
||||
|
||||
let selected_plugins = (group_prompt)(prompt);
|
||||
// TODO: Skip groups where all plugins are NotUsable
|
||||
|
||||
match selection {
|
||||
Some(selected_plugins) => {
|
||||
for i in selected_plugins {
|
||||
let plugin = &group.plugins.plugin[i];
|
||||
let plugin = &group.plugins.plugin[*i];
|
||||
|
||||
// Add files from selected plugin
|
||||
if let Some(files) = &plugin.files {
|
||||
state.add_files(files);
|
||||
self.state.add_files(files);
|
||||
}
|
||||
|
||||
// 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);
|
||||
self.state.set_flag(&flag.name, &flag.flag_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)),
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate conditional file installs based on final flag state
|
||||
if let Some(conditional) = &fomod_config.conditional_file_installs {
|
||||
for pattern in &conditional.patterns.pattern {
|
||||
if evaluate_module_depbendecy(&pattern.dependencies, &state, installed_plugins) {
|
||||
state.add_files(&pattern.files);
|
||||
}
|
||||
}
|
||||
}
|
||||
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(state.into_file_list())
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ mod api;
|
||||
mod downloader;
|
||||
mod url;
|
||||
|
||||
pub use api::NexusAPI;
|
||||
pub use api::{ModInfo, NexusAPI};
|
||||
pub use downloader::download_nxm;
|
||||
pub use url::NXMUrl;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use crate::nexus::NXMUrl;
|
||||
use crate::{nexus::NXMUrl, types::GameType};
|
||||
|
||||
const NEXUS_ENDPOINT: &str = "https://api.nexusmods.com";
|
||||
|
||||
@@ -95,7 +95,7 @@ pub struct ModInfo {
|
||||
pub mod_id: u64,
|
||||
// pub game_id: u64,
|
||||
// pub allow_rating: bool,
|
||||
// pub domain_name: String,
|
||||
pub domain_name: String,
|
||||
// pub category_id: u64,
|
||||
pub version: String,
|
||||
// pub endorsement_count: u64,
|
||||
@@ -145,6 +145,10 @@ impl ModInfo {
|
||||
if short_name.len() > MAX_CHARS {
|
||||
short_name.truncate(MAX_CHARS);
|
||||
}
|
||||
format!("{}-{}-{}", short_name, self.mod_id, self.version)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,21 +14,18 @@ use crate::nexus::{
|
||||
|
||||
pub fn download_nxm(
|
||||
api_key: &str,
|
||||
link: &str,
|
||||
nxm_url: &NXMUrl,
|
||||
target_dir: impl AsRef<Path>,
|
||||
) -> anyhow::Result<(PathBuf, ModInfo)> {
|
||||
let api = NexusAPI::new(api_key);
|
||||
let Some(parsed_url) = NXMUrl::parse_url(link) else {
|
||||
return Err(anyhow!("Failed to parse url"));
|
||||
};
|
||||
|
||||
let mod_info = api.mod_info(&parsed_url.game, &parsed_url.mod_id)?;
|
||||
let links = api.generate_download_link_for_file(&parsed_url)?;
|
||||
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, original_filename);
|
||||
let download_path = target_dir.as_ref().join(parsed_url.game).join(filename);
|
||||
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)?;
|
||||
@@ -58,12 +55,12 @@ fn download_mod(mod_dl_link: &DownloadLocation, target: impl AsRef<Path>) -> any
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gen_filename_for_mod(mod_info: &ModInfo, dl_filename: &str) -> String {
|
||||
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, mod_info.version, ext)
|
||||
format!("{}-{}.{}", mod_info.mod_id, file_id, ext)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
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 {
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{types::link::Link, utils::walk_all_files};
|
||||
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>) -> Self {
|
||||
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<Vec<Link>, io::Error> {
|
||||
let links: Vec<Link> = walk_all_files(&self.path)?
|
||||
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(),
|
||||
@@ -39,4 +45,8 @@ impl Game {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
use serde::{
|
||||
Deserialize, Deserializer, Serialize, Serializer,
|
||||
de::{self, Visitor},
|
||||
};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
fmt::{self, Debug},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::mod_file::ModFile;
|
||||
|
||||
/// A link between a file from a mod and a destination in a ModdedInstance
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
|
||||
#[serde(from = "(PathBuf, PathBuf)", into = "(PathBuf,PathBuf)")]
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Link {
|
||||
src: PathBuf,
|
||||
dst: PathBuf,
|
||||
@@ -36,18 +37,46 @@ impl Link {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(PathBuf, PathBuf)> for Link {
|
||||
fn from(value: (PathBuf, PathBuf)) -> Self {
|
||||
Self {
|
||||
src: value.0,
|
||||
dst: value.1,
|
||||
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 From<Link> for (PathBuf, PathBuf) {
|
||||
fn from(value: Link) -> Self {
|
||||
(value.src, value.dst)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ 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)]
|
||||
@@ -22,6 +27,14 @@ pub struct ModConfig {
|
||||
#[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 {
|
||||
@@ -31,9 +44,26 @@ impl ModConfig {
|
||||
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
|
||||
@@ -55,8 +85,24 @@ impl ModConfig {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
fs::{self, read_to_string},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
@@ -106,8 +105,12 @@ impl ModdedInstance {
|
||||
&self.mods
|
||||
}
|
||||
|
||||
pub fn active_plugins(&self) -> impl Iterator<Item = &OsStr> {
|
||||
self.mods.iter().flat_map(|e| e.active_plugins())
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +159,5 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -57,32 +57,62 @@ impl RootConfig {
|
||||
}
|
||||
|
||||
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)
|
||||
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() {
|
||||
ModdedInstance::load_from_file(self.self_parent.join(&conf.path))
|
||||
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)
|
||||
}
|
||||
@@ -90,7 +120,12 @@ impl RootConfig {
|
||||
|
||||
pub fn mod_location(&self) -> PathBuf {
|
||||
if self.mod_location.is_relative() {
|
||||
self.self_parent.join(&self.mod_location)
|
||||
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()
|
||||
}
|
||||
@@ -103,7 +138,12 @@ impl RootConfig {
|
||||
pub fn download_location(&self) -> Option<PathBuf> {
|
||||
self.download_location.as_ref().map(|e| {
|
||||
if e.is_relative() {
|
||||
self.self_parent.join(e)
|
||||
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()
|
||||
}
|
||||
@@ -118,11 +158,16 @@ struct InstancePointer {
|
||||
|
||||
#[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"))]),
|
||||
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()),
|
||||
@@ -151,6 +196,7 @@ mod tests {
|
||||
|
||||
let unwraped = game.expect("Asserted before");
|
||||
assert_eq!(unwraped.install_location(), "/games/sse");
|
||||
assert_eq!(unwraped.game_type(), GameType::SkyrimSE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
108
src/unpacker.rs
108
src/unpacker.rs
@@ -1,28 +1,104 @@
|
||||
use std::{fs, path::Path};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
use crate::types::{ModConfig, RootConfig};
|
||||
|
||||
pub fn unpack(
|
||||
root_config: &RootConfig,
|
||||
id: &str,
|
||||
path: impl AsRef<Path>,
|
||||
) -> anyhow::Result<ModConfig> {
|
||||
let extract_to = root_config.mod_location().join(id);
|
||||
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"));
|
||||
return Err(anyhow!(
|
||||
"File already exists: {}",
|
||||
extract_to.as_ref().to_string_lossy()
|
||||
));
|
||||
}
|
||||
|
||||
unpack_7z_file(path, &extract_to)?;
|
||||
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"))
|
||||
}
|
||||
}?;
|
||||
|
||||
let new_mod = ModConfig::new(id, id);
|
||||
unnest_dir(extract_to)?;
|
||||
|
||||
Ok(new_mod)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unpack_7z_file(path: impl AsRef<Path>, to: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
sevenz_rust::decompress_file(path, to)?;
|
||||
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
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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>,
|
||||
@@ -44,7 +45,7 @@ pub fn resolve_case_insensitive(
|
||||
}
|
||||
|
||||
/// Use walkdir to walk all actual files in a dir
|
||||
/// Returns early id any error occurs
|
||||
/// Returns early if any error occurs
|
||||
pub fn walk_all_files(
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<impl Iterator<Item = walkdir::DirEntry>, walkdir::Error> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{collections::HashSet, error::Error, path::PathBuf};
|
||||
|
||||
use fomod_manager::{
|
||||
instance::{files_to_install_mod, insert_mod_to_instance},
|
||||
actions::{insert_mod_to_instance, resolve_files_for_install},
|
||||
types::{Link, RootConfig},
|
||||
};
|
||||
|
||||
@@ -20,9 +20,14 @@ fn add_plain() -> Result<(), Box<dyn Error>> {
|
||||
let mod_to_install = root_config
|
||||
.mod_by_id("add_test_plain")
|
||||
.expect("Mod not found");
|
||||
let files_to_add = files_to_install_mod(&root_config, &instance, &mod_to_install)?;
|
||||
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)?;
|
||||
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
|
||||
|
||||
let installed_mods = instance.mods();
|
||||
|
||||
@@ -53,9 +58,14 @@ fn add_nested() -> Result<(), Box<dyn Error>> {
|
||||
let mod_to_install = root_config
|
||||
.mod_by_id("add_test_nested")
|
||||
.expect("Mod not found");
|
||||
let files_to_add = files_to_install_mod(&root_config, &instance, &mod_to_install)?;
|
||||
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)?;
|
||||
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
|
||||
|
||||
let installed_mods = instance.mods();
|
||||
|
||||
@@ -86,9 +96,14 @@ fn add_root() -> Result<(), Box<dyn Error>> {
|
||||
let mod_to_install = root_config
|
||||
.mod_by_id("add_test_root")
|
||||
.expect("Mod not found");
|
||||
let files_to_add = files_to_install_mod(&root_config, &instance, &mod_to_install)?;
|
||||
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)?;
|
||||
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
|
||||
|
||||
let installed_mods = instance.mods();
|
||||
|
||||
@@ -117,9 +132,14 @@ fn add_filter() -> Result<(), Box<dyn Error>> {
|
||||
let mod_to_install = root_config
|
||||
.mod_by_id("add_test_filter")
|
||||
.expect("Mod not found");
|
||||
let files_to_add = files_to_install_mod(&root_config, &instance, &mod_to_install)?;
|
||||
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)?;
|
||||
insert_mod_to_instance(&mut instance, &mod_to_install, &files_to_add, 0);
|
||||
|
||||
let installed_mods = instance.mods();
|
||||
|
||||
|
||||
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>
|
||||
@@ -10,171 +10,99 @@ load_order = [
|
||||
"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 = "skyui"
|
||||
files = [
|
||||
[
|
||||
"SkyUI_SE.esp",
|
||||
"Data/SkyUI_SE.esp",
|
||||
],
|
||||
[
|
||||
"SkyUI_SE.bsa",
|
||||
"Data/SkyUI_SE.bsa",
|
||||
],
|
||||
game_file_overrides = [
|
||||
"skse64_loader.exe -> SkyrimSELauncher.exe"
|
||||
]
|
||||
priority = 0
|
||||
|
||||
[[mods]]
|
||||
id = "skse"
|
||||
files = [
|
||||
[
|
||||
"Data/Scripts/actorbase.pex",
|
||||
"Data/Scripts/actorbase.pex",
|
||||
],
|
||||
[
|
||||
"Data/Scripts/weather.pex",
|
||||
"Data/Scripts/weather.pex",
|
||||
],
|
||||
[
|
||||
"skse64_loader.exe",
|
||||
"skse64_loader.exe",
|
||||
],
|
||||
[
|
||||
"Data/Scripts/headpart.pex",
|
||||
"Data/Scripts/headpart.pex",
|
||||
],
|
||||
[
|
||||
"Data/Scripts/math.pex",
|
||||
"Data/Scripts/form.pex",
|
||||
"Data/Scripts/soulgem.pex",
|
||||
"Data/Scripts/soulgem.pex",
|
||||
],
|
||||
[
|
||||
"Data/Scripts/modevent.pex",
|
||||
"Data/Scripts/modevent.pex",
|
||||
],
|
||||
[
|
||||
"Data/Scripts/actorvalueinfo.pex",
|
||||
"Data/Scripts/actorvalueinfo.pex",
|
||||
],
|
||||
[
|
||||
"Data/Scripts/book.pex",
|
||||
"Data/Scripts/book.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/potion.pex",
|
||||
],
|
||||
[
|
||||
"Data/Scripts/spell.pex",
|
||||
"Data/Scripts/spell.pex",
|
||||
],
|
||||
[
|
||||
"Data/Scripts/perk.pex",
|
||||
"Data/Scripts/perk.pex",
|
||||
],
|
||||
[
|
||||
"Data/Scripts/actor.pex",
|
||||
"Data/Scripts/game.pex",
|
||||
"Data/Scripts/armor.pex",
|
||||
"Data/Scripts/headpart.pex",
|
||||
"Data/Scripts/objectreference.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 = "deadly_spells"
|
||||
id = "SkyUI-12604-35407"
|
||||
files = [
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalsnowhole01_n.dds",
|
||||
"Data/textures/impactdecals/decalsnowhole01_n.dds",
|
||||
],
|
||||
[
|
||||
"40 Two Fire Esp/textures/impactdecals/decalflamespread01_g.dds",
|
||||
"Data/textures/impactdecals/decalflamespread01_g.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalsparkburn01_g.dds",
|
||||
"Data/textures/impactdecals/decalsparkburn01_g.dds",
|
||||
],
|
||||
[
|
||||
"40 Two Fire Esp/textures/impactdecals/decalflamespread01.dds",
|
||||
"Data/textures/impactdecals/decalflamespread01.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalfrostimpact01_n.dds",
|
||||
"Data/textures/impactdecals/decalfrostimpact01_n.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalspitimpact01_n.dds",
|
||||
"Data/textures/impactdecals/decalspitimpact01_n.dds",
|
||||
],
|
||||
[
|
||||
"40 Two Fire Esp/DeadlySpellImpacts - Two Fire.esp",
|
||||
"Data/DeadlySpellImpacts - Two Fire.esp",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalsnowmelt01.dds",
|
||||
"Data/textures/impactdecals/decalsnowmelt01.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalspitimpact01.dds",
|
||||
"Data/textures/impactdecals/decalspitimpact01.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalsnowhole01.dds",
|
||||
"Data/textures/impactdecals/decalsnowhole01.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalsparkburn01.dds",
|
||||
"Data/textures/impactdecals/decalsparkburn01.dds",
|
||||
],
|
||||
[
|
||||
"10 Fire Cracks/textures/impactdecals/decalflameburn01_g.dds",
|
||||
"Data/textures/impactdecals/decalflameburn01_g.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalsnowmelt01_n.dds",
|
||||
"Data/textures/impactdecals/decalsnowmelt01_n.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalfrostimpact01.dds",
|
||||
"Data/textures/impactdecals/decalfrostimpact01.dds",
|
||||
],
|
||||
[
|
||||
"10 Fire Cracks/textures/impactdecals/decalflameburn01_n.dds",
|
||||
"Data/textures/impactdecals/decalflameburn01_n.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalsnowmelt01_g.dds",
|
||||
"Data/textures/impactdecals/decalsnowmelt01_g.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalsparkburn01_n.dds",
|
||||
"Data/textures/impactdecals/decalsparkburn01_n.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/textures/impactdecals/decalsnowhole01_g.dds",
|
||||
"Data/textures/impactdecals/decalsnowhole01_g.dds",
|
||||
],
|
||||
[
|
||||
"40 Two Fire Esp/Manual Installation of the Two Fire Option.txt",
|
||||
"Data/Manual Installation of the Two Fire Option.txt",
|
||||
],
|
||||
[
|
||||
"10 Fire Cracks/textures/impactdecals/decalflameburn01.dds",
|
||||
"Data/textures/impactdecals/decalflameburn01.dds",
|
||||
],
|
||||
[
|
||||
"40 Two Fire Esp/textures/impactdecals/decalflamespread01_n.dds",
|
||||
"Data/textures/impactdecals/decalflamespread01_n.dds",
|
||||
],
|
||||
[
|
||||
"000 Core Files/DeadlySpellImpacts.esp",
|
||||
"Data/DeadlySpellImpacts.esp",
|
||||
],
|
||||
"SkyUI_SE.bsa -> Data/SkyUI_SE.bsa",
|
||||
"SkyUI_SE.esp -> Data/SkyUI_SE.esp",
|
||||
]
|
||||
priority = 1
|
||||
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
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ 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"
|
||||
|
||||
@@ -34,6 +34,7 @@ fn parse() {
|
||||
"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");
|
||||
}
|
||||
@@ -38,14 +38,14 @@ fn parse_complex() {
|
||||
let unwraped = inst.expect("Asserted before");
|
||||
|
||||
assert_eq!(unwraped.game_id(), "sse");
|
||||
assert_eq!(unwraped.load_order().len(), 11);
|
||||
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");
|
||||
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);
|
||||
|
||||
@@ -39,6 +39,13 @@ fn parse_complex() {
|
||||
"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")
|
||||
|
||||
Reference in New Issue
Block a user