From 0a18a6b0e649d0ce4864f06146fd4e94d9b369f1 Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Tue, 14 May 2024 14:03:39 -0400 Subject: [PATCH 1/5] feat: impl json-rpc types, routing, deserializers Introduce foundational JSON-RPC types, deserialization, and an initial swing at a POC method API for the project and package. --- src/main.rs | 1 + src/server/method.rs | 147 +++++++++++++++++++++++++++++++++++++++++++ src/server/mod.rs | 8 +++ src/server/proto.rs | 104 ++++++++++++++++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 src/server/method.rs create mode 100644 src/server/mod.rs create mode 100644 src/server/proto.rs diff --git a/src/main.rs b/src/main.rs index 2d6b831..e68f5d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ mod error; mod game; mod package; mod project; +mod server; mod ts; mod ui; mod util; diff --git a/src/server/method.rs b/src/server/method.rs new file mode 100644 index 0000000..d9d9734 --- /dev/null +++ b/src/server/method.rs @@ -0,0 +1,147 @@ +use std::fmt::Display; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +#[derive(thiserror::Error, Debug, PartialEq, Eq)] +pub enum Error { + #[error("The method '{0}' is malformed or otherwise invalid.")] + BadMethod(String), + #[error("The method '{0}' is invalid.")] + InvalidMethodName(String), +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum Method { + Project(ProjectMethod), + Package(PackageMethod), +} + +impl Display for Method { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + panic!("Invalid use, Method cannot be serialized (despite it being possible to do so)."); + } +} + +/// Method namespace registration. +impl FromStr for Method { + type Err = Error; + + fn from_str(value: &str) -> Result { + let mut split = value.split('/'); + let (namespace, name) = ( + split.next().ok_or_else(|| Error::BadMethod(value.into()))?, + split.next().ok_or_else(|| Error::BadMethod(value.into()))?, + ); + + // Route namespaces to the appropriate enum variants for construction. + Ok(match namespace { + "project" => Self::Project(ProjectMethod::from_str(&name)?), + "package" => Self::Package(PackageMethod::from_str(&name)?), + x => Err(Error::InvalidMethodName(x.into()))?, + }) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum ProjectMethod { + /// Set the project directory context. This locks the project, if it exists. + SetContext, + /// Release the project directory context. This unlocks the project, if a lock exists. + ReleaseContext, + /// Get project metadata. + GetMetadata, + /// Add one or more packages to the project. + AddPackages, + /// Remove one or more packages from the project. + RemovePackages, + /// Get a list of currently installed packages. + GetPackages, + /// Determine if the current context is a valid project. + IsValid, +} + +impl FromStr for ProjectMethod { + type Err = Error; + + fn from_str(value: &str) -> Result { + Ok(match value { + "set_context" => Self::SetContext, + "release_context" => Self::ReleaseContext, + "get_metadata" => Self::GetMetadata, + "add_packages" => Self::AddPackages, + "remove_packages" => Self::RemovePackages, + "get_packages" => Self::GetPackages, + "is_valid" => Self::IsValid, + x => Err(Error::InvalidMethodName(x.into()))?, + }) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum PackageMethod { + /// Get metadata about this package. + GetMetadata, + /// Determine if the package exists within the cache. + IsCached, +} + +impl FromStr for PackageMethod { + type Err = Error; + + fn from_str(value: &str) -> Result { + Ok(match value { + "get_metadata" => Self::GetMetadata, + "is_cached" => Self::IsCached, + x => Err(Error::InvalidMethodName(x.into()))?, + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_namespace_resolve() { + let method = "project/set_context"; + let resolved = Method::from_str(method).unwrap(); + assert_eq!(resolved, Method::Project(ProjectMethod::SetContext)); + + let method = "project/release_context"; + let resolved = Method::from_str(method).unwrap(); + assert_eq!(resolved, Method::Project(ProjectMethod::ReleaseContext)); + + // Assert that methods with invalid structure are caught. + let method = "null"; + let resolved = Method::from_str(method); + assert!(resolved.is_err()); + assert!(matches!(resolved.err().unwrap(), Error::BadMethod(..))); + + // Assert that invalid methods with correct structure are caught. + let method = "null/null"; + let resolved = Method::from_str(method); + assert!(resolved.is_err()); + assert!(matches!( + resolved.err().unwrap(), + Error::InvalidMethodName(..), + )); + + // Assert that name resolution can handle bad names. + let name = "null"; + let resolved = ProjectMethod::from_str(name); + assert!(resolved.is_err()); + assert!(matches!( + resolved.err().unwrap(), + Error::InvalidMethodName(..), + )); + + let name = "null"; + let resolved = PackageMethod::from_str(name); + assert!(resolved.is_err()); + assert!(matches!( + resolved.err().unwrap(), + Error::InvalidMethodName(..), + )); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..7600575 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,8 @@ +mod method; +mod proto; + +/// 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. +pub async fn start() {} diff --git a/src/server/proto.rs b/src/server/proto.rs new file mode 100644 index 0000000..7fd0485 --- /dev/null +++ b/src/server/proto.rs @@ -0,0 +1,104 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_with::{serde_as, DisplayFromStr}; + +use crate::server::method::Method; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("The provided message '{0}' could not be parsed as JSON.")] + InvalidMessage(String), +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(untagged)] +pub enum Message { + Request(Request), + Response(Response), +} + +impl Message { + pub fn from_json(json: &str) -> Result { + serde_json::from_str::(json).map_err(|e| Error::InvalidMessage(e.to_string())) + } +} + +/// This FromStr wrapper is here specifically for serde_with deserialization. +impl FromStr for Message { + type Err = Error; + + fn from_str(s: &str) -> Result { + Message::from_json(s) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(untagged)] +pub enum Id { + Int(isize), + String(String), +} + +#[serde_as] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Request { + /// 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: Method, + + /// This field is null for notifications. + #[serde(default = "Value::default")] + #[serde(skip_serializing_if = "Value::is_null")] + pub params: Value, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Response { + pub id: Id, + pub content: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ErrorMessage { + pub id: Id, +} + +#[cfg(test)] +mod test { + use super::*; + use crate::server::method::{PackageMethod, ProjectMethod}; + + #[test] + fn test_request_deserialize() { + let request = "{ \"id\": 1, \"method\": \"project/set_context\" }"; + let de = Message::from_str(request).unwrap(); + let cmp = Request { + id: Id::Int(1), + method: Method::Project(ProjectMethod::SetContext), + params: Value::Null, + }; + assert_eq!(de, Message::Request(cmp)); + + let request = "{ \"id\": \"oksamies\", \"method\": \"package/get_metadata\" }"; + let de = Message::from_str(request).unwrap(); + let cmp = Request { + id: Id::String(String::from("oksamies")), + method: Method::Package(PackageMethod::GetMetadata), + params: Value::Null, + }; + assert_eq!(de, Message::Request(cmp)); + + // At this point the error is pretty obfuscated behind serde_json::Error, so we just + // check to see if an error was returned. + let request = "{ \"id\": \"oksamies\", \"method\": \"null/null\" }"; + let de: Result = serde_json::from_str(request); + assert!(de.is_err()); + } +} From 09d89423dbd8116236db95c0719c1f628d07bd51 Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Mon, 27 May 2024 16:14:43 -0400 Subject: [PATCH 2/5] refactor methods into submodule, impl simple msg loop --- src/server/lock.rs | 31 +++++++ src/server/method.rs | 147 --------------------------------- src/server/method/mod.rs | 43 ++++++++++ src/server/method/package.rs | 21 +++++ src/server/method/project.rs | 55 +++++++++++++ src/server/mod.rs | 84 ++++++++++++++++++- src/server/proto.rs | 154 ++++++++++++++++++++++++----------- src/server/route.rs | 37 +++++++++ 8 files changed, 376 insertions(+), 196 deletions(-) create mode 100644 src/server/lock.rs delete mode 100644 src/server/method.rs create mode 100644 src/server/method/mod.rs create mode 100644 src/server/method/package.rs create mode 100644 src/server/method/project.rs create mode 100644 src/server/route.rs diff --git a/src/server/lock.rs b/src/server/lock.rs new file mode 100644 index 0000000..d50e1e9 --- /dev/null +++ b/src/server/lock.rs @@ -0,0 +1,31 @@ +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("The lock at {0:?} is already acquired by a process with PID {1}.")] + InUse(PathBuf, u32), +} + +/// 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: &'static str = ".server-lock"; + +pub struct ProjectLock<'a> { + file: File, + path: &'a Path, +} + +impl<'a> ProjectLock<'_> { + /// Attempt to acquire a lock on the provided directory. + pub fn lock(project_path: &'a Path) -> Result { + todo!() + } +} + +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); + } +} diff --git a/src/server/method.rs b/src/server/method.rs deleted file mode 100644 index d9d9734..0000000 --- a/src/server/method.rs +++ /dev/null @@ -1,147 +0,0 @@ -use std::fmt::Display; -use std::str::FromStr; - -use serde::{Deserialize, Serialize}; - -#[derive(thiserror::Error, Debug, PartialEq, Eq)] -pub enum Error { - #[error("The method '{0}' is malformed or otherwise invalid.")] - BadMethod(String), - #[error("The method '{0}' is invalid.")] - InvalidMethodName(String), -} - -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] -pub enum Method { - Project(ProjectMethod), - Package(PackageMethod), -} - -impl Display for Method { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - panic!("Invalid use, Method cannot be serialized (despite it being possible to do so)."); - } -} - -/// Method namespace registration. -impl FromStr for Method { - type Err = Error; - - fn from_str(value: &str) -> Result { - let mut split = value.split('/'); - let (namespace, name) = ( - split.next().ok_or_else(|| Error::BadMethod(value.into()))?, - split.next().ok_or_else(|| Error::BadMethod(value.into()))?, - ); - - // Route namespaces to the appropriate enum variants for construction. - Ok(match namespace { - "project" => Self::Project(ProjectMethod::from_str(&name)?), - "package" => Self::Package(PackageMethod::from_str(&name)?), - x => Err(Error::InvalidMethodName(x.into()))?, - }) - } -} - -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] -pub enum ProjectMethod { - /// Set the project directory context. This locks the project, if it exists. - SetContext, - /// Release the project directory context. This unlocks the project, if a lock exists. - ReleaseContext, - /// Get project metadata. - GetMetadata, - /// Add one or more packages to the project. - AddPackages, - /// Remove one or more packages from the project. - RemovePackages, - /// Get a list of currently installed packages. - GetPackages, - /// Determine if the current context is a valid project. - IsValid, -} - -impl FromStr for ProjectMethod { - type Err = Error; - - fn from_str(value: &str) -> Result { - Ok(match value { - "set_context" => Self::SetContext, - "release_context" => Self::ReleaseContext, - "get_metadata" => Self::GetMetadata, - "add_packages" => Self::AddPackages, - "remove_packages" => Self::RemovePackages, - "get_packages" => Self::GetPackages, - "is_valid" => Self::IsValid, - x => Err(Error::InvalidMethodName(x.into()))?, - }) - } -} - -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] -pub enum PackageMethod { - /// Get metadata about this package. - GetMetadata, - /// Determine if the package exists within the cache. - IsCached, -} - -impl FromStr for PackageMethod { - type Err = Error; - - fn from_str(value: &str) -> Result { - Ok(match value { - "get_metadata" => Self::GetMetadata, - "is_cached" => Self::IsCached, - x => Err(Error::InvalidMethodName(x.into()))?, - }) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_namespace_resolve() { - let method = "project/set_context"; - let resolved = Method::from_str(method).unwrap(); - assert_eq!(resolved, Method::Project(ProjectMethod::SetContext)); - - let method = "project/release_context"; - let resolved = Method::from_str(method).unwrap(); - assert_eq!(resolved, Method::Project(ProjectMethod::ReleaseContext)); - - // Assert that methods with invalid structure are caught. - let method = "null"; - let resolved = Method::from_str(method); - assert!(resolved.is_err()); - assert!(matches!(resolved.err().unwrap(), Error::BadMethod(..))); - - // Assert that invalid methods with correct structure are caught. - let method = "null/null"; - let resolved = Method::from_str(method); - assert!(resolved.is_err()); - assert!(matches!( - resolved.err().unwrap(), - Error::InvalidMethodName(..), - )); - - // Assert that name resolution can handle bad names. - let name = "null"; - let resolved = ProjectMethod::from_str(name); - assert!(resolved.is_err()); - assert!(matches!( - resolved.err().unwrap(), - Error::InvalidMethodName(..), - )); - - let name = "null"; - let resolved = PackageMethod::from_str(name); - assert!(resolved.is_err()); - assert!(matches!( - resolved.err().unwrap(), - Error::InvalidMethodName(..), - )); - } -} diff --git a/src/server/method/mod.rs b/src/server/method/mod.rs new file mode 100644 index 0000000..a18e951 --- /dev/null +++ b/src/server/method/mod.rs @@ -0,0 +1,43 @@ +pub mod package; +pub mod project; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use self::package::PackageMethod; +use self::project::ProjectMethod; +use super::Error; + +#[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(|| Error::InvalidMethod(method.into()))?, + split + .next() + .ok_or_else(|| Error::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(Error::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..dc75bfd --- /dev/null +++ b/src/server/method/package.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +use super::Error; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum PackageMethod { + /// Get metadata about this package. + GetMetadata, + /// Determine if the package exists within the cache. + IsCached, +} + +impl PackageMethod { + pub fn from_value(method: &str, value: serde_json::Value) -> Result { + Ok(match method { + "get_metadata" => Self::GetMetadata, + "is_cached" => Self::IsCached, + x => Err(Error::InvalidMethod(x.into()))?, + }) + } +} diff --git a/src/server/method/project.rs b/src/server/method/project.rs new file mode 100644 index 0000000..e4c5b4e --- /dev/null +++ b/src/server/method/project.rs @@ -0,0 +1,55 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::ts::package_reference::PackageReference; + +use super::Error; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum ProjectMethod { + /// Set the project directory context. This locks the project, if it exists. + SetContext(SetContext), + /// Release the project directory context. This unlocks the project, if a lock exists. + ReleaseContext, + /// 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. + GetPackages, + /// Determine if the current context is a valid project. + IsValid, +} + +impl ProjectMethod { + pub fn from_value(method: &str, value: serde_json::Value) -> Result { + Ok(match method { + "set_context" => Self::SetContext(super::parse_value(value)?), + "release_context" => Self::ReleaseContext, + "get_metadata" => Self::GetMetadata, + "add_packages" => Self::AddPackages(super::parse_value(value)?), + "remove_packages" => Self::RemovePackages(super::parse_value(value)?), + "get_packages" => Self::GetPackages, + "is_valid" => Self::IsValid, + x => Err(Error::InvalidMethod(x.into()))?, + }) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct SetContext { + 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 index 7600575..4f3fc40 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,8 +1,90 @@ +use std::io::Write; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::RwLock; +use std::{io, thread}; + +use self::proto::{Message, Request, Response}; + mod method; mod proto; +mod route; + +trait ToJson { + fn to_json(&self) -> Result; +} + +/// This error type exists to wrap library errors into a single easy-to-use package. +#[derive(thiserror::Error, Debug)] +#[repr(isize)] +pub enum Error { + /// 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, + + /// Wrapper error types and codes. + #[error("${0:?}")] + ProjectError(String) = 1000, + + #[error("{0:?}")] + PackageError(String) = 2000, +} + +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!() + } +} /// 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. -pub async fn start() {} +async fn start() { + let stdin = io::stdin(); + let mut line = String::new(); + let (tx, rx) = mpsc::channel::>(); + + let cancel = RwLock::new(false); + + // Responses are published through the tx send channel. + thread::spawn(move || respond_msg(rx, 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, tx.clone()).await; + res.to_json().unwrap(); + } +} + +fn respond_msg(rx: Receiver>, cancel: RwLock) { + let mut stdout = io::stdout(); + while let Ok(res) = rx.recv() { + let msg = res.map(|x| serde_json::to_string(&x).unwrap()); + stdout.write_all(msg.unwrap().as_bytes()); + stdout.write_all("\n".as_bytes()); + } +} + +/// Route and execute the request, returning the result. +async fn route(line: &str, tx: Sender>) -> Result { + let req = Message::from_json(line); + todo!() +} diff --git a/src/server/proto.rs b/src/server/proto.rs index 7fd0485..096998a 100644 --- a/src/server/proto.rs +++ b/src/server/proto.rs @@ -1,36 +1,26 @@ -use std::str::FromStr; - use serde::{Deserialize, Serialize}; use serde_json::Value; -use serde_with::{serde_as, DisplayFromStr}; use crate::server::method::Method; +use crate::server::Error; -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("The provided message '{0}' could not be parsed as JSON.")] - InvalidMessage(String), -} +const JRPC_VER: &str = "2.0"; #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(untagged)] pub enum Message { - Request(Request), + Request(RequestInner), Response(Response), } impl Message { pub fn from_json(json: &str) -> Result { - serde_json::from_str::(json).map_err(|e| Error::InvalidMessage(e.to_string())) - } -} - -/// This FromStr wrapper is here specifically for serde_with deserialization. -impl FromStr for Message { - type Err = Error; + let msg = serde_json::from_str::(json).map_err(Error::InvalidJson)?; - fn from_str(s: &str) -> Result { - Message::from_json(s) + match msg { + Message::Request(x) if x.jsonrpc != JRPC_VER => Err(Error::InvalidMethod(x.jsonrpc)), + _ => Ok(msg), + } } } @@ -41,17 +31,21 @@ pub enum Id { String(String), } -#[serde_as] +/// 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 Request { +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: Method, + // #[serde_as(as = "DisplayFromStr")] + pub method: String, /// This field is null for notifications. #[serde(default = "Value::default")] @@ -59,6 +53,31 @@ pub struct Request { 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, @@ -66,39 +85,78 @@ pub struct Response { } #[derive(Serialize, Deserialize, Debug)] -pub struct ErrorMessage { - pub id: Id, +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::{PackageMethod, ProjectMethod}; + use crate::server::method::package::PackageMethod; + use crate::server::method::project::{ProjectMethod, SetContext}; + + #[test] + fn test_jrpc_ver_validate() { + let data = r#"{ "jsonrpc": "2.0", "id": 1, "method": "oksamies""#; + } #[test] fn test_request_deserialize() { - let request = "{ \"id\": 1, \"method\": \"project/set_context\" }"; - let de = Message::from_str(request).unwrap(); - let cmp = Request { - id: Id::Int(1), - method: Method::Project(ProjectMethod::SetContext), - params: Value::Null, - }; - assert_eq!(de, Message::Request(cmp)); - - let request = "{ \"id\": \"oksamies\", \"method\": \"package/get_metadata\" }"; - let de = Message::from_str(request).unwrap(); - let cmp = Request { - id: Id::String(String::from("oksamies")), - method: Method::Package(PackageMethod::GetMetadata), - params: Value::Null, - }; - assert_eq!(de, Message::Request(cmp)); - - // At this point the error is pretty obfuscated behind serde_json::Error, so we just - // check to see if an error was returned. - let request = "{ \"id\": \"oksamies\", \"method\": \"null/null\" }"; - let de: Result = serde_json::from_str(request); - assert!(de.is_err()); + 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::SetContext(SetContext { .. })) + )); + + 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::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); + assert!(matches!(rq, Err(Error::InvalidJson(..)))); } } diff --git a/src/server/route.rs b/src/server/route.rs new file mode 100644 index 0000000..6ecc1a7 --- /dev/null +++ b/src/server/route.rs @@ -0,0 +1,37 @@ +use std::sync::mpsc::Sender; + +use self::package::PackageMethod; +use self::project::ProjectMethod; + +use super::Error; +use super::method::*; +use super::proto::{Request, Response}; + +/// Route the request to the appropriate TCLI backend. +pub fn route(request: Request) -> Result<(), ()> { + match request.method { + Method::Package(PackageMethod::IsCached) => panic!(), + _ => panic!(), + }; + + Ok(()) +} + +fn route_project(method: ProjectMethod, tx: Sender>) { + match method { + ProjectMethod::SetContext(_) => todo!(), + ProjectMethod::ReleaseContext => todo!(), + ProjectMethod::GetMetadata => todo!(), + ProjectMethod::AddPackages(_) => todo!(), + ProjectMethod::RemovePackages(_) => todo!(), + ProjectMethod::GetPackages => todo!(), + ProjectMethod::IsValid => todo!(), + } +} + +fn route_package(method: PackageMethod) { + match method { + PackageMethod::IsCached => (), + PackageMethod::GetMetadata => (), + } +} From 074526b4dae84f50df93c51af4ee76aee1937863 Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Mon, 27 May 2024 16:48:49 -0400 Subject: [PATCH 3/5] implement primitive file-based project lock --- Cargo.lock | 112 +++++++++++++-------------------------------- Cargo.toml | 9 +++- src/server/lock.rs | 56 +++++++++++++++++------ src/server/mod.rs | 1 + 4 files changed, 85 insertions(+), 93 deletions(-) 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/server/lock.rs b/src/server/lock.rs index d50e1e9..dd057ac 100644 --- a/src/server/lock.rs +++ b/src/server/lock.rs @@ -1,31 +1,61 @@ use std::fs::{self, File}; use std::path::{Path, PathBuf}; -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("The lock at {0:?} is already acquired by a process with PID {1}.")] - InUse(PathBuf, u32), -} - /// 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: &'static str = ".server-lock"; -pub struct ProjectLock<'a> { +pub struct ProjectLock { file: File, - path: &'a Path, + path: PathBuf, } -impl<'a> ProjectLock<'_> { +impl ProjectLock { /// Attempt to acquire a lock on the provided directory. - pub fn lock(project_path: &'a Path) -> Result { - todo!() + 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<'_> { +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); + fs::remove_file(&self.path).unwrap(); + } +} + +#[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/mod.rs b/src/server/mod.rs index 4f3fc40..a1f1d7d 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -5,6 +5,7 @@ use std::{io, thread}; use self::proto::{Message, Request, Response}; +mod lock; mod method; mod proto; mod route; From f4fbf1983f1a2f0ae304f238590cf94bf6b15225 Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Mon, 10 Feb 2025 02:58:22 -0500 Subject: [PATCH 4/5] refactor error variants to be more module-specific --- src/error.rs | 166 +++++++++++++-------------------- src/game/error.rs | 30 ++++++ src/game/import/ea.rs | 10 +- src/game/import/egs.rs | 14 ++- src/game/import/gamepass.rs | 13 ++- src/game/import/mod.rs | 42 +-------- src/game/import/nodrm.rs | 8 +- src/game/import/steam.rs | 18 ++-- src/game/mod.rs | 1 + src/game/proc.rs | 6 +- src/main.rs | 97 +++++++++---------- src/package/cache.rs | 4 +- src/package/error.rs | 35 +++++++ src/package/index.rs | 16 ++-- src/package/install/mod.rs | 48 +++++----- src/package/mod.rs | 14 ++- src/project/error.rs | 20 ++++ src/project/lock.rs | 2 +- src/project/manifest.rs | 22 +++-- src/project/mod.rs | 32 ++++--- src/project/publish.rs | 8 +- src/ts/error.rs | 11 +++ src/ts/experimental/index.rs | 14 +-- src/ts/experimental/publish.rs | 9 +- src/ts/mod.rs | 1 + src/util/file.rs | 6 +- src/util/reg.rs | 20 ++-- src/util/temp_file.rs | 2 +- 28 files changed, 369 insertions(+), 300 deletions(-) create mode 100644 src/game/error.rs create mode 100644 src/package/error.rs create mode 100644 src/project/error.rs create mode 100644 src/ts/error.rs diff --git a/src/error.rs b/src/error.rs index ced46c5..5fcc731 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,141 +1,109 @@ use std::path::{Path, PathBuf}; -use crate::ts::version::Version; +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}")] + Io(#[from] IoError), + + #[error("{0}")] + JsonParse(#[from] serde_json::Error), - #[error("The directory at {0} does not exist or is otherwise not accessible.")] - DirectoryNotFound(PathBuf), + #[error("{0}")] + TomlDeserialize(#[from] toml::de::Error), - #[error("A network error occurred while sending an API request.")] - NetworkError(#[from] reqwest::Error), + #[error("{0}")] + TomlSerialize(#[from] toml::ser::Error), +} - #[error("The path at {0} is actually a file.")] - ProjectDirIsFile(PathBuf), +#[derive(Debug, thiserror::Error)] +pub enum IoError { + #[error("A file IO error occured: {0}.")] + Native(std::io::Error, Option), - #[error("A project configuration already exists at {0}.")] - ProjectAlreadyExists(PathBuf), + #[error("File not found: {0}.")] + FileNotFound(PathBuf), - #[error("A generic IO error occurred: {0}")] - GenericIoError(#[from] std::io::Error), + #[error("Expected directory at '{0}', got file.")] + DirectoryIsFile(PathBuf), - #[error("A file IO error occurred at path {0}: {1}")] - FileIoError(PathBuf, std::io::Error), + #[error("Directory not found: {0}.")] + DirNotFound(PathBuf), - #[error("Invalid version.")] - InvalidVersion(#[from] crate::ts::version::VersionParseError), + #[error("{0}")] + DirWalker(walkdir::Error), - #[error("Failed to read project file. {0}")] - FailedDeserializeProject(#[from] toml::de::Error), + #[error("Failed to find file '{0}' within the directory '{1}.")] + FailedFileSearch(String, PathBuf), - #[error("No project exists at the path {0}.")] - NoProjectFile(PathBuf), + #[error("Failed to read subkey at '{0}'.")] + RegistrySubkeyRead(String), + + #[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), +} - #[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: 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)) + } } 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 e68f5d1..c45d329 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,16 +6,18 @@ 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,7 +31,7 @@ mod error; mod game; mod package; mod project; -mod server; +// mod server; mod ts; mod ui; mod util; @@ -48,7 +50,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, @@ -74,7 +76,7 @@ async fn main() -> Result<(), Error> { } Ok(()) - }, + } Commands::Build { package_name, package_namespace, @@ -88,7 +90,7 @@ async fn main() -> Result<(), Error> { .name_override(package_name) .version_override(package_version) .output_dir_override(output_dir); - + project.build(overrides)?; Ok(()) } @@ -103,31 +105,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) } }?; @@ -183,9 +185,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)?; @@ -200,61 +200,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); @@ -287,7 +285,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}"); @@ -339,7 +340,7 @@ async fn main() -> Result<(), Error> { for package in graph.digest() { 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)?; @@ -349,5 +350,7 @@ async fn main() -> Result<(), Error> { Ok(()) } }, - } + }; + + test } diff --git a/src/package/cache.rs b/src/package/cache.rs index 3884eec..da88091 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")); 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..14738dd 100644 --- a/src/package/mod.rs +++ b/src/package/mod.rs @@ -2,6 +2,7 @@ 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..0539adf 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::error::Error; use crate::package::Package; -use crate::Error; use crate::package::resolver::{DependencyGraph, InnerDepGraph}; #[derive(Serialize, Deserialize, Debug)] 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/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); From 55c26483859bba2358ae7c837423d76cabec015e Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Sat, 1 Mar 2025 02:11:03 -0500 Subject: [PATCH 5/5] implement minimal suite of server methods --- src/cli.rs | 6 ++ src/error.rs | 41 ++++++-- src/main.rs | 12 ++- src/package/cache.rs | 4 + src/package/mod.rs | 2 +- src/project/lock.rs | 16 ++- src/server/lock.rs | 4 +- src/server/method/mod.rs | 18 +++- src/server/method/package.rs | 50 +++++++++- src/server/method/project.rs | 71 +++++++++++--- src/server/mod.rs | 182 ++++++++++++++++++++++++++++------- src/server/proto.rs | 58 ++++++++--- src/server/route.rs | 37 ------- 13 files changed, 382 insertions(+), 119 deletions(-) delete mode 100644 src/server/route.rs 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 5fcc731..9e31022 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +use crate::server::ServerError; use crate::ts::error::ApiError; use crate::game::error::GameError; @@ -22,21 +23,18 @@ pub enum Error { Api(#[from] ApiError), #[error("{0}")] - Io(#[from] IoError), - - #[error("{0}")] - JsonParse(#[from] serde_json::Error), + Server(#[from] ServerError), #[error("{0}")] - TomlDeserialize(#[from] toml::de::Error), + Io(#[from] IoError), #[error("{0}")] - TomlSerialize(#[from] toml::ser::Error), + Parse(#[from] ParseError), } #[derive(Debug, thiserror::Error)] pub enum IoError { - #[error("A file IO error occured: {0}.")] + #[error("A file IO error occurred: {0}.")] Native(std::io::Error, Option), #[error("File not found: {0}.")] @@ -64,6 +62,18 @@ pub enum IoError { 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)) @@ -82,6 +92,23 @@ impl From for Error { } } +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)) + } +} + +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; } diff --git a/src/main.rs b/src/main.rs index c45d329..581960d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +use std::io::{self, Stdin}; use std::path::PathBuf; use clap::Parser; @@ -31,7 +32,7 @@ mod error; mod game; mod package; mod project; -// mod server; +mod server; mod ts; mod ui; mod util; @@ -338,6 +339,8 @@ 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; @@ -350,6 +353,13 @@ 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 da88091..2425ff3 100644 --- a/src/package/cache.rs +++ b/src/package/cache.rs @@ -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/mod.rs b/src/package/mod.rs index 14738dd..f1496ec 100644 --- a/src/package/mod.rs +++ b/src/package/mod.rs @@ -1,4 +1,4 @@ -mod cache; +pub mod cache; pub mod index; pub mod install; pub mod resolver; diff --git a/src/project/lock.rs b/src/project/lock.rs index 0539adf..7b3b28e 100644 --- a/src/project/lock.rs +++ b/src/project/lock.rs @@ -8,7 +8,7 @@ use md5::Md5; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::error::Error; -use crate::package::Package; +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/server/lock.rs b/src/server/lock.rs index dd057ac..5efda8b 100644 --- a/src/server/lock.rs +++ b/src/server/lock.rs @@ -3,7 +3,7 @@ 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: &'static str = ".server-lock"; +const LOCKFILE: &str = ".server-lock"; pub struct ProjectLock { file: File, @@ -27,7 +27,7 @@ impl ProjectLock { 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).unwrap(); + fs::remove_file(&self.path).expect("Failed to remove lockfile."); } } diff --git a/src/server/method/mod.rs b/src/server/method/mod.rs index a18e951..8c24916 100644 --- a/src/server/method/mod.rs +++ b/src/server/method/mod.rs @@ -1,12 +1,22 @@ 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::Error; +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 { @@ -22,10 +32,10 @@ impl Method { let (namespace, name) = ( split .next() - .ok_or_else(|| Error::InvalidMethod(method.into()))?, + .ok_or_else(|| ServerError::InvalidMethod(method.into()))?, split .next() - .ok_or_else(|| Error::InvalidMethod(method.into()))?, + .ok_or_else(|| ServerError::InvalidMethod(method.into()))?, ); // Route namespaces to the appropriate enum variants for construction. @@ -33,7 +43,7 @@ impl Method { "exit" => Self::Exit, "project" => Self::Project(ProjectMethod::from_value(name, value)?), "package" => Self::Package(PackageMethod::from_value(name, value)?), - x => Err(Error::InvalidMethod(x.into()))?, + x => Err(ServerError::InvalidMethod(x.into()))?, }) } } diff --git a/src/server/method/package.rs b/src/server/method/package.rs index dc75bfd..1cc88c6 100644 --- a/src/server/method/package.rs +++ b/src/server/method/package.rs @@ -1,21 +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(GetMetadata), /// Determine if the package exists within the cache. - IsCached, + 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, - "is_cached" => Self::IsCached, - x => Err(Error::InvalidMethod(x.into()))?, + "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 index e4c5b4e..51e9f23 100644 --- a/src/server/method/project.rs +++ b/src/server/method/project.rs @@ -1,17 +1,19 @@ use std::path::PathBuf; +use std::sync::Arc; -use serde::{Deserialize, Serialize}; - +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 { - /// Set the project directory context. This locks the project, if it exists. - SetContext(SetContext), - /// Release the project directory context. This unlocks the project, if a lock exists. - ReleaseContext, + /// Opens a project, locking it in the process. + Open(OpenProject), /// Get project metadata. GetMetadata, /// Add one or more packages to the project. @@ -19,28 +21,67 @@ pub enum ProjectMethod { /// Remove one or more packages from the project. RemovePackages(RemovePackages), /// Get a list of currently installed packages. - GetPackages, - /// Determine if the current context is a valid project. - IsValid, + 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 { - "set_context" => Self::SetContext(super::parse_value(value)?), - "release_context" => Self::ReleaseContext, + "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)?), - "get_packages" => Self::GetPackages, - "is_valid" => Self::IsValid, - x => Err(Error::InvalidMethod(x.into()))?, + "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 SetContext { +pub struct OpenProject { path: PathBuf, } diff --git a/src/server/mod.rs b/src/server/mod.rs index a1f1d7d..889b780 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,23 +1,36 @@ -use std::io::Write; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver, Sender}; -use std::sync::RwLock; +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; -mod route; 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 Error { +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, @@ -28,12 +41,8 @@ pub enum Error { #[error("Recieved invalid params for method {0}: {1}")] InvalidParams(String, String) = -32602, - /// Wrapper error types and codes. - #[error("${0:?}")] - ProjectError(String) = 1000, - - #[error("{0:?}")] - PackageError(String) = 2000, + #[error("")] + InvalidContext = 0, } impl Error { @@ -50,42 +59,149 @@ impl ToJson for Result { } } -/// 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 (tx, rx) = mpsc::channel::>(); +/// 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); - // Responses are published through the tx send channel. + // 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 { - // 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, tx.clone()).await; - res.to_json().unwrap(); + 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?; } } -fn respond_msg(rx: Receiver>, cancel: RwLock) { +/// 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) = rx.recv() { - let msg = res.map(|x| serde_json::to_string(&x).unwrap()); + 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. -async fn route(line: &str, tx: Sender>) -> Result { - let req = Message::from_json(line); - todo!() -} +// 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 index 096998a..3d9d585 100644 --- a/src/server/proto.rs +++ b/src/server/proto.rs @@ -4,6 +4,8 @@ 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)] @@ -15,10 +17,12 @@ pub enum Message { impl Message { pub fn from_json(json: &str) -> Result { - let msg = serde_json::from_str::(json).map_err(Error::InvalidJson)?; + 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(Error::InvalidMethod(x.jsonrpc)), + Message::Request(x) if x.jsonrpc != JRPC_VER => Err(ServerError::InvalidMethod(x.jsonrpc))?, _ => Ok(msg), } } @@ -81,7 +85,33 @@ impl TryFrom for Request { #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct Response { pub id: Id, - pub content: Option, + + #[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)] @@ -103,7 +133,8 @@ impl From for RpcError { mod test { use super::*; use crate::server::method::package::PackageMethod; - use crate::server::method::project::{ProjectMethod, SetContext}; + use crate::server::method::project::{ProjectMethod, OpenProject}; + use crate::server::ServerError; #[test] fn test_jrpc_ver_validate() { @@ -122,7 +153,7 @@ mod test { assert_eq!(rq.id, Id::Int(1)); assert!(matches!( rq.method, - Method::Project(ProjectMethod::SetContext(SetContext { .. })) + Method::Project(ProjectMethod::Open(OpenProject { .. })) )); let data = r#"{ "jsonrpc": "2.0", "id": "oksamies", "method": "package/get_metadata" }"#; @@ -131,12 +162,12 @@ mod test { 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) - )); + // 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" }"#; @@ -147,7 +178,7 @@ mod test { // ...but should then fail to be converted into a typed Request. let rq = Request::try_from(rq); - assert!(matches!(rq, Err(Error::InvalidMethod(..)))); // Invalid methods should still be deserialized aok as they're checked by typed Request struct. + 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 } }"#; @@ -157,6 +188,7 @@ mod test { assert!(matches!(rq.params, Value::Object(..))); let rq = Request::try_from(rq); - assert!(matches!(rq, Err(Error::InvalidJson(..)))); + panic!("{rq:?}"); + assert!(matches!(rq, Err(Error::Server(ServerError::InvalidJson(..))))); } } diff --git a/src/server/route.rs b/src/server/route.rs deleted file mode 100644 index 6ecc1a7..0000000 --- a/src/server/route.rs +++ /dev/null @@ -1,37 +0,0 @@ -use std::sync::mpsc::Sender; - -use self::package::PackageMethod; -use self::project::ProjectMethod; - -use super::Error; -use super::method::*; -use super::proto::{Request, Response}; - -/// Route the request to the appropriate TCLI backend. -pub fn route(request: Request) -> Result<(), ()> { - match request.method { - Method::Package(PackageMethod::IsCached) => panic!(), - _ => panic!(), - }; - - Ok(()) -} - -fn route_project(method: ProjectMethod, tx: Sender>) { - match method { - ProjectMethod::SetContext(_) => todo!(), - ProjectMethod::ReleaseContext => todo!(), - ProjectMethod::GetMetadata => todo!(), - ProjectMethod::AddPackages(_) => todo!(), - ProjectMethod::RemovePackages(_) => todo!(), - ProjectMethod::GetPackages => todo!(), - ProjectMethod::IsValid => todo!(), - } -} - -fn route_package(method: PackageMethod) { - match method { - PackageMethod::IsCached => (), - PackageMethod::GetMetadata => (), - } -}