1#![doc = include_str!("../t/LIBRARY.md")]
2
3use {
6 anyhow::{Context, Result, anyhow},
7 dirs::home_dir,
8 rayon::prelude::*,
9 regex::RegexSet,
10 reqwest::blocking::Client,
11 serde::{Deserialize, Serialize},
12 sprint::{Command, Pipe, Shell},
13 std::{
14 collections::BTreeMap,
15 fs::File,
16 path::{Path, PathBuf},
17 sync::LazyLock,
18 },
19};
20
21static CLIENT: LazyLock<Client> = LazyLock::new(|| {
24 let mut client = Client::builder().user_agent("cargo-list");
25
26 if let Ok(url) = std::env::var("CARGO_LIST_PROXY") {
29 match reqwest::Proxy::all(&url) {
30 Ok(proxy) => {
31 client = client.proxy(proxy);
32 }
33 Err(e) => {
34 eprintln!(
35 "\
36 Warning: The `CARGO_LIST_PROXY` environment variable is set but is invalid \
37 ({e}); ignoring!\
38 ",
39 );
40 }
41 }
42 }
43
44 client.build().expect("create reqwest client")
45});
46
47#[derive(Debug, Default, Serialize, Eq, PartialEq, Hash, Clone)]
51pub enum Kind {
52 Local,
53 Git,
54
55 #[default]
56 External,
57}
58
59use Kind::{External, Git, Local};
60
61pub const ALL_KINDS: [Kind; 3] = [Local, Git, External];
63
64impl Kind {
65 fn from(source: &str) -> Kind {
66 if source.starts_with("git+") {
67 Git
68 } else if source.starts_with("path+") {
69 Local
70 } else {
71 External
72 }
73 }
74}
75
76#[derive(Debug, Serialize, Deserialize)]
80pub struct Crates {
81 installs: BTreeMap<String, Crate>,
82
83 #[serde(skip)]
84 pub active_toolchain: String,
85
86 #[serde(skip)]
87 pub active_version: String,
88}
89
90impl Crates {
91 pub fn from(path: &Path) -> Result<Crates> {
104 Crates::from_include(path, &[])
105 }
106
107 #[must_use]
111 pub fn is_empty(&self) -> bool {
112 self.installs.is_empty()
113 }
114
115 #[must_use]
117 pub fn crates(&self) -> BTreeMap<&str, &Crate> {
118 self.installs
119 .values()
120 .map(|x| (x.name.as_str(), x))
121 .collect()
122 }
123
124 #[allow(clippy::missing_panics_doc)]
134 pub fn from_include(path: &Path, patterns: &[&str]) -> Result<Crates> {
135 let mut crates: Crates = serde_json::from_reader(File::open(path)?)?;
136 if !patterns.is_empty() {
137 let set = RegexSet::new(patterns)?;
138 crates.installs = crates
139 .installs
140 .into_par_iter()
141 .filter_map(|(k, v)| {
142 if set.is_match(k.split_once(' ').unwrap().0) {
143 Some((k, v))
144 } else {
145 None
146 }
147 })
148 .collect();
149 }
150 crates.active_toolchain = active_toolchain();
151 crates.active_version = crates
152 .active_toolchain
153 .lines()
154 .filter_map(|line| {
155 line.split(' ')
156 .skip_while(|&word| word != "rustc")
157 .nth(1)
158 .map(ToString::to_string)
159 })
160 .nth(0)
161 .unwrap();
162 let errors = crates
163 .installs
164 .par_iter_mut()
165 .filter_map(|(k, v)| {
166 v.init(k, &crates.active_version)
167 .with_context(|| format!("Failed to process crate '{k}'"))
168 .err()
169 })
170 .collect::<Vec<_>>();
171 if errors.is_empty() {
172 Ok(crates)
173 } else {
174 Err(anyhow!(format!(
175 "Errors: {}",
176 errors
177 .iter()
178 .map(ToString::to_string)
179 .collect::<Vec<_>>()
180 .join(", ")
181 )))
182 }
183 }
184}
185
186#[derive(Debug, Serialize, Deserialize)]
190#[allow(clippy::struct_excessive_bools)]
191pub struct Crate {
192 #[serde(skip_deserializing)]
193 pub name: String,
194
195 #[serde(skip_deserializing)]
196 pub kind: Kind,
197
198 #[serde(skip_deserializing)]
199 pub installed: String,
200
201 #[serde(skip_deserializing)]
202 installed_: Option<semver::Version>,
203
204 #[serde(skip_deserializing)]
205 prerelease: bool,
206
207 #[serde(skip_deserializing)]
208 pub available: String,
209
210 #[serde(skip_deserializing)]
211 pub newer: Vec<String>,
212
213 #[serde(skip_deserializing)]
214 pub rust_version: String,
215
216 #[serde(skip_deserializing)]
217 pub outdated: bool,
218
219 #[serde(skip_deserializing)]
220 pub outdated_rust: bool,
221
222 #[serde(skip_deserializing)]
223 source: String,
224
225 pub version_req: Option<String>,
226 bins: Vec<String>,
227 features: Vec<String>,
228 all_features: bool,
229 no_default_features: bool,
230 profile: String,
231 target: String,
232 rustc: String,
233}
234
235impl Crate {
236 fn init(&mut self, k: &str, active_version: &str) -> Result<()> {
238 let mut s = k.split(' ');
239 self.name = s.next().unwrap().to_string();
240 self.installed = s.next().unwrap().to_string();
241 self.source = s
242 .next()
243 .unwrap()
244 .strip_prefix('(')
245 .unwrap()
246 .strip_suffix(')')
247 .unwrap()
248 .to_string();
249 self.installed_ = semver::Version::parse(&self.installed).ok();
250 self.prerelease = self.installed_.as_ref().is_some_and(|x| !x.pre.is_empty());
251
252 self.kind = Kind::from(&self.source);
253
254 self.rust_version = self
255 .rustc
256 .strip_prefix("rustc ")
257 .unwrap()
258 .split_once(' ')
259 .unwrap()
260 .0
261 .to_string();
262
263 self.outdated_rust = self.rust_version != active_version;
264
265 if self.kind == External {
266 (self.available, self.newer) = latest(&self.name, &self.version_req, self.prerelease)?;
267 self.outdated = self.installed != self.available;
268 }
269
270 Ok(())
271 }
272
273 pub fn update_command(&self, pinned: bool) -> Vec<String> {
281 let mut r = vec!["cargo", "install"];
282
283 if self.no_default_features {
284 r.push("--no-default-features");
285 }
286
287 let features = if self.features.is_empty() {
288 None
289 } else {
290 Some(self.features.join(","))
291 };
292 if let Some(features) = &features {
293 r.push("-F");
294 r.push(features);
295 }
296
297 if !pinned && let Some(version) = &self.version_req {
298 r.push("--version");
299 r.push(version);
300 }
301
302 r.push("--profile");
303 r.push(&self.profile);
304
305 r.push("--target");
306 r.push(&self.target);
307
308 if self.outdated_rust {
309 r.push("--force");
310 }
311
312 if self.kind == Git {
313 r.push("--git");
314 r.push(&self.source[4..self.source.find('#').unwrap()]);
315 for bin in &self.bins {
316 r.push(bin);
317 }
318 } else {
319 r.push(&self.name);
320 }
321
322 r.into_iter().map(String::from).collect()
323 }
324}
325
326#[derive(Debug, Deserialize)]
333struct Versions {
334 versions: Vec<Version>,
335}
336
337impl Versions {
338 fn iter(&self) -> std::slice::Iter<'_, Version> {
339 self.versions.iter()
340 }
341
342 fn available(&self, prerelease: bool) -> Vec<&Version> {
343 self.iter().filter(|x| x.is_available(prerelease)).collect()
344 }
345}
346
347#[derive(Debug, Deserialize)]
348struct Version {
349 num: semver::Version,
350 yanked: bool,
351}
352
353impl Version {
354 fn is_available(&self, prerelease: bool) -> bool {
355 if !prerelease && !self.num.pre.is_empty() {
356 return false;
357 }
358 !self.yanked
359 }
360}
361
362pub fn latest(
373 name: &str,
374 version_req: &Option<String>,
375 prerelease: bool,
376) -> Result<(String, Vec<String>)> {
377 let url = format!("https://crates.io/api/v1/crates/{name}/versions");
378 let res = CLIENT.get(&url).send()?;
379 let res = res.error_for_status()?;
380 let versions = res.json::<Versions>()?;
381 let available = versions.available(prerelease);
382 if let Some(req_str) = version_req {
383 let req = semver::VersionReq::parse(req_str)?;
384 let mut newer = vec![];
385 for v in &available {
386 if req.matches(&v.num) {
387 return Ok((v.num.to_string(), newer));
388 }
389 newer.push(v.num.to_string());
390 }
391
392 Err(anyhow!(
403 "\
404 Failed to find an available version matching the requirement `{req_str}` \
405 (available: {:?})\
406 ",
407 available
408 .iter()
409 .take(5)
410 .map(|v| v.num.to_string())
411 .collect::<Vec<_>>()
412 ))
413 } else if available.is_empty() {
414 Err(anyhow!("Failed to find any available version"))
415 } else {
416 Ok((available[0].num.to_string(), vec![]))
417 }
418}
419
420#[must_use]
422pub fn active_toolchain() -> String {
423 let r = Shell {
424 print: false,
425 ..Default::default()
426 }
427 .run(&[Command {
428 command: String::from("rustup show active-toolchain -v"),
429 stdout: Pipe::string(),
430 ..Default::default()
431 }]);
432 if let Pipe::String(Some(s)) = &r[0].stdout {
433 s.clone()
434 } else {
435 String::new()
436 }
437}
438
439#[must_use]
447pub fn expanduser(path: &str) -> PathBuf {
448 if path == "~" {
449 home_dir().unwrap().join(&path[1..])
450 } else if path.starts_with('~') {
451 home_dir().unwrap().join(&path[2..])
452 } else {
453 PathBuf::from(&path)
454 }
455}