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 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 {
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}