Skip to main content

cargo_list/
lib.rs

1#![doc = include_str!("../t/LIBRARY.md")]
2
3//--------------------------------------------------------------------------------------------------
4
5use {
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
21//--------------------------------------------------------------------------------------------------
22
23static CLIENT: LazyLock<Client> = LazyLock::new(|| {
24    let mut client = Client::builder().user_agent("cargo-list");
25
26    // If the CARGO_LIST_PROXY environment variable is set,
27    // try to use its value to set the reqwest client proxy
28    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//--------------------------------------------------------------------------------------------------
48
49/// Crate kind
50#[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
61/// All crate kinds
62pub 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//--------------------------------------------------------------------------------------------------
77
78/// All installed crates
79#[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    /**
92    Deserialize from a `~/.cargo/.crates2.json` file and process each crate in
93    parallel to:
94
95    * Parse the name, version, source, rust version
96    * Get the latest avaiable version
97    * Determine the crate type
98
99    # Errors
100
101    Returns an error if not able to read the file at the given path
102    */
103    pub fn from(path: &Path) -> Result<Crates> {
104        Crates::from_include(path, &[])
105    }
106
107    /**
108    Return true if no crates are installed
109    */
110    #[must_use]
111    pub fn is_empty(&self) -> bool {
112        self.installs.is_empty()
113    }
114
115    /// Return a view of all crates
116    #[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    /**
125    Like the [`Crates::from`] method, but accepts zero or more include patterns to match against
126    crate names
127
128    # Errors
129
130    Returns an error if not able to read the file at the given path or a pattern is not a valid
131    regular expression
132    */
133    #[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//--------------------------------------------------------------------------------------------------
187
188/// Individual installed crate
189#[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    /// Initialize additional fields after deserialization
237    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    /**
274    Generate the cargo install command to update the crate
275
276    # Panics
277
278    Panics if a git crate and not able to find `#` in the source
279    */
280    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//--------------------------------------------------------------------------------------------------
327
328/**
329Deserialize the crate version object returned via the crates.io API
330(`https://crates.io/api/v1/crates/{name}/versions`) in the [`latest()`] function
331*/
332#[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
362//--------------------------------------------------------------------------------------------------
363
364/**
365Get the latest available (not prerelease or yanked) version(s) for a crate, optionally matching a
366required version
367
368# Errors
369
370Returns an error if not able to get the versions via the REST API
371*/
372pub 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        // If we haven't found a match yet, but we are allowing prereleases,
393        // it's possible the requirement string didn't explicitly opt-in to prereleases (like
394        // `^2.0.0`) but the available versions are prereleases (like `2.0.0-rc.37`).
395        // In this specific case, if we found *no* matching versions, we might want to be lenient,
396        // but semver::VersionReq is strict.
397        // However, if the user INSTALLED a prerelease, usually the version_req in `.crates2.json`
398        // reflects that (e.g. it might be `=2.0.0-rc.37` or `^2.0.0-rc.37`).
399        // If the error persists, it means even with prereleases included in `available`, none
400        // matched `req`.
401
402        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/// Get the active toolchain
421#[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/**
440Expand a path with an optional tilde (`~`)
441
442# Panics
443
444Panics if not able to get the user's home directory
445*/
446#[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}