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 Flatpak

use crate::provider::prelude::*;

/// Provider for Flatpak packages.
#[derive(Debug, Default, PartialEq)]
// In the future these may get (mutable) internal state.
#[allow(missing_copy_implementations)]
pub struct Flatpak {}

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

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

#[async_trait]
impl IsProvider for Flatpak {
    async fn search_internal(
        &self,
        command: &str,
        target_env: Arc<Environment>,
    ) -> ProviderResult<Vec<Candidate>> {
        let mut state = FlatpakState::default();
        state
            .discover_remotes(&target_env)
            .await
            .map_err(|e| match e {
                RemoteError::Execution(ExecutionError::NotFound(val)) => {
                    ProviderError::Requirements(val)
                }
                RemoteError::Execution(e) => ProviderError::Execution(e),
                err => ProviderError::ApplicationError(anyhow::Error::new(err)),
            })?;

        let candidates = state
            .get_remote_flatpaks(&target_env, command)
            .await
            .map_err(Error::RemoteFlatpaks)?;

        state
            .check_installed_flatpaks(&target_env, candidates)
            .await
            .map_err(Error::LocalFlatpaks)
            .map_err(|e| e.into())
    }
}

#[derive(Debug, Default)]
struct FlatpakState {
    remotes: Vec<Remote>,
}

impl FlatpakState {
    /// Discover all remotes configured in an environment.
    ///
    /// Remotes are stored internally for later use. You must call this function before
    /// [`FlatpakState::get_remote_flatpaks()`] or [`FlatpakState::check_installed_flatpaks()`].
    async fn discover_remotes(&mut self, env: &Arc<Environment>) -> Result<(), RemoteError> {
        let stdout = env
            .output_of(cmd!("flatpak", "remotes", "--columns=name:f,options:f"))
            .await?;

        // When there is no result, flatpak will still at least print a single newline char.
        if stdout.trim().is_empty() {
            return Err(RemoteError::NoRemote);
        }

        for line in stdout.lines() {
            match line.split_once('\t') {
                Some((name, opts)) => {
                    let remote_type = if opts.contains("user") {
                        RemoteType::User
                    } else if opts.contains("system") {
                        RemoteType::System
                    } else {
                        return Err(RemoteError::UnknownType(opts.to_string()));
                    };

                    let remote = Remote {
                        name: name.to_string(),
                        r#type: remote_type,
                    };

                    self.remotes.push(remote);
                }
                None => return Err(RemoteError::Parse(stdout)),
            }
        }

        Ok(())
    }

    /// Retrieves all flatpaks in all remotes and filters them.
    ///
    /// Filtering is performed by performing a case-insensitive match against the `search_for`
    /// argument. No fuzzy matching or similar is currently used.
    async fn get_remote_flatpaks(
        &mut self,
        env: &Arc<Environment>,
        search_for: &str,
    ) -> Result<Vec<Candidate>, RemoteFlatpakError> {
        // Sanity checks. No reason to be friendly here, this is a simple usage error.
        debug_assert!(
            !self.remotes.is_empty(),
            "cannot query remote flatpaks without a remote"
        );
        let mut candidates: Vec<Candidate> = vec![];

        for remote in &self.remotes {
            let output = env
                .output_of(cmd!(
                    "flatpak",
                    "remote-ls",
                    "--app",
                    "--cached",
                    "--columns=application:f,version:f,origin:f,description:f",
                    &remote.r#type.to_cli(),
                    &remote.name
                ))
                .await
                .map_err(|e| RemoteFlatpakError::Execution {
                    remote: remote.clone(),
                    source: e,
                })?;

            'next_line: for line in output.lines() {
                let mut candidate = Candidate::default();
                for (index, split) in line.split('\t').enumerate() {
                    match index {
                        0 => {
                            if split.to_lowercase().contains(&search_for.to_lowercase()) {
                                candidate.package = split.to_string();
                            } else {
                                continue 'next_line;
                            }
                        }
                        1 => candidate.version = split.to_string(),
                        2 => candidate.origin = remote.to_origin(),
                        3 => candidate.description = split.to_string(),
                        _ => warn!("superfluous fragment '{}' in line '{}'", split, line),
                    }
                }

                if !candidate.package.is_empty() {
                    let mut install_action = cmd!(
                        "flatpak",
                        "install",
                        "--app",
                        &remote.r#type.to_cli(),
                        &remote.name,
                        &candidate.package
                    );
                    install_action.needs_privileges(matches!(remote.r#type, RemoteType::System));
                    candidate.actions.install = Some(install_action);

                    candidate.actions.execute = cmd!(
                        "flatpak".to_string(),
                        "run".to_string(),
                        remote.r#type.to_cli(),
                        candidate.package.clone()
                    );

                    candidates.push(candidate);
                }
            }
        }

        Ok(candidates)
    }

    /// Take a list of candidates and update their installation status.
    ///
    /// Queries the locally installed flatpaks and updates the `action.install` metadata to reflect
    /// the installation status.
    async fn check_installed_flatpaks(
        &self,
        env: &Arc<Environment>,
        mut candidates: Vec<Candidate>,
    ) -> Result<Vec<Candidate>, LocalFlatpaksError> {
        let output = env
            .output_of(cmd!(
                "flatpak",
                "list",
                "--app",
                "--columns=application:f,version:f,origin:f,installation:f,description:f"
            ))
            .await?;

        for candidate in candidates.iter_mut() {
            if output.lines().any(|line| {
                let (origin, installation) = candidate
                    .origin
                    .trim_end_matches(')')
                    .split_once(" (")
                    .with_context(|| {
                        format!(
                            "failed to unparse package origin '{}' into origin and installation",
                            candidate.origin
                        )
                    })
                    .to_log()
                    .unwrap_or(("", ""));
                line.contains(&candidate.package)
                    && line.contains(&candidate.version)
                    && line.contains(&candidate.description)
                    && line.contains(origin)
                    && line.contains(installation)
            }) {
                // Already installed
                candidate.actions.install = None;
            }
        }

        Ok(candidates)
    }
}

/// Rust-representation of a configured flatpak remote
#[derive(Debug, Default, PartialEq, Clone)]
pub struct Remote {
    /// Pure name of the remote
    name: String,
    /// Type of remote
    r#type: RemoteType,
}

impl Remote {
    /// Convert a remote into a human-readable origin representation.
    pub fn to_origin(&self) -> String {
        self.to_string()
    }
}

impl fmt::Display for Remote {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} ({})", self.name, self.r#type)
    }
}

/// Types of remote
#[derive(Debug, Default, PartialEq, Clone)]
pub enum RemoteType {
    /// User default remote.
    #[default]
    User,
    /// System default remote.
    System,
    /// A different, specific remote.
    Other(String),
}

impl RemoteType {
    /// Convert a remote type into an appropriate flatpak CLI flag.
    pub fn to_cli(&self) -> String {
        match self {
            Self::User => "--user".to_string(),
            Self::System => "--system".to_string(),
            Self::Other(val) => format!("--installation={}", val),
        }
    }
}

impl fmt::Display for RemoteType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::User => write!(f, "user"),
            Self::System => write!(f, "system"),
            Self::Other(val) => write!(f, "{}", val),
        }
    }
}

/// Overarching error for Flatpak interactions.
#[derive(Debug, ThisError)]
pub enum Error {
    /// Error with remote handling.
    #[error("failed to obtain remotes to search in")]
    Remote(#[from] RemoteError),

    /// Error with local flatpak interactions.
    #[error(transparent)]
    LocalFlatpaks(#[from] LocalFlatpaksError),

    /// Error with remote flatpak interactions.
    #[error(transparent)]
    RemoteFlatpaks(#[from] RemoteFlatpakError),
}

impl From<Error> for ProviderError {
    fn from(value: Error) -> Self {
        match value {
            Error::Remote(RemoteError::Execution(ExecutionError::NotFound(val))) => {
                ProviderError::Requirements(val)
            }
            _ => ProviderError::ApplicationError(anyhow::Error::new(value)),
        }
    }
}

/// Errors from trying to discover configured flatpak remotes.
#[derive(Debug, ThisError, Display)]
pub enum RemoteError {
    /// no configured remote found
    NoRemote,

    /// failed to parse remote info from output: {0}
    Parse(String),

    /// failed to determine remote type from options: '{0}'
    UnknownType(String),

    /// failed to query configured flatpak remotes
    Execution(#[from] ExecutionError),
}

/// Error with remote interactions.
#[derive(Debug, ThisError, Display)]
pub enum RemoteFlatpakError {
    /// failed to query available applications from remote '{remote}': {source}
    Execution {
        /// The remote that failed to respond.
        remote: Remote,
        /// The underlying error source.
        source: ExecutionError,
    },
}

/// Error with local flatpak interactions.
#[derive(Debug, ThisError, Display)]
pub enum LocalFlatpaksError {
    /// failed to query locally installed flatpaks
    Execution(#[from] ExecutionError),
}

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

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

    #[test]
    fn no_remotes() {
        let query = quick_test!(Flatpak::new(), Ok("\n".to_string()));

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

    #[test]
    fn single_remote_nonexistent_app() {
        let query = quick_test!(
            Flatpak::new(),
            // The remotes
            Ok("foobar\tuser\n".to_string()),
            // Flatpaks in that remote
            Ok("app.cool.my\t0.12.56-beta\tfoobar\tSome descriptive text\n".to_string()),
            // Locally installed flatpaks
            Ok("\n".to_string())
        );

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

    #[test]
    fn single_remote_matching_app() {
        let query = quick_test!(
            Flatpak::new(),
            // The remotes
            Ok("foobar\tuser\n".to_string()),
            // Flatpaks in that remote
            Ok("app.cool.my-test-app\t0.12.56-beta\tfoobar\tSome descriptive text\n".to_string()),
            // Locally installed flatpaks
            Ok("\n".to_string())
        );

        let result = query.results.unwrap();
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].package, "app.cool.my-test-app".to_string());
        assert_eq!(result[0].origin, "foobar (user)".to_string());
    }
}