cnf-lib 0.6.0

Distribution-agnostic 'command not found'-handler
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: (C) 2023 Andreas Hartmann <hartan@7x.de>
// This file is part of cnf-lib, available at <https://gitlab.com/hartang/rust/cnf>

//! # Search packages with cargo (Rust)
use crate::provider::prelude::*;

use regex::Regex;

/// Provider for the `cargo` package manager of the Rust programming language.
#[derive(Default, Debug, PartialEq)]
// In the future these may get (mutable) internal state.
#[allow(missing_copy_implementations)]
pub struct Cargo;

impl fmt::Display for Cargo {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "cargo")
    }
}

impl Cargo {
    /// Create a new instance.
    pub fn new() -> Self {
        Default::default()
    }

    /// Parse candidate list from search output.
    fn get_candidates_from_search_output(&self, output: &str) -> ProviderResult<Vec<Candidate>> {
        let err_context = "failed to parse output from cargo";
        trace!(output, "parsing search output");

        let lines = output
            .lines()
            .map(|s| s.to_string())
            .collect::<Vec<String>>();

        let mut results = vec![];

        let cargo_regex = Regex::new(
            "^(?P<package>[a-zA-Z0-9-_]+) = \"(?P<version>.*)\"\\s+# (?P<description>.+)$",
        )
        .context(err_context)?;

        for line in lines {
            if line.is_empty() {
                continue;
            }
            match cargo_regex.captures(&line) {
                Some(caps) => {
                    let mut candidate = Candidate {
                        package: match_to_string(&caps, 1).context(err_context)?,
                        version: match_to_string(&caps, 2).context(err_context)?,
                        description: match_to_string(&caps, 3).context(err_context)?,
                        origin: "".to_string(),
                        ..Candidate::default()
                    };
                    candidate.actions.install = Some(cmd!(
                        "cargo".into(),
                        "install".into(),
                        "--version".into(),
                        candidate.version.clone(),
                        candidate.package.clone()
                    ));

                    results.push(candidate);
                }
                None => {
                    trace!("regex didn't match on line '{}'", line);
                    continue;
                }
            }
        }

        Ok(results)
    }
}

fn match_to_string<'r>(capture: &'r regex::Captures<'r>, index: usize) -> ProviderResult<String> {
    Ok(capture
        .get(index)
        .with_context(|| format!("failed to retrieve regex capture group {}", index))?
        .as_str()
        .to_string())
}

#[async_trait]
impl IsProvider for Cargo {
    async fn search_internal(
        &self,
        command: &str,
        target_env: Arc<Environment>,
    ) -> ProviderResult<Vec<Candidate>> {
        let stdout = target_env
            .output_of(cmd!(
                "cargo", "search", "--limit", "5", "--color", "never", command
            ))
            .await?;

        let mut candidates = self.get_candidates_from_search_output(&stdout)?;
        // Fill in the execution details
        for c in candidates.iter_mut() {
            c.actions.execute = cmd!(command.to_string());
        }

        Ok(candidates)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test::prelude::*;

    #[test]
    fn initialize() {
        let _cargo = Cargo::new();
    }

    test::default_tests!(Cargo::new());

    /// Searching a nonexistent package creates empty output, which means "Command not found".
    ///
    /// - Searched with: cargo 1.69.0
    /// - Search command: "cargo search --limit 5 --color never 'asdlwhajksdmwdankjs'"
    #[test]
    fn matches_empty() {
        let query = quick_test!(Cargo::new(), Ok("".to_string()));

        assert::is_err!(query);
        assert::err::not_found!(query);
    }

    /// Searching an existent package
    ///
    /// - Searched with: cargo 1.69.0
    /// - Search command: "cargo search --limit 5 --color never zellij"
    #[test]
    fn matches_zellij() {
        let query = quick_test!(Cargo::new(), Ok("
zellij = \"0.36.0\"                          # A terminal workspace with batteries included
zellij-runner = \"0.2.0\"                    # Session runner/switcher for Zellij
zellij-client = \"0.36.0\"                   # The client-side library for Zellij
zellij-server = \"0.36.0\"                   # The server-side library for Zellij
zellij-tile = \"0.36.0\"                     # A small client-side library for writing Zellij plugins
... and 11 crates more (use --limit N to see more)
".to_string()));

        let result = query.results.unwrap();

        assert!(result.len() == 5);
        assert!(result[0].package == "zellij");
        assert!(result[0].version == "0.36.0");
        assert!(result[0].origin.is_empty());
        assert!(result[0].description == "A terminal workspace with batteries included");
        assert!(result[1].description == "Session runner/switcher for Zellij");
    }

    /// Searching without network connection.
    ///
    /// - Searched with: cargo 1.69.0
    /// - Search command: "cargo search --limit 5 --color never zellij"
    #[test]
    fn no_network() {
        let query = quick_test!(Cargo::new(), Err(ExecutionError::NonZero {
            command: "cargo".to_string(),
            output: std::process::Output {
                stdout: r"".into(),
                stderr: r"error: failed to retrieve search results from the registry at https://crates.io

Caused by:
  [6] Couldn't resolve host name (Could not resolve host: crates.io)
".into(),
                status: ExitStatus::from_raw(101),
            },
        }));

        assert::is_err!(query);
        assert::err::execution!(query);
    }
}