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 pacman
use crate::provider::prelude::*;
use std::str::FromStr;

/// Error from pacman interactions.
#[derive(Debug, ThisError, Display)]
pub enum Error {
    /// pacman database files don't exist, please update database files
    NoDatabase,

    /// failed to parse stderr from pacman: '{0}'
    ParseError(String),
}

/// Shorthand to get error type from stderr
impl FromStr for Error {
    type Err = Self;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s.contains("warning: database file") & s.contains("not exist (use '-Fy' to download)") {
            Ok(Self::NoDatabase)
        } else {
            Err(Self::ParseError(s.to_string()))
        }
    }
}

/// Provider for the `pacman` package manager.
#[derive(Default, Debug, PartialEq)]
// In the future these may get (mutable) internal state.
#[allow(missing_copy_implementations)]
pub struct Pacman;

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

impl Pacman {
    /// Create a new instance of the `pacman` provider.
    pub fn new() -> Self {
        Default::default()
    }

    /// Parse stdout for package descriptions.
    fn get_candidates_from_files_output(&self, output: String) -> ProviderResult<Vec<Candidate>> {
        let mut results = vec![];

        for line in output.lines() {
            let mut candidate = Candidate::default();
            for (index, piece) in line.splitn(4, '\0').enumerate() {
                let piece = piece.to_string();
                match index {
                    0 => candidate.origin = piece,
                    1 => candidate.package = piece,
                    2 => candidate.version = piece,
                    3 => candidate.actions.execute = cmd!(piece),
                    _ => panic!("line contained superfluous piece {}", piece),
                }
            }
            if !candidate.package.is_empty() {
                results.push(candidate);
            }
        }

        Ok(results)
    }
}

#[async_trait]
impl IsProvider for Pacman {
    async fn search_internal(
        &self,
        command: &str,
        target_env: Arc<Environment>,
    ) -> ProviderResult<Vec<Candidate>> {
        let stdout = match target_env
            .output_of(cmd!(
                "pacman",
                "-F",
                "--noconfirm",
                "--machinereadable",
                command
            ))
            .await
        {
            Ok(val) => val,
            Err(ExecutionError::NonZero { ref output, .. })
                if (output.stdout.is_empty() && output.stderr.is_empty()) =>
            {
                return Err(ProviderError::NotFound(command.to_string()));
            }
            Err(e) => return Err(ProviderError::from(e)),
        };

        let mut candidates = self.get_candidates_from_files_output(stdout)?;
        candidates.iter_mut().for_each(|candidate| {
            if candidate.actions.execute.is_empty() {
                candidate.actions.execute = cmd!(command);
            }
        });

        Ok(candidates)
    }
}

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

    #[test]
    fn initialize() {
        let _pacman = Pacman::new();
    }

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

    /// Searching without system cache
    ///
    /// - Searched with: pacman 6.0.2
    /// - Search command: "pacman -F --noconfirm --machinereadable asdwasda"
    #[test]
    fn cache_empty() {
        let query =
            quick_test!(Pacman::new(), Err(ExecutionError::NonZero {
            command: "pacman".to_string(),
            output: std::process::Output {
                stdout: r"".into(),
                stderr: r"warning: database file for 'core' does not exist (use '-Fy' to download)
warning: database file for 'extra' does not exist (use '-Fy' to download)
warning: database file for 'community' does not exist (use '-Fy' to download)
".into(),
                status: ExitStatus::from_raw(1),
            }
        }));

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

    /// Searching nonexistent package
    ///
    /// - Searched with: pacman 6.0.2
    /// - Search command: "pacman -F --noconfirm --machinereadable asdwasda"
    #[test]
    fn search_nonexistent() {
        let query = quick_test!(
            Pacman::new(),
            Err(ExecutionError::NonZero {
                command: "pacman".to_string(),
                output: std::process::Output {
                    stdout: r"".into(),
                    stderr: r"".into(),
                    status: ExitStatus::from_raw(1),
                }
            })
        );

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

    /// Searching existent package
    ///
    /// - Searched with: pacman 6.0.2
    /// - Search command: "pacman -F --noconfirm --machinereadable htop"
    #[test]
    fn matches_htop() {
        let query = quick_test!(
            Pacman::new(),
            Ok("
extra\0bash-completion\x002.11-3\0usr/share/bash-completion/completions/htop
extra\0htop\x003.2.2-1\0usr/bin/htop
community\0pcp\x006.0.3-1\0etc/pcp/pmlogconf/tools/htop
community\0pcp\x006.0.3-1\0var/lib/pcp/config/pmlogconf/tools/htop
"
            .to_string())
        );

        let result = query.results.unwrap();

        assert_eq!(result.len(), 4);
        assert!(result[0].package.starts_with("bash-completion"));
        assert_eq!(result[0].version, "2.11-3");
        assert_eq!(result[0].origin, "extra");
        assert!(result[0].description.is_empty());
        assert_eq!(
            result[0].actions.execute,
            vec!["usr/share/bash-completion/completions/htop"].into()
        );
        assert!(result[1].package.starts_with("htop"))
    }
}