commit 4abc55d7c1a76c89d10a0e9d74c73a47e37e03f0 Author: trivernis Date: Sat Jan 21 11:13:42 2023 +0100 Add web api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5c493ae --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "nenv" +version = "0.1.0" +edition = "2021" + +[lib] +name = "nenv" + +[[bin]] +name = "nenv" +path = "src/main.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = { version = "4.1.1", features = ["derive"] } +color-eyre = "0.6.2" +dirs = "4.0.0" +futures-util = "0.3.25" +indicatif = "0.17.3" +lazy_static = "1.4.0" +miette = "5.5.0" +reqwest = { version = "0.11.14", features = ["json", "stream"] } +semver = { version = "1.0.16", features = ["std", "serde"] } +serde = { version = "1.0.152", features = ["derive"] } +serde_json = "1.0.91" +thiserror = "1.0.38" +tokio = { version = "1.24.2", features = ["rt", "macros", "tracing", "net", "fs", "time"] } +tracing = "0.1.37" +tracing-subscriber = "0.3.16" + diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 0000000..7a1f5d3 --- /dev/null +++ b/src/args.rs @@ -0,0 +1,94 @@ +use std::str::FromStr; + +use clap::{Parser, Subcommand}; + +#[derive(Clone, Debug, Parser)] +#[clap(infer_subcommands = true)] +pub struct Args { + #[command(subcommand)] + pub commmand: Command, +} + +#[derive(Clone, Debug, Subcommand)] +pub enum Command { + #[command()] + Install(InstallArgs), + + #[command()] + Use(UseArgs), + + #[command()] + Default, + + #[command(short_flag = 'v', aliases = &["--version"])] + Version, +} + +#[derive(Clone, Debug, Parser)] +pub struct InstallArgs { + #[arg()] + pub version: Version, +} + +#[derive(Clone, Debug, Parser)] +pub struct UseArgs { + #[arg()] + pub version: Version, +} + +impl FromStr for Version { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + let input = s.to_lowercase(); + + let version = match &*input { + "latest" => Self::Latest, + "lts" => Self::Lts, + _ => Self::SemVer(SemVersion::from_str(s)?), + }; + + Ok(version) + } +} + +#[derive(Clone, Debug)] +pub enum Version { + Latest, + Lts, + SemVer(SemVersion), +} + +#[derive(Clone, Debug)] +pub struct SemVersion { + pub major: u8, + pub minor: Option, + pub patch: Option, +} + +impl FromStr for SemVersion { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + let mut major = s; + let mut minor = None; + let mut patch = None; + + if let Some((maj, rest)) = s.split_once('.') { + major = maj; + + if let Some((min, pat)) = rest.split_once('.') { + minor = Some(min.parse().map_err(|_| "minor is not a number")?); + patch = Some(pat.parse().map_err(|_| "patch is not a number")?); + } else { + minor = Some(rest.parse().map_err(|_| "minor is not a number")?); + } + } + + Ok(Self { + major: major.parse().map_err(|_| "major is not a number")?, + minor, + patch, + }) + } +} diff --git a/src/consts.rs b/src/consts.rs new file mode 100644 index 0000000..bd326ee --- /dev/null +++ b/src/consts.rs @@ -0,0 +1,63 @@ +use lazy_static::lazy_static; +use std::path::PathBuf; + +pub const NODE_DIST_URL: &str = "https://nodejs.org/dist"; + +lazy_static! { + pub static ref CFG_DIR: PathBuf = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".config")) + .join(PathBuf::from("nenv")); + pub static ref DATA_DIR: PathBuf = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".data")) + .join(PathBuf::from("nenv")); + pub static ref NODE_PATH: PathBuf = DATA_DIR.join(PathBuf::from("current")); + pub static ref NODE_VERSIONS_PATH: PathBuf = DATA_DIR.join(PathBuf::from("versions")); + pub static ref NODE_ARCHIVE_SUFFIX: String = format!("-{OS}-{ARCH}.{ARCHIVE_TYPE}"); +} + +macro_rules! map_arch { + ($($arch:literal => $node_arch: literal),+) => { + map_arch!($($arch => $node_arch,)+); + }; + ($($arch:literal => $node_arch: literal),+,) => { + $( + #[cfg(target_arch = $arch)] + pub const ARCH: &'static str = $node_arch; + )+ + }; +} + +map_arch!( + "x86_64" => "x64", + "x86" => "x86", + "arm" => "armv7l", + "aarch64" => "arm64", + "riscv32" => "armv7l", + "powerpc64" => "ppc64", + "powerpc64le" => "ppc64le", + "s390x" => "s390x", +); + +macro_rules! map_os { + ($($os:literal => $node_os: literal),+) => { + map_arch!($($os => $node_os,)+); + }; + ($($os:literal => $node_os: literal),+,) => { + $( + #[cfg(target_os = $os)] + pub const OS: &'static str = $node_os; + )+ + }; +} + +map_os!( + "linux" => "linux", + "windows" => "win", + "macos" => "darwin", +); + +#[cfg(not(target_os = "windows"))] +pub const ARCHIVE_TYPE: &'static str = "tar.gz"; + +#[cfg(target_os = "windows")] +pub const ARCHIVE_TYPE: &'static str = "zip"; diff --git a/src/download.rs b/src/download.rs new file mode 100644 index 0000000..f654cbe --- /dev/null +++ b/src/download.rs @@ -0,0 +1,5 @@ +use crate::{error::LibResult, Version}; + +pub async fn download_version(version: Version) -> LibResult<()> { + todo!("Download node version to data dir") +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..024df1e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,9 @@ +use thiserror::Error; + +pub(crate) type LibResult = Result; +pub(crate) type LibError = Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum Error {} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..571389a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,13 @@ +mod consts; +mod download; +pub mod error; +mod utils; +mod web_api; + +pub enum Version { + Latest, + Lts, + Specific(u8, Option, Option), +} + +pub fn install(version: Version) {} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a237494 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,11 @@ +use args::Args; +use clap::Parser; + +mod args; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + color_eyre::install().unwrap(); + let args: Args = Args::parse(); + dbg!(args); +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..9e52401 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,31 @@ +use std::{borrow::Borrow, collections::HashMap, hash::Hash}; + +use crate::web_api::VersionInfo; + +/// Converts the list of versions to a tree for easy version lookup +pub fn convert_version_list_to_tree( + version_infos: Vec, +) -> HashMap>> { + let mut version_map = HashMap::new(); + + for info in version_infos { + let major_map = version_map.get_mut_or_insert(info.version.major, HashMap::new()); + let minor_map = major_map.get_mut_or_insert(info.version.minor, HashMap::new()); + minor_map.insert(info.version.patch, info); + } + + version_map +} + +trait GetOrInsert { + fn get_mut_or_insert(&mut self, key: K, default_value: V) -> &mut V; +} + +impl GetOrInsert for HashMap { + fn get_mut_or_insert(&mut self, key: K, default_value: V) -> &mut V { + if !self.contains_key(&key) { + self.insert(key, default_value); + } + self.get_mut(&key).unwrap() + } +} diff --git a/src/web_api/error.rs b/src/web_api/error.rs new file mode 100644 index 0000000..19f8570 --- /dev/null +++ b/src/web_api/error.rs @@ -0,0 +1,25 @@ +use std::io; + +use lazy_static::__Deref; +use miette::Diagnostic; +use thiserror::Error; + +pub type ApiResult = Result; + +#[derive(Debug, Error, Diagnostic)] +pub enum ApiError { + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + + #[error(transparent)] + Io(#[from] io::Error), + + #[error("{0}")] + Other(#[help] String), +} + +impl ApiError { + pub fn other(error: S) -> Self { + Self::Other(error.to_string()) + } +} diff --git a/src/web_api/mod.rs b/src/web_api/mod.rs new file mode 100644 index 0000000..ad560c2 --- /dev/null +++ b/src/web_api/mod.rs @@ -0,0 +1,101 @@ +use std::{ + cmp::min, + fmt::{Debug, Display}, + time::Duration, +}; + +use crate::consts::NODE_ARCHIVE_SUFFIX; + +use self::error::{ApiError, ApiResult}; +use indicatif::{ProgressBar, ProgressStyle}; +use reqwest::Client; + +pub mod error; +mod model; +use futures_util::StreamExt; +pub use model::*; +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +#[cfg(test)] +mod test; + +#[derive(Clone, Debug)] +pub struct NodejsAccess { + base_url: String, + client: Client, +} + +impl Default for NodejsAccess { + fn default() -> Self { + Self::new("https://nodejs.org/dist") + } +} + +impl NodejsAccess { + /// Creates a new instance to access the nodejs website + pub fn new(base_url: S) -> Self { + Self { + base_url: base_url.to_string(), + client: Client::new(), + } + } + + /// Returns the list of available node versions + #[tracing::instrument(level = "trace")] + pub async fn get_versions(&self) -> ApiResult> { + let versions = self + .client + .get(format!("{}/index.json", self.base_url)) + .send() + .await? + .json() + .await?; + + Ok(versions) + } + + /// Downloads a specific node version + /// and writes it to the given writer + #[tracing::instrument(level = "trace", skip(writer))] + pub async fn download_version( + &self, + version: S, + writer: &mut W, + ) -> ApiResult { + let res = self + .client + .get(format!( + "{}/v{version}/node-v{version}{}", + self.base_url, *NODE_ARCHIVE_SUFFIX + )) + .send() + .await?; + let total_size = res + .content_length() + .ok_or_else(|| ApiError::other("Missing content length"))?; + let pb = ProgressBar::new(total_size); + pb.set_message(format!("Downloading node v{version}")); + pb.set_style( + ProgressStyle::default_bar() + .template( + "{msg} {spinner}\n[{wide_bar}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", + ) + .unwrap(), + ); + pb.enable_steady_tick(Duration::from_millis(50)); + let mut stream = res.bytes_stream(); + let mut total_downloaded = 0; + + while let Some(item) = stream.next().await { + let chunk = item?; + writer.write_all(&chunk).await?; + total_downloaded = min(chunk.len() as u64 + total_downloaded, total_size); + pb.set_position(total_downloaded); + } + + writer.flush().await?; + pb.finish_with_message(format!("Downloaded node v{version}.")); + + Ok(total_downloaded) + } +} diff --git a/src/web_api/model.rs b/src/web_api/model.rs new file mode 100644 index 0000000..05284c6 --- /dev/null +++ b/src/web_api/model.rs @@ -0,0 +1,49 @@ +use std::borrow::Cow; + +use serde::{Deserialize, Deserializer}; + +/// Represents a single nodejs version info entry +/// as retrieved from nodejs.org +#[derive(Clone, Debug, Deserialize)] +pub struct VersionInfo { + #[serde(deserialize_with = "deserialize_prefixed_version")] + pub version: semver::Version, + pub date: String, + pub modules: Option, + + #[serde(deserialize_with = "deserialize_false_as_none")] + pub lts: Option, + pub security: bool, + #[serde(flatten)] + pub module_versions: ModuleVersions, + pub files: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ModuleVersions { + pub v8: String, + pub npm: Option, + pub uv: Option, + pub zlib: Option, + pub openssl: Option, +} + +fn deserialize_false_as_none<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Ok(String::deserialize(deserializer).ok()) +} + +fn deserialize_prefixed_version<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result { + let version = String::deserialize(deserializer)?; + let version = if let Some(v) = version.strip_prefix('v') { + Cow::Borrowed(v) + } else { + Cow::Owned(version) + }; + let version = semver::Version::parse(version.as_ref()).map_err(serde::de::Error::custom)?; + + Ok(version) +} diff --git a/src/web_api/test.rs b/src/web_api/test.rs new file mode 100644 index 0000000..47e9919 --- /dev/null +++ b/src/web_api/test.rs @@ -0,0 +1,19 @@ +use tokio::io::sink; + +use super::NodejsAccess; + +#[tokio::test] +async fn it_fetches_all_versions() { + let versions = NodejsAccess::default().get_versions().await.unwrap(); + assert!(!versions.is_empty()); +} + +#[tokio::test] +async fn it_downloads_a_specific_version() { + let mut writer = sink(); + let bytes_written = NodejsAccess::default() + .download_version("15.0.0", &mut writer) + .await + .unwrap(); + assert!(bytes_written > 0); +}