diff --git a/Cargo.lock b/Cargo.lock index 4a987b86..cac2eaf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2794,7 +2794,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "snafu", "steamlocate", "strum 0.26.2", "task-local-extensions", @@ -2820,8 +2819,8 @@ dependencies = [ "repak", "reqwest", "serde", - "snafu", "steamlocate", + "thiserror", "tracing", "tracing-appender", "tracing-subscriber", @@ -4378,27 +4377,6 @@ dependencies = [ "serde", ] -[[package]] -name = "snafu" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d342c51730e54029130d7dc9fd735d28c4cd360f1368c01981d4f03ff207f096" -dependencies = [ - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080c44971436b1af15d6f61ddd8b543995cf63ab8e677d46b00cc06f4ef267a0" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.48", -] - [[package]] name = "socket2" version = "0.4.10" diff --git a/Cargo.toml b/Cargo.toml index 84781b0e..6e93a2b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter", "std", "registry"] } tokio = "1.35.1" reqwest = { version = "0.11.23", default-features = false, features = ["blocking", "rustls", "json"] } -snafu = "0.8.0" +thiserror = "1" [package] name = "mint" @@ -80,7 +80,6 @@ sha2 = "0.10.8" steamlocate.workspace = true task-local-extensions = "0.1.4" tempfile = "3.9.0" -thiserror = "1.0.56" tokio = { workspace = true, features = ["full"] } tracing.workspace = true typetag = "0.2.16" @@ -92,7 +91,7 @@ repak.workspace = true include_dir = "0.7.3" postcard.workspace = true fs-err.workspace = true -snafu.workspace = true +thiserror.workspace = true strum = { version = "0.26", features = ["derive"] } itertools.workspace = true egui_dnd = "0.6.0" diff --git a/mint_lib/Cargo.toml b/mint_lib/Cargo.toml index b36e7b96..fee4fddc 100644 --- a/mint_lib/Cargo.toml +++ b/mint_lib/Cargo.toml @@ -17,4 +17,4 @@ tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true reqwest.workspace = true -snafu.workspace = true +thiserror.workspace = true diff --git a/mint_lib/src/error.rs b/mint_lib/src/error.rs index 118a232e..ff6a6c39 100644 --- a/mint_lib/src/error.rs +++ b/mint_lib/src/error.rs @@ -1,32 +1,27 @@ -use snafu::Snafu; +use thiserror::Error; -#[derive(Debug, Snafu)] -#[snafu(display("mint encountered an error: {msg}"))] -pub struct GenericError { - pub msg: String, -} +/// Possible errors when using the mint lib. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum MintError { + /// Failed to update. + #[error("failed to fetch github release: {summary}")] + FetchGithubReleaseFailed { + summary: String, + details: Option, + }, -pub trait ResultExt { - fn generic(self, msg: String) -> Result; - fn with_generic(self, f: F) -> Result - where - F: FnOnce(E) -> String; -} + /// Failed to locate Deep Rock Galactic installation. + #[error("unable to locate Deep Rock Galactic installation: {summary}")] + UnknownInstallation { + summary: String, + details: Option, + }, -impl ResultExt for Result { - fn generic(self, msg: String) -> Result { - match self { - Ok(ok) => Ok(ok), - Err(_) => Err(GenericError { msg }), - } - } - fn with_generic(self, f: F) -> Result - where - F: FnOnce(E) -> String, - { - match self { - Ok(ok) => Ok(ok), - Err(e) => Err(GenericError { msg: f(e) }), - } - } + /// Failed to setup tracing. + #[error("failed to setup logging")] + LogSetupFailed { + summary: String, + details: Option, + }, } diff --git a/mint_lib/src/installation.rs b/mint_lib/src/installation.rs new file mode 100644 index 00000000..7c14579e --- /dev/null +++ b/mint_lib/src/installation.rs @@ -0,0 +1,203 @@ +use std::path::{Path, PathBuf}; + +use crate::MintError; + +#[derive(Debug)] +pub enum DRGInstallationType { + Steam, + Xbox, +} + +fn io_err>(e: std::io::Error, msg: S) -> MintError { + MintError::UnknownInstallation { + summary: msg.as_ref().to_string(), + details: Some(e.to_string()), + } +} + +fn bad_file_name>(msg: S) -> MintError { + MintError::UnknownInstallation { + summary: msg.as_ref().to_string(), + details: None, + } +} + +fn unexpected_file_name>(msg: S, expected: &[&str], found: &str) -> MintError { + let candidates = expected + .iter() + .map(|e| format!("\"{e}\"")) + .collect::>(); + let candidates = candidates.join(", "); + let expectation = format!("expected one of [{candidates}] but found \"{found}\""); + + MintError::UnknownInstallation { + summary: msg.as_ref().to_string(), + details: Some(expectation), + } +} + +fn mk_lowercase_file_name, S: AsRef>( + path: P, + err_msg: S, +) -> Result { + let Some(mut file_name) = path + .as_ref() + .file_name() + .map(|n| n.to_string_lossy().to_string()) + else { + return Err(bad_file_name(err_msg)); + }; + file_name.make_ascii_lowercase(); + Ok(file_name) +} + +const STEAM_EXE_FILE_NAME: &str = "fsd-win64-shipping.exe"; +const XBOX_EXE_FILE_NAME: &str = "fsd-wingdk-shipping.exe"; + +const STEAM_PAK_FILE_NAME: &str = "fsd-windowsnoeditor.pak"; +const XBOX_PAK_FILE_NAME: &str = "fsd-wingdk.pak"; + +impl DRGInstallationType { + pub fn from_exe_path() -> Result { + let exe_path = std::env::current_exe() + .map_err(|e| io_err(e, "failed to get path of current executable"))?; + + let file_name = mk_lowercase_file_name( + exe_path, + "unable to get file name component of executable path", + )?; + + match file_name.as_ref() { + STEAM_EXE_FILE_NAME => Ok(Self::Steam), + XBOX_EXE_FILE_NAME => Ok(Self::Xbox), + n => Err(unexpected_file_name( + "unexpected executable file name", + &[STEAM_EXE_FILE_NAME, XBOX_EXE_FILE_NAME], + n, + )), + } + } + + pub fn from_pak_path>(pak: P) -> Result { + let file_name = mk_lowercase_file_name(pak, "failed to get pak file name")?; + match file_name.as_ref() { + STEAM_PAK_FILE_NAME => Ok(Self::Steam), + XBOX_PAK_FILE_NAME => Ok(Self::Steam), + n => Err(unexpected_file_name( + "unexpected pak file name", + &[STEAM_PAK_FILE_NAME, XBOX_PAK_FILE_NAME], + n, + )), + } + } + + pub fn binaries_directory_name(&self) -> &'static str { + match self { + Self::Steam => "Win64", + Self::Xbox => "WinGDK", + } + } + + pub fn main_pak_name(&self) -> &'static str { + match self { + Self::Steam => "FSD-WindowsNoEditor.pak", + Self::Xbox => "FSD-WinGDK.pak", + } + } + + pub fn hook_dll_name(&self) -> &'static str { + match self { + Self::Steam => "x3daudio1_7.dll", + Self::Xbox => "d3d9.dll", + } + } +} + +#[derive(Debug)] +pub struct DRGInstallation { + pub root: PathBuf, + pub installation_type: DRGInstallationType, +} + +impl DRGInstallation { + /// Returns first DRG installation found. Only supports Steam version. + /// TODO locate Xbox version + pub fn find() -> Option { + steamlocate::SteamDir::locate() + .ok() + .and_then(|steamdir| { + steamdir + .find_app(548430) + .ok() + .flatten() + .map(|(app, library)| { + library + .resolve_app_dir(&app) + .join("FSD/Content/Paks/FSD-WindowsNoEditor.pak") + }) + }) + .and_then(|path| Self::from_pak_path(path).ok()) + } + + pub fn from_pak_path>(pak: P) -> Result { + let pak_root = pak + .as_ref() + .parent() + .and_then(Path::parent) + .and_then(Path::parent); + + match pak_root { + Some(root) => Ok(Self { + root: root.to_path_buf(), + installation_type: DRGInstallationType::from_pak_path(pak)?, + }), + None => Err(MintError::UnknownInstallation { + summary: "failed to determine pak root".to_string(), + details: Some(format!("given path was {}", pak.as_ref().display())), + }), + } + } + + pub fn binaries_directory(&self) -> PathBuf { + self.root + .join("Binaries") + .join(self.installation_type.binaries_directory_name()) + } + + pub fn paks_path(&self) -> PathBuf { + self.root.join("Content").join("Paks") + } + + pub fn main_pak(&self) -> PathBuf { + self.root + .join("Content") + .join("Paks") + .join(self.installation_type.main_pak_name()) + } + + pub fn modio_directory(&self) -> Option { + match self.installation_type { + DRGInstallationType::Steam => { + #[cfg(target_os = "windows")] + { + Some(PathBuf::from("C:\\Users\\Public\\mod.io\\2475")) + } + #[cfg(target_os = "linux")] + { + steamlocate::SteamDir::locate() + .map(|s| { + s.path().join( + "steamapps/compatdata/548430/pfx/drive_c/users/Public/mod.io/2475", + ) + }) + .ok() + } + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + { + None // TODO + } + } + DRGInstallationType::Xbox => None, + } + } +} diff --git a/mint_lib/src/lib.rs b/mint_lib/src/lib.rs index f4ef0865..e3aff70f 100644 --- a/mint_lib/src/lib.rs +++ b/mint_lib/src/lib.rs @@ -1,207 +1,11 @@ pub mod error; +pub mod installation; +pub mod logging; pub mod mod_info; pub mod update; -use std::{ - io::BufWriter, - path::{Path, PathBuf}, -}; - -use anyhow::{bail, Context, Result}; -use fs_err as fs; -use tracing::*; -use tracing_subscriber::fmt::format::FmtSpan; - -#[derive(Debug)] -pub enum DRGInstallationType { - Steam, - Xbox, -} - -impl DRGInstallationType { - pub fn from_exe_path() -> Result { - let exe_name = std::env::current_exe() - .context("could not determine running exe")? - .file_name() - .context("failed to get exe path")? - .to_string_lossy() - .to_lowercase(); - Ok(match exe_name.as_str() { - "fsd-win64-shipping.exe" => Self::Steam, - "fsd-wingdk-shipping.exe" => Self::Xbox, - _ => bail!("unrecognized exe file name: {exe_name}"), - }) - } -} - -impl DRGInstallationType { - pub fn from_pak_path>(pak: P) -> Result { - let pak_name = pak - .as_ref() - .file_name() - .context("failed to get pak file name")? - .to_string_lossy() - .to_lowercase(); - Ok(match pak_name.as_str() { - "fsd-windowsnoeditor.pak" => Self::Steam, - "fsd-wingdk.pak" => Self::Xbox, - _ => bail!("unrecognized pak file name: {pak_name}"), - }) - } - pub fn binaries_directory_name(&self) -> &'static str { - match self { - Self::Steam => "Win64", - Self::Xbox => "WinGDK", - } - } - pub fn main_pak_name(&self) -> &'static str { - match self { - Self::Steam => "FSD-WindowsNoEditor.pak", - Self::Xbox => "FSD-WinGDK.pak", - } - } - pub fn hook_dll_name(&self) -> &'static str { - match self { - Self::Steam => "x3daudio1_7.dll", - Self::Xbox => "d3d9.dll", - } - } -} - -#[derive(Debug)] -pub struct DRGInstallation { - pub root: PathBuf, - pub installation_type: DRGInstallationType, -} - -impl DRGInstallation { - /// Returns first DRG installation found. Only supports Steam version - /// TODO locate Xbox version - pub fn find() -> Option { - steamlocate::SteamDir::locate() - .ok() - .and_then(|steamdir| { - steamdir - .find_app(548430) - .ok() - .flatten() - .map(|(app, library)| { - library - .resolve_app_dir(&app) - .join("FSD/Content/Paks/FSD-WindowsNoEditor.pak") - }) - }) - .and_then(|path| Self::from_pak_path(path).ok()) - } - pub fn from_pak_path>(pak: P) -> Result { - let root = pak - .as_ref() - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .context("failed to get pak parent directory")? - .to_path_buf(); - Ok(Self { - root, - installation_type: DRGInstallationType::from_pak_path(pak)?, - }) - } - pub fn binaries_directory(&self) -> PathBuf { - self.root - .join("Binaries") - .join(self.installation_type.binaries_directory_name()) - } - pub fn paks_path(&self) -> PathBuf { - self.root.join("Content").join("Paks") - } - pub fn main_pak(&self) -> PathBuf { - self.root - .join("Content") - .join("Paks") - .join(self.installation_type.main_pak_name()) - } - pub fn modio_directory(&self) -> Option { - match self.installation_type { - DRGInstallationType::Steam => { - #[cfg(target_os = "windows")] - { - Some(PathBuf::from("C:\\Users\\Public\\mod.io\\2475")) - } - #[cfg(target_os = "linux")] - { - steamlocate::SteamDir::locate() - .map(|s| { - s.path().join( - "steamapps/compatdata/548430/pfx/drive_c/users/Public/mod.io/2475", - ) - }) - .ok() - } - #[cfg(not(any(target_os = "windows", target_os = "linux")))] - { - None // TODO - } - } - DRGInstallationType::Xbox => None, - } - } -} - -pub fn setup_logging>( - log_path: P, - target: &str, -) -> Result { - use tracing::metadata::LevelFilter; - use tracing_subscriber::prelude::*; - use tracing_subscriber::{ - field::RecordFields, - filter, - fmt::{ - self, - format::{Pretty, Writer}, - FormatFields, - }, - EnvFilter, - }; - - /// Workaround for . - struct NewType(Pretty); - - impl<'writer> FormatFields<'writer> for NewType { - fn format_fields( - &self, - writer: Writer<'writer>, - fields: R, - ) -> core::fmt::Result { - self.0.format_fields(writer, fields) - } - } - - let f = fs::File::create(log_path.as_ref())?; - let writer = BufWriter::new(f); - let (log_file_appender, guard) = tracing_appender::non_blocking(writer); - let debug_file_log = fmt::layer() - .with_writer(log_file_appender) - .fmt_fields(NewType(Pretty::default())) - .with_ansi(false) - .with_filter(filter::Targets::new().with_target(target, Level::DEBUG)); - let stderr_log = fmt::layer() - .with_writer(std::io::stderr) - .event_format(tracing_subscriber::fmt::format().without_time()) - .with_span_events(FmtSpan::CLOSE) - .with_filter( - EnvFilter::builder() - .with_default_directive(LevelFilter::INFO.into()) - .from_env_lossy(), - ); - let subscriber = tracing_subscriber::registry() - .with(stderr_log) - .with(debug_file_log); - - tracing::subscriber::set_global_default(subscriber)?; - - debug!("tracing subscriber setup"); - info!("writing logs to {:?}", log_path.as_ref().display()); - - Ok(guard) -} +pub use error::MintError; +pub use installation::{DRGInstallation, DRGInstallationType}; +pub use logging::setup_logging; +pub use mod_info::*; +pub use update::{get_latest_release, GitHubRelease}; diff --git a/mint_lib/src/logging.rs b/mint_lib/src/logging.rs new file mode 100644 index 00000000..8130bbf9 --- /dev/null +++ b/mint_lib/src/logging.rs @@ -0,0 +1,74 @@ +use std::io::BufWriter; +use std::path::Path; + +use fs_err as fs; +use tracing::*; +use tracing_subscriber::fmt::format::FmtSpan; + +use crate::MintError; + +pub fn setup_logging>( + log_path: P, + target: &str, +) -> Result { + use tracing::metadata::LevelFilter; + use tracing_subscriber::prelude::*; + use tracing_subscriber::{ + field::RecordFields, + filter, + fmt::{ + self, + format::{Pretty, Writer}, + FormatFields, + }, + EnvFilter, + }; + + /// Workaround for . + struct NewType(Pretty); + + impl<'writer> FormatFields<'writer> for NewType { + fn format_fields( + &self, + writer: Writer<'writer>, + fields: R, + ) -> core::fmt::Result { + self.0.format_fields(writer, fields) + } + } + + let f = fs::File::create(log_path.as_ref()).map_err(|_| MintError::LogSetupFailed { + summary: "failed to create log file".to_string(), + details: Some(format!("log file path: `{}`", log_path.as_ref().display())), + })?; + + let writer = BufWriter::new(f); + let (log_file_appender, guard) = tracing_appender::non_blocking(writer); + let debug_file_log = fmt::layer() + .with_writer(log_file_appender) + .fmt_fields(NewType(Pretty::default())) + .with_ansi(false) + .with_filter(filter::Targets::new().with_target(target, Level::DEBUG)); + let stderr_log = fmt::layer() + .with_writer(std::io::stderr) + .event_format(tracing_subscriber::fmt::format().without_time()) + .with_span_events(FmtSpan::CLOSE) + .with_filter( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), + ); + let subscriber = tracing_subscriber::registry() + .with(stderr_log) + .with(debug_file_log); + + tracing::subscriber::set_global_default(subscriber).map_err(|e| MintError::LogSetupFailed { + summary: "failed to register global default tracing subscriber".to_string(), + details: Some(format!("{e}")), + })?; + + debug!("tracing subscriber setup"); + info!("writing logs to {:?}", log_path.as_ref().display()); + + Ok(guard) +} diff --git a/mint_lib/src/update.rs b/mint_lib/src/update.rs index d1b2f2e4..24970ea2 100644 --- a/mint_lib/src/update.rs +++ b/mint_lib/src/update.rs @@ -1,5 +1,4 @@ -use crate::error::GenericError; -use crate::error::ResultExt; +use crate::error::MintError; pub const GITHUB_RELEASE_URL: &str = "https://api.github.com/repos/trumank/mint/releases/latest"; pub const GITHUB_REQ_USER_AGENT: &str = "trumank/mint"; @@ -11,16 +10,28 @@ pub struct GitHubRelease { pub body: String, } -pub async fn get_latest_release() -> Result { - reqwest::Client::builder() +fn fail_reqwest>(e: reqwest::Error, error_summary: S) -> MintError { + MintError::FetchGithubReleaseFailed { + summary: error_summary.as_ref().to_string(), + details: Some(e.to_string()), + } +} + +pub async fn get_latest_release() -> Result { + let client = reqwest::Client::builder() .user_agent(GITHUB_REQ_USER_AGENT) .build() - .generic("failed to construct reqwest client".to_string())? - .get(GITHUB_RELEASE_URL) - .send() - .await - .generic("check self update request failed".to_string())? + .map_err(|e| fail_reqwest(e, "failed to construct reqwest client"))?; + + let response = + client.get(GITHUB_RELEASE_URL).send().await.map_err(|e| { + fail_reqwest(e, "failed to receive response from `{GITHUB_RELEASE_URL}`") + })?; + + let rel_info = response .json::() .await - .generic("check self update response is error".to_string()) + .map_err(|e| fail_reqwest(e, "failed to deserialize github release info"))?; + + Ok(rel_info) } diff --git a/src/gui/message.rs b/src/gui/message.rs index 78ab7f74..78a3f670 100644 --- a/src/gui/message.rs +++ b/src/gui/message.rs @@ -3,7 +3,6 @@ use std::ops::DerefMut; use std::time::SystemTime; use std::{collections::HashMap, sync::Arc}; -use snafu::prelude::*; use tokio::{ sync::mpsc::{self, Sender}, task::JoinHandle, @@ -24,9 +23,9 @@ use crate::{ providers::{FetchProgress, ModInfo, ModStore}, state::ModConfig, }; -use mint_lib::error::GenericError; use mint_lib::mod_info::MetaConfig; use mint_lib::update::GitHubRelease; +use mint_lib::MintError; #[derive(Debug)] pub struct MessageHandle { @@ -215,8 +214,8 @@ impl Integrate { app.last_action = Some(LastAction::success("integration complete".to_string())); } Err(ref e) - if let IntegrationError::ProviderError { ref source } = e - && let ProviderError::NoProvider { url: _, factory } = source => + if let IntegrationError::ProviderError(src) = e + && let ProviderError::NoProvider { url: _, factory } = src => { app.window_provider_parameters = Some(WindowProviderParameters::new(factory, &app.state)); @@ -303,7 +302,7 @@ impl UpdateCache { #[derive(Debug)] pub struct CheckUpdates { rid: RequestID, - result: Result, + result: Result, } impl CheckUpdates { @@ -405,7 +404,8 @@ async fn integrate_async( to_integrate.into_iter().zip(paths).collect(), ) }) - .await??; + .await + .unwrap()?; Ok(()) } @@ -474,8 +474,8 @@ impl LintMods { Some(LastAction::success("lint mod report complete".to_string())); } Err(ref e) - if let IntegrationError::ProviderError { ref source } = e - && let ProviderError::NoProvider { url: _, factory } = source => + if let IntegrationError::ProviderError(src) = e + && let ProviderError::NoProvider { factory, .. } = src => { app.window_provider_parameters = Some(WindowProviderParameters::new(factory, &app.state)); @@ -640,33 +640,40 @@ async fn self_update_async( )) .send() .await - .map_err(Into::into) - .with_context(|_| SelfUpdateFailedSnafu)? + .map_err(IntegrationError::SelfUpdateFailed)? .error_for_status() - .map_err(Into::into) - .with_context(|_| SelfUpdateFailedSnafu)?; + .map_err(IntegrationError::SelfUpdateFailed)?; let size = response.content_length(); debug!(?response); debug!(?size); let tmp_dir = tempfile::Builder::new() .prefix("self_update") - .tempdir_in(std::env::current_dir()?)?; + .tempdir_in(std::env::current_dir().unwrap()) + .expect("failed to create tempdir"); let tmp_archive_path = tmp_dir.path().join(asset_name); let mut tmp_archive = tokio::fs::File::create(&tmp_archive_path) .await - .map_err(Into::into) - .with_context(|_| SelfUpdateFailedSnafu)?; + .map_err(|e| IntegrationError::IoError { + summary: "self update failed".to_string(), + details: format!("{e}"), + })?; let mut stream = response.bytes_stream(); let mut total_bytes_written = 0; while let Some(bytes) = stream .try_next() .await - .map_err(Into::into) - .with_context(|_| SelfUpdateFailedSnafu)? + .map_err(IntegrationError::SelfUpdateFailed)? { - let bytes_written = tmp_archive.write(&bytes).await?; + let bytes_written = + tmp_archive + .write(&bytes) + .await + .map_err(|e| IntegrationError::IoError { + summary: "self update failed".to_string(), + details: format!("{e}"), + })?; total_bytes_written += bytes_written; if let Some(size) = size { tx.send(SelfUpdateProgress::Progress { @@ -696,20 +703,24 @@ async fn self_update_async( self_update::Extract::from_source(&tmp_archive_path) .archive(self_update::ArchiveKind::Zip) .extract_file(tmp_dir.path(), bin_name) - .map_err(Into::into) - .with_context(|_| SelfUpdateFailedSnafu)?; + .map_err(|e| IntegrationError::IoError { + summary: "self update failed".to_string(), + details: format!("{e}"), + })?; info!("replacing old executable with new executable"); let tmp_file = tmp_dir.path().join("replacement_tmp"); let bin_path = tmp_dir.path().join(bin_name); - let original_exe_path = std::env::current_exe()?; + let original_exe_path = std::env::current_exe().unwrap(); self_update::Move::from_source(&bin_path) .replace_using_temp(&tmp_file) .to_dest(&original_exe_path) - .map_err(Into::into) - .with_context(|_| SelfUpdateFailedSnafu)?; + .map_err(|e| IntegrationError::IoError { + summary: "self update failed".to_string(), + details: format!("{e}"), + })?; #[cfg(target_os = "linux")] { @@ -721,11 +732,12 @@ async fn self_update_async( Ok(original_exe_path) }) - .await??; + .await + .unwrap()?; tx.send(SelfUpdateProgress::Complete).await.unwrap(); - info!("update successful"); + info!("self-update successful"); Ok(original_exe_path) } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index d259b09c..ab2493f4 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -25,7 +25,6 @@ use eframe::{ }; use egui_commonmark::{CommonMarkCache, CommonMarkViewer}; use itertools::Itertools as _; -use mint_lib::error::ResultExt as _; use mint_lib::mod_info::{ModioTags, RequiredStatus}; use mint_lib::update::GitHubRelease; use strum::{EnumIter, IntoEnumIterator}; @@ -47,14 +46,14 @@ use crate::{ ApprovalStatus, FetchProgress, ModInfo, ModSpecification, ModStore, ProviderFactory, }, state::{ModConfig, ModData_v0_1_0 as ModData, ModOrGroup, ModProfile, State}, - MintError, + AppError, }; use message::MessageHandle; use request_counter::{RequestCounter, RequestID}; use self::toggle_switch::toggle_switch; -pub fn gui(dirs: Dirs, args: Option>) -> Result<(), MintError> { +pub fn gui(dirs: Dirs, args: Option>) -> Result<(), AppError> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([900.0, 500.0]) @@ -66,7 +65,7 @@ pub fn gui(dirs: Dirs, args: Option>) -> Result<(), MintError> { options, Box::new(|cc| Box::new(App::new(cc, dirs, args).unwrap())), ) - .with_generic(|e| format!("{e}"))?; + .map_err(|e| AppError::GenericError(format!("{e}")))?; Ok(()) } @@ -209,7 +208,7 @@ impl App { cc: &eframe::CreationContext, dirs: Dirs, args: Option>, - ) -> Result { + ) -> Result { let (tx, rx) = mpsc::channel(10); let state = State::init(dirs)?; diff --git a/src/integrate.rs b/src/integrate.rs index 25a745fc..642210be 100644 --- a/src/integrate.rs +++ b/src/integrate.rs @@ -3,11 +3,13 @@ use std::io::{BufReader, BufWriter, Cursor, ErrorKind, Read, Seek, Write}; use std::path::{Path, PathBuf}; use fs_err as fs; - -use repak::PakWriter; use serde::Deserialize; -use snafu::{prelude::*, Whatever}; +use thiserror::Error; use tracing::info; + +use mint_lib::mod_info::{ApprovalStatus, Meta, MetaConfig, MetaMod, SemverVersion}; +use mint_lib::{DRGInstallation, MintError}; +use repak::PakWriter; use uasset_utils::asset_registry::{AssetRegistry, Readable as _, Writable as _}; use uasset_utils::paths::{PakPath, PakPathBuf, PakPathComponentTrait}; use uasset_utils::splice::{ @@ -15,12 +17,6 @@ use uasset_utils::splice::{ }; use unreal_asset::engine_version::EngineVersion; use unreal_asset::AssetBuilder; - -use crate::mod_lints::LintError; -use crate::providers::{ModInfo, ProviderError, ReadSeek}; -use mint_lib::mod_info::{ApprovalStatus, Meta, MetaConfig, MetaMod, SemverVersion}; -use mint_lib::DRGInstallation; - use unreal_asset::{ exports::ExportBaseTrait, flags::EObjectFlags, @@ -34,67 +30,174 @@ use unreal_asset::{ Asset, }; -/// Why does the uninstall function require a list of Modio mod IDs? -/// Glad you ask. The official integration enables *every mod the user has installed* once it gets -/// re-enabled. We do the user a favor and collect all the installed mods and explicitly add them -/// back to the config so they will be disabled when the game is launched again. Since we have -/// Modio IDs anyway, with just a little more effort we can make the 'uninstall' button work as an -/// 'install' button for the official integration. Best anti-feature ever. -#[tracing::instrument(level = "debug", skip(path_pak))] -pub fn uninstall>(path_pak: P, modio_mods: HashSet) -> Result<(), Whatever> { - let installation = DRGInstallation::from_pak_path(path_pak) - .whatever_context("failed to get DRG installation")?; - let path_mods_pak = installation.paks_path().join("mods_P.pak"); - match fs::remove_file(&path_mods_pak) { - Ok(()) => Ok(()), - Err(e) if e.kind() == ErrorKind::NotFound => Ok(()), - Err(e) => Err(e), +use crate::mod_lints::LintError; +use crate::providers::{ModInfo, ProviderError, ReadSeek}; + +#[derive(Debug, Error)] +pub enum IntegrationError { + #[error("failed to uninstall: {summary}")] + UninstallFailed { summary: String, details: String }, + #[error("failed to determine game installation: {summary}")] + UnknownGameInstallation { summary: String, details: String }, + #[error("encountered I/O error: {summary}")] + IoError { summary: String, details: String }, + #[error("unable to process pak: {summary}")] + InvalidPak { + summary: String, + details: String, + path: Option, + }, + #[error("failed to build asset: {cause}")] + AssetBuildFailure { + cause: unreal_asset::Error, + mod_info: Option, + mod_asset_path: Option, + }, + #[error("failed to process `AssetRegister.bin`: {summary}")] + AssetRegistryFailure { + summary: String, + details: String, + mod_info: Option, + }, + #[error("failed to read asset `{asset_path}`: {summary}")] + AssetReadFailure { + summary: String, + asset_path: String, + details: String, + }, + #[error("failed to write to mod bundle: {summary}")] + WriteModBundleFailed { summary: String, details: String }, + #[error("failed to read mod file \"{}\" @ `{path}`: {summary}", mod_info.name)] + ModReadFailure { + summary: String, + details: String, + path: String, + mod_info: ModInfo, + }, + #[error("failed to read mod \"{}\" asset \"{mod_asset_path}\": {summary}", mod_info.name)] + ModAssetReadFailure { + summary: String, + details: String, + mod_info: ModInfo, + mod_asset_path: String, + }, + #[error("{0}")] + ProviderError(#[from] ProviderError), + #[error("invalid file within zip archive: {0}")] + InvalidZipFile(zip::result::ZipError), + #[error("error encountered while linting: {0}")] + LintError(#[from] LintError), + #[error("error encountered while trying to self-update: {0}")] + SelfUpdateFailed(reqwest::Error), +} + +impl IntegrationError { + pub(crate) fn opt_mod_id(&self) -> Option { + match self { + IntegrationError::AssetBuildFailure { + mod_info: Some(mod_info), + .. + } + | IntegrationError::AssetRegistryFailure { + mod_info: Some(mod_info), + .. + } + | IntegrationError::ModReadFailure { mod_info, .. } + | IntegrationError::ModAssetReadFailure { mod_info, .. } => mod_info.modio_id, + IntegrationError::ProviderError(p) => p.opt_mod_id(), + _ => None, + } } - .with_whatever_context(|_| format!("failed to remove {}", path_mods_pak.display()))?; - #[cfg(feature = "hook")] +} + +fn no_install_info, S: AsRef>( + path: P, + e: MintError, + msg: S, +) -> IntegrationError { + IntegrationError::UninstallFailed { + summary: msg.as_ref().to_string(), + details: format!("pak path: `{}`, caused by: {e}", path.as_ref().display()), + } +} + +fn try_remove_file, S: AsRef>(path: P, msg: S) -> Result<(), IntegrationError> { + if let Err(e) = fs::remove_file(&path) + && e.kind() != ErrorKind::NotFound { - let path_hook_dll = installation + Err(IntegrationError::UninstallFailed { + summary: msg.as_ref().to_string(), + details: format!("path: `{}`, cause: {e}", path.as_ref().display()), + }) + } else { + Ok(()) + } +} + +/// Why does the uninstall function require a list of Modio mod IDs? Glad you ask. The official +/// integration enables *every mod the user has installed* once it gets re-enabled. We do the user a +/// favor and collect all the installed mods and explicitly add them back to the config so they will +/// be disabled when the game is launched again. Since we have Modio IDs anyway, with just a little +/// more effort we can make the 'uninstall' button work as an 'install' button for the official +/// integration. Best anti-feature ever. +#[tracing::instrument(level = "debug", skip(game_pak_path))] +pub fn uninstall>( + game_pak_path: P, + modio_mods: HashSet, +) -> Result<(), IntegrationError> { + let game_pak_path = game_pak_path.as_ref(); + let installation = DRGInstallation::from_pak_path(game_pak_path) + .map_err(|e| no_install_info(game_pak_path, e, "failed to determine game installation"))?; + + let mods_pak_path = installation.paks_path().join("mods_P.pak"); + try_remove_file(&mods_pak_path, "failed to remove generated mod pak")?; + + if cfg!(feature = "hook") { + let hook_dll_path = installation .binaries_directory() .join(installation.installation_type.hook_dll_name()); - match fs::remove_file(&path_hook_dll) { - Ok(()) => Ok(()), - Err(e) if e.kind() == ErrorKind::NotFound => Ok(()), - Err(e) => Err(e), - } - .with_whatever_context(|_| format!("failed to remove {}", path_hook_dll.display()))?; + try_remove_file(&hook_dll_path, "failed to remove dll hook")?; } - uninstall_modio(&installation, modio_mods).ok(); + + try_uninstall_modio(&installation, modio_mods); Ok(()) } +/// Try remove mod.io data, but it's okay if we fail to remove it. #[tracing::instrument(level = "debug")] -fn uninstall_modio( - installation: &DRGInstallation, - modio_mods: HashSet, -) -> Result<(), Whatever> { +fn try_uninstall_modio(installation: &DRGInstallation, modio_mods: HashSet) { #[derive(Debug, Deserialize)] struct ModioState { #[serde(rename = "Mods")] mods: Vec, } + #[derive(Debug, Deserialize)] struct ModioMod { #[serde(rename = "ID")] id: u32, } + let Some(modio_dir) = installation.modio_directory() else { - return Ok(()); + return; + }; + + let Ok(state) = fs::File::open(modio_dir.join("metadata/state.json")) else { + return; + }; + + let Ok(modio_state): Result = + serde_json::from_reader(std::io::BufReader::new(state)) + else { + return; }; - let modio_state: ModioState = serde_json::from_reader(std::io::BufReader::new( - fs::File::open(modio_dir.join("metadata/state.json")) - .whatever_context("failed to read mod.io metadata/state.json")?, - )) - .whatever_context("failed to parse mod.io metadata/state.json")?; + let config_path = installation .root .join("Saved/Config/WindowsNoEditor/GameUserSettings.ini"); - let mut config = ini::Ini::load_from_file(&config_path) - .whatever_context("failed to load GameUserSettings.ini")?; + let Ok(mut config) = ini::Ini::load_from_file(&config_path) else { + return; + }; let ignore_keys = HashSet::from(["CurrentModioUserId"]); @@ -102,17 +205,30 @@ fn uninstall_modio( .entry(Some("/Script/FSD.UserGeneratedContent".to_string())) .or_insert_with(Default::default); if let Some(ugc_section) = config.section_mut(Some("/Script/FSD.UserGeneratedContent")) { - let local_mods = installation - .root - .join("Mods") - .read_dir() - .whatever_context("failed to read game Mods directory")? - .map(|f| { - let f = f.whatever_context("failed to read game Mods subdirectory")?; - Ok((!f.path().is_file()) - .then_some(f.file_name().to_string_lossy().to_string().to_string())) - }) - .collect::>, Whatever>>()?; + let local_mods_dir = installation.root.join("Mods"); + + let mut local_mods = vec![]; + let Ok(dir_entries) = local_mods_dir.read_dir() else { + return; + }; + for entry in dir_entries { + let Ok(entry) = entry else { + return; + }; + + if entry.path().is_dir() { + let Some(file_name) = entry + .path() + .file_name() + .map(|name| name.to_string_lossy().to_string()) + else { + return; + }; + + local_mods.push(file_name); + } + } + let to_remove = HashSet::from_iter(ugc_section.iter().map(|(k, _)| k)) .difference(&ignore_keys) .map(|&k| k.to_owned()) @@ -130,129 +246,181 @@ fn uninstall_modio( }, ); } - for m in local_mods.into_iter().flatten() { + + for m in local_mods { ugc_section.insert(m, "False"); } ugc_section.insert("CheckGameversion", "False"); } - config - .write_to_file_opt( - config_path, - ini::WriteOption { - line_separator: ini::LineSeparator::CRLF, - ..Default::default() - }, - ) - .whatever_context("failed to write to GameUserSettings.ini")?; - Ok(()) + let _ = config.write_to_file_opt( + config_path, + ini::WriteOption { + line_separator: ini::LineSeparator::CRLF, + ..Default::default() + }, + ); } static INTEGRATION_DIR: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets/integration"); -#[derive(Debug, Snafu)] -#[snafu(visibility(pub(crate)))] -pub enum IntegrationError { - #[snafu(display("unable to determine DRG installation at provided path {}", path.display()))] - DrgInstallationNotFound { path: PathBuf }, - #[snafu(transparent)] - IoError { source: std::io::Error }, - #[snafu(transparent)] - RepakError { source: repak::Error }, - #[snafu(transparent)] - UnrealAssetError { source: unreal_asset::Error }, - #[snafu(display("mod {:?}: I/O error encountered during its processing", mod_info.name))] - CtxtIoError { - source: std::io::Error, - mod_info: ModInfo, - }, - #[snafu(display("mod {:?}: repak error encountered during its processing", mod_info.name))] - CtxtRepakError { - source: repak::Error, - mod_info: ModInfo, - }, - #[snafu(display( - "mod {:?}: modfile {} contains unexpected prefix", - mod_info.name, - modfile_path - ))] - ModfileInvalidPrefix { - mod_info: ModInfo, - modfile_path: String, - }, - #[snafu(display( - "mod {:?}: failed to integrate: {source}", - mod_info.name, - ))] - CtxtGenericError { - source: Box, - mod_info: ModInfo, - }, - #[snafu(transparent)] - ProviderError { source: ProviderError }, - #[snafu(display("integration error: {msg}"))] - GenericError { msg: String }, - #[snafu(transparent)] - JoinError { source: tokio::task::JoinError }, - #[snafu(transparent)] - LintError { source: LintError }, - #[snafu(display("self update failed: {source:?}"))] - SelfUpdateFailed { - source: Box, - }, +fn io_error, P: AsRef>( + msg: S, + e: std::io::Error, + path: Option

, +) -> IntegrationError { + IntegrationError::IoError { + summary: msg.as_ref().to_string(), + details: if let Some(path) = path { + format!("path: `{}`, cause: {e}", path.as_ref().display()) + } else { + format!("{e}") + }, + } } -impl IntegrationError { - pub fn opt_mod_id(&self) -> Option { - match self { - IntegrationError::CtxtIoError { mod_info, .. } - | IntegrationError::CtxtRepakError { mod_info, .. } - | IntegrationError::CtxtGenericError { mod_info, .. } - | IntegrationError::ModfileInvalidPrefix { mod_info, .. } => mod_info.modio_id, - IntegrationError::ProviderError { source } => source.opt_mod_id(), - _ => None, - } +fn repak_error, P: AsRef>( + msg: S, + e: repak::Error, + path: Option

, +) -> IntegrationError { + IntegrationError::InvalidPak { + summary: msg.as_ref().to_string(), + details: format!("{e}"), + path: path.map(|p| p.as_ref().to_path_buf()), + } +} + +fn unreal_asset_error, P: AsRef>( + msg: S, + e: unreal_asset::Error, + path: Option

, +) -> IntegrationError { + IntegrationError::InvalidPak { + summary: msg.as_ref().to_string(), + details: format!("{e}"), + path: path.map(|p| p.as_ref().to_path_buf()), + } +} + +fn fail_asset_registry, T: AsRef>( + summary: S, + details: T, + mod_info: Option, +) -> IntegrationError { + IntegrationError::AssetRegistryFailure { + summary: summary.as_ref().to_string(), + details: details.as_ref().to_string(), + mod_info, + } +} + +fn read_asset_failed, P: AsRef>( + msg: S, + e: repak::Error, + path: Option

, +) -> IntegrationError { + IntegrationError::InvalidPak { + summary: msg.as_ref().to_string(), + details: format!("{e}"), + path: path.map(|p| p.as_ref().to_path_buf()), + } +} + +fn fail_read_mod, T: AsRef, P: AsRef>( + summary: S, + details: T, + mod_info: ModInfo, + path: P, +) -> IntegrationError { + IntegrationError::ModReadFailure { + summary: summary.as_ref().to_string(), + details: details.as_ref().to_string(), + path: path.as_ref().to_string_lossy().to_string(), + mod_info, + } +} + +fn fail_read_mod_asset, T: AsRef, P: AsRef>( + summary: S, + details: T, + mod_info: ModInfo, + mod_asset_path: P, +) -> IntegrationError { + IntegrationError::ModAssetReadFailure { + summary: summary.as_ref().to_string(), + details: details.as_ref().to_string(), + mod_asset_path: mod_asset_path.as_ref().to_string(), + mod_info, } } #[tracing::instrument(skip_all)] pub fn integrate>( - path_pak: P, + game_pak_path: P, config: MetaConfig, mods: Vec<(ModInfo, PathBuf)>, ) -> Result<(), IntegrationError> { - let Ok(installation) = DRGInstallation::from_pak_path(&path_pak) else { - return Err(IntegrationError::DrgInstallationNotFound { - path: path_pak.as_ref().to_path_buf(), + let game_pak_path = game_pak_path.as_ref(); + let Ok(installation) = DRGInstallation::from_pak_path(game_pak_path) else { + return Err(IntegrationError::UnknownGameInstallation { + summary: "failed to identify game installation".to_string(), + details: format!("search based on `{}`", game_pak_path.display()), }); }; - let path_mod_pak = installation.paks_path().join("mods_P.pak"); + let mod_pak_path = installation.paks_path().join("mods_P.pak"); + + let game_pak_file = fs::File::open(game_pak_path) + .map_err(|e| io_error("failed to open game pak file", e, Some(&game_pak_path)))?; - let mut fsd_pak_reader = BufReader::new(fs::File::open(path_pak.as_ref())?); - let fsd_pak = repak::PakBuilder::new().reader(&mut fsd_pak_reader)?; + let mut fsd_pak_reader = BufReader::new(game_pak_file); + + let fsd_pak = repak::PakBuilder::new() + .reader(&mut fsd_pak_reader) + .map_err(|e| { + repak_error( + "failed to process game pak, possibly invalid", + e, + Some(&game_pak_path), + ) + })?; #[derive(Debug, Default)] - struct RawAsset { + struct RawAsset<'path, 'mod_info> { + path: Option<&'path str>, + mod_info: Option<&'mod_info ModInfo>, uasset: Option>, uexp: Option>, } - impl RawAsset { + impl RawAsset<'_, '_> { fn parse(&self) -> Result>>, IntegrationError> { - Ok(AssetBuilder::new( + let asset = AssetBuilder::new( Cursor::new(self.uasset.as_ref().unwrap()), EngineVersion::VER_UE4_27, ) .bulk(Cursor::new(self.uexp.as_ref().unwrap())) - .build()?) + .build() + .map_err(|e| IntegrationError::AssetBuildFailure { + cause: e, + mod_info: self.mod_info.cloned(), + mod_asset_path: self.path.map(|p| p.to_string()), + })?; + + Ok(asset) } } let ar_path = "FSD/AssetRegistry.bin"; + + let raw_asset_registry = fsd_pak + .get(ar_path, &mut fsd_pak_reader) + .map_err(|e| fail_asset_registry("failed to read asset registry", format!("{e}"), None))?; let mut asset_registry = - AssetRegistry::read(&mut Cursor::new(fsd_pak.get(ar_path, &mut fsd_pak_reader)?)) - .map_err(|e| IntegrationError::GenericError { msg: e.to_string() })?; + AssetRegistry::read(&mut Cursor::new(raw_asset_registry)).map_err(|e| { + fail_asset_registry("failed to deserialize asset registry", format!("{e}"), None) + })?; let mut other_deferred = vec![]; let mut deferred = |path| { @@ -284,41 +452,61 @@ pub fn integrate>( // collect assets from game pak file for (path, asset) in &mut deferred_assets { // TODO repak should return an option... - asset.uasset = match fsd_pak.get(&format!("{path}.uasset"), &mut fsd_pak_reader) { - Ok(file) => Ok(Some(file)), - Err(repak::Error::MissingEntry(_)) => Ok(None), - Err(e) => Err(e), - }?; - asset.uexp = match fsd_pak.get(&format!("{path}.uexp"), &mut fsd_pak_reader) { - Ok(file) => Ok(Some(file)), - Err(repak::Error::MissingEntry(_)) => Ok(None), - Err(e) => Err(e), - }?; + let uasset_path = format!("{path}.uasset"); + asset.uasset = match fsd_pak.get(&uasset_path, &mut fsd_pak_reader) { + Ok(file) => Some(file), + Err(repak::Error::MissingEntry(_)) => None, + Err(e) => { + return Err(read_asset_failed( + "failed to read uasset", + e, + Some(&uasset_path), + )); + } + }; + + let uexp_path = format!("{path}.uexp"); + asset.uexp = match fsd_pak.get(&uexp_path, &mut fsd_pak_reader) { + Ok(file) => Some(file), + Err(repak::Error::MissingEntry(_)) => None, + Err(e) => { + return Err(read_asset_failed( + "failed to read uexp", + e, + Some(&uexp_path), + )) + } + }; } - let mut bundle = ModBundleWriter::new( - BufWriter::new( - fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&path_mod_pak)?, - ), - &fsd_pak.files(), - )?; + let mod_bundle_file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&mod_pak_path) + .map_err(|e| { + io_error( + "failed to open mod bundle file in r+w truncate mode", + e, + Some(&mod_pak_path), + ) + })?; + + let mut bundle = ModBundleWriter::new(BufWriter::new(mod_bundle_file), &fsd_pak.files())?; #[cfg(feature = "hook")] { - let path_hook_dll = installation + let hook_dll_path = installation .binaries_directory() .join(installation.installation_type.hook_dll_name()); let hook_dll = include_bytes!(env!("CARGO_CDYLIB_FILE_HOOK_hook")); - if path_hook_dll + if hook_dll_path .metadata() .map(|m| m.len() != hook_dll.len() as u64) .unwrap_or(true) { - fs::write(&path_hook_dll, hook_dll)?; + fs::write(&hook_dll_path, hook_dll) + .map_err(|e| io_error("failed to write hook dll", e, Some(&hook_dll_path)))?; } } @@ -328,24 +516,32 @@ pub fn integrate>( let mut added_paths = HashSet::new(); for (mod_info, path) in &mods { - let raw_mod_file = fs::File::open(path).with_context(|_| CtxtIoSnafu { - mod_info: mod_info.clone(), - })?; - let mut buf = get_pak_from_data(Box::new(BufReader::new(raw_mod_file))).map_err(|e| { - if let IntegrationError::IoError { source } = e { - IntegrationError::CtxtIoError { - source, - mod_info: mod_info.clone(), - } - } else { - e - } + let raw_mod_file = fs::File::open(path).map_err(|e| { + fail_read_mod( + "could not open mod blob", + format!("{e}"), + mod_info.clone(), + path, + ) })?; - let pak = repak::PakBuilder::new() - .reader(&mut buf) - .with_context(|_| CtxtRepakSnafu { - mod_info: mod_info.clone(), + + let mut buf = + extract_pak_from_blob(Box::new(BufReader::new(raw_mod_file)), path).map_err(|e| { + fail_read_mod( + "could not obtain mod file from raw mod blob", + format!("{e}"), + mod_info.clone(), + path, + ) })?; + let pak = repak::PakBuilder::new().reader(&mut buf).map_err(|e| { + fail_read_mod( + "could not interpret mod file as valid UE 4.27 mod pak", + format!("{e}"), + mod_info.clone(), + path, + ) + })?; let mount = PakPath::new(pak.mount_point()); @@ -356,9 +552,13 @@ pub fn integrate>( let j = mount.join(&p); Ok(( j.strip_prefix("../../../") - .map_err(|_| IntegrationError::ModfileInvalidPrefix { - mod_info: mod_info.clone(), - modfile_path: j.to_string(), + .map_err(|e| { + fail_read_mod_asset( + "could not strip prefix", + format!("{e}"), + mod_info.clone(), + &p, + ) })? .to_path_buf(), p, @@ -366,42 +566,57 @@ pub fn integrate>( }) .collect::, _>>()?; - for (normalized, pak_path) in &pak_files { + for (normalized, asset_path) in &pak_files { match normalized.extension() { Some("uasset" | "umap") if pak_files.contains_key(&normalized.with_extension("uexp")) => { - let uasset = pak - .get(pak_path, &mut buf) - .with_context(|_| CtxtRepakSnafu { - mod_info: mod_info.clone(), - })?; + let uasset = pak.get(asset_path, &mut buf).map_err(|e| { + fail_read_mod_asset( + "failed to read uasset file", + format!("{e}"), + mod_info.clone(), + asset_path, + ) + })?; let uexp = pak .get( - PakPath::new(pak_path).with_extension("uexp").as_str(), + PakPath::new(asset_path).with_extension("uexp").as_str(), &mut buf, ) - .with_context(|_| CtxtRepakSnafu { - mod_info: mod_info.clone(), + .map_err(|e| { + fail_read_mod_asset( + "failed to read uexp file", + format!("{e}"), + mod_info.clone(), + asset_path, + ) })?; let asset = AssetBuilder::new(Cursor::new(uasset), EngineVersion::VER_UE4_27) .bulk(Cursor::new(uexp)) .skip_data(true) - .build()?; + .build() + .map_err(|e| IntegrationError::AssetBuildFailure { + cause: e, + mod_info: Some(mod_info.clone()), + mod_asset_path: Some(asset_path.clone()), + })?; + asset_registry .populate(normalized.with_extension("").as_str(), &asset) - .map_err(|e| IntegrationError::CtxtGenericError { - source: e.into(), - mod_info: mod_info.clone(), + .map_err(|e| IntegrationError::AssetRegistryFailure { + summary: "failed to populate asset registry with mod info".to_string(), + details: format!("{e}"), + mod_info: Some(mod_info.clone()), })?; } _ => {} } } - for (normalized, pak_path) in pak_files { + for (normalized, asset_path) in pak_files { let lowercase = normalized.as_str().to_ascii_lowercase(); if added_paths.contains(&lowercase) { continue; @@ -423,11 +638,14 @@ pub fn integrate>( } } - let file_data = pak - .get(&pak_path, &mut buf) - .with_context(|_| CtxtRepakSnafu { - mod_info: mod_info.clone(), - })?; + let file_data = pak.get(&asset_path, &mut buf).map_err(|e| { + fail_read_mod_asset( + "failed to extract asset data", + format!("{e}"), + mod_info.clone(), + &asset_path, + ) + })?; if let Some(raw) = normalized .as_str() .strip_suffix(".uasset") @@ -479,9 +697,9 @@ pub fn integrate>( bundle.write_meta(config, &mods)?; let mut buf = vec![]; - asset_registry - .write(&mut buf) - .map_err(|e| IntegrationError::GenericError { msg: e.to_string() })?; + asset_registry.write(&mut buf).map_err(|e| { + fail_asset_registry("failed to serialize asset registry", format!("{e}"), None) + })?; bundle.write_file(&buf, ar_path)?; bundle.finish()?; @@ -489,7 +707,7 @@ pub fn integrate>( info!( "{} mods installed to {}", mods.len(), - path_mod_pak.display() + mod_pak_path.display() ); Ok(()) @@ -571,7 +789,8 @@ impl ModBundleWriter { fn write_file(&mut self, data: &[u8], path: &str) -> Result<(), IntegrationError> { self.pak_writer - .write_file(self.normalize_path(path).as_str(), data)?; + .write_file(self.normalize_path(path).as_str(), data) + .map_err(|e| repak_error("failed to write file", e, Some(path)))?; Ok(()) } @@ -582,9 +801,13 @@ impl ModBundleWriter { ) -> Result<(), IntegrationError> { let mut data_out = (Cursor::new(vec![]), Cursor::new(vec![])); - asset.write_data(&mut data_out.0, Some(&mut data_out.1))?; - data_out.0.rewind()?; - data_out.1.rewind()?; + asset + .write_data(&mut data_out.0, Some(&mut data_out.1)) + .map_err(|e| unreal_asset_error("failed to write asset data", e, Some(path)))?; + + let rewind_err = |e| io_error("failed to rewind asset data", e, Some(path)); + data_out.0.rewind().map_err(rewind_err)?; + data_out.1.rewind().map_err(rewind_err)?; self.write_file(&data_out.0.into_inner(), &format!("{path}.uasset"))?; self.write_file(&data_out.1.into_inner(), &format!("{path}.uexp"))?; @@ -628,7 +851,9 @@ impl ModBundleWriter { } fn finish(self) -> Result<(), IntegrationError> { - self.pak_writer.write_index()?; + self.pak_writer + .write_index() + .map_err(|e| repak_error("failed to write pak index", e, None::<&Path>))?; Ok(()) } } @@ -639,22 +864,30 @@ struct Dir { children: HashMap, } -pub(crate) fn get_pak_from_data( +/// Try to extract a valid Unreal `.pak` from a given data blob. The data blob can be: +/// +/// 1. A zip archive containing a valid Unreal `.pak`, or +/// 2. A valid Unreal `.pak` itself. +/// +/// If a zip archive contains multiple valid `.pak`s, then the first encountered `.pak` is picked, +/// but the iteration order of `.pak`s within the zip archive is unspecified. +pub(crate) fn extract_pak_from_blob>( mut data: Box, + path: P, ) -> Result, IntegrationError> { if let Ok(mut archive) = zip::ZipArchive::new(&mut data) { (0..archive.len()) .map(|i| -> Result>, IntegrationError> { let mut file = archive .by_index(i) - .map_err(|_| IntegrationError::GenericError { - msg: "failed to extract file in zip archive".to_string(), - })?; + .map_err(IntegrationError::InvalidZipFile)?; match file.enclosed_name() { Some(p) => { if file.is_file() && p.extension() == Some(std::ffi::OsStr::new("pak")) { let mut buf = vec![]; - file.read_to_end(&mut buf)?; + let p = p.to_path_buf(); + file.read_to_end(&mut buf) + .map_err(|e| io_error("failed to read zip file", e, Some(p)))?; Ok(Some(Box::new(Cursor::new(buf)))) } else { Ok(None) @@ -664,11 +897,14 @@ pub(crate) fn get_pak_from_data( } }) .find_map(Result::transpose) - .context(GenericSnafu { - msg: "zip archive does not contain pak", + .ok_or_else(|| IntegrationError::InvalidPak { + summary: "failed to extract a valid `.pak` file from zip archive".to_string(), + details: String::new(), + path: Some(path.as_ref().to_path_buf()), })? } else { - data.rewind()?; + data.rewind() + .map_err(|e| io_error("failed to rewind data", e, Some(path)))?; Ok(data) } } diff --git a/src/lib.rs b/src/lib.rs index e5f980f8..7c94e193 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,28 +17,29 @@ use directories::ProjectDirs; use fs_err as fs; use integrate::IntegrationError; use providers::{ModResolution, ModSpecification, ProviderError, ProviderFactory}; -use snafu::prelude::*; -use state::{State, StateError}; +use thiserror::Error; use tracing::*; -#[derive(Debug, Snafu)] -pub enum MintError { - #[snafu(transparent)] - IoError { source: std::io::Error }, - #[snafu(transparent)] - RepakError { source: repak::Error }, - #[snafu(transparent)] - ProviderError { source: ProviderError }, - #[snafu(transparent)] - IntegrationError { source: IntegrationError }, - #[snafu(transparent)] - GenericError { - source: mint_lib::error::GenericError, - }, - #[snafu(transparent)] - StateError { source: StateError }, - #[snafu(display("invalid DRG pak path: {path}"))] - InvalidDrgPak { path: String }, +use state::{State, StateError}; + +#[derive(Debug, Error)] +pub enum AppError { + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error(transparent)] + RepakError(#[from] repak::Error), + #[error(transparent)] + ProviderError(#[from] ProviderError), + #[error(transparent)] + IntegrationError(#[from] IntegrationError), + #[error(transparent)] + MintError(#[from] mint_lib::MintError), + #[error(transparent)] + StateError(#[from] StateError), + #[error("invalid DRG pak path: {}", path.display())] + InvalidDrgPak { path: PathBuf }, + #[error("{0}")] + GenericError(String), } #[derive(Debug)] @@ -49,7 +50,7 @@ pub struct Dirs { } impl Dirs { - pub fn default_xdg() -> Result { + pub fn default_xdg() -> Result { let legacy_dirs = ProjectDirs::from("", "", "drg-mod-integration") .expect("failed to construct project dirs"); @@ -69,7 +70,7 @@ impl Dirs { ) } - pub fn from_path>(path: P) -> Result { + pub fn from_path>(path: P) -> Result { Self::from_paths( path.as_ref().join("config"), path.as_ref().join("cache"), @@ -81,7 +82,7 @@ impl Dirs { config_dir: P, cache_dir: P, data_dir: P, - ) -> Result { + ) -> Result { fs::create_dir_all(&config_dir)?; fs::create_dir_all(&cache_dir)?; fs::create_dir_all(&data_dir)?; @@ -94,7 +95,7 @@ impl Dirs { } } -pub fn is_drg_pak>(path: P) -> Result<(), MintError> { +pub fn is_drg_pak>(path: P) -> Result<(), AppError> { let mut reader = std::io::BufReader::new(fs::File::open(path.as_ref())?); let pak = repak::PakBuilder::new().reader(&mut reader)?; pak.get("FSD/FSD.uproject", &mut reader)?; @@ -153,7 +154,7 @@ pub async fn resolve_unordered_and_integrate>( async fn resolve_into_urls<'b>( state: &State, mod_specs: &[ModSpecification], -) -> Result, MintError> { +) -> Result, AppError> { let mods = state.store.resolve_mods(mod_specs, false).await?; let mods_set = mod_specs @@ -190,7 +191,7 @@ async fn resolve_into_urls<'b>( pub async fn resolve_ordered( state: &State, mod_specs: &[ModSpecification], -) -> Result, MintError> { +) -> Result, AppError> { let urls = resolve_into_urls(state, mod_specs).await?; Ok(state .store @@ -204,17 +205,17 @@ pub async fn resolve_unordered_and_integrate_with_provider_init( mod_specs: &[ModSpecification], update: bool, init: F, -) -> Result<(), MintError> +) -> Result<(), AppError> where P: AsRef, - F: Fn(&mut State, String, &ProviderFactory) -> Result<(), MintError>, + F: Fn(&mut State, String, &ProviderFactory) -> Result<(), AppError>, { loop { match resolve_unordered_and_integrate(&game_path, state, mod_specs, update).await { Ok(()) => return Ok(()), Err(ref e) - if let IntegrationError::ProviderError { ref source } = e - && let ProviderError::NoProvider { ref url, factory } = source => + if let IntegrationError::ProviderError(src) = e + && let ProviderError::NoProvider { ref url, factory } = src => { init(state, url.clone(), factory)? } @@ -228,17 +229,17 @@ pub async fn resolve_ordered_with_provider_init( state: &mut State, mod_specs: &[ModSpecification], init: F, -) -> Result, MintError> +) -> Result, AppError> where - F: Fn(&mut State, String, &ProviderFactory) -> Result<(), MintError>, + F: Fn(&mut State, String, &ProviderFactory) -> Result<(), AppError>, { loop { match resolve_ordered(state, mod_specs).await { Ok(mod_paths) => return Ok(mod_paths), Err(ref e) - if let MintError::IntegrationError { ref source } = e - && let IntegrationError::ProviderError { ref source } = source - && let ProviderError::NoProvider { ref url, factory } = source => + if let AppError::IntegrationError(src) = e + && let IntegrationError::ProviderError(src) = src + && let ProviderError::NoProvider { ref url, factory } = src => { init(state, url.clone(), factory)? } diff --git a/src/main.rs b/src/main.rs index 479f7858..195e34ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,8 +9,8 @@ use mint::mod_lints::{run_lints, LintId}; use mint::providers::ProviderFactory; use mint::{gui::gui, providers::ModSpecification, state::State}; use mint::{ - resolve_ordered_with_provider_init, resolve_unordered_and_integrate_with_provider_init, Dirs, - MintError, + resolve_ordered_with_provider_init, resolve_unordered_and_integrate_with_provider_init, + AppError, Dirs, }; /// Command line integration tool. @@ -160,7 +160,7 @@ fn init_provider( state: &mut State, url: String, factory: &ProviderFactory, -) -> Result<(), MintError> { +) -> Result<(), AppError> { info!("initializing provider for {:?}", url); let params = state diff --git a/src/mod_lints/mod.rs b/src/mod_lints/mod.rs index 054135a9..666ad5c2 100644 --- a/src/mod_lints/mod.rs +++ b/src/mod_lints/mod.rs @@ -15,10 +15,11 @@ use std::path::{Path, PathBuf}; use fs_err as fs; use indexmap::IndexSet; -use repak::PakReader; -use snafu::prelude::*; +use thiserror::Error; use tracing::trace; +use repak::PakReader; + use self::archive_multiple_paks::ArchiveMultiplePaksLint; use self::archive_only_non_pak_files::ArchiveOnlyNonPakFilesLint; use self::asset_register_bin::AssetRegisterBinLint; @@ -32,21 +33,21 @@ use self::unmodified_game_assets::UnmodifiedGameAssetsLint; use crate::mod_lints::conflicting_mods::ConflictingModsLint; use crate::providers::{ModSpecification, ReadSeek}; -#[derive(Debug, Snafu)] +#[derive(Debug, Error)] pub enum LintError { - #[snafu(transparent)] - RepakError { source: repak::Error }, - #[snafu(transparent)] - IoError { source: std::io::Error }, - #[snafu(transparent)] - PrefixMismatch { source: std::path::StripPrefixError }, - #[snafu(display("empty archive"))] + #[error("repak error: {0}")] + RepakError(#[from] repak::Error), + #[error("io error while linting: {0}")] + IoError(#[from] std::io::Error), + #[error("failed to strip prefix: {0}")] + PrefixMismatch(#[from] std::path::StripPrefixError), + #[error("encountered empty archive")] EmptyArchive, - #[snafu(display("zip archive error"))] + #[error("failed to process zip archive")] ZipArchiveError, - #[snafu(display("zip only contains non-pak files"))] + #[error("zip only contains non-pak files")] OnlyNonPakFiles, - #[snafu(display("some lints require specifying a valid game pak path"))] + #[error("some lints require specifying a valid game pak path, but no valid game pak path was provided")] InvalidGamePath, } @@ -78,7 +79,8 @@ impl LintCtxt { MultiplePakFilesHandler: FnMut(ModSpecification), { for (mod_spec, mod_pak_path) in &self.mods { - let maybe_archive_reader = Box::new(BufReader::new(fs::File::open(mod_pak_path)?)); + let file = fs::File::open(mod_pak_path)?; + let maybe_archive_reader = Box::new(BufReader::new(file)); let bufs = match lint_get_all_files_from_data(maybe_archive_reader) { Ok(bufs) => bufs, Err(e) => match e { @@ -165,7 +167,9 @@ pub(crate) fn lint_get_all_files_from_data( mut data: Box, ) -> Result, LintError> { if let Ok(mut archive) = zip::ZipArchive::new(&mut data) { - ensure!(!archive.is_empty(), EmptyArchiveSnafu); + if archive.is_empty() { + return Err(LintError::EmptyArchive); + } let mut files = Vec::new(); for i in 0..archive.len() { @@ -199,7 +203,7 @@ pub(crate) fn lint_get_all_files_from_data( { Ok(files) } else { - OnlyNonPakFilesSnafu.fail()? + Err(LintError::OnlyNonPakFiles) } } else { data.rewind()?; diff --git a/src/mod_lints/unmodified_game_assets.rs b/src/mod_lints/unmodified_game_assets.rs index cff15b64..2b8593c7 100644 --- a/src/mod_lints/unmodified_game_assets.rs +++ b/src/mod_lints/unmodified_game_assets.rs @@ -11,7 +11,7 @@ use tracing::trace; use crate::providers::ModSpecification; -use super::{InvalidGamePathSnafu, Lint, LintCtxt, LintError}; +use super::{Lint, LintCtxt, LintError}; #[derive(Default)] pub struct UnmodifiedGameAssetsLint; @@ -21,7 +21,7 @@ impl Lint for UnmodifiedGameAssetsLint { fn check_mods(&mut self, lcx: &LintCtxt) -> Result { let Some(game_pak_path) = &lcx.fsd_pak_path else { - InvalidGamePathSnafu.fail()? + return Err(LintError::InvalidGamePath); }; // Adapted from diff --git a/src/providers/cache.rs b/src/providers/cache.rs index d7e10797..dc262a29 100644 --- a/src/providers/cache.rs +++ b/src/providers/cache.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, RwLock}; use fs_err as fs; use serde::{Deserialize, Serialize}; -use snafu::prelude::*; +use thiserror::Error; use crate::state::config::ConfigWrapper; @@ -96,22 +96,21 @@ impl Default for MaybeVersionedCache { } } -#[derive(Debug, Snafu)] +#[derive(Debug, Error)] pub enum CacheError { - #[snafu(display("failed to read cache.json with provided path {}", search_path.display()))] + #[error("failed to read cache.json with provided path {}", search_path.display())] CacheJsonReadFailed { source: std::io::Error, search_path: PathBuf, }, - #[snafu(display("failed to deserialize cache.json into dynamic JSON value: {reason}"))] + #[error("failed to deserialize cache.json into dynamic JSON value: {reason}")] DeserializeJsonFailed { - #[snafu(source(false))] source: Option, reason: &'static str, }, - #[snafu(display("failed attempt to deserialize as legacy cache format"))] + #[error("failed to deserialize as legacy cache format: {source}")] DeserializeLegacyCacheFailed { source: serde_json::Error }, - #[snafu(display("failed to deserialize as cache {version} format"))] + #[error("failed to deserialize as cache {version} format: {source}")] DeserializeVersionedCacheFailed { source: serde_json::Error, version: &'static str, @@ -151,9 +150,12 @@ pub(crate) fn read_cache_metadata_or_default( Ok(c) => { MaybeVersionedCache::Versioned(VersionAnnotatedCache::V0_0_0(c)) } - Err(e) => Err(e).context(DeserializeVersionedCacheFailedSnafu { - version: "v0.0.0", - })?, + Err(e) => { + return Err(CacheError::DeserializeVersionedCacheFailed { + source: e, + version: "v0.0.0", + }); + } } } _ => unimplemented!(), @@ -163,14 +165,17 @@ pub(crate) fn read_cache_metadata_or_default( // numeric keys in hashmaps, see . match serde_json::from_slice::>>(&buf) { Ok(c) => MaybeVersionedCache::Legacy(Cache_v0_0_0 { cache: c }), - Err(e) => Err(e).context(DeserializeLegacyCacheFailedSnafu)?, + Err(e) => return Err(CacheError::DeserializeLegacyCacheFailed { source: e }), } } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => MaybeVersionedCache::default(), - Err(e) => Err(e).context(CacheJsonReadFailedSnafu { - search_path: cache_metadata_path.to_owned(), - })?, + Err(e) => { + return Err(CacheError::CacheJsonReadFailed { + source: e, + search_path: cache_metadata_path.to_owned(), + }) + } }; let cache: VersionAnnotatedCache = match cache { @@ -186,8 +191,8 @@ pub(crate) fn read_cache_metadata_or_default( #[derive(Debug, Serialize, Deserialize)] pub struct BlobRef(String); -#[derive(Debug, Snafu)] -#[snafu(display("blob cache {kind} failed"))] +#[derive(Debug, Error)] +#[error("blob cache {kind} failed: {source}")] pub struct BlobCacheError { source: std::io::Error, kind: &'static str, @@ -214,8 +219,14 @@ impl BlobCache { let hash = hex::encode(hasher.finalize()); let tmp = self.path.join(format!(".{hash}")); - fs::write(&tmp, blob).context(BlobCacheSnafu { kind: "write" })?; - fs::rename(tmp, self.path.join(&hash)).context(BlobCacheSnafu { kind: "rename" })?; + fs::write(&tmp, blob).map_err(|e| BlobCacheError { + source: e, + kind: "write", + })?; + fs::rename(tmp, self.path.join(&hash)).map_err(|e| BlobCacheError { + source: e, + kind: "rename", + })?; Ok(BlobRef(hash)) } diff --git a/src/providers/http.rs b/src/providers/http.rs index 3d30e6f5..1e8f592c 100644 --- a/src/providers/http.rs +++ b/src/providers/http.rs @@ -135,11 +135,13 @@ impl ModProvider for HttpProvider { .get(&url.0) .send() .await - .context(RequestFailedSnafu { + .map_err(|e| ProviderError::RequestFailed { + source: e, url: url.0.to_string(), })? .error_for_status() - .context(ResponseSnafu { + .map_err(|e| ProviderError::ResponseError { + source: e, url: url.0.to_string(), })?; let size = response.content_length(); // TODO will be incorrect if compressed @@ -147,16 +149,16 @@ impl ModProvider for HttpProvider { .headers() .get(reqwest::header::HeaderName::from_static("content-type")) { - let content_type = mime.to_str().context(InvalidMimeSnafu { + let content_type = mime.to_str().map_err(|e| ProviderError::InvalidMime { + source: e, url: url.0.to_string(), })?; - ensure!( - ["application/zip", "application/octet-stream"].contains(&content_type), - UnexpectedContentTypeSnafu { + if !["application/zip", "application/octet-stream"].contains(&content_type) { + return Err(ProviderError::UnexpectedContentType { found_content_type: content_type.to_string(), url: url.0.to_string(), - } - ); + }); + } } use futures::stream::TryStreamExt; @@ -164,13 +166,20 @@ impl ModProvider for HttpProvider { let mut cursor = std::io::Cursor::new(vec![]); let mut stream = response.bytes_stream(); - while let Some(bytes) = stream.try_next().await.with_context(|_| FetchSnafu { - url: url.0.to_string(), - })? { + while let Some(bytes) = + stream + .try_next() + .await + .map_err(|e| ProviderError::FetchError { + source: e, + url: url.0.to_string(), + })? + { cursor .write_all(&bytes) .await - .with_context(|_| BufferIoSnafu { + .map_err(|e| ProviderError::BufferIoError { + source: e, url: url.0.to_string(), })?; if let Some(size) = size { diff --git a/src/providers/mod.rs b/src/providers/mod.rs index dd979fd0..272a9af6 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -5,7 +5,7 @@ pub mod modio; pub mod cache; pub mod mod_store; -use snafu::prelude::*; +use thiserror::Error; use tokio::sync::mpsc::Sender; use std::collections::HashMap; @@ -69,65 +69,66 @@ pub trait ModProvider: Send + Sync { fn get_version_name(&self, spec: &ModSpecification, cache: ProviderCache) -> Option; } -#[derive(Debug, Snafu)] +#[derive(Debug, Error)] pub enum ProviderError { - #[snafu(display("failed to initialize provider {id} with parameters {parameters:?}"))] + #[error("failed to initialize provider {id} with parameters {parameters:?}")] InitProviderFailed { id: &'static str, parameters: HashMap, }, - #[snafu(transparent)] - CacheError { source: CacheError }, - #[snafu(transparent)] - DrgModioError { source: DrgModioError }, - #[snafu(display("mod.io-related error encountered while working on mod {mod_id}: {source}"))] - ModCtxtModioError { source: ::modio::Error, mod_id: u32 }, - #[snafu(display("I/O error encountered while working on mod {mod_id}: {source}"))] - ModCtxtIoError { source: std::io::Error, mod_id: u32 }, - #[snafu(transparent)] - BlobCacheError { source: BlobCacheError }, - #[snafu(display("could not find mod provider for {url}"))] + #[error(transparent)] + CacheError(#[from] CacheError), + #[error(transparent)] + DrgModioError(#[from] DrgModioError), + #[error("mod.io-related error encountered while working on mod {mod_id}: {source}")] + ModioErrorWithModCtxt { source: ::modio::Error, mod_id: u32 }, + #[error("I/O error encountered while working on mod {mod_id}: {source}")] + IoErrorWithModCtxt { source: std::io::Error, mod_id: u32 }, + #[error(transparent)] + BlobCacheError(#[from] BlobCacheError), + #[error("could not find mod provider for <{url}>")] ProviderNotFound { url: String }, + #[error("no provider found given <{url}> and factory {}", factory.id)] NoProvider { url: String, factory: &'static ProviderFactory, }, - #[snafu(display("invalid url <{url}>"))] + #[error("invalid url <{url}>")] InvalidUrl { url: String }, - #[snafu(display("request for <{url}> failed: {source}"))] + #[error("request for <{url}> failed: {source}")] RequestFailed { source: reqwest::Error, url: String }, - #[snafu(display("response from <{url}> failed: {source}"))] + #[error("response from <{url}> failed: {source}")] ResponseError { source: reqwest::Error, url: String }, - #[snafu(display("mime from <{url}> contains non-ascii characters"))] + #[error("mime from <{url}> contains non-ascii characters")] InvalidMime { source: reqwest::header::ToStrError, url: String, }, - #[snafu(display("unexpected content type from <{url}>: {found_content_type}"))] + #[error("unexpected content type from <{url}>: {found_content_type}")] UnexpectedContentType { found_content_type: String, url: String, }, - #[snafu(display("error while fetching mod <{url}>"))] + #[error("error while fetching mod <{url}>")] FetchError { source: reqwest::Error, url: String }, - #[snafu(display("error processing <{url}> while writing to local buffer"))] + #[error("error processing <{url}> while writing to local buffer")] BufferIoError { source: std::io::Error, url: String }, - #[snafu(display("preview mod links cannot be added directly, please subscribe to the mod on mod.io and and then use the non-preview link"))] + #[error("preview mod links cannot be added directly, please subscribe to the mod on mod.io and and then use the non-preview link")] PreviewLink { url: String }, - #[snafu(display("mod <{url}> does not have an associated modfile"))] + #[error("mod <{url}> does not have an associated modfile")] NoAssociatedModfile { url: String }, - #[snafu(display("multiple mods returned for name \"{name_id}\""))] + #[error("multiple mods returned for name \"{name_id}\"")] AmbiguousModNameId { name_id: String }, - #[snafu(display("no mods returned for name \"{name_id}\""))] + #[error("no mods returned for name \"{name_id}\"")] NoModsForNameId { name_id: String }, } impl ProviderError { pub fn opt_mod_id(&self) -> Option { match self { - ProviderError::DrgModioError { source } => source.opt_mod_id(), - ProviderError::ModCtxtModioError { mod_id, .. } - | ProviderError::ModCtxtIoError { mod_id, .. } => Some(*mod_id), + ProviderError::DrgModioError(source) => source.opt_mod_id(), + ProviderError::ModioErrorWithModCtxt { mod_id, .. } + | ProviderError::IoErrorWithModCtxt { mod_id, .. } => Some(*mod_id), _ => None, } } diff --git a/src/providers/mod_store.rs b/src/providers/mod_store.rs index d5983d0a..6a68d1a5 100644 --- a/src/providers/mod_store.rs +++ b/src/providers/mod_store.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; use std::path::Path; -use snafu::prelude::*; use tracing::*; use crate::providers::*; @@ -79,18 +78,17 @@ impl ModStore { pub fn get_provider(&self, url: &str) -> Result, ProviderError> { let factory = Self::get_provider_factories() .find(|f| (f.can_provide)(url)) - .context(ProviderNotFoundSnafu { + .ok_or_else(|| ProviderError::ProviderNotFound { url: url.to_string(), })?; let lock = self.providers.read().unwrap(); - Ok(match lock.get(factory.id) { - Some(e) => e.clone(), - None => NoProviderSnafu { + match lock.get(factory.id) { + Some(e) => Ok(e.clone()), + None => Err(ProviderError::NoProvider { url: url.to_string(), factory, - } - .fail()?, - }) + }), + } } pub async fn resolve_mods( diff --git a/src/providers/modio.rs b/src/providers/modio.rs index 3bea1cd6..fd0374ef 100644 --- a/src/providers/modio.rs +++ b/src/providers/modio.rs @@ -182,45 +182,57 @@ impl Middleware for LoggingMiddleware { } } -#[derive(Debug, Snafu)] +#[derive(Debug, Error)] pub enum DrgModioError { - #[snafu(display("missing OAuth token"))] + #[error("missing OAuth token")] MissingOauthToken, - #[snafu(display("mod.io error: {source}"))] - GenericModioError { source: modio::Error }, - #[snafu(display("failed to perform basic mod.io probe: {source}"))] - CheckFailed { source: modio::Error }, - #[snafu(display("failed to fetch mod files for <{url}> (mod_id = {mod_id}): {source}"))] + #[error("mod.io error: {0}")] + GenericModioError(modio::Error), + #[error("failed to perform basic mod.io probe: {0}")] + CheckFailed(modio::Error), + #[error("failed to fetch mod files for <{url}> (mod_id = {mod_id}): {source}")] FetchModFilesFailed { source: modio::Error, url: String, mod_id: u32, }, - #[snafu(display( - "failed to fetch mod file {modfile_id} for <{url}> (mod_id = {mod_id}): {source}" - ))] + #[error("failed to fetch mod file {modfile_id} for <{url}> (mod_id = {mod_id}): {source}")] FetchModFileFailed { source: modio::Error, url: String, mod_id: u32, modfile_id: u32, }, - #[snafu(display("failed to fetch mod <{url}> (mod_id = {mod_id}): {source}"))] + #[error("failed to fetch mod <{url}> (mod_id = {mod_id}): {source}")] FetchModFailed { source: modio::Error, url: String, mod_id: u32, }, - #[snafu(display( - "failed to fetch dependencies for mod <{url}> (mod_id = {mod_id}): {source}" - ))] + #[error("failed to fetch dependencies for mod <{url}> (mod_id = {mod_id}): {source}")] FetchDependenciesFailed { source: modio::Error, url: String, mod_id: u32, }, - #[snafu(display("encountered mod.io-related error: {msg}"))] - GenericError { msg: &'static str }, + #[error("failed to fetch mod by name with filter `{filter}`: {source}")] + NoByNameMatch { + source: modio::Error, + filter: String, + }, + #[error("failed to fetch mods with ids `{mod_ids:?}`: {source}")] + FetchModsByIdsFailed { + source: modio::Error, + mod_ids: Vec, + }, + #[error("failed to fetch mod updates for `{mod_ids:?}` since {last_update}: {source}")] + FetchModUpdatesFailed { + source: modio::Error, + mod_ids: Vec, + last_update: u64, + }, + #[error("encountered mod.io-related error: {0}")] + GenericError(&'static str), } impl DrgModioError { @@ -279,11 +291,13 @@ impl DrgModio for modio::Modio { let modio = modio::Modio::new( modio::Credentials::with_token( "".to_owned(), // TODO patch modio to not use API key at all - parameters.get("oauth").context(MissingOauthTokenSnafu)?, + parameters + .get("oauth") + .ok_or_else(|| DrgModioError::MissingOauthToken)?, ), client, ) - .context(GenericModioSnafu)?; + .map_err(DrgModioError::GenericModioError)?; Ok(modio) } @@ -297,7 +311,7 @@ impl DrgModio for modio::Modio { .search(Id::eq(0)) .collect() .await - .context(CheckFailedSnafu)?; + .map_err(DrgModioError::CheckFailed)?; Ok(()) } @@ -312,16 +326,18 @@ impl DrgModio for modio::Modio { .search(Id::ne(0)) .collect() .await - .with_context(|_| FetchModFilesFailedSnafu { + .map_err(|e| DrgModioError::FetchModFilesFailed { mod_id: id, url: url.clone(), + source: e, })?; - let r#mod = self - .game(MODIO_DRG_ID) - .mod_(id) - .get() - .await - .context(FetchModFailedSnafu { mod_id: id, url })?; + let r#mod = self.game(MODIO_DRG_ID).mod_(id).get().await.map_err(|e| { + DrgModioError::FetchModFailed { + mod_id: id, + url, + source: e, + } + })?; Ok(ModioMod::new(r#mod, files)) } @@ -337,16 +353,21 @@ impl DrgModio for modio::Modio { .search(Id::ne(0)) .collect() .await - .with_context(|_| FetchModFilesFailedSnafu { + .map_err(|e| DrgModioError::FetchModFilesFailed { mod_id, url: url.clone(), + source: e, })?; let r#mod = self .game(MODIO_DRG_ID) .mod_(mod_id) .get() .await - .context(FetchModFailedSnafu { mod_id, url })?; + .map_err(|e| DrgModioError::FetchModFailed { + mod_id, + url, + source: e, + })?; Ok(ModioMod::new(r#mod, files)) } @@ -363,10 +384,11 @@ impl DrgModio for modio::Modio { .file(modfile_id) .get() .await - .with_context(|_| FetchModFileFailedSnafu { - url, + .map_err(|e| DrgModioError::FetchModFileFailed { mod_id, modfile_id, + url, + source: e, })?; Ok(file) } @@ -382,7 +404,11 @@ impl DrgModio for modio::Modio { .dependencies() .list() .await - .with_context(|_| FetchDependenciesFailedSnafu { url, mod_id })? + .map_err(|e| DrgModioError::FetchDependenciesFailed { + source: e, + url, + mod_id, + })? .into_iter() .map(|d| d.mod_id) .collect::>()) @@ -402,7 +428,10 @@ impl DrgModio for modio::Modio { .search(filter) .collect() .await - .context(GenericModioSnafu)? + .map_err(|e| DrgModioError::NoByNameMatch { + source: e, + filter: name_id.to_string(), + })? .into_iter() .map(|m| m.into()) .collect()) @@ -415,7 +444,7 @@ impl DrgModio for modio::Modio { use modio::filter::In; use modio::mods::filters::Id; - let filter = Id::_in(filter_ids); + let filter = Id::_in(filter_ids.clone()); Ok(self .game(MODIO_DRG_ID) @@ -423,7 +452,10 @@ impl DrgModio for modio::Modio { .search(filter) .collect() .await - .context(GenericModioSnafu)?) + .map_err(|e| DrgModioError::FetchModsByIdsFailed { + source: e, + mod_ids: filter_ids, + })?) } async fn fetch_mod_updates_since( @@ -448,12 +480,16 @@ impl DrgModio for modio::Modio { EventTypes::ModCommentAdded, EventTypes::ModCommentDeleted, ]) - .and(ModId::_in(mod_ids)) + .and(ModId::_in(mod_ids.clone())) .and(DateAdded::gt(last_update)), ) .collect() .await - .context(GenericModioSnafu)?; + .map_err(|e| DrgModioError::FetchModUpdatesFailed { + source: e, + mod_ids, + last_update, + })?; Ok(events.iter().map(|e| e.mod_id).collect::>()) } @@ -473,12 +509,11 @@ impl ModProvider for ModioProvider { update: bool, cache: ProviderCache, ) -> Result { - ensure!( - !spec.url.contains("?preview="), - PreviewLinkSnafu { - url: spec.url.to_string() - } - ); + if spec.url.contains("?preview=") { + return Err(ProviderError::PreviewLink { + url: spec.url.to_string(), + }); + } fn read_cache(cache: &ProviderCache, update: bool, f: F) -> Option where @@ -506,9 +541,11 @@ impl ModProvider for ModioProvider { } let url = &spec.url; - let captures = re_mod().captures(url).context(InvalidUrlSnafu { - url: url.to_string(), - })?; + let captures = re_mod() + .captures(url) + .ok_or_else(|| ProviderError::InvalidUrl { + url: url.to_string(), + })?; if let (Some(mod_id), Some(_modfile_id)) = (captures.name("mod_id"), captures.name("modfile_id")) @@ -630,7 +667,7 @@ impl ModProvider for ModioProvider { mod_id, Some( mod_.latest_modfile - .with_context(|| NoAssociatedModfileSnafu { + .ok_or_else(|| ProviderError::NoAssociatedModfile { url: url.to_string(), })?, ), @@ -654,7 +691,7 @@ impl ModProvider for ModioProvider { c.mods.insert(id, mod_.clone()); c.mod_id_map.insert(mod_.name_id, id); }); - modfile_id.with_context(|| NoAssociatedModfileSnafu { + modfile_id.ok_or_else(|| ProviderError::NoAssociatedModfile { url: url.to_string(), })? } @@ -668,10 +705,9 @@ impl ModProvider for ModioProvider { } else { let mut mods = self.modio.fetch_mods_by_name(name_id).await?; if mods.len() > 1 { - AmbiguousModNameIdSnafu { + return Err(ProviderError::AmbiguousModNameId { name_id: name_id.to_string(), - } - .fail()? + }); } else if let Some(mod_) = mods.pop() { let mod_id = mod_.id; let mod_ = self.modio.fetch_mod(spec.url.clone(), mod_id).await?; @@ -680,7 +716,7 @@ impl ModProvider for ModioProvider { c.mods.insert(mod_id, mod_.clone()); c.mod_id_map.insert(mod_.name_id, mod_id); }); - let file = modfile_id.with_context(|| NoAssociatedModfileSnafu { + let file = modfile_id.ok_or_else(|| ProviderError::NoAssociatedModfile { url: url.to_string(), })?; @@ -690,10 +726,9 @@ impl ModProvider for ModioProvider { Some(file), ))) } else { - NoModsForNameIdSnafu { + Err(ProviderError::NoModsForNameId { name_id: name_id.to_string(), - } - .fail()? + }) } } } @@ -710,7 +745,7 @@ impl ModProvider for ModioProvider { let url = &res.url; let captures = re_mod() .captures(&res.url.0) - .with_context(|| InvalidUrlSnafu { + .ok_or_else(|| ProviderError::InvalidUrl { url: url.0.to_string(), })?; @@ -759,12 +794,12 @@ impl ModProvider for ModioProvider { while let Some(bytes) = stream .try_next() .await - .with_context(|_| ModCtxtModioSnafu { mod_id })? + .map_err(|e| ProviderError::ModioErrorWithModCtxt { source: e, mod_id })? { cursor .write_all(&bytes) .await - .with_context(|_| ModCtxtIoSnafu { mod_id })?; + .map_err(|e| ProviderError::IoErrorWithModCtxt { source: e, mod_id })?; if let Some(tx) = &tx { tx.send(FetchProgress::Progress { resolution: res.clone(), @@ -798,10 +833,9 @@ impl ModProvider for ModioProvider { }, ) } else { - InvalidUrlSnafu { + Err(ProviderError::InvalidUrl { url: url.0.to_string(), - } - .fail()? + }) } } @@ -1107,19 +1141,19 @@ mod test { mod_names .get(name) .map(|id| vec![ModioModResponse { id: **id }]) - .ok_or(DrgModioError::GenericError { msg: "not found" }) + .ok_or(DrgModioError::GenericError("not found")) }); mock.expect_fetch_mod().times(1).returning(move |_, id| { mods.get(&id) .map(|m| m.mod_.clone()) - .ok_or(DrgModioError::GenericError { msg: "not found" }) + .ok_or(DrgModioError::GenericError("not found")) }); mock.expect_fetch_dependencies() .times(1) .returning(move |_, id| { mods.get(&id) .map(|m| m.dependencies.clone()) - .ok_or(DrgModioError::GenericError { msg: "not found" }) + .ok_or(DrgModioError::GenericError("not found")) }); let cache = Arc::new(RwLock::new(ConfigWrapper::::memory( diff --git a/src/state/config.rs b/src/state/config.rs index bdbd699e..7cb0e978 100644 --- a/src/state/config.rs +++ b/src/state/config.rs @@ -42,9 +42,9 @@ impl ConfigWrapper { temp_file .write_all( &serde_json::to_vec_pretty(&self.config) - .context(CfgSerializationFailedSnafu)?, + .map_err(StateError::CfgSerializationFailed)?, ) - .context(CfgSaveFailedSnafu)?; + .map_err(StateError::CfgSaveFailed)?; temp_file.persist(final_path)?; } Ok(()) diff --git a/src/state/mod.rs b/src/state/mod.rs index e0bef7c9..a9e87dab 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -9,7 +9,7 @@ use std::{ use fs_err as fs; use serde::{Deserialize, Serialize}; -use snafu::prelude::*; +use thiserror::Error; use self::config::ConfigWrapper; use crate::{ @@ -432,28 +432,28 @@ impl From<&VersionAnnotatedConfig> for MetaConfig { } } -#[derive(Debug, Snafu)] +#[derive(Debug, Error)] pub enum StateError { - #[snafu(display("failed to deserialize user config"))] - CfgDeserializationFailed { source: serde_json::Error }, - #[snafu(display("unsupported config version"))] + #[error("failed to deserialize user config: {0}")] + CfgDeserializationFailed(serde_json::Error), + #[error("unsupported config version")] UnsupportedCfgVersion, - #[snafu(display("failed to read config.json"))] - CfgReadFailed { source: std::io::Error }, - #[snafu(display("failed to save config"))] - CfgSaveFailed { source: std::io::Error }, - #[snafu(display("failed to serialize user config"))] - CfgSerializationFailed { source: serde_json::Error }, - #[snafu(transparent)] - IoError { source: std::io::Error }, - #[snafu(transparent)] - PersistError { source: tempfile::PersistError }, - #[snafu(transparent)] - ProviderError { source: ProviderError }, - #[snafu(display("failed to deserialize mod data"))] - ModDataDeserializationFailed { source: serde_json::Error }, - #[snafu(display("failed to deserialize legacy profiles"))] - LegacyProfilesDeserializationFailed { source: serde_json::Error }, + #[error("failed to read config.json: {0}")] + CfgReadFailed(std::io::Error), + #[error("failed to save config: {0}")] + CfgSaveFailed(std::io::Error), + #[error("failed to serialize user config: {0}")] + CfgSerializationFailed(serde_json::Error), + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error(transparent)] + PersistError(#[from] tempfile::PersistError), + #[error(transparent)] + ProviderError(#[from] ProviderError), + #[error("failed to deserialize mod data: {0}")] + ModDataDeserializationFailed(serde_json::Error), + #[error("failed to deserialize legacy profiles: {0}")] + LegacyProfilesDeserializationFailed(serde_json::Error), } pub struct State { @@ -492,11 +492,13 @@ fn read_config_or_default(config_path: &PathBuf) -> Result { let config = serde_json::from_slice::(&buf) - .context(CfgDeserializationFailedSnafu)?; + .map_err(StateError::CfgDeserializationFailed)?; match config { MaybeVersionedConfig::Versioned(v) => match v { VersionAnnotatedConfig::V0_0_0(v) => VersionAnnotatedConfig::V0_0_0(v), - VersionAnnotatedConfig::Unsupported => UnsupportedCfgVersionSnafu.fail()?, + VersionAnnotatedConfig::Unsupported => { + return Err(StateError::UnsupportedCfgVersion) + } }, MaybeVersionedConfig::Legacy(legacy) => { VersionAnnotatedConfig::V0_0_0(Config_v0_0_0 { @@ -518,12 +520,12 @@ fn read_mod_data_or_default( ) -> Result { let mod_data = match fs::read(mod_data_path) { Ok(buf) => serde_json::from_slice::(&buf) - .context(ModDataDeserializationFailedSnafu)?, + .map_err(StateError::ModDataDeserializationFailed)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { match fs::read(&legacy_mod_profiles_path) { Ok(buf) => { let mod_data = serde_json::from_slice::(&buf) - .context(LegacyProfilesDeserializationFailedSnafu)?; + .map_err(StateError::LegacyProfilesDeserializationFailed)?; fs::remove_file(&legacy_mod_profiles_path)?; mod_data }