orn_cli/
update_notifier.rs

1use curl::easy::{Easy, List};
2use serde_derive::Deserialize;
3
4const REGISTRY_URL: &str = "https://crates.io";
5
6#[derive(Debug, thiserror::Error)]
7enum ErrorKind {
8    #[error("Error while parsing json: {0}")]
9    UnableToParseJson(String),
10    #[error("Error received from registry: {0}")]
11    RegistryError(String),
12}
13
14#[derive(Deserialize, Debug, Clone)]
15struct VersionResponse {
16    versions: Option<Vec<Version>>,
17    errors: Option<Vec<JsonError>>,
18}
19
20#[derive(Deserialize, Debug, Clone)]
21struct JsonError {
22    detail: String,
23}
24
25#[derive(Deserialize, Debug, Clone)]
26struct Version {
27    num: String,
28}
29
30fn get_latest_from_json(resp: &VersionResponse) -> Result<String, Box<dyn std::error::Error>> {
31    if let Some(versions) = &resp.versions {
32        match versions.first() {
33            Some(version) => Ok(version.num.clone()),
34            None => Err(ErrorKind::UnableToParseJson("Versions array is empty".to_string()).into()),
35        }
36    } else if let Some(errors) = &resp.errors {
37        match errors.first() {
38            Some(error) => Err(ErrorKind::RegistryError(error.detail.clone()).into()),
39            None => Err(
40                ErrorKind::UnableToParseJson("No errors in the errors array".to_string()).into(),
41            ),
42        }
43    } else {
44        Err(ErrorKind::UnableToParseJson(
45            "Invalid json response, does not have versions or errors".to_string(),
46        )
47        .into())
48    }
49}
50
51fn get_latest_version(
52    crate_name: &str,
53    registry_url: &str,
54) -> Result<String, Box<dyn std::error::Error>> {
55    // We use curl-rust here to save us importing a bunch of dependencies pulled in with reqwest
56    // We're okay with a blocking api since it's only one small request
57    let mut easy = Easy::new();
58
59    let url = format!("{}/api/v1/crates/{}/versions", registry_url, crate_name);
60    easy.url(&url)?;
61    let mut list = List::new();
62    list.append("User-Agent: Update-notifier (teshaq@mozilla.com)")?;
63    easy.http_headers(list)?;
64    let mut resp_buf = Vec::new();
65    // Create a different lifetime for `transfer` since it
66    // borrows resp_buf in it's closure
67
68    {
69        let mut transfer = easy.transfer();
70        transfer.write_function(|data| {
71            resp_buf.extend_from_slice(data);
72            Ok(data.len())
73        })?;
74        transfer.perform()?;
75    }
76    let resp = std::str::from_utf8(&resp_buf)?;
77    let json_resp = serde_json::from_str(resp)?;
78    get_latest_from_json(&json_resp)
79}
80
81fn generate_notice(name: &str, current_version: &str, latest_version: &str) -> String {
82    let line_1 = format!(
83        "A new version of {} is available! {} → {}",
84        name, current_version, latest_version
85    );
86
87    let suggestion = format!("cargo install {}", name);
88    let line_2 = format!("Use `{}` to install version {}", suggestion, latest_version);
89
90    let url = format!("{}/crates/{}", REGISTRY_URL, name);
91    let line_3 = format!("Check {} for more details", url);
92    let mut border_line = String::from("\n───────────────────────────────────────────────────────");
93    let extension = "─";
94    for _ in 0..name.len() {
95        border_line.push_str(extension);
96    }
97    border_line.push('\n');
98    format!(
99        "{}
100    {}
101    {}
102    {}
103    {}",
104        border_line, line_1, line_2, line_3, border_line
105    )
106}
107
108fn print_notice(name: &str, current_version: &str, latest_version: &str) {
109    print!("{}", generate_notice(name, current_version, latest_version));
110}
111
112pub fn check_latest_version(
113    name: &str,
114    current_version: &str,
115    registry_url: &str,
116) -> Result<bool, Box<dyn std::error::Error>> {
117    let latest_version = get_latest_version(name, registry_url)?;
118    if latest_version != current_version {
119        print_notice(name, current_version, &latest_version);
120        return Ok(false);
121    }
122    Ok(true)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    #[test]
129    fn test_latest_version() {
130        let mut server = mockito::Server::new();
131        let _m = server
132            .mock("GET", "/api/v1/crates/asdev/versions")
133            .with_status(200)
134            .with_header("Content-Type", "application/json")
135            .with_body(
136                r#"
137            {"versions" : [
138                {
139                    "id": 229435,
140                    "crate": "asdev",
141                    "num": "0.1.3"
142                }
143            ]}"#,
144            )
145            .create();
146        let latest_version = get_latest_version("asdev", &server.url()).unwrap();
147        _m.expect(1).assert();
148        assert_eq!(latest_version, "0.1.3")
149    }
150
151    #[test]
152    fn test_no_crates_io_entry() {
153        let mut server = mockito::Server::new();
154        let _m = server
155            .mock(
156                "GET",
157                "/api/v1/crates/kefjhkajvcnklsajdfhwksajnceknc/versions",
158            )
159            .with_status(404)
160            .with_header("Content-Type", "application/json")
161            .with_body(
162                r#"
163                {"errors":[{"detail":"Not Found"}]}"#,
164            )
165            .create();
166        let latest_version = get_latest_version("kefjhkajvcnklsajdfhwksajnceknc", &server.url())
167            .expect_err("Should be an error");
168        _m.expect(1).assert();
169        assert_eq!(
170            latest_version.to_string(),
171            ErrorKind::RegistryError("Not Found".to_string()).to_string()
172        );
173    }
174
175    #[test]
176    fn test_same_version() {
177        let mut server = mockito::Server::new();
178        let _m = server
179            .mock("GET", "/api/v1/crates/sameVersion/versions")
180            .with_status(200)
181            .with_header("Content-Type", "application/json")
182            .with_body(
183                r#"
184            {"versions" : [
185                {
186                    "id": 229435,
187                    "crate": "sameVersion",
188                    "num": "0.1.3"
189                }
190            ]}"#,
191            )
192            .create();
193        check_latest_version("sameVersion", "0.1.3", &server.url()).unwrap();
194        _m.expect(1).assert();
195    }
196
197    #[test]
198    fn test_not_update_available() {
199        let mut server = mockito::Server::new();
200        let _m = server
201            .mock("GET", "/api/v1/crates/noUpdate/versions")
202            .with_status(200)
203            .with_header("Content-Type", "application/json")
204            .with_body(
205                r#"
206            {"versions" : [
207                {
208                    "id": 229435,
209                    "crate": "noUpdate",
210                    "num": "0.1.3"
211                }
212            ]}"#,
213            )
214            .create();
215        check_latest_version("noUpdate", "0.1.2", &server.url()).unwrap();
216        _m.expect(1).assert();
217    }
218
219    #[test]
220    fn test_output() {
221        assert_eq!(generate_notice("asdev", "0.1.2", "0.1.3"), "\n────────────────────────────────────────────────────────────\n\n    A new version of asdev is available! 0.1.2 → 0.1.3\n    Use `cargo install asdev` to install version 0.1.3\n    Check https://crates.io/crates/asdev for more details\n    \n────────────────────────────────────────────────────────────\n");
222    }
223
224    #[test]
225    fn test_interval_not_exceeded() {
226        let mut server = mockito::Server::new();
227        let _m = server
228            .mock("GET", "/api/v1/crates/notExceeded/versions")
229            .with_status(200)
230            .with_header("Content-Type", "application/json")
231            .with_body(
232                r#"
233            {"versions" : [
234                {
235                    "id": 229435,
236                    "crate": "notExceeded",
237                    "num": "0.1.3"
238                }
239            ]}"#,
240            )
241            .create();
242        check_latest_version("notExceeded", "0.1.2", &server.url()).unwrap();
243        _m.expect(1).assert()
244    }
245
246    #[test]
247    fn test_interval_exceeded() {
248        let mut server = mockito::Server::new();
249        let _m = server
250            .mock("GET", "/api/v1/crates/intervalExceeded/versions")
251            .with_status(200)
252            .with_header("Content-Type", "application/json")
253            .with_body(
254                r#"
255            {"versions" : [
256                {
257                    "id": 229435,
258                    "crate": "intervalExceeded",
259                    "num": "0.1.3"
260                }
261            ]}"#,
262            )
263            .create();
264
265        check_latest_version("intervalExceeded", "0.1.2", &server.url()).unwrap();
266        _m.expect(1).assert()
267    }
268}