cnf_lib/provider/
cargo.rs

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