cargo_list/
lib.rs

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