Skip to content

Commit

Permalink
[#33] Better end-of-sync output (#80)
Browse files Browse the repository at this point in the history
Resolves #33

This PR changes the fetching algorithm significantly in an attempt to
improve the final output. Specifically,

1. Information about assets of all tools is fetched first before
downloading the tools. This way users see all validation errors before
the downloading has started.
> The only validation errors left are missing `exe` files in assets, I
believe. Or GitHub Rate-Limit error when trying to download an asset.
2. Estimated download size is printed before the downloading (so users
can see how much it would take to download).
3. The correct tags are shown immediately.
4. Lots of refactoring.

A single GIF worth a thousand words:

![gif_1](https://user-images.githubusercontent.com/4276606/189847152-c20c69ca-1467-4e4e-8af6-42634d7ae35d.gif)
  • Loading branch information
chshersh authored Sep 13, 2022
1 parent 56168b1 commit b4f0962
Show file tree
Hide file tree
Showing 11 changed files with 471 additions and 242 deletions.
6 changes: 3 additions & 3 deletions src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ pub struct ConfigAsset {
/// Defaults to `repo` if not specified
pub exe_name: Option<String>,

/// Name of the specific asset to download
pub asset_name: AssetName,

/// Release tag to download
/// Defaults to the latest release
pub tag: Option<String>,

/// Name of the specific asset to download
pub asset_name: AssetName,
}

impl Config {
Expand Down
1 change: 1 addition & 0 deletions src/infra.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod client;
103 changes: 103 additions & 0 deletions src/infra/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
use std::env;
use std::error::Error;
use std::io::Read;

use crate::model::release::{Asset, Release};

/// GitHub API client to handle all API requests
pub struct Client {
pub owner: String,
pub repo: String,
pub version: String,
}

impl Client {
fn release_url(&self) -> String {
format!(
"https://api.github.com/repos/{owner}/{repo}/releases/{version}",
owner = self.owner,
repo = self.repo,
version = self.version,
)
}

fn asset_url(&self, asset_id: u32) -> String {
format!(
"https://api.github.com/repos/{owner}/{repo}/releases/assets/{asset_id}",
owner = self.owner,
repo = self.repo,
asset_id = asset_id
)
}

pub fn fetch_release_info(&self) -> Result<Release, Box<dyn Error>> {
let release_url = self.release_url();

let req = add_auth_header(
ureq::get(&release_url)
.set("Accept", "application/vnd.github+json")
.set("User-Agent", "chshersh/tool-sync-0.2.0"),
);

let release: Release = req.call()?.into_json()?;

Ok(release)
}

pub fn get_asset_stream(
&self,
asset: &Asset,
) -> Result<Box<dyn Read + Send + Sync>, ureq::Error> {
let asset_url = self.asset_url(asset.id);

let req = add_auth_header(
ureq::get(&asset_url)
.set("Accept", "application/octet-stream")
.set("User-Agent", "chshersh/tool-sync-0.2.0"),
);

Ok(req.call()?.into_reader())
}
}

fn add_auth_header(req: ureq::Request) -> ureq::Request {
match env::var("GITHUB_TOKEN") {
Err(_) => req,
Ok(token) => req.set("Authorization", &format!("token {}", token)),
}
}

#[cfg(test)]
mod tests {
use super::*;

use crate::model::tool::ToolInfoTag;

#[test]
fn release_url_with_latest_tag_is_correct() {
let client = Client {
owner: String::from("OWNER"),
repo: String::from("REPO"),
version: ToolInfoTag::Latest.to_str_version(),
};

assert_eq!(
client.release_url(),
"https://api.github.com/repos/OWNER/REPO/releases/latest"
);
}

#[test]
fn release_url_with_specific_tag_is_correct() {
let client = Client {
owner: String::from("OWNER"),
repo: String::from("REPO"),
version: ToolInfoTag::Specific(String::from("SPECIFIC_TAG")).to_str_version(),
};

assert_eq!(
client.release_url(),
"https://api.github.com/repos/OWNER/REPO/releases/tags/SPECIFIC_TAG"
);
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod config;
mod err;
mod infra;
mod model;
mod sync;

Expand Down
2 changes: 1 addition & 1 deletion src/model/release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ pub struct Release {
pub assets: Vec<Asset>,
}

#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
pub struct Asset {
pub id: u32,
pub name: String,
Expand Down
48 changes: 45 additions & 3 deletions src/model/tool.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use super::release::Asset;
use crate::infra::client::Client;
use crate::model::asset_name::AssetName;

#[derive(Debug, PartialEq, Eq)]
Expand All @@ -21,7 +23,7 @@ impl ToolError {
ToolError::Suggestion { perhaps } => {
format!("[suggestion] Perhaps you meant: '{}'?", perhaps)
}
ToolError::Invalid => "[error] Not detailed enough configuration)".to_string(),
ToolError::Invalid => "[error] Not detailed enough configuration".to_string(),
}
}
}
Expand Down Expand Up @@ -59,9 +61,49 @@ pub struct ToolInfo {
/// Executable name inside the .tar.gz or .zip archive
pub exe_name: String,

/// Version tag
pub tag: ToolInfoTag,

/// Asset name depending on the OS
pub asset_name: AssetName,
}

/// Version tag
pub tag: ToolInfoTag,
impl ToolInfo {
pub fn select_asset(&self, assets: &[Asset]) -> Result<Asset, String> {
match self.asset_name.get_name_by_os() {
None => Err(String::from(
"Don't know the asset name for this OS: specify it explicitly in the config",
)),
Some(asset_name) => {
let asset = assets.iter().find(|&asset| asset.name.contains(asset_name));

match asset {
None => Err(format!("No asset matching name: {}", asset_name)),
Some(asset) => Ok(asset.clone()),
}
}
}
}
}

/// All information about the tool, needed to download its asset after fetching
/// the release and asset info. Values of this type are created in
/// `src/sync/prefetch.rs` from `ToolInfo`.
pub struct ToolAsset {
/// Name of the tool (e.g. "ripgrep" or "exa")
pub tool_name: String,

/// Specific git tag (e.g. "v3.4.2")
/// This value is the result of `ToolInfoTag::to_str_version` so "latest"
/// **can't** be here.
pub tag: String,

/// Executable name inside the .tar.gz or .zip archive
pub exe_name: String,

/// The selected asset
pub asset: Asset,

/// GitHub API client that produces the stream for downloading the asset
pub client: Client,
}
70 changes: 51 additions & 19 deletions src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,29 @@ mod configure;
mod db;
mod download;
mod install;
mod prefetch;
mod progress;

use console::Emoji;

use crate::config::schema::Config;
use crate::sync::install::Installer;
use crate::sync::progress::SyncProgress;

use self::install::Installer;
use self::prefetch::prefetch;
use self::progress::SyncProgress;
use self::progress::ToolPair;

pub fn sync(config: Config) {
if config.tools.is_empty() {
eprintln!(
r#"No tools to sync. Have you configured 'tool-sync'?
no_tools_message()
} else {
sync_tools(config)
}
}

fn no_tools_message() {
eprintln!(
r#"No tools to sync. Have you configured 'tool-sync'?
Put the following into the $HOME/.tool.toml file for the simplest configuration:
Expand All @@ -27,21 +40,40 @@ Put the following into the $HOME/.tool.toml file for the simplest configuration:
For more details, refer to the official documentation:
* /~https://github.com/chshersh/tool-sync#tool-sync"#
);
} else {
let store_directory = config.ensure_store_directory();

let tools: Vec<String> = config.tools.keys().cloned().collect();
let tags: Vec<String> = config
.tools
.values()
.map(|config_asset| config_asset.tag.clone().unwrap_or_else(|| "latest".into()))
.collect();
let sync_progress = SyncProgress::new(tools, tags);
let installer = Installer::mk(store_directory, sync_progress);

for (tool_name, config_asset) in config.tools.iter() {
installer.install(tool_name, config_asset);
);
}

const DONE: Emoji<'_, '_> = Emoji("✨ ", "* ");
const DIRECTORY: Emoji<'_, '_> = Emoji("📁 ", "* ");

fn sync_tools(config: Config) {
let store_directory = config.ensure_store_directory();
let tool_assets = prefetch(config.tools);

let tool_pairs = tool_assets
.iter()
.map(|ta| ToolPair {
name: &ta.tool_name,
tag: &ta.tag,
})
.collect();

let sync_progress = SyncProgress::new(tool_pairs);
let installer = Installer::mk(store_directory.as_path(), sync_progress);

let mut installed_tools: u64 = 0;

for tool_asset in tool_assets {
let is_successs = installer.install(tool_asset);
if is_successs {
installed_tools += 1
}
}

eprintln!("{} Successfully installed {} tools!", DONE, installed_tools);
eprintln!(
"{} Installation directory: {}",
DIRECTORY,
store_directory.display()
);
}
Loading

0 comments on commit b4f0962

Please sign in to comment.