1use crate::cmd::{
10 format_cmd, get_cmd_stdout_utf8, wait_for_child, RunCommandError,
11};
12use std::fmt::{self, Display, Formatter};
13use std::io::Read;
14use std::process::{Child, Command, Stdio};
15
16#[derive(Debug)]
18pub enum GetCrateVersionsError {
19 NotPublished,
21
22 Internal {
24 msg: String,
26
27 cause: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
29 },
30}
31
32impl Display for GetCrateVersionsError {
33 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
34 write!(f, "failed to get crate versions: ")?;
35 match self {
36 Self::NotPublished => write!(f, "crate has not yet been published"),
37 Self::Internal { msg, .. } => {
38 write!(f, "{msg}")
39 }
40 }
41 }
42}
43
44impl std::error::Error for GetCrateVersionsError {
45 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46 match self {
47 Self::NotPublished => None,
48 Self::Internal { cause, .. } => cause.as_ref().map(|err| {
49 let err: &(dyn std::error::Error + 'static) = &**err;
52 err
53 }),
54 }
55 }
56}
57
58pub struct CrateRegistry {
60 pub registry_url: String,
62}
63
64impl CrateRegistry {
65 pub const DEFAULT_REGISTRY: &'static str = "https://index.crates.io";
67
68 pub fn new() -> Self {
70 Self {
71 registry_url: Self::DEFAULT_REGISTRY.to_string(),
72 }
73 }
74
75 fn get_crate_url(&self, crate_name: &str) -> String {
77 assert!(!crate_name.is_empty());
78
79 let mut url = self.registry_url.clone();
80 if !url.ends_with('/') {
81 url.push('/');
82 }
83
84 if crate_name.len() == 1 {
86 url.push_str(&format!("1/{crate_name}"));
87 } else if crate_name.len() == 2 {
88 url.push_str(&format!("2/{crate_name}"));
89 } else if crate_name.len() == 3 {
90 url.push_str(&format!("3/{}/{}", &crate_name[0..1], crate_name));
91 } else {
92 url.push_str(&format!(
93 "{}/{}/{}",
94 &crate_name[..2],
95 &crate_name[2..4],
96 crate_name
97 ));
98 }
99
100 url
101 }
102
103 pub fn get_crate_versions(
108 &self,
109 crate_name: &str,
110 ) -> Result<Vec<String>, GetCrateVersionsError> {
111 let (mut curl_proc, curl_cmd_str) = spawn_curl(self, crate_name)
112 .map_err(|err| GetCrateVersionsError::Internal {
113 msg: "failed to launch curl".to_string(),
114 cause: Some(Box::new(err)),
115 })?;
116
117 let mut curl_stderr_pipe = curl_proc.stderr.take().unwrap();
119 let curl_stdout_pipe = curl_proc.stdout.take().unwrap();
120
121 let versions_result = parse_versions_from_crate_json(curl_stdout_pipe);
122
123 wait_for_child(curl_proc, curl_cmd_str).map_err(|err| {
124 GetCrateVersionsError::Internal {
125 msg: "curl failed".to_string(),
126 cause: Some(Box::new(err)),
127 }
128 })?;
129
130 let mut stderr_bytes = Vec::new();
131 curl_stderr_pipe
132 .read_to_end(&mut stderr_bytes)
133 .map_err(|err| GetCrateVersionsError::Internal {
134 msg: "failed to read http code from curl".to_string(),
135 cause: Some(Box::new(err)),
136 })?;
137
138 let stderr = String::from_utf8(stderr_bytes).map_err(|err| {
139 GetCrateVersionsError::Internal {
140 msg: "curl http code is not utf-8".to_string(),
141 cause: Some(Box::new(err)),
142 }
143 })?;
144
145 let code: i32 = stderr.trim().parse().map_err(|_| {
146 GetCrateVersionsError::Internal {
147 msg: format!("invalid HTTP code: {stderr:?}"),
148 cause: None,
149 }
150 })?;
151 if code == 404 {
152 return Err(GetCrateVersionsError::NotPublished);
153 }
154 if code != 200 {
155 return Err(GetCrateVersionsError::Internal {
156 msg: format!("invalid HTTP code: {code}"),
157 cause: None,
158 });
159 }
160
161 versions_result.map_err(|err| GetCrateVersionsError::Internal {
162 msg: "jq failed".to_string(),
163 cause: Some(Box::new(err)),
164 })
165 }
166}
167
168impl Default for CrateRegistry {
169 fn default() -> Self {
170 Self::new()
171 }
172}
173
174fn spawn_curl(
175 registry: &CrateRegistry,
176 crate_name: &str,
177) -> Result<(Child, String), RunCommandError> {
178 let mut cmd = Command::new("curl");
179 cmd.args(["--silent"]);
181 cmd.args(["--write-out", "%{stderr}%{http_code}"]);
183 cmd.arg(registry.get_crate_url(crate_name));
185 cmd.stderr(Stdio::piped());
187 cmd.stdout(Stdio::piped());
188 let curl_cmd_str = format_cmd(&cmd);
189 let child = cmd.spawn().map_err(|err| RunCommandError::Launch {
190 cmd: curl_cmd_str.clone(),
191 err,
192 })?;
193 Ok((child, curl_cmd_str))
194}
195
196fn parse_versions_from_crate_json(
197 input: impl Into<Stdio>,
198) -> Result<Vec<String>, RunCommandError> {
199 let mut cmd = Command::new("jq");
200 cmd.arg("--raw-output");
202 cmd.arg(".vers");
204 cmd.stdin(input);
205 let output = get_cmd_stdout_utf8(cmd)?;
206
207 Ok(output.lines().map(|l| l.to_string()).collect())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use std::fs::{self, File};
214 use tempfile::tempdir;
215
216 #[test]
217 fn test_url() {
218 let cargo = CrateRegistry::new();
219
220 assert_eq!(cargo.get_crate_url("a"), "https://index.crates.io/1/a");
221
222 assert_eq!(cargo.get_crate_url("aa"), "https://index.crates.io/2/aa");
223
224 assert_eq!(
225 cargo.get_crate_url("aaa"),
226 "https://index.crates.io/3/a/aaa"
227 );
228
229 assert_eq!(
230 cargo.get_crate_url("release-utils"),
231 "https://index.crates.io/re/le/release-utils"
232 );
233 }
234
235 #[test]
236 fn test_jq() {
237 let tmp_dir = tempdir().unwrap();
238 let path = tmp_dir.path().join("crate.json");
239 fs::write(&path, r#"{"name":"release-utils","vers":"0.2.4","deps":[{"name":"anyhow","req":"^1.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"cargo_metadata","req":"^0.18.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"crates-index","req":"^2.3.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"ureq","req":"^2.8.0","features":["http-interop"],"optional":false,"default_features":true,"target":null,"kind":"normal"}],"cksum":"92959b131c3d34846e39fed70bd7504684df0c6937ae736860329bd67836922e","features":{},"yanked":false,"rust_version":"1.70"}
240{"name":"release-utils","vers":"0.3.0","deps":[{"name":"anyhow","req":"^1.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"cargo_metadata","req":"^0.18.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"crates-index","req":"^2.3.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"tempfile","req":"^3.9.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"dev"},{"name":"ureq","req":"^2.8.0","features":["http-interop"],"optional":false,"default_features":true,"target":null,"kind":"normal"}],"cksum":"ce9721f93fd5cc4aa5cb82e9e550af437c55adfc49731984185e691442a932f9","features":{},"yanked":false,"rust_version":"1.70"}
241{"name":"release-utils","vers":"0.4.0","deps":[{"name":"anyhow","req":"^1.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"cargo_metadata","req":"^0.18.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"crates-index","req":"^2.3.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"tempfile","req":"^3.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"dev"},{"name":"ureq","req":"^2.8.0","features":["http-interop"],"optional":false,"default_features":true,"target":null,"kind":"normal"}],"cksum":"0aa93a5aaaed004e0222a3207cf5ec5dc15a39baea0e412bebfb7aa7bb8fa14c","features":{},"yanked":false,"rust_version":"1.70"}
242{"name":"release-utils","vers":"0.4.1","deps":[{"name":"anyhow","req":"^1.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"cargo_metadata","req":"^0.18.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"crates-index","req":"^2.3.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal"},{"name":"tempfile","req":"^3.0.0","features":[],"optional":false,"default_features":true,"target":null,"kind":"dev"},{"name":"ureq","req":"^2.8.0","features":["http-interop"],"optional":false,"default_features":true,"target":null,"kind":"normal"}],"cksum":"02922e087d9f1da9f783ca54f4621f1a156ffc3f8563d66c2d74b5d2d6363ccf","features":{},"yanked":false,"rust_version":"1.70"}
243"#).unwrap();
244 let file = File::open(path).unwrap();
245 let versions = parse_versions_from_crate_json(file).unwrap();
246 assert_eq!(versions, ["0.2.4", "0.3.0", "0.4.0", "0.4.1"]);
247 }
248}