update_notifier/
lib.rs

1use ansi_term::Color::{Blue, Green, Red, Yellow};
2use chrono::{DateTime, Utc};
3use configstore::{AppUI, Configstore};
4use curl::easy::{Easy, List};
5use serde_derive::{Deserialize, Serialize};
6use std::time::Duration;
7
8const REGISTRY_URL: &str = "https://crates.io";
9
10#[cfg(test)]
11use mockito;
12
13fn get_base_url() -> String {
14    #[cfg(not(test))]
15    let url = format!("{}/api/v1/crates", REGISTRY_URL);
16    #[cfg(test)]
17    let url = format!("{}/api/v1/crates", mockito::server_url());
18    url
19}
20
21#[derive(Debug, thiserror::Error)]
22enum ErrorKind {
23    #[error("Error while parsing json: {0}")]
24    UnableToParseJson(String),
25    #[error("Error received from registry: {0}")]
26    RegistryError(String),
27}
28
29#[derive(Deserialize, Debug, Clone)]
30struct VersionResponse {
31    versions: Option<Vec<Version>>,
32    errors: Option<Vec<JsonError>>,
33}
34
35#[derive(Deserialize, Debug, Clone)]
36struct JsonError {
37    detail: String,
38}
39
40#[derive(Deserialize, Debug, Clone)]
41struct Version {
42    num: String,
43}
44
45fn get_latest_from_json(
46    resp: &VersionResponse,
47) -> std::result::Result<String, Box<dyn std::error::Error>> {
48    if let Some(versions) = &resp.versions {
49        match versions.first() {
50            Some(version) => Ok(version.num.clone()),
51            None => Err(ErrorKind::UnableToParseJson("Versions array is empty".to_string()).into()),
52        }
53    } else if let Some(errors) = &resp.errors {
54        match errors.first() {
55            Some(error) => Err(ErrorKind::RegistryError(error.detail.clone()).into()),
56            None => Err(
57                ErrorKind::UnableToParseJson("No errors in the errors array".to_string()).into(),
58            ),
59        }
60    } else {
61        Err(ErrorKind::UnableToParseJson(
62            "Invalid json response, does not have versions or errors".to_string(),
63        )
64        .into())
65    }
66}
67
68fn get_latest_version(crate_name: &str) -> std::result::Result<String, Box<dyn std::error::Error>> {
69    // We use curl-rust here to save us importing a bunch of dependencies pulled in with reqwest
70    // We're okay with a blocking api since it's only one small request
71    let mut easy = Easy::new();
72    let base_url = get_base_url();
73    let url = format!("{}/{}/versions", base_url, crate_name);
74    easy.url(&url)?;
75    let mut list = List::new();
76    list.append("USER-AGENT Update-notifier (teshaq@mozilla.com)")?;
77    easy.http_headers(list)?;
78    let mut resp_buf = Vec::new();
79    // Create a different lifetime for `transfer` since it
80    // borrows resp_buf in it's closure
81    {
82        let mut transfer = easy.transfer();
83        transfer.write_function(|data| {
84            resp_buf.extend_from_slice(data);
85            Ok(data.len())
86        })?;
87        transfer.perform()?;
88    }
89    let resp = std::str::from_utf8(&resp_buf)?;
90
91    let json_resp: VersionResponse = serde_json::from_str(resp)?;
92    get_latest_from_json(&json_resp)
93}
94
95fn generate_notice(name: &str, current_version: &str, latest_version: &str) -> String {
96    let line_1 = format!(
97        "A new version of {} is available! {} → {}",
98        Green.bold().paint(name),
99        Red.bold().paint(current_version),
100        Green.bold().paint(latest_version)
101    );
102    let line_2 = format!(
103        "Use `{}` to install version {}",
104        Blue.bold().paint(format!("cargo install {}", name)),
105        Green.bold().paint(latest_version)
106    );
107    let line_3 = format!(
108        "Check {} for more details",
109        Yellow.paint(format!("{}/crates/{}", REGISTRY_URL, name))
110    );
111    let mut border_line = String::from("\n───────────────────────────────────────────────────────");
112    let extension = "─";
113    for _ in 0..name.len() {
114        border_line.push_str(extension);
115    }
116    border_line.push('\n');
117    format!(
118        "{}
119    {}
120    {}
121    {}
122    {}",
123        border_line, line_1, line_2, line_3, border_line
124    )
125}
126
127fn print_notice(name: &str, current_version: &str, latest_version: &str) {
128    print!("{}", generate_notice(name, current_version, latest_version));
129}
130
131#[derive(Deserialize, Serialize)]
132struct Config {
133    last_checked: DateTime<Utc>,
134}
135
136fn get_app_name(name: &str) -> String {
137    let mut app_name: String = String::from(name);
138    app_name.push_str("-update-notifier");
139    #[cfg(test)]
140    app_name.push_str("-test");
141    app_name
142}
143
144fn update_time(date_time: DateTime<Utc>, name: &str) -> Result<(), Box<dyn std::error::Error>> {
145    let config = Config {
146        last_checked: date_time,
147    };
148    let config_store = Configstore::new(&get_app_name(name), AppUI::CommandLine)?;
149    config_store.set("config", config)?;
150    Ok(())
151}
152
153fn compare_with_latest(
154    name: &str,
155    current_version: &str,
156) -> Result<(), Box<dyn std::error::Error>> {
157    let latest_version = get_latest_version(name)?;
158    if latest_version != current_version {
159        print_notice(name, current_version, &latest_version);
160    }
161    let date_time = Utc::now();
162    update_time(date_time, name)
163}
164
165/// Checks if there exists an update by checking against crates.io and notifies the user by printing to stdout
166///
167/// # Arguments
168///
169///   * `name` -  The name of the crate, you can use `env!("CARGO_PKG_NAME")`
170///   * `current_version` - The version of the CLI, use `env!("CARGO_PKG_VERSION")`
171///   * `interval` - Duration representing the interval.
172///
173/// # Examples
174///
175/// ```
176/// use update_notifier::check_version;
177///
178/// check_version(
179///   env!("CARGO_PKG_NAME"),
180///   env!("CARGO_PKG_VERSION"),
181///   std::time::Duration::from_secs(0),
182///   ).ok();
183//
184///```
185///
186/// # Errors
187///
188/// Could error either if your plateform does not have a config directory or if an the crate name is not in the registry
189pub fn check_version(
190    name: &str,
191    current_version: &str,
192    interval: Duration,
193) -> Result<(), Box<dyn std::error::Error>> {
194    let date_time_now = Utc::now();
195    let config_store = Configstore::new(&get_app_name(name), AppUI::CommandLine)?;
196    match config_store.get::<Config>("config") {
197        Ok(config) => {
198            let prev_time = config.last_checked;
199            let duration_interval = chrono::Duration::from_std(interval)?;
200            if date_time_now.signed_duration_since(prev_time) >= duration_interval {
201                compare_with_latest(name, current_version)
202            } else {
203                Ok(())
204            }
205        }
206        Err(_) => compare_with_latest(name, current_version),
207    }
208}
209
210#[cfg(test)]
211mod tests;