cargo_list/
lib.rs

1#![doc = include_str!("../t/LIBRARY.md")]
2
3//--------------------------------------------------------------------------------------------------
4
5use {
6    anyhow::{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    Client::builder()
25        .user_agent("cargo-list")
26        .build()
27        .expect("create reqwest client")
28});
29
30//--------------------------------------------------------------------------------------------------
31
32/// Crate kind
33#[derive(Debug, Default, Serialize, Eq, PartialEq, Hash, Clone)]
34pub enum Kind {
35    Local,
36    Git,
37
38    #[default]
39    External,
40}
41
42use Kind::{External, Git, Local};
43
44/// All crate kinds
45pub const ALL_KINDS: [Kind; 3] = [Local, Git, External];
46
47impl Kind {
48    fn from(source: &str) -> Kind {
49        if source.starts_with("git+") {
50            Git
51        } else if source.starts_with("path+") {
52            Local
53        } else {
54            External
55        }
56    }
57}
58
59//--------------------------------------------------------------------------------------------------
60
61/// All installed crates
62#[derive(Debug, Serialize, Deserialize)]
63pub struct Crates {
64    installs: BTreeMap<String, Crate>,
65
66    #[serde(skip)]
67    pub active_toolchain: String,
68
69    #[serde(skip)]
70    pub active_version: String,
71}
72
73impl Crates {
74    /**
75    Deserialize from a `~/.cargo/.crates2.json` file and process each crate in
76    parallel to:
77
78    * Parse the name, version, source, rust version
79    * Get the latest avaiable version
80    * Determine the crate type
81
82    # Errors
83
84    Returns an error if not able to read the file at the given path
85    */
86    pub fn from(path: &Path) -> Result<Crates> {
87        Crates::from_include(path, &[])
88    }
89
90    /**
91    Return true if no crates are installed
92    */
93    #[must_use]
94    pub fn is_empty(&self) -> bool {
95        self.installs.is_empty()
96    }
97
98    /// Return a view of all crates
99    #[must_use]
100    pub fn crates(&self) -> BTreeMap<&str, &Crate> {
101        self.installs
102            .values()
103            .map(|x| (x.name.as_str(), x))
104            .collect()
105    }
106
107    /**
108    Like the [`Crates::from`] method, but accepts zero or more include patterns to match against
109    crate names
110
111    # Errors
112
113    Returns an error if not able to read the file at the given path or a pattern is not a valid
114    regular expression
115    */
116    #[allow(clippy::missing_panics_doc)]
117    pub fn from_include(path: &Path, patterns: &[&str]) -> Result<Crates> {
118        let mut crates: Crates = serde_json::from_reader(File::open(path)?)?;
119        if !patterns.is_empty() {
120            let set = RegexSet::new(patterns)?;
121            crates.installs = crates
122                .installs
123                .into_par_iter()
124                .filter_map(|(k, v)| {
125                    if set.is_match(k.split_once(' ').unwrap().0) {
126                        Some((k, v))
127                    } else {
128                        None
129                    }
130                })
131                .collect();
132        }
133        crates.active_toolchain = active_toolchain();
134        crates.active_version = crates
135            .active_toolchain
136            .lines()
137            .filter_map(|line| {
138                line.split(' ')
139                    .skip_while(|&word| word != "rustc")
140                    .nth(1)
141                    .map(ToString::to_string)
142            })
143            .nth(0)
144            .unwrap();
145        let errors = crates
146            .installs
147            .par_iter_mut()
148            .filter_map(|(k, v)| v.init(k, &crates.active_version).err())
149            .collect::<Vec<_>>();
150        if errors.is_empty() {
151            Ok(crates)
152        } else {
153            Err(anyhow!(format!(
154                "Errors: {}",
155                errors
156                    .iter()
157                    .map(ToString::to_string)
158                    .collect::<Vec<_>>()
159                    .join(", ")
160            )))
161        }
162    }
163}
164
165//--------------------------------------------------------------------------------------------------
166
167/// Individual installed crate
168#[derive(Debug, Serialize, Deserialize)]
169#[allow(clippy::struct_excessive_bools)]
170pub struct Crate {
171    #[serde(skip_deserializing)]
172    pub name: String,
173
174    #[serde(skip_deserializing)]
175    pub kind: Kind,
176
177    #[serde(skip_deserializing)]
178    pub installed: String,
179
180    #[serde(skip_deserializing)]
181    pub available: String,
182
183    #[serde(skip_deserializing)]
184    pub newer: Vec<String>,
185
186    #[serde(skip_deserializing)]
187    pub rust_version: String,
188
189    #[serde(skip_deserializing)]
190    pub outdated: bool,
191
192    #[serde(skip_deserializing)]
193    pub outdated_rust: bool,
194
195    #[serde(skip_deserializing)]
196    source: String,
197
198    pub version_req: Option<String>,
199    bins: Vec<String>,
200    features: Vec<String>,
201    all_features: bool,
202    no_default_features: bool,
203    profile: String,
204    target: String,
205    rustc: String,
206}
207
208impl Crate {
209    /// Initialize additional fields after deserialization
210    fn init(&mut self, k: &str, active_version: &str) -> Result<()> {
211        let mut s = k.split(' ');
212        self.name = s.next().unwrap().to_string();
213        self.installed = s.next().unwrap().to_string();
214        self.source = s
215            .next()
216            .unwrap()
217            .strip_prefix('(')
218            .unwrap()
219            .strip_suffix(')')
220            .unwrap()
221            .to_string();
222
223        self.kind = Kind::from(&self.source);
224
225        self.rust_version = self
226            .rustc
227            .strip_prefix("rustc ")
228            .unwrap()
229            .split_once(' ')
230            .unwrap()
231            .0
232            .to_string();
233
234        self.outdated_rust = self.rust_version != active_version;
235
236        if self.kind == External {
237            (self.available, self.newer) = latest(&self.name, &self.version_req)?;
238            self.outdated = self.installed != self.available;
239        }
240
241        Ok(())
242    }
243
244    /**
245    Generate the cargo install command to update the crate
246
247    # Panics
248
249    Panics if a git crate and not able to find `#` in the source
250    */
251    pub fn update_command(&self, pinned: bool) -> Vec<String> {
252        let mut r = vec!["cargo", "install"];
253
254        if self.no_default_features {
255            r.push("--no-default-features");
256        }
257
258        let features = if self.features.is_empty() {
259            None
260        } else {
261            Some(self.features.join(","))
262        };
263        if let Some(features) = &features {
264            r.push("-F");
265            r.push(features);
266        }
267
268        if !pinned && let Some(version) = &self.version_req {
269            r.push("--version");
270            r.push(version);
271        }
272
273        r.push("--profile");
274        r.push(&self.profile);
275
276        r.push("--target");
277        r.push(&self.target);
278
279        if self.outdated_rust {
280            r.push("--force");
281        }
282
283        if self.kind == Git {
284            r.push("--git");
285            r.push(&self.source[4..self.source.find('#').unwrap()]);
286            for bin in &self.bins {
287                r.push(bin);
288            }
289        } else {
290            r.push(&self.name);
291        }
292
293        r.into_iter().map(String::from).collect()
294    }
295}
296
297//--------------------------------------------------------------------------------------------------
298
299/**
300Deserialize the crate version object returned via the crates.io API
301(`https://crates.io/api/v1/crates/{name}/versions`) in the [`latest()`] function
302*/
303#[derive(Debug, Deserialize)]
304struct Versions {
305    versions: Vec<Version>,
306}
307
308impl Versions {
309    fn iter(&self) -> std::slice::Iter<'_, Version> {
310        self.versions.iter()
311    }
312
313    fn available(&self) -> Vec<&Version> {
314        self.iter().filter(|x| x.is_available()).collect()
315    }
316}
317
318#[derive(Debug, Deserialize)]
319struct Version {
320    num: semver::Version,
321    yanked: bool,
322}
323
324impl Version {
325    fn is_available(&self) -> bool {
326        self.num.pre.is_empty() && !self.yanked
327    }
328}
329
330//--------------------------------------------------------------------------------------------------
331
332/**
333Get the latest available (not prerelease or yanked) version(s) for a crate, optionally matching a
334required version
335
336# Errors
337
338Returns an error if not able to get the versions via the REST API
339*/
340pub fn latest(name: &str, version_req: &Option<String>) -> Result<(String, Vec<String>)> {
341    let url = format!("https://crates.io/api/v1/crates/{name}/versions");
342    let res = CLIENT.get(url).send()?;
343    let versions = res.json::<Versions>()?;
344    let available = versions.available();
345    if let Some(req) = version_req {
346        let req = semver::VersionReq::parse(req)?;
347        let mut newer = vec![];
348        for v in &available {
349            if req.matches(&v.num) {
350                return Ok((v.num.to_string(), newer));
351            }
352            newer.push(v.num.to_string());
353        }
354        Err(anyhow!(
355            "Failed to find an available version matching the requirement"
356        ))
357    } else if available.is_empty() {
358        Err(anyhow!("Failed to find any available version"))
359    } else {
360        Ok((available[0].num.to_string(), vec![]))
361    }
362}
363
364/// Get the active toolchain
365#[must_use]
366pub fn active_toolchain() -> String {
367    let r = Shell {
368        print: false,
369        ..Default::default()
370    }
371    .run(&[Command {
372        command: String::from("rustup show active-toolchain -v"),
373        stdout: Pipe::string(),
374        ..Default::default()
375    }]);
376    if let Pipe::String(Some(s)) = &r[0].stdout {
377        s.clone()
378    } else {
379        String::new()
380    }
381}
382
383/**
384Expand a path with an optional tilde (`~`)
385
386# Panics
387
388Panics if not able to get the user's home directory
389*/
390#[must_use]
391pub fn expanduser(path: &str) -> PathBuf {
392    if path == "~" {
393        home_dir().unwrap().join(&path[1..])
394    } else if path.starts_with('~') {
395        home_dir().unwrap().join(&path[2..])
396    } else {
397        PathBuf::from(&path)
398    }
399}