Skip to main content

cnf_lib/provider/
cargo.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// SPDX-FileCopyrightText: (C) 2023 Andreas Hartmann <hartan@7x.de>
3// This file is part of cnf-lib, available at <https://gitlab.com/hartang/rust/cnf>
4
5//! # Search packages with cargo (Rust)
6use crate::provider::prelude::*;
7
8use regex::Regex;
9
10/// Provider for the `cargo` package manager of the Rust programming language.
11#[derive(Default, Debug, PartialEq)]
12// In the future these may get (mutable) internal state.
13#[allow(missing_copy_implementations)]
14pub struct Cargo;
15
16impl fmt::Display for Cargo {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        write!(f, "cargo")
19    }
20}
21
22impl Cargo {
23    /// Create a new instance.
24    pub fn new() -> Self {
25        Default::default()
26    }
27
28    /// Parse candidate list from search output.
29    fn get_candidates_from_search_output(&self, output: &str) -> ProviderResult<Vec<Candidate>> {
30        let err_context = "failed to parse output from cargo";
31        trace!(output, "parsing search output");
32
33        let lines = output
34            .lines()
35            .map(|s| s.to_string())
36            .collect::<Vec<String>>();
37
38        let mut results = vec![];
39
40        let cargo_regex = Regex::new(
41            "^(?P<package>[a-zA-Z0-9-_]+) = \"(?P<version>.*)\"\\s+# (?P<description>.+)$",
42        )
43        .context(err_context)?;
44
45        for line in lines {
46            if line.is_empty() {
47                continue;
48            }
49            match cargo_regex.captures(&line) {
50                Some(caps) => {
51                    let mut candidate = Candidate {
52                        package: match_to_string(&caps, 1).context(err_context)?,
53                        version: match_to_string(&caps, 2).context(err_context)?,
54                        description: match_to_string(&caps, 3).context(err_context)?,
55                        origin: "".to_string(),
56                        ..Candidate::default()
57                    };
58                    candidate.actions.install = Some(cmd!(
59                        "cargo".into(),
60                        "install".into(),
61                        "--version".into(),
62                        candidate.version.clone(),
63                        candidate.package.clone()
64                    ));
65
66                    results.push(candidate);
67                }
68                None => {
69                    trace!("regex didn't match on line '{}'", line);
70                    continue;
71                }
72            }
73        }
74
75        Ok(results)
76    }
77}
78
79fn match_to_string<'r>(capture: &'r regex::Captures<'r>, index: usize) -> ProviderResult<String> {
80    Ok(capture
81        .get(index)
82        .with_context(|| format!("failed to retrieve regex capture group {}", index))?
83        .as_str()
84        .to_string())
85}
86
87#[async_trait]
88impl IsProvider for Cargo {
89    async fn search_internal(
90        &self,
91        command: &str,
92        target_env: Arc<Environment>,
93    ) -> ProviderResult<Vec<Candidate>> {
94        let stdout = target_env
95            .output_of(cmd!(
96                "cargo", "search", "--limit", "5", "--color", "never", command
97            ))
98            .await?;
99
100        let mut candidates = self.get_candidates_from_search_output(&stdout)?;
101        // Fill in the execution details
102        for c in candidates.iter_mut() {
103            c.actions.execute = cmd!(command.to_string());
104        }
105
106        Ok(candidates)
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::test::prelude::*;
114
115    #[test]
116    fn initialize() {
117        let _cargo = Cargo::new();
118    }
119
120    test::default_tests!(Cargo::new());
121
122    /// Searching a nonexistent package creates empty output, which means "Command not found".
123    ///
124    /// - Searched with: cargo 1.69.0
125    /// - Search command: "cargo search --limit 5 --color never 'asdlwhajksdmwdankjs'"
126    #[test]
127    fn matches_empty() {
128        let query = quick_test!(Cargo::new(), Ok("".to_string()));
129
130        assert::is_err!(query);
131        assert::err::not_found!(query);
132    }
133
134    /// Searching an existent package
135    ///
136    /// - Searched with: cargo 1.69.0
137    /// - Search command: "cargo search --limit 5 --color never zellij"
138    #[test]
139    fn matches_zellij() {
140        let query = quick_test!(Cargo::new(), Ok("
141zellij = \"0.36.0\"                          # A terminal workspace with batteries included
142zellij-runner = \"0.2.0\"                    # Session runner/switcher for Zellij
143zellij-client = \"0.36.0\"                   # The client-side library for Zellij
144zellij-server = \"0.36.0\"                   # The server-side library for Zellij
145zellij-tile = \"0.36.0\"                     # A small client-side library for writing Zellij plugins
146... and 11 crates more (use --limit N to see more)
147".to_string()));
148
149        let result = query.results.unwrap();
150
151        assert!(result.len() == 5);
152        assert!(result[0].package == "zellij");
153        assert!(result[0].version == "0.36.0");
154        assert!(result[0].origin.is_empty());
155        assert!(result[0].description == "A terminal workspace with batteries included");
156        assert!(result[1].description == "Session runner/switcher for Zellij");
157    }
158
159    /// Searching without network connection.
160    ///
161    /// - Searched with: cargo 1.69.0
162    /// - Search command: "cargo search --limit 5 --color never zellij"
163    #[test]
164    fn no_network() {
165        let query = quick_test!(Cargo::new(), Err(ExecutionError::NonZero {
166            command: "cargo".to_string(),
167            output: std::process::Output {
168                stdout: r"".into(),
169                stderr: r"error: failed to retrieve search results from the registry at https://crates.io
170
171Caused by:
172  [6] Couldn't resolve host name (Could not resolve host: crates.io)
173".into(),
174                status: ExitStatus::from_raw(101),
175            },
176        }));
177
178        assert::is_err!(query);
179        assert::err::execution!(query);
180    }
181}