diff --git a/Cargo.lock b/Cargo.lock index 0ea0699..e808cc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,9 +170,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.3.3" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" [[package]] name = "block-buffer" @@ -542,33 +542,19 @@ checksum = "88bffebc5d80432c9b140ee17875ff173a8ab62faad5b257da912bd2f6c1c0a1" [[package]] name = "errno" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" -dependencies = [ - "errno-dragonfly", - "libc", - "windows-sys 0.48.0", -] - -[[package]] -name = "errno-dragonfly" -version = "0.1.2" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ - "cc", "libc", + "windows-sys 0.52.0", ] [[package]] name = "fastrand" -version = "1.9.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "figment" @@ -974,17 +960,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "io-lifetimes" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "ipnet" version = "2.8.0" @@ -998,7 +973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ "hermit-abi", - "rustix 0.38.3", + "rustix", "windows-sys 0.48.0", ] @@ -1062,9 +1037,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.150" +version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "libredox" @@ -1072,22 +1047,16 @@ version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" dependencies = [ - "bitflags 2.3.3", + "bitflags 2.5.0", "libc", - "redox_syscall 0.4.1", + "redox_syscall", ] [[package]] name = "linux-raw-sys" -version = "0.3.8" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" - -[[package]] -name = "linux-raw-sys" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "log" @@ -1491,15 +1460,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "redox_syscall" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.4.1" @@ -1567,29 +1527,15 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustix" -version = "0.37.23" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d69718bf81c6127a49dc64e44a742e8bb9213c0ff8869a22c308f84c1d4ab06" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.5.0", "errno", - "io-lifetimes", "libc", - "linux-raw-sys 0.3.8", - "windows-sys 0.48.0", -] - -[[package]] -name = "rustix" -version = "0.38.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac5ffa1efe7548069688cd7028f32591853cd7b5b756d41bcffd2353e4fc75b4" -dependencies = [ - "bitflags 2.3.3", - "errno", - "libc", - "linux-raw-sys 0.4.3", - "windows-sys 0.48.0", + "linux-raw-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1896,6 +1842,7 @@ dependencies = [ "serde_with", "steamlocate", "sysinfo", + "tempfile", "thiserror", "tokio", "tokio-util", @@ -1908,16 +1855,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.6.0" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31c0432476357e58790aaa47a8efb0c5138f137343f3b5f23bd36a27e3b0a6d6" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ - "autocfg", "cfg-if", "fastrand", - "redox_syscall 0.3.5", - "rustix 0.37.23", - "windows-sys 0.48.0", + "rustix", + "windows-sys 0.52.0", ] [[package]] @@ -2414,6 +2359,15 @@ dependencies = [ "windows-targets 0.48.1", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.4", +] + [[package]] name = "windows-targets" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index ac0b386..9a01616 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,11 @@ indicatif = { version = "0.17.8", features = ["improved_unicode", "tokio"] } # async runtime and helpers futures = "0.3" futures-util = "0.3" -tokio = { version = "1.34", features = ["macros", "process", "rt-multi-thread"]} +tokio = { version = "1.34", features = [ + "macros", + "process", + "rt-multi-thread", +] } tokio-util = { version = "0.7", features = ["io"] } async-compression = { version = "0.4", features = ["futures-io", "gzip"] } @@ -45,3 +49,6 @@ log = "0.4.21" [target.'cfg(windows)'.dependencies] winreg = "0.50" + +[dev-dependencies] +tempfile = "3.10" diff --git a/src/cli.rs b/src/cli.rs index 5f8edb2..9543b8e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -257,4 +257,10 @@ pub enum Commands { /// Update the tcli ecosystem schema. UpdateSchema, + + /// Start the tcli server. + Server { + #[clap(long, default_value = "./")] + project_path: PathBuf, + }, } diff --git a/src/error.rs b/src/error.rs index ced46c5..9e31022 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,141 +1,136 @@ use std::path::{Path, PathBuf}; -use crate::ts::version::Version; +use crate::server::ServerError; +use crate::ts::error::ApiError; + +use crate::game::error::GameError; +use crate::package::error::PackageError; +use crate::project::error::ProjectError; -#[allow(clippy::enum_variant_names)] #[derive(Debug, thiserror::Error)] #[repr(u32)] pub enum Error { - #[error("An API error occurred.")] - ApiError { - source: reqwest::Error, - response_body: Option, - } = 1, + #[error("{0}")] + Game(#[from] GameError), - #[error("A game import error occurred.")] - GameImportError(#[from] crate::game::import::Error), + #[error("{0}")] + Package(#[from] PackageError), - #[error("The file at {0} does not exist or is otherwise not accessible.")] - FileNotFound(PathBuf), + #[error("{0}")] + Project(#[from] ProjectError), + + #[error("{0}")] + Api(#[from] ApiError), + + #[error("{0}")] + Server(#[from] ServerError), - #[error("The directory at {0} does not exist or is otherwise not accessible.")] - DirectoryNotFound(PathBuf), + #[error("{0}")] + Io(#[from] IoError), - #[error("A network error occurred while sending an API request.")] - NetworkError(#[from] reqwest::Error), + #[error("{0}")] + Parse(#[from] ParseError), +} + +#[derive(Debug, thiserror::Error)] +pub enum IoError { + #[error("A file IO error occurred: {0}.")] + Native(std::io::Error, Option), - #[error("The path at {0} is actually a file.")] - ProjectDirIsFile(PathBuf), + #[error("File not found: {0}.")] + FileNotFound(PathBuf), - #[error("A project configuration already exists at {0}.")] - ProjectAlreadyExists(PathBuf), + #[error("Expected directory at '{0}', got file.")] + DirectoryIsFile(PathBuf), - #[error("A generic IO error occurred: {0}")] - GenericIoError(#[from] std::io::Error), + #[error("Directory not found: {0}.")] + DirNotFound(PathBuf), - #[error("A file IO error occurred at path {0}: {1}")] - FileIoError(PathBuf, std::io::Error), + #[error("{0}")] + DirWalker(walkdir::Error), - #[error("Invalid version.")] - InvalidVersion(#[from] crate::ts::version::VersionParseError), + #[error("Failed to find file '{0}' within the directory '{1}.")] + FailedFileSearch(String, PathBuf), - #[error("Failed to read project file. {0}")] - FailedDeserializeProject(#[from] toml::de::Error), + #[error("Failed to read subkey at '{0}'.")] + RegistrySubkeyRead(String), - #[error("No project exists at the path {0}.")] - NoProjectFile(PathBuf), + #[error("Failed to read value with name '{0}' at key '{1}'.")] + RegistryValueRead(String, String), #[error("Failed modifying zip file: {0}.")] ZipError(#[from] zip::result::ZipError), +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("A json parse error occurred: {0}")] + Json(#[from] serde_json::Error), + + #[error("A toml serialization error occurred: {0}")] + TomlSe(#[from] toml::ser::Error), + + #[error("A toml deserialization error occured: {0}")] + TomlDe(#[from] toml::de::Error), +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(IoError::Native(value, None)) + } +} + +impl From for Error { + fn from(value: reqwest::Error) -> Self { + Self::Api(ApiError::BadRequest { source: value, response_body: None }) + } +} + +impl From for Error { + fn from(value: zip::result::ZipError) -> Self { + Self::Io(IoError::ZipError(value)) + } +} + +impl From for Error { + fn from(value: serde_json::Error) -> Self { + Self::Parse(ParseError::Json(value)) + } +} +impl From for Error { + fn from(value: toml::de::Error) -> Self { + Self::Parse(ParseError::TomlDe(value)) + } +} - #[error("Project is missing required table '{0}'.")] - MissingTable(&'static str), - - #[error("Missing repository url.")] - MissingRepository, - - #[error("Missing auth token.")] - MissingAuthToken, - - #[error("The game identifier '{0}' does not exist within the ecosystem schema.")] - InvalidGameId(String), - - #[error("An error occurred while parsing JSON: {0}")] - JsonParserError(#[from] serde_json::Error), - - #[error("An error occured while serializing TOML: {0}")] - TomlSerializer(#[from] toml::ser::Error), - - #[error("The installer does not contain a valid manifest.")] - InstallerNoManifest, - - #[error( - "The installer executable for the current OS and architecture combination does not exist." - )] - InstallerNotExecutable, - - #[error( - " - The installer '{package_id}' does not support the current tcli installer protocol. - Expected: {our_version:#?} - Recieved: {given_version:#?} - " - )] - InstallerBadVersion { - package_id: String, - given_version: Version, - our_version: Version, - }, - - #[error( - "The installer '{package_id}' did not respond correctly: - \t{message}" - )] - InstallerBadResponse { package_id: String, message: String }, - - #[error("The installer returned an error:\n\t{message}")] - InstallerError { message: String }, - - #[error( - "The provided game id '{0}' does not exist or has not been imported into this profile." - )] - BadGameId(String), - - #[error("The Steam app with id '{0}' could not be found.")] - SteamAppNotFound(u32), +impl From for Error { + fn from(value: toml::ser::Error) -> Self { + Self::Parse(ParseError::TomlSe(value)) + } } pub trait IoResultToTcli { - fn map_fs_error(self, path: impl AsRef) -> Result; + fn map_fs_error(self, path: impl AsRef) -> Result; } impl IoResultToTcli for Result { - fn map_fs_error(self, path: impl AsRef) -> Result { - self.map_err(|e| Error::FileIoError(path.as_ref().into(), e)) + fn map_fs_error(self, path: impl AsRef) -> Result { + self.map_err(|e| IoError::Native(e, Some(path.as_ref().into()))) } } pub trait ReqwestToTcli: Sized { - async fn error_for_status_tcli(self) -> Result; + async fn error_for_status_tcli(self) -> Result; } impl ReqwestToTcli for reqwest::Response { - async fn error_for_status_tcli(self) -> Result { + async fn error_for_status_tcli(self) -> Result { match self.error_for_status_ref() { Ok(_) => Ok(self), - Err(err) => Err(Error::ApiError { + Err(err) => Err(ApiError::BadRequest { source: err, response_body: self.text().await.ok(), }), } } } - -impl From for Error { - fn from(value: walkdir::Error) -> Self { - Self::FileIoError( - value.path().unwrap_or(Path::new("")).into(), - value.into_io_error().unwrap(), - ) - } -} diff --git a/src/game/error.rs b/src/game/error.rs new file mode 100644 index 0000000..51707db --- /dev/null +++ b/src/game/error.rs @@ -0,0 +1,30 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +#[repr(u32)] +pub enum GameError { + #[error("The game '{0}' is not supported by platform '{1}'.")] + NotSupported(String, String), + + #[error("Could not find game with id '{0}' within the ecosystem schema.")] + BadGameId(String), + + #[error("Could not find the game '{0}' installed via platform '{1}'.")] + NotFound(String, String), + + #[error("Could not find any of '{possible_names:?}' in base directory: '{base_path}'.")] + ExeNotFound { possible_names: Vec, base_path: PathBuf}, + + #[error("The Steam library could not be automatically found.")] + SteamDirNotFound, + + #[error("The path '{0}' does not refer to a valid Steam directory.")] + SteamDirBadPath(PathBuf), + + #[error("The app with id '{0}' could not be found in the Steam instance at '{1}'.")] + SteamAppNotFound(u32, PathBuf), + + // This should probably live elsewhere but it's fine here for now. + #[error("An error occured while fetching the ecosystem schema.")] + EcosystemSchema, +} diff --git a/src/game/import/ea.rs b/src/game/import/ea.rs index 595fbaa..8282d2b 100644 --- a/src/game/import/ea.rs +++ b/src/game/import/ea.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; -use super::{Error, GameImporter}; +use super::GameImporter; +use crate::error::Error; +use crate::game::error::GameError; use crate::game::import::ImportBase; use crate::game::registry::{ActiveDistribution, GameData}; use crate::ts::v1::models::ecosystem::GameDefPlatform; @@ -34,8 +36,10 @@ impl GameImporter for EaImporter { .clone() .or_else(|| super::find_game_exe(&r2mm.exe_names, &game_dir)) .ok_or_else(|| { - super::Error::ExeNotFound(base.game_def.label.clone(), game_dir.clone()) - })?; + GameError::ExeNotFound { + possible_names: r2mm.exe_names.clone(), + base_path: game_dir.clone(), + }})?; let dist = ActiveDistribution { dist: GameDefPlatform::Origin { identifier: self.ident.to_string(), diff --git a/src/game/import/egs.rs b/src/game/import/egs.rs index d73c443..fd2c2a7 100644 --- a/src/game/import/egs.rs +++ b/src/game/import/egs.rs @@ -3,7 +3,9 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -use super::{Error, GameImporter, ImportBase}; +use super::{GameImporter, ImportBase}; +use crate::error::{IoError, Error}; +use crate::game::error::GameError; use crate::game::registry::{ActiveDistribution, GameData}; use crate::ts::v1::models::ecosystem::GameDefPlatform; use crate::util::reg::{self, HKey}; @@ -42,7 +44,7 @@ impl GameImporter for EgsImporter { let manifests_dir = PathBuf::from(value).join("Manifests"); if !manifests_dir.exists() { - Err(Error::DirNotFound(manifests_dir.clone()))?; + Err(IoError::DirNotFound(manifests_dir.clone()))?; } // Manifest files are JSON files with .item extensions. @@ -68,7 +70,7 @@ impl GameImporter for EgsImporter { None } }) - .ok_or_else(|| super::Error::NotFound(game_label.clone(), "EGS".to_string()))?; + .ok_or_else(|| GameError::NotFound(game_label.clone(), "EGS".to_string()))?; let r2mm = base.game_def.r2modman.as_ref().expect( "Expected a valid r2mm field in the ecosystem schema, got nothing. This is a bug.", @@ -80,8 +82,10 @@ impl GameImporter for EgsImporter { .clone() .or_else(|| super::find_game_exe(&r2mm.exe_names, &game_dir)) .ok_or_else(|| { - super::Error::ExeNotFound(base.game_def.label.clone(), game_dir.clone()) - })?; + GameError::ExeNotFound { + possible_names: r2mm.exe_names.clone(), + base_path: game_dir.clone(), + }})?; let dist = ActiveDistribution { dist: GameDefPlatform::Other, game_dir: game_dir.to_path_buf(), diff --git a/src/game/import/gamepass.rs b/src/game/import/gamepass.rs index 75cc626..04cfb53 100644 --- a/src/game/import/gamepass.rs +++ b/src/game/import/gamepass.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; -use super::Error; use super::{GameImporter, ImportBase}; +use crate::error::Error; +use crate::game::error::GameError; use crate::game::registry::{ActiveDistribution, GameData}; use crate::ts::v1::models::ecosystem::GameDefPlatform; use crate::util::reg::{self, HKey}; @@ -26,7 +27,7 @@ impl GameImporter for GamepassImporter { .into_iter() .find(|x| x.key.starts_with(&self.ident)) .ok_or_else(|| { - super::Error::NotFound(base.game_def.label.clone(), "Gamepass".to_string()) + GameError::NotFound(base.game_def.label.clone(), "Gamepass".to_string()) })? .val .replace('\"', ""); @@ -35,7 +36,7 @@ impl GameImporter for GamepassImporter { .into_iter() .next() .ok_or_else(|| { - super::Error::NotFound(base.game_def.label.clone(), "Gamepass".to_string()) + GameError::NotFound(base.game_def.label.clone(), "Gamepass".to_string()) })?; let game_dir = PathBuf::from(reg::get_value_at(HKey::LocalMachine, &game_root, "Root")?); @@ -49,8 +50,10 @@ impl GameImporter for GamepassImporter { .clone() .or_else(|| super::find_game_exe(&r2mm.exe_names, &game_dir)) .ok_or_else(|| { - super::Error::ExeNotFound(base.game_def.label.clone(), game_dir.clone()) - })?; + GameError::ExeNotFound { + possible_names: r2mm.exe_names.clone(), + base_path: game_dir.clone(), + }})?; let dist = ActiveDistribution { dist: GameDefPlatform::GamePass { identifier: self.ident.to_string(), diff --git a/src/game/import/mod.rs b/src/game/import/mod.rs index f99f881..571129b 100644 --- a/src/game/import/mod.rs +++ b/src/game/import/mod.rs @@ -6,47 +6,15 @@ pub mod steam; use std::path::{Path, PathBuf}; +use super::error::GameError; use super::registry::{ActiveDistribution, GameData}; +use crate::error::Error; use crate::game::import::ea::EaImporter; use crate::game::import::egs::EgsImporter; use crate::game::import::gamepass::GamepassImporter; use crate::game::import::steam::SteamImporter; use crate::ts::v1::models::ecosystem::GameDef; use crate::ts::v1::{ecosystem, models::ecosystem::GameDefPlatform}; -use crate::util::reg; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("The game '{0}' is not supported by platform '{1}'.")] - NotSupported(String, String), - - #[error("A game with id '{0}' could not be found within the ecosystem schema.")] - InvalidGameId(String), - - #[error("Could not find the game '{0}' installed via the platform '{1}'.")] - NotFound(String, String), - - #[error("The EGS directory at '{0}' does not exist or is unreadable.")] - DirNotFound(PathBuf), - - #[error("Could not find the executable for game '{0}' within the dir '{1}'.")] - ExeNotFound(String, PathBuf), - - #[error("An error occured while fetching the ecosystem schema.")] - EcosystemSchema, - - #[error("Unable to read the registry.")] - RegistryRead(#[from] reg::Error), - - #[error("The Steam library could not be automatically found.")] - SteamDirNotFound, - - #[error("The path '{0}' does not refer to a valid Steam directory.")] - SteamDirBadPath(PathBuf), - - #[error("The app with id '{0}' could not be found in the Steam instance at '{1}'.")] - SteamAppNotFound(u32, PathBuf), -} pub trait GameImporter { fn construct(self: Box, base: ImportBase) -> Result; @@ -71,10 +39,10 @@ impl ImportBase { pub async fn new(game_id: &str) -> Result { let game_def = ecosystem::get_schema() .await - .map_err(|_| Error::EcosystemSchema)? + .map_err(|_| GameError::EcosystemSchema)? .games .get(game_id) - .ok_or_else(|| Error::InvalidGameId(game_id.into()))? + .ok_or_else(|| GameError::BadGameId(game_id.into()))? .clone(); Ok(ImportBase { @@ -119,7 +87,7 @@ pub fn select_importer(base: &ImportBase) -> Result, Error } _ => None, }) - .ok_or_else(|| Error::NotSupported(base.game_id.clone(), "".into())) + .ok_or_else(|| GameError::NotSupported(base.game_id.clone(), "".into()).into()) } pub fn find_game_exe(possible: &[String], base_path: &Path) -> Option { diff --git a/src/game/import/nodrm.rs b/src/game/import/nodrm.rs index 5e07db4..7ad567b 100644 --- a/src/game/import/nodrm.rs +++ b/src/game/import/nodrm.rs @@ -1,6 +1,8 @@ use std::path::{Path, PathBuf}; -use super::{Error, GameImporter, ImportBase}; +use crate::error::{Error, IoError}; +use crate::game::error::GameError; +use super::{GameImporter, ImportBase}; use crate::game::registry::{ActiveDistribution, GameData}; use crate::ts::v1::models::ecosystem::GameDefPlatform; @@ -19,7 +21,7 @@ impl NoDrmImporter { impl GameImporter for NoDrmImporter { fn construct(self: Box, base: ImportBase) -> Result { if !self.game_dir.exists() { - Err(Error::DirNotFound(self.game_dir.to_path_buf()))?; + Err(IoError::DirNotFound(self.game_dir.to_path_buf()))?; } let r2mm = base.game_def.r2modman.as_ref().expect( @@ -32,7 +34,7 @@ impl GameImporter for NoDrmImporter { .clone() .or_else(|| super::find_game_exe(&r2mm.exe_names, &self.game_dir)) .ok_or_else(|| { - super::Error::ExeNotFound(base.game_def.label.clone(), self.game_dir.clone()) + GameError::ExeNotFound { possible_names: r2mm.exe_names.clone(), base_path: self.game_dir.clone() } })?; let dist = ActiveDistribution { dist: GameDefPlatform::Other, diff --git a/src/game/import/steam.rs b/src/game/import/steam.rs index bab687e..7db54d6 100644 --- a/src/game/import/steam.rs +++ b/src/game/import/steam.rs @@ -2,7 +2,9 @@ use std::path::PathBuf; use steamlocate::SteamDir; -use super::{Error, GameImporter, ImportBase}; +use super::{GameImporter, ImportBase}; +use crate::error::Error; +use crate::game::error::GameError; use crate::game::registry::{ActiveDistribution, GameData}; use crate::ts::v1::models::ecosystem::GameDefPlatform; @@ -39,9 +41,9 @@ impl GameImporter for SteamImporter { .map_or_else(SteamDir::locate, |x| SteamDir::from_dir(x)) .map_err(|e: steamlocate::Error| match e { steamlocate::Error::InvalidSteamDir(_) => { - Error::SteamDirBadPath(self.steam_dir.as_ref().unwrap().to_path_buf()) + GameError::SteamDirBadPath(self.steam_dir.as_ref().unwrap().to_path_buf()) } - steamlocate::Error::FailedLocate(_) => Error::SteamDirNotFound, + steamlocate::Error::FailedLocate(_) => GameError::SteamDirNotFound, _ => unreachable!(), })?; @@ -54,14 +56,14 @@ impl GameImporter for SteamImporter { ) }) .ok_or_else(|| { - Error::SteamAppNotFound(self.appid, steam.path().to_path_buf()) + GameError::SteamAppNotFound(self.appid, steam.path().to_path_buf()) })?; lib.resolve_app_dir(&app) } }; if !app_dir.is_dir() { - Err(Error::SteamDirNotFound)?; + Err(GameError::SteamDirNotFound)?; } let r2mm = base.game_def.r2modman.as_ref().expect( @@ -74,8 +76,10 @@ impl GameImporter for SteamImporter { .map(|x| app_dir.join(x)) .find(|x| x.is_file()) .ok_or_else(|| { - super::Error::ExeNotFound(base.game_def.label.clone(), app_dir.clone()) - })?; + GameError::ExeNotFound { + possible_names: r2mm.exe_names.clone(), + base_path: app_dir.clone(), + }})?; let dist = ActiveDistribution { dist: GameDefPlatform::Steam { diff --git a/src/game/mod.rs b/src/game/mod.rs index 85e1eea..f6b4fde 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -2,3 +2,4 @@ pub mod ecosystem; pub mod import; pub mod proc; pub mod registry; +pub mod error; diff --git a/src/game/proc.rs b/src/game/proc.rs index 51d36f2..b9a6517 100644 --- a/src/game/proc.rs +++ b/src/game/proc.rs @@ -1,8 +1,8 @@ use std::{ffi::OsStr, path::{Path, PathBuf}}; use sysinfo::{ Pid, - ProcessExt, - System, + ProcessExt, + System, SystemExt }; @@ -23,7 +23,7 @@ pub fn get_pid_files(dir: &Path) -> Result, Error> { pub fn is_running(pid: usize) -> bool { let mut system = System::new(); system.refresh_processes(); - + system.process(Pid::from(pid)).is_some() } diff --git a/src/main.rs b/src/main.rs index 2d6b831..581960d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,24 @@ #![allow(dead_code)] +use std::io::{self, Stdin}; use std::path::PathBuf; use clap::Parser; use cli::InitSubcommand; use colored::Colorize; use directories::BaseDirs; +use error::{IoError, Error}; use game::import::GameImporter; use once_cell::sync::Lazy; +use project::error::ProjectError; use project::ProjectKind; +use ts::error::ApiError; use wildmatch::WildMatch; use crate::cli::{Args, Commands, ListSubcommand}; use crate::config::Vars; -use crate::error::Error; -use crate::game::{ecosystem, registry}; use crate::game::import::{self, ImportBase, ImportOverrides}; +use crate::game::{ecosystem, registry}; use crate::package::resolver::DependencyGraph; use crate::package::Package; use crate::project::lock::LockFile; @@ -29,6 +32,7 @@ mod error; mod game; mod package; mod project; +mod server; mod ts; mod ui; mod util; @@ -47,7 +51,7 @@ async fn main() -> Result<(), Error> { std::fs::create_dir_all(TCLI_HOME.as_path())?; } - match Args::parse().commands { + let test: Result<(), Error> = match Args::parse().commands { Commands::Init { command, overwrite, @@ -73,7 +77,7 @@ async fn main() -> Result<(), Error> { } Ok(()) - }, + } Commands::Build { package_name, package_namespace, @@ -87,7 +91,7 @@ async fn main() -> Result<(), Error> { .name_override(package_name) .version_override(package_version) .output_dir_override(output_dir); - + project.build(overrides)?; Ok(()) } @@ -102,31 +106,31 @@ async fn main() -> Result<(), Error> { } => { token = token.or_else(|| Vars::AuthKey.into_var().ok()); if token.is_none() { - return Err(Error::MissingAuthToken); + Err(ApiError::MissingAuthToken)?; } - + let project = Project::open(&project_path)?; let manifest = project.get_manifest()?; - + ts::init_repository( manifest .config .repository .as_deref() - .ok_or(Error::MissingRepository)?, + .ok_or(ProjectError::MissingRepository)?, token.as_deref(), ); let archive_path = match package_archive { Some(x) if x.is_file() => Ok(x), - Some(x) => Err(Error::FileNotFound(x)), + Some(x) => Err(IoError::FileNotFound(x))?, None => { let overrides = ProjectOverrides::new() .namespace_override(package_namespace) .name_override(package_name) .version_override(package_version) .repository_override(repository); - + project.build(overrides) } }?; @@ -182,9 +186,7 @@ async fn main() -> Result<(), Error> { custom_exe: None, game_dir: game_dir.clone(), }; - let import_base = ImportBase::new(&game_id) - .await? - .with_overrides(overrides); + let import_base = ImportBase::new(&game_id).await?.with_overrides(overrides); if platform.is_none() { let importer = import::select_importer(&import_base)?; @@ -199,61 +201,59 @@ async fn main() -> Result<(), Error> { let importer: Box = match (ident, platform.as_str()) { (Some(ident), "steam") => { - Box::new(import::steam::SteamImporter::new(ident).with_steam_dir(steam_dir)) as _ - } - (None, "nodrm") => { - Box::new(import::nodrm::NoDrmImporter::new(game_dir.as_ref().unwrap())) as _ + Box::new(import::steam::SteamImporter::new(ident).with_steam_dir(steam_dir)) + as _ } - _ => panic!("Manually importing games from '{platform}' is not implemented") + (None, "nodrm") => Box::new(import::nodrm::NoDrmImporter::new( + game_dir.as_ref().unwrap(), + )) as _, + _ => panic!("Manually importing games from '{platform}' is not implemented"), }; let game_data = importer.construct(import_base)?; let res = project.add_game_data(game_data); - println!("{} has been imported into the current project", game_id.green()); + println!( + "{} has been imported into the current project", + game_id.green() + ); res } - - Commands::Run { - game_id, - vanilla, - args, - tcli_directory: _, - repository: _, - project_path, - trailing_args + + Commands::Run { + game_id, + vanilla, + args, + tcli_directory: _, + repository: _, + project_path, + trailing_args, } => { let project = Project::open(&project_path)?; - let args = args.unwrap_or(vec![]) + let args = args + .unwrap_or(vec![]) .into_iter() .chain(trailing_args.into_iter()) .collect::>(); - - project.start_game( - &game_id, - !vanilla, - args, - ).await?; + + project.start_game(&game_id, !vanilla, args).await?; Ok(()) } - Commands::Stop { - id, - project_path, - } => { + Commands::Stop { id, project_path } => { match id.parse::() { Ok(x) => { game::proc::kill(x); - }, + } Err(_) => { let project = Project::open(&project_path)?; project.stop_game(&id)?; } }; - + Ok(()) } - + Commands::UpdateSchema {} => { ts::init_repository("https://thunderstore.io", None); @@ -286,7 +286,10 @@ async fn main() -> Result<(), Error> { Ok(()) } Commands::List { command } => match command { - ListSubcommand::Platforms { target, detected: _ } => { + ListSubcommand::Platforms { + target, + detected: _, + } => { let platforms = registry::get_supported_platforms(&target); println!("TCLI supports the following platforms on {target}"); @@ -336,9 +339,11 @@ async fn main() -> Result<(), Error> { let graph = DependencyGraph::from_graph(lock.package_graph); for package in graph.digest() { + println!("{}", package); + let package = Package::from_any(package).await?; let Some(meta) = package.get_metadata().await? else { - continue + continue; }; let str = serde_json::to_string_pretty(&meta)?; @@ -348,5 +353,14 @@ async fn main() -> Result<(), Error> { Ok(()) } }, - } + Commands::Server { project_path }=> { + let read = io::stdin(); + let write = io::stdout(); + server::spawn(read, write, &project_path).await?; + + Ok(()) + }, + }; + + test } diff --git a/src/package/cache.rs b/src/package/cache.rs index 3884eec..2425ff3 100644 --- a/src/package/cache.rs +++ b/src/package/cache.rs @@ -3,10 +3,10 @@ use std::path::PathBuf; use once_cell::sync::Lazy; -use crate::error::IoResultToTcli; +use crate::error::{IoResultToTcli, Error}; use crate::ts::package_reference::PackageReference; use crate::util::TempFile; -use crate::{Error, TCLI_HOME}; +use crate::TCLI_HOME; static CACHE_LOCATION: Lazy = Lazy::new(|| TCLI_HOME.join("package_cache")); @@ -21,3 +21,7 @@ pub async fn get_temp_zip_file( pub fn get_cache_location(package: &PackageReference) -> PathBuf { CACHE_LOCATION.join(package.to_string()) } + +pub fn is_cached(package: &PackageReference) -> bool { + CACHE_LOCATION.join(package.to_string()).exists() +} diff --git a/src/package/error.rs b/src/package/error.rs new file mode 100644 index 0000000..84aa9d6 --- /dev/null +++ b/src/package/error.rs @@ -0,0 +1,35 @@ +use crate::ts::version::Version; + +#[derive(Debug, thiserror::Error)] +#[repr(u32)] +pub enum PackageError { + #[error("The installer does not contain a valid manifest.")] + InstallerNoManifest, + + #[error( + "The installer executable for the current OS and architecture combination does not exist." + )] + InstallerNotExecutable, + + #[error( + " + The installer '{package_id}' does not support the current tcli installer protocol. + Expected: {our_version:#?} + Recieved: {given_version:#?} + " + )] + InstallerBadVersion { + package_id: String, + given_version: Version, + our_version: Version, + }, + + #[error( + "The installer '{package_id}' did not respond correctly: + \t{message}" + )] + InstallerBadResponse { package_id: String, message: String }, + + #[error("The installer returned an error:\n\t{message}")] + InstallerError { message: String }, +} diff --git a/src/package/index.rs b/src/package/index.rs index 45ee5ba..78aae08 100644 --- a/src/package/index.rs +++ b/src/package/index.rs @@ -13,7 +13,7 @@ use tokio::fs::OpenOptions; use tokio::io::AsyncWriteExt; use crate::util::file; -use crate::error::Error; +use crate::error::{IoError, Error}; use crate::ts::experimental; use crate::ts::experimental::index::PackageIndexEntry; use crate::ts::package_reference::PackageReference; @@ -25,7 +25,7 @@ struct IndexHeader { } /// An index which contains packages and optimized methods to query them. -/// +/// /// Structurally this refers to three separate files, all contained within TCLI_HOME/index by default. /// 1. The package header `IndexHeader`. This contains index metadata like last update time, etc. /// 2. The package lookup table, `IndexLookup`. This is a fast-lookup datastructure which binds @@ -37,7 +37,7 @@ pub struct PackageIndex { // Yes, we're continuing this naming scheme. Why? I can't come up with anything better. tight_lookup: HashMap, - loose_lookup: HashMap>, + loose_lookup: HashMap>, index_file: File, } @@ -57,7 +57,7 @@ struct LookupTableEntry { impl PackageIndex { /// Determine if the package index requires an update. - /// + /// /// An update is requires if any of the following conditions are true: /// - Index version is less than the remote version /// - Index does not exist @@ -78,13 +78,13 @@ impl PackageIndex { } /// Syncronize the local and remote package index. - /// + /// /// This will syncronize regardless of local and remote update timestamps. /// Use `PackageIndex::requires_update` to determine if an index update is actually required. pub async fn sync(tcli_home: &Path) -> Result<(), Error> { // Assert internal file structure. if !tcli_home.is_dir() { - Err(Error::DirectoryNotFound(tcli_home.into()))?; + Err(IoError::DirNotFound(tcli_home.into()))?; } let index_dir = tcli_home.join("index"); @@ -132,7 +132,7 @@ impl PackageIndex { index_out.write_all(chunk.as_bytes()).await?; } - + let header_path = index_dir.join("header.json"); let header = IndexHeader { update_time: experimental::index::get_index_update_time().await? @@ -205,7 +205,7 @@ impl PackageIndex { .iter() .filter_map(|x| self.lookup.get(*x)) .filter_map(|x| self.read_index_string(x).ok()) - .map(|x| serde_json::from_str(&x)) + .map(|ref x| serde_json::from_str(x)) .collect::, _>>(); if let Err(ref e) = pkgs { diff --git a/src/package/install/mod.rs b/src/package/install/mod.rs index 4bee9cf..d6bcc29 100644 --- a/src/package/install/mod.rs +++ b/src/package/install/mod.rs @@ -11,9 +11,11 @@ use self::api::{Request, TrackedFile}; use self::api::Response; use self::api::PROTOCOL_VERSION; use self::manifest::InstallerManifest; +use super::error::PackageError; use super::Package; +use crate::error::IoError; +use crate::error::Error; use crate::ui::reporter::{Progress, VoidProgress, ProgressBarTrait}; -use crate::Error; pub mod api; mod legacy_compat; @@ -37,7 +39,7 @@ impl Installer { let manifest = { let path = cache_dir.join("installer.json"); if !path.is_file() { - Err(Error::InstallerNoManifest)? + Err(PackageError::InstallerNoManifest)? } else { let contents = fs::read_to_string(path)?; serde_json::from_str::(&contents)? @@ -54,7 +56,7 @@ impl Installer { .find(|x| { x.architecture.to_string() == current_arch && x.target_os.to_string() == current_os }) - .ok_or(Error::InstallerNotExecutable)?; + .ok_or(PackageError::InstallerNotExecutable)?; let exec_path = { let abs = cache_dir.join(&matrix.executable); @@ -62,7 +64,7 @@ impl Installer { if abs.is_file() { Ok(abs) } else { - Err(crate::Error::FileNotFound(abs)) + Err(IoError::FileNotFound(abs)) } }?; @@ -71,14 +73,14 @@ impl Installer { // Validate that the installer is (a) executable and (b) is using a valid protocol version. let response = installer.run(&Request::Version).await?; let Response::Version { author: _, identifier: _, protocol } = response else { - Err(Error::InstallerBadResponse { + Err(PackageError::InstallerBadResponse { package_id: package.identifier.to_string(), message: "The installer did not respond with a valid or otherwise serializable Version response variant.".to_string(), })? }; if protocol.major != PROTOCOL_VERSION.major { - Err(Error::InstallerBadVersion { + Err(PackageError::InstallerBadVersion { package_id: package.identifier.to_string(), given_version: protocol, our_version: PROTOCOL_VERSION, @@ -96,23 +98,23 @@ impl Installer { "TCLI_INSTALLER_OVERRIDE is set to {}, which does not point to a file that actually exists.", override_installer.to_str().unwrap() ) } - + Installer { exec_path: override_installer } } pub async fn install_package( - &self, - package: &Package, - package_dir: &Path, - state_dir: &Path, - staging_dir: &Path, + &self, + package: &Package, + package_dir: &Path, + state_dir: &Path, + staging_dir: &Path, reporter: &dyn ProgressBarTrait - ) -> Result, Error> { + ) -> Result, Error> { // Determine if the package is a modloader or not. let is_modloader = package.identifier.name.to_lowercase().contains("bepinex"); - + let request = Request::PackageInstall { is_modloader, package: package.identifier.clone(), @@ -129,7 +131,7 @@ impl Installer { package.identifier.version.to_string().truecolor(90, 90, 90) ); reporter.set_message(format!("Installing {progress_message}")); - + let response = self.run(&request).await?; match response { Response::PackageInstall { tracked_files, post_hook_context: _ } => { @@ -137,14 +139,14 @@ impl Installer { } Response::Error { message } => { - Err(Error::InstallerError { message }) + Err(PackageError::InstallerError { message })? } x => { - let message = + let message = format!("Didn't recieve one of the expected variants: Response::PackageInstall or Response::Error. Got: {x:#?}"); - - Err(Error::InstallerBadResponse { package_id: package.identifier.to_string(), message }) + + Err(PackageError::InstallerBadResponse { package_id: package.identifier.to_string(), message })? } } } @@ -180,12 +182,12 @@ impl Installer { let response = self.run(&request).await?; match response { Response::PackageUninstall { post_hook_context: _ } => Ok(()), - Response::Error { message } => Err(Error::InstallerError { message }), + Response::Error { message } => Err(PackageError::InstallerError { message })?, x => { let message = format!("Didn't recieve one of the expected variants: Response::PackageInstall or Response::Error. Got: {x:#?}"); - Err(Error::InstallerBadResponse { package_id: package.identifier.to_string(), message }) + Err(PackageError::InstallerBadResponse { package_id: package.identifier.to_string(), message })? } } } @@ -211,13 +213,13 @@ impl Installer { pub async fn run(&self, arg: &Request) -> Result { let args_json = serde_json::to_string(arg)?; - + let child = Command::new(&self.exec_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .arg(&args_json) .spawn()?; - + // Execute the installer, capturing and deserializing any output. // TODO: Safety check here to warn / stop an installer from blowing up the heap. let mut output_str = String::new(); diff --git a/src/package/mod.rs b/src/package/mod.rs index 46921a1..f1496ec 100644 --- a/src/package/mod.rs +++ b/src/package/mod.rs @@ -1,7 +1,8 @@ -mod cache; +pub mod cache; pub mod index; pub mod install; pub mod resolver; +pub mod error; use std::borrow::Borrow; use std::fs::File; @@ -15,7 +16,7 @@ use serde_with::{self, serde_as, DisplayFromStr}; use tokio::fs; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use crate::error::{Error, IoResultToTcli}; +use crate::error::{IoError, IoResultToTcli, Error}; use crate::ts::package_manifest::PackageManifestV1; use crate::ts::package_reference::PackageReference; use crate::ts::{self, CLIENT}; @@ -50,7 +51,7 @@ pub struct Package { } impl Package { - /// Attempt to resolve the package from the local cache or remote. + /// Attempt to resolve the package from the local cache or remote. /// This does not download the package, it just finds its "source". pub async fn from_any(ident: impl Borrow) -> Result { if cache::get_cache_location(ident.borrow()).exists() { @@ -154,7 +155,7 @@ impl Package { }; let icon = package_dir.join("icon.png"); let reference = package_dir.file_name().unwrap().to_string_lossy().to_string(); - + Ok(Some(PackageMetadata { manifest, reference, @@ -215,11 +216,14 @@ fn add_to_cache(package: &PackageReference, zipfile: impl Read + Seek) -> Result match std::fs::remove_dir_all(&output_path) { Ok(_) => (), Err(e) if e.kind() == ErrorKind::NotFound => (), - Err(e) => return Err(e).map_fs_error(&output_path), + Err(e) => Err(e).map_fs_error(&output_path)?, }; std::fs::create_dir_all(&output_path).map_fs_error(&output_path)?; - zip::read::ZipArchive::new(zipfile)?.extract(&output_path)?; + zip::read::ZipArchive::new(zipfile) + .map_err(IoError::ZipError)? + .extract(&output_path) + .map_err(IoError::ZipError)?; Ok(output_path) } diff --git a/src/project/error.rs b/src/project/error.rs new file mode 100644 index 0000000..2f61da1 --- /dev/null +++ b/src/project/error.rs @@ -0,0 +1,20 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +#[repr(u32)] +pub enum ProjectError { + #[error("A project configuration already exists at {0}.")] + ProjectAlreadyExists(PathBuf), + + #[error("No project exists at the path {0}.")] + NoProjectFile(PathBuf), + + #[error("Project is missing required table '{0}'.")] + MissingTable(&'static str), + + #[error("Missing repository url.")] + MissingRepository, + + #[error("The game identifier '{0}' does not exist within the ecosystem schema.")] + InvalidGameId(String), +} diff --git a/src/project/lock.rs b/src/project/lock.rs index bf8bbe2..7b3b28e 100644 --- a/src/project/lock.rs +++ b/src/project/lock.rs @@ -7,8 +7,8 @@ use md5::Md5; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use crate::package::Package; -use crate::Error; +use crate::error::Error; +use crate::package::{Package, PackageMetadata}; use crate::package::resolver::{DependencyGraph, InnerDepGraph}; #[derive(Serialize, Deserialize, Debug)] @@ -76,6 +76,20 @@ impl LockFile { Ok(()) } + + pub async fn installed_packages(self) -> Result, Error> { + let graph = DependencyGraph::from_graph(self.package_graph); + let mut packages = Vec::new(); + + for package in graph.digest() { + let package = Package::from_any(package).await?; + if let Some(meta) = package.get_metadata().await? { + packages.push(meta); + } + } + + Ok(packages) + } } pub fn serialize( diff --git a/src/project/manifest.rs b/src/project/manifest.rs index c654304..ef6c694 100644 --- a/src/project/manifest.rs +++ b/src/project/manifest.rs @@ -8,17 +8,19 @@ use crate::project::overrides::ProjectOverrides; use crate::ts::package_reference::{self, PackageReference}; use crate::ts::version::Version; +use super::error::ProjectError; + #[derive(Serialize, Deserialize, Debug)] pub struct ProjectManifest { pub config: ConfigData, pub package: Option, pub build: Option, - + pub publish: Option>, - + #[serde(flatten)] pub dependencies: DependencyData, - + #[serde(skip)] pub project_dir: Option, } @@ -51,10 +53,10 @@ impl ProjectManifest { pub fn read_from_file(path: impl AsRef) -> Result { let path = path.as_ref(); - let text = fs::read_to_string(path).map_err(|_| Error::NoProjectFile(path.into()))?; - + let text = fs::read_to_string(path).map_err(|_| ProjectError::NoProjectFile(path.into()))?; + let mut manifest: ProjectManifest = toml::from_str(&text)?; - + manifest.project_dir = Some( path.parent() .map(|p| p.to_path_buf()) @@ -76,8 +78,8 @@ impl ProjectManifest { let package = self .package .as_mut() - .ok_or(Error::MissingTable("package"))?; - + .ok_or(ProjectError::MissingTable("package"))?; + if let Some(namespace) = overrides.namespace { package.namespace = namespace; } @@ -91,7 +93,7 @@ impl ProjectManifest { if let Some(output_dir) = overrides.output_dir { self.build .as_mut() - .ok_or(Error::MissingTable("build"))? + .ok_or(ProjectError::MissingTable("build"))? .outdir = output_dir; } if let Some(repository) = overrides.repository { @@ -201,7 +203,7 @@ pub struct DependencyData { #[serde(default)] #[serde(with = "package_reference::ser::table")] pub dependencies: Vec, - + #[serde(default)] #[serde(rename = "dev-dependencies")] #[serde(with = "package_reference::ser::table")] diff --git a/src/project/mod.rs b/src/project/mod.rs index 8365d2f..345ae8e 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -7,13 +7,14 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use colored::Colorize; +use error::ProjectError; use futures::future::try_join_all; pub use publish::publish; use tokio::sync::Semaphore; use zip::write::FileOptions; use self::lock::LockFile; -use crate::error::{Error, IoResultToTcli}; +use crate::error::{IoError, IoResultToTcli, Error}; use crate::game::registry::GameData; use crate::game::{proc, registry}; use crate::package::index::PackageIndex; @@ -34,6 +35,7 @@ pub mod manifest; pub mod overrides; mod publish; mod state; +pub mod error; pub enum ProjectKind { Dev(ProjectOverrides), @@ -78,7 +80,7 @@ impl Project { pub fn validate(&self) -> Result<(), Error> { // A directory without a manifest is *not* a project. if !self.manifest_path.is_file() { - Err(Error::NoProjectFile(self.manifest_path.to_path_buf()))?; + Err(ProjectError::NoProjectFile(self.manifest_path.to_path_buf()))?; } // Everything within .tcli is assumed to be replacable. Therefore we only care @@ -86,7 +88,7 @@ impl Project { let dotdir = self.base_dir.join(".tcli"); if !dotdir.is_dir() { fs::create_dir(dotdir)?; - } + } Ok(()) } @@ -118,7 +120,7 @@ impl Project { project_kind: ProjectKind, ) -> Result { if project_dir.is_file() { - return Err(Error::ProjectDirIsFile(project_dir.into())); + Err(IoError::DirectoryIsFile(project_dir.into()))?; } if !project_dir.is_dir() { @@ -146,9 +148,9 @@ impl Project { let mut manifest_file = match options.open(&manifest_path) { Ok(x) => Ok(x), Err(e) if e.kind() == ErrorKind::AlreadyExists => { - Err(Error::ProjectAlreadyExists(manifest_path.clone())) + Err(ProjectError::ProjectAlreadyExists(manifest_path.clone())) } - Err(e) => Err(Error::FileIoError(manifest_path.to_path_buf(), e)), + Err(e) => Err(IoError::Native(e, Some(manifest_path.to_path_buf())))?, }?; write!( @@ -196,7 +198,7 @@ impl Project { .write_all(include_bytes!("../../resources/icon.png")) .unwrap(), Err(e) if e.kind() == ErrorKind::AlreadyExists => {} - Err(e) => Err(Error::FileIoError(icon_path, e))?, + Err(e) => Err(IoError::Native(e, Some(icon_path)))?, } let readme_path = project_dir.join("README.md"); @@ -211,7 +213,7 @@ impl Project { package.namespace, package.name, package.description )?, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(e) => return Err(Error::FileIoError(readme_path, e)), + Err(e) => Err(IoError::Native(e, Some(readme_path)))?, } let dist_dir = project.base_dir.join("dist"); @@ -509,7 +511,7 @@ impl Project { args: Vec, ) -> Result<(), Error> { let game_data = registry::get_game_data(&self.game_registry_path, game_id) - .ok_or_else(|| Error::InvalidGameId(game_id.to_string()))?; + .ok_or_else(|| ProjectError::InvalidGameId(game_id.to_string()))?; let game_dist = game_data.active_distribution; let game_dir = &game_dist.game_dir; @@ -567,13 +569,13 @@ impl Project { pub fn stop_game(&self, game_id: &str) -> Result<(), Error> { let game_data = registry::get_game_data(&self.game_registry_path, game_id) - .ok_or_else(|| Error::BadGameId(game_id.to_string()))?; + .ok_or_else(|| ProjectError::InvalidGameId(game_id.to_string()))?; let mut pid_file = self.base_dir.join(".tcli").join(game_data.identifier); pid_file.set_extension("pid"); if !pid_file.is_file() { - Err(Error::FileNotFound(pid_file.clone()))?; + Err(IoError::FileNotFound(pid_file.clone()))?; } let pid = fs::read_to_string(&pid_file)?.parse::().unwrap(); @@ -596,18 +598,18 @@ impl Project { let package = manifest .package .as_ref() - .ok_or(Error::MissingTable("package"))?; + .ok_or(ProjectError::MissingTable("package"))?; let build = manifest .build .as_ref() - .ok_or(Error::MissingTable("build"))?; + .ok_or(ProjectError::MissingTable("build"))?; let output_dir = project_dir.join(&build.outdir); match fs::create_dir_all(&output_dir) { Ok(_) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), - Err(e) => Err(Error::FileIoError(output_dir.clone(), e)), + Err(e) => Err(IoError::Native(e, Some(output_dir.clone()))), }?; let output_path = output_dir.join(format!( @@ -628,7 +630,7 @@ impl Project { // first elem is always the root, even when the path given is to a file for file in walkdir::WalkDir::new(&source_path).follow_links(true) { - let file = file?; + let file = file.map_err(IoError::DirWalker)?; let inner_path = file .path() diff --git a/src/project/publish.rs b/src/project/publish.rs index c6f9004..281be1a 100644 --- a/src/project/publish.rs +++ b/src/project/publish.rs @@ -1,10 +1,12 @@ use std::path::PathBuf; -use crate::error::Error; +use crate::error::{IoError, Error}; use crate::project::manifest::ProjectManifest; use crate::ts::experimental::models::publish::PackageSubmissionMetadata; use crate::ts::experimental::publish; +use super::error::ProjectError; + pub async fn publish( manifest: &ProjectManifest, archive_path: PathBuf, @@ -12,10 +14,10 @@ pub async fn publish( let package = manifest .package .as_ref() - .ok_or(Error::MissingTable("package"))?; + .ok_or(ProjectError::MissingTable("package"))?; if !archive_path.is_file() { - Err(Error::FileNotFound(archive_path.clone()))?; + Err(IoError::FileNotFound(archive_path.clone()))?; } let publish = manifest.publish.as_ref().unwrap(); diff --git a/src/server/lock.rs b/src/server/lock.rs new file mode 100644 index 0000000..5efda8b --- /dev/null +++ b/src/server/lock.rs @@ -0,0 +1,61 @@ +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; + +/// Only one server process can "own" a project at a single time. +/// We enforce this exclusive access through the creation and deletion of this lockfile. +const LOCKFILE: &str = ".server-lock"; + +pub struct ProjectLock { + file: File, + path: PathBuf, +} + +impl ProjectLock { + /// Attempt to acquire a lock on the provided directory. + pub fn lock(project_path: &Path) -> Option { + let lock = project_path.join(LOCKFILE); + match lock.is_file() { + true => None, + false => { + let file = File::create(&lock).ok()?; + Some(Self { file, path: lock }) + } + } + } +} + +impl Drop for ProjectLock { + fn drop(&mut self) { + // The file handle is dropped before this, so we can safely delete it. + fs::remove_file(&self.path).expect("Failed to remove lockfile."); + } +} + +#[cfg(test)] +mod test { + use super::*; + use tempfile::TempDir; + + /// Test that project locks behave in the following way: + /// - Attempting to lock an already locked project MUST return None. + /// - Attempting to lock a project that is not locked will return a ProjectLock. + /// - Dropping a ProjectLock will remove the lockfile from the project. + #[test] + fn test_project_lock() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Acquire a lock on the directory. + { + let _lock = + ProjectLock::lock(root).expect("Failed to acquire lock on empty directory."); + + // Attempting to acquire it again will return None. + let relock = ProjectLock::lock(root); + assert!(relock.is_none()); + } + + // Now that the previous lock is dropped we *should* be able to re-acquire it. + let _lock = ProjectLock::lock(root).expect("Failed to acquire lock on empty directory."); + } +} diff --git a/src/server/method/mod.rs b/src/server/method/mod.rs new file mode 100644 index 0000000..8c24916 --- /dev/null +++ b/src/server/method/mod.rs @@ -0,0 +1,53 @@ +pub mod package; +pub mod project; + +use std::sync::RwLock; + +use futures::channel::mpsc::Sender; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::project::Project; + +use self::package::PackageMethod; +use self::project::ProjectMethod; +use super::proto::Response; +use super::{Error, ServerError}; + +pub trait Routeable { + async fn route(&self, ctx: RwLock, send: Sender>); +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum Method { + Exit, + Project(ProjectMethod), + Package(PackageMethod), +} + +/// Method namespace registration. +impl Method { + pub fn from_value(method: &str, value: serde_json::Value) -> Result { + let mut split = method.split('/'); + let (namespace, name) = ( + split + .next() + .ok_or_else(|| ServerError::InvalidMethod(method.into()))?, + split + .next() + .ok_or_else(|| ServerError::InvalidMethod(method.into()))?, + ); + + // Route namespaces to the appropriate enum variants for construction. + Ok(match namespace { + "exit" => Self::Exit, + "project" => Self::Project(ProjectMethod::from_value(name, value)?), + "package" => Self::Package(PackageMethod::from_value(name, value)?), + x => Err(ServerError::InvalidMethod(x.into()))?, + }) + } +} + +pub fn parse_value(value: serde_json::Value) -> Result { + serde_json::from_value(value) +} diff --git a/src/server/method/package.rs b/src/server/method/package.rs new file mode 100644 index 0000000..1cc88c6 --- /dev/null +++ b/src/server/method/package.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +use crate::package::{cache, Package}; +use crate::server::proto::{Id, Response}; +use crate::ts::package_reference::PackageReference; +use crate::TCLI_HOME; +use crate::server::{Runtime, ServerError}; +use crate::package::index::PackageIndex; + +use super::Error; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum PackageMethod { + /// Get metadata about this package. + GetMetadata(GetMetadata), + /// Determine if the package exists within the cache. + IsCached(IsCached), + /// Syncronize the package index. + SyncIndex, +} + +impl PackageMethod { + pub fn from_value(method: &str, value: serde_json::Value) -> Result { + Ok(match method { + "get_metadata" => Self::GetMetadata(super::parse_value(value)?), + "is_cached" => Self::IsCached(super::parse_value(value)?), + "sync_index" => Self::SyncIndex, + x => Err(ServerError::InvalidMethod(x.into()))?, + }) + } + + pub async fn route(&self, rt: &mut Runtime) -> Result<(), Error> { + match self { + Self::GetMetadata(data) => { + let index = PackageIndex::open(&TCLI_HOME).await?; + let package = index.get_package(&data.package).unwrap(); + rt.send(Response::data_ok(Id::String("diowadaw".into()), package)); + }, + Self::IsCached(data) => { + let is_cached = cache::is_cached(&data.package); + rt.send(Response::data_ok(Id::String("dwdawdwa".into()), is_cached)); + } + Self::SyncIndex => { + PackageIndex::sync(&TCLI_HOME).await?; + rt.send(Response::ok(Id::String("dwada".into()))); + }, + } + + Ok(()) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct IsCached { + package: PackageReference, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct GetMetadata { + package: PackageReference, +} diff --git a/src/server/method/project.rs b/src/server/method/project.rs new file mode 100644 index 0000000..51e9f23 --- /dev/null +++ b/src/server/method/project.rs @@ -0,0 +1,96 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use crate::project::ProjectKind; +use crate::server::proto::{Id, Response, ResponseData}; +use crate::{project::Project, ui::reporter::VoidReporter}; +use crate::ts::package_reference::PackageReference; +use crate::server::{Runtime, ServerError}; +use serde::{Deserialize, Serialize}; + +use super::Error; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum ProjectMethod { + /// Opens a project, locking it in the process. + Open(OpenProject), + /// Get project metadata. + GetMetadata, + /// Add one or more packages to the project. + AddPackages(AddPackages), + /// Remove one or more packages from the project. + RemovePackages(RemovePackages), + /// Get a list of currently installed packages. + InstalledPackages, +} + +impl From> for ServerError { + fn from(val: Option) -> Self { + ServerError::InvalidContext + } +} + +impl ProjectMethod { + pub fn from_value(method: &str, value: serde_json::Value) -> Result { + Ok(match method { + "open" => Self::Open(super::parse_value(value)?), + "get_metadata" => Self::GetMetadata, + "add_packages" => Self::AddPackages(super::parse_value(value)?), + "remove_packages" => Self::RemovePackages(super::parse_value(value)?), + "installed_packages" => Self::InstalledPackages, + x => Err(ServerError::InvalidMethod(x.into()))?, + }) + } + + /// Route and execute various project methods. + /// Each of these call and interact directly with global project state. + pub async fn route(&self, rt: &mut Runtime) -> Result<(), Error> { + match self { + ProjectMethod::Open(OpenProject { path }) => { + // Unlock the previous ctx (if it exists) and relock this one. + rt.proj = Arc::new(Project::open(path) + .unwrap_or(Project::create_new(path, true, ProjectKind::Profile)?)) + }, + ProjectMethod::GetMetadata => { + rt.send(Response { + id: Id::String("OK".into()), + data: ResponseData::Result(format!("{:?}", rt.proj.statefile_path)) + }); + }, + ProjectMethod::AddPackages(packages) => { + rt.proj.add_packages(&packages.packages[..])?; + rt.proj.commit(Box::new(VoidReporter), false).await?; + }, + ProjectMethod::RemovePackages(packages) => { + rt.proj.remove_packages(&packages.packages[..])?; + rt.proj.commit(Box::new(VoidReporter), false).await?; + }, + ProjectMethod::InstalledPackages => { + let lock = rt.proj.get_lockfile()?; + let installed = lock.installed_packages().await?; + + rt.send(Response { + id: Id::Int(installed.len() as _), + data: ResponseData::Result(serde_json::to_string(&installed)?), + }); + }, + } + + Ok(()) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct OpenProject { + path: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct AddPackages { + packages: Vec, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct RemovePackages { + packages: Vec, +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..889b780 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,207 @@ +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, RwLock}; +use std::{io, thread}; + +use lock::ProjectLock; +use once_cell::sync::Lazy; +use proto::ResponseData; + +use crate::error::Error; +use crate::project::Project; +use crate::ts; + +use self::proto::{Message, Request, Response}; + +mod lock; +mod method; +mod proto; + +trait ToJson { + fn to_json(&self) -> Result; +} + +/// This is our project dir singleton. It will likely be refactored, but also likely not. +/// It's buried within a couple layers of abstraction. The Lazy is because PathBuf does not have +/// a static new(), RwLock is so we can have thread-safe interior mutability. +static PROJECT_DIR: Lazy> = Lazy::new(Default::default); + +/// This error type exists to wrap library errors into a single easy-to-use package. +#[derive(thiserror::Error, Debug)] +#[repr(isize)] +pub enum ServerError { + /// A partial implementation of the error variants described by the JRPC spec. + #[error("Failed to serialize JSON: {0:?}")] + InvalidJson(#[from] serde_json::Error) = -32700, + + #[error("The method {0} is not valid.")] + InvalidMethod(String) = -32601, + + #[error("Recieved invalid params for method {0}: {1}")] + InvalidParams(String, String) = -32602, + + #[error("")] + InvalidContext = 0, +} + +impl Error { + pub fn discriminant(&self) -> isize { + // SAFETY: `Self` is `repr(isize)` with layout `repr(C)`, with each variant having an isize + // as its first field, so we can access this value without a pointer offset. + unsafe { *<*const _>::from(self).cast::() } + } +} + +impl ToJson for Result { + fn to_json(&self) -> Result { + todo!() + } +} + +/// Runtime context for the server. This is mutable state, protected through a RwLock. +/// Mutations require a lock first, attainable through ::lock(). +struct Runtime { + tx: Sender, + proj: Arc, +} + +impl Runtime { + pub fn send(&self, response: Response) { + self.tx.send(Message::Response(response)).expect("Failed to write to mpsc tx channel."); + } +} + +/// Runtime context, mutable or otherwise. This contains the project, by which most +/// project-specific ops go through. +struct RtContext { + pub project: Project, + pub lock: ProjectLock, +} + +/// Create the server runtime from the provided read and write channels. +/// This lives for the lifespan of the process. +pub async fn spawn(read: impl Read, write: impl Write, project_dir: &Path) -> Result<(), Error> { + let (tx, rx) = mpsc::channel::(); + let cancel = RwLock::new(false); + + // This thread recieves internal mpsc messages, serializes, and writes them to stdout. + thread::spawn(move || respond_msg(rx, cancel)); + + // Begin looping over stdin messages. + let stdin = io::stdin(); + let mut line = String::new(); + + let mut rt = Runtime { + tx, + proj: Arc::new(Project::open(project_dir)?), + }; + + ts::init_repository("https://thunderstore.io", None); + + loop { + if let Err(e) = stdin.read_line(&mut line) { + panic!(""); + }; + + println!("LINE: {line}"); + + match Message::from_json(&line) { + Ok(msg) => route(msg, &mut rt).await?, + Err(e) => { + rt.tx.send(Message::Response(Response { + id: proto::Id::String("FUCK".into()), + data: ResponseData::Error(e.to_string()), + })).unwrap(); + }, + }; + + // if let Ok(msg) = Message::from_json(&line) { + // } else { + // } + + // let msg = Message::from_json(&line); + // route(msg, &rt).await?; + } +} + +/// Route +async fn route(msg: Message, rt: &mut Runtime) -> Result<(), Error> { + match msg { + Message::Request(rq) => route_rq(Request::try_from(rq)?, rt).await?, + Message::Response(_) => panic!(), + } + + Ok(()) +} + +// Request routing +async fn route_rq(rq: Request, rt: &mut Runtime) -> Result<(), Error> { + match rq.method { + method::Method::Exit => todo!(), + method::Method::Project(proj) => proj.route(rt).await?, + method::Method::Package(pack) => pack.route(rt).await?, + } + + Ok(()) +} + + +// /// The daemon's entrypoint. This is a psuedo event loop which does the following in step: +// /// 1. Read JSON-RPC input(s) from stdin. +// /// 2. Route each input. +// /// 3. Serialize the output and write to stdout. +// async fn start() { +// let stdin = io::stdin(); +// let mut line = String::new(); +// let (send, recv) = mpsc::channel::>(); + +// let cancel = RwLock::new(false); + +// // Responses are published through the tx send channel. +// // thread::spawn(move || respond_msg(recv, cancel)); + +// loop { +// // Block the main thread until we have an input line available to be read. +// // This is ok because, in theory, tasks will be processed on background threads. +// if let Err(e) = stdin.read_line(&mut line) { +// panic!("") +// } +// let res = route(&line, self.ctx, send.clone()).await; +// res.to_json().unwrap(); +// } +// } + +fn respond_msg(recv: Receiver, cancel: RwLock) { + let mut stdout = io::stdout(); + while let Ok(res) = recv.recv() { + let msg = serde_json::to_string(&res); + stdout.write_all(msg.unwrap().as_bytes()); + stdout.write_all("\n".as_bytes()); + } +} + +// Route and execute the request, returning the result. +// Messages, including the result of subsequent computation, are sent over the sender channel. +// async fn route(line: &str, ctx: RwLock, send: Sender>) -> Result { +// let req = Message::from_json(line)?; +// match req { +// Message::Request(rq) => route_rq(Request::try_from(rq)?, ctx, send).await, +// Message::Response(_) => panic!(), +// } +// } + +// /// Do the actual Request routing here. +// /// One more level of abstraction. This routes calls to their actual implementation within +// /// the method module. +// async fn route_rq( +// request: Request, +// ctx: RwLock, +// send: Sender>, +// ) -> Result { +// match request.method { +// method::Method::Exit => todo!(), +// method::Method::Project(x) => x.route(ctx, send).await, +// method::Method::Package(_) => todo!(), +// } +// diff --git a/src/server/proto.rs b/src/server/proto.rs new file mode 100644 index 0000000..3d9d585 --- /dev/null +++ b/src/server/proto.rs @@ -0,0 +1,194 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::server::method::Method; +use crate::server::Error; + +use super::ServerError; + +const JRPC_VER: &str = "2.0"; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(untagged)] +pub enum Message { + Request(RequestInner), + Response(Response), +} + +impl Message { + pub fn from_json(json: &str) -> Result { + let msg = serde_json::from_str::(json).inspect_err(|e| { + println!("{e:?}"); + }).map_err(ServerError::InvalidJson)?; + + match msg { + Message::Request(x) if x.jsonrpc != JRPC_VER => Err(ServerError::InvalidMethod(x.jsonrpc))?, + _ => Ok(msg), + } + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(untagged)] +pub enum Id { + Int(isize), + String(String), +} + +/// This is the raw representation of a JSON-RPC request *before* we convert it +/// into a structured type. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct RequestInner { + /// The JSON-RPC protocol version. Must be 2.0 as per the spec. + pub jsonrpc: String, + + /// An identifier which, as per the JSON-RPC spec, can either be an integer + /// or string. We use an untagged enum to allow serde to transparenly parse these types. + pub id: Id, + + /// This field is deserialized into a Method enum variant via Method::from_str. + /// Unfortunately this means that errors returned from Method::from_str are lost. + // #[serde_as(as = "DisplayFromStr")] + pub method: String, + + /// This field is null for notifications. + #[serde(default = "Value::default")] + #[serde(skip_serializing_if = "Value::is_null")] + pub params: Value, +} + +/// A structured JSON-RPC request. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Request { + /// The JSON-RPC identifier. + pub id: Id, + /// The method with data, if any. + pub method: Method, +} + +impl TryFrom for Request { + type Error = super::Error; + + fn try_from(value: RequestInner) -> Result { + // We deserialize the params value depending on the provided method. + // This is done by passing it to the from_value function of the Method type, + // which iterates down through nested enums until we have a concrete type for the Value + // and a valid method variant. + let method = Method::from_value(&value.method, value.params)?; + Ok(Self { + id: value.id, + method, + }) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Response { + pub id: Id, + + #[serde(flatten)] + pub data: ResponseData, +} + +impl Response { + pub fn data_ok(id: Id, data: impl Serialize) -> Response { + Response { + id, + data: ResponseData::Result(serde_json::to_string(&data).unwrap()), + } + } + + pub fn ok(id: Id) -> Response { + Response { + id, + data: ResponseData::Result("OK".into()), + } + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum ResponseData { + #[serde(rename = "result")] + Result(String), + #[serde(rename = "error")] + Error(String), +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RpcError { + pub code: isize, + pub message: String, +} + +impl From for RpcError { + fn from(value: Error) -> Self { + Self { + code: value.discriminant(), + message: value.to_string(), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::server::method::package::PackageMethod; + use crate::server::method::project::{ProjectMethod, OpenProject}; + use crate::server::ServerError; + + #[test] + fn test_jrpc_ver_validate() { + let data = r#"{ "jsonrpc": "2.0", "id": 1, "method": "oksamies""#; + } + + #[test] + fn test_request_deserialize() { + let data = r#"{ "jsonrpc": "2.0", "id": 1, "method": "project/set_context", "params": { "path": "/some/path" } }"#; + let rq: RequestInner = serde_json::from_str(&data).unwrap(); + assert_eq!(rq.id, Id::Int(1)); + assert_eq!(rq.method, "project/set_context"); + assert!(matches!(rq.params, Value::Object(..))); + + let rq = Request::try_from(rq).unwrap(); + assert_eq!(rq.id, Id::Int(1)); + assert!(matches!( + rq.method, + Method::Project(ProjectMethod::Open(OpenProject { .. })) + )); + + let data = r#"{ "jsonrpc": "2.0", "id": "oksamies", "method": "package/get_metadata" }"#; + let rq: RequestInner = serde_json::from_str(&data).unwrap(); + assert_eq!(rq.id, Id::String("oksamies".into())); + assert_eq!(rq.method, "package/get_metadata"); + assert_eq!(rq.params, Value::Null); + + // let rq = Request::try_from(rq).unwrap(); + // assert_eq!(rq.id, Id::String("oksamies".into())); + // assert!(matches!( + // rq.method, + // Method::Package(PackageMethod::GetMetadata) + // )); + + // Invalid methods should still be deserialized aok as they're checked by typed Request struct. + let data = r#"{ "jsonrpc": "2.0", "id": "oksamies", "method": "null/null" }"#; + let rq: RequestInner = serde_json::from_str(&data).unwrap(); + assert_eq!(rq.id, Id::String("oksamies".into())); + assert_eq!(rq.method, "null/null"); + assert_eq!(rq.params, Value::Null); + + // ...but should then fail to be converted into a typed Request. + let rq = Request::try_from(rq); + assert!(matches!(rq, Err(Error::Server(ServerError::InvalidMethod(..))))); // Invalid methods should still be deserialized aok as they're checked by typed Request struct. + + // Likewise, valid methods with garbage data should also fail when converted to typed. + let data = r#"{ "jsonrpc": "2.0", "id": "oksamies", "method": "project/set_context", "params": { "garbage": 1 } }"#; + let rq: RequestInner = serde_json::from_str(&data).unwrap(); + assert_eq!(rq.id, Id::String("oksamies".into())); + assert_eq!(rq.method, "project/set_context"); + assert!(matches!(rq.params, Value::Object(..))); + + let rq = Request::try_from(rq); + panic!("{rq:?}"); + assert!(matches!(rq, Err(Error::Server(ServerError::InvalidJson(..))))); + } +} diff --git a/src/ts/error.rs b/src/ts/error.rs new file mode 100644 index 0000000..e00f557 --- /dev/null +++ b/src/ts/error.rs @@ -0,0 +1,11 @@ +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("Missing auth token.")] + MissingAuthToken, + + #[error("An API error occurred.")] + BadRequest { + source: reqwest::Error, + response_body: Option, + }, +} diff --git a/src/ts/experimental/index.rs b/src/ts/experimental/index.rs index 75b0ebe..57869a4 100644 --- a/src/ts/experimental/index.rs +++ b/src/ts/experimental/index.rs @@ -5,7 +5,7 @@ use futures::io::{self, BufReader, ErrorKind}; use futures_util::Stream; use serde::{Serialize, Deserialize}; -use crate::Error; +use crate::error::{IoError, Error}; use crate::ts::package_reference::PackageReference; use crate::ts::{CLIENT, EX}; use crate::ts::version::Version; @@ -26,7 +26,7 @@ pub async fn get_index() -> Result, Error> { .get(format!("{EX}/package-index")) .send().await? .error_for_status()?; - + let reader = response .bytes_stream() .map_err(|e| io::Error::new(ErrorKind::Other, e)) @@ -40,7 +40,7 @@ pub async fn get_index() -> Result, Error> { while let Some(line) = lines.next().await { let line = line?; let parsed = serde_json::from_str(&line)?; - + entries.push(parsed); } @@ -52,7 +52,7 @@ pub async fn get_index_streamed() -> Result Result serde_json::from_str(&x).map_err(|e| e.into()), - Err(e) => Err(Error::GenericIoError(e)) + Err(e) => Err(Error::Io(IoError::Native(e, None))) }); Ok(lines) } -pub async fn get_index_streamed_raw() -> Result>, Error> { +pub async fn get_index_streamed_raw() -> Result>, Error> { let response = CLIENT .get(format!("{EX}/package-index")) .send().await? @@ -84,7 +84,7 @@ pub async fn get_index_streamed_raw() -> Result Ok(x), - Err(e) => Err(Error::GenericIoError(e)) + Err(e) => Err(IoError::Native(e, None)), }); Ok(lines) diff --git a/src/ts/experimental/publish.rs b/src/ts/experimental/publish.rs index a9fc336..570fed9 100644 --- a/src/ts/experimental/publish.rs +++ b/src/ts/experimental/publish.rs @@ -12,6 +12,7 @@ use reqwest::{header, Body}; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use crate::error::{Error, IoResultToTcli, ReqwestToTcli}; +use crate::ts::error::ApiError; use crate::ts::experimental::models::publish::*; use crate::ts::{AUTH, CLIENT, EX}; use crate::ui::PROGRESS_STYLE; @@ -23,7 +24,7 @@ pub async fn usermedia_initiate( .post(format!("{EX}/usermedia/initiate-upload/")) .header( header::AUTHORIZATION, - AUTH.get().ok_or(Error::MissingAuthToken)?, + AUTH.get().ok_or(ApiError::MissingAuthToken)?, ) .json(params) .send() @@ -42,7 +43,7 @@ pub async fn usermedia_finish( .post(format!("{EX}/usermedia/{uuid}/finish-upload/")) .header( header::AUTHORIZATION, - AUTH.get().ok_or(Error::MissingAuthToken)?, + AUTH.get().ok_or(ApiError::MissingAuthToken)?, ) .json(params) .send() @@ -57,7 +58,7 @@ pub async fn usermedia_abort(uuid: String) -> Result<(), Error> { .post(format!("{EX}/usermedia/{uuid}/abort-upload/")) .header( header::AUTHORIZATION, - AUTH.get().ok_or(Error::MissingAuthToken)?, + AUTH.get().ok_or(ApiError::MissingAuthToken)?, ) .send() .await? @@ -168,7 +169,7 @@ pub async fn package_submit( .post(format!("{EX}/submission/submit/")) .header( header::AUTHORIZATION, - AUTH.get().ok_or(Error::MissingAuthToken)?, + AUTH.get().ok_or(ApiError::MissingAuthToken)?, ) .json(params) .send() diff --git a/src/ts/mod.rs b/src/ts/mod.rs index 42ec782..a8c9ec9 100644 --- a/src/ts/mod.rs +++ b/src/ts/mod.rs @@ -9,6 +9,7 @@ pub mod package_manifest; pub mod package_reference; pub mod v1; pub mod version; +pub mod error; pub struct RepositoryUrl(OnceCell); diff --git a/src/util/file.rs b/src/util/file.rs index 21defdb..47b0bba 100644 --- a/src/util/file.rs +++ b/src/util/file.rs @@ -4,7 +4,7 @@ use std::path::Path; use md5::{Digest, Md5}; use md5::digest::FixedOutput; use walkdir::WalkDir; -use crate::error::Error; +use crate::error::{IoError, Error}; pub fn md5(file: &Path) -> Result { let mut md5 = Md5::new(); @@ -17,7 +17,7 @@ pub fn md5(file: &Path) -> Result { // Recursively remove empty directories starting at a given path. pub fn remove_empty_dirs(root: &Path, remove_root: bool) -> Result<(), Error> { if root.is_file() || !root.exists() { - Err(Error::DirectoryNotFound(root.to_path_buf()))?; + Err(IoError::DirNotFound(root.to_path_buf()))?; } let dirs = WalkDir::new(root) @@ -57,7 +57,7 @@ pub fn remove_empty_dirs(root: &Path, remove_root: bool) -> Result<(), Error> { } /// Read buf.len() bytes at the offset within the file. -/// +/// /// This function exists to ameliorate the differences in which Windows and Unix platforms /// implement file offset reads. pub fn read_offset(file: &File, buf: &mut [u8], offset: u64) -> Result { diff --git a/src/util/reg.rs b/src/util/reg.rs index f115886..6f5dc07 100644 --- a/src/util/reg.rs +++ b/src/util/reg.rs @@ -23,22 +23,24 @@ pub struct RegKeyVal { mod inner { use winreg::RegKey; - use super::{Error, HKey, RegKeyVal}; + use crate::error::IoError; - pub fn get_value_at(hkey: HKey, subkey: &str, name: &str) -> Result { + use super::{HKey, RegKeyVal}; + + pub fn get_value_at(hkey: HKey, subkey: &str, name: &str) -> Result { open_subkey(hkey, subkey)? .get_value(name) - .map_err(|_| Error::RegistryValueRead(subkey.to_string(), name.to_string())) + .map_err(|_| IoError::RegistryValueRead(subkey.to_string(), name.to_string())) } - pub fn get_keys_at(hkey: HKey, subkey: &str) -> Result, Error> { + pub fn get_keys_at(hkey: HKey, subkey: &str) -> Result, IoError> { open_subkey(hkey, subkey)? .enum_keys() .collect::, _>>() - .map_err(|_| Error::RegistrySubkeyRead(subkey.to_string())) + .map_err(|_| IoError::RegistrySubkeyRead(subkey.to_string())) } - pub fn get_values_at(hkey: HKey, subkey: &str) -> Result, Error> { + pub fn get_values_at(hkey: HKey, subkey: &str) -> Result, IoError> { open_subkey(hkey, subkey)? .enum_values() .map(|x| match x { @@ -49,14 +51,14 @@ mod inner { Err(e) => Err(e), }) .collect::, _>>() - .map_err(|_| Error::RegistrySubkeyRead(subkey.to_string())) + .map_err(|_| IoError::RegistrySubkeyRead(subkey.to_string())) } - fn open_subkey(hkey: HKey, subkey: &str) -> Result { + fn open_subkey(hkey: HKey, subkey: &str) -> Result { let local = RegKey::predef(hkey as _); local .open_subkey(subkey) - .map_err(|_| Error::RegistrySubkeyRead(subkey.to_string())) + .map_err(|_| IoError::RegistrySubkeyRead(subkey.to_string())) } } diff --git a/src/util/temp_file.rs b/src/util/temp_file.rs index f9f4fdb..f3f63ee 100644 --- a/src/util/temp_file.rs +++ b/src/util/temp_file.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; -use crate::error::{Error, IoResultToTcli}; +use crate::error::{IoResultToTcli, Error}; pub struct TempFile(Option, Option);