diff --git a/Cargo.toml b/Cargo.toml index dd047e6..561a5fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mpm" -version = "0.4.0" +version = "0.5.0" edition = "2021" license = "AGPL-3.0" @@ -8,22 +8,16 @@ license = "AGPL-3.0" anyhow = "1.0.81" clap = { version = "4.5", features = ["derive"] } colored = "2.1" -elevate = "0.6.1" +sudo = "0.6" xmltree = "0.10" os_info = "3.8.2" strum = { version = "0.26", features = ["derive"] } tabled = "0.15" tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } -serde = { version = "1.0.197", features = ["derive"], optional = true } -serde_json = {version = "1.0.115", optional = true } - -[features] -default = ["cli"] -cli = ["verify", "json"] -verify = [] -serde = ["dep:serde"] -json = ["serde", "dep:serde_json"] +ambassador = "0.3.6" +serde_json = "1.0.115" +serde = "1.0.197" [dev-dependencies] tracing-test = "0.2.4" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..1d3e097 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,143 @@ +use std::str::FromStr; + +use clap::{Parser, Subcommand}; + +use crate::common::{Package, PackageManager}; + +#[derive(Parser)] +#[command( + author, + version, + about = "A generic package manager.", + long_about = "A generic package manager for interfacing with multiple distro and platform specific package managers." +)] + +/// Cli for PackageManager. +/// +/// It is public because other tools can use this interface to pass the command +/// line args. +pub struct Cli { + #[command(subcommand)] + command: MpmCommands, + + /// Optionally specify a package manager that you want to use. If not given, + /// mpm will search for default package manager on this system. + #[arg(long, short)] + manager: Option, + + /// Set output to be in json format. + #[arg(long, default_value_t = false)] + json: bool, +} + +#[derive(Subcommand)] +pub enum MpmCommands { + #[command(about = "List supported package managers and display their availability")] + Managers, + + #[command(about = "Search for a given sub-string and list matching packages")] + Search { string: String }, + + #[command(about = "List all packages that are installed")] + List, + + #[command( + about = "Install the given package(s)", + long_about = "Install the given package(s).\nIf a specific version of the package is desired, it can be specified using the format @.\nNote: version information is optional." + )] + Install { + #[clap(required = true)] + packages: Vec, + }, + + #[command( + about = "Uninstall the given package(s)", + long_about = "Uninstall the given package(s).\nIf a specific version of the package is desired, it can be specified using the format @.\nNote: version information is optional." + )] + Uninstall { + #[clap(required = true)] + packages: Vec, + }, + + #[command( + about = "Add the provided third-party repo location to the package manager", + long_about = "Provide a repo in the form of a URL or package manager specific repo format to add it to the list of repositories of the package manager" + )] + Repo { repo: String }, + + #[command( + about = "Updates the cached package repository data", + long_about = "Sync the cached package repository data.\nNote: this behavior might not be consistent among package managers; when sync is not supported, the package manager might simply update itself." + )] + Sync, + + #[command(about = "Update/upgrade the given package(s) or (--)all of them")] + #[group(required = true)] + Update { + packages: Vec, + #[arg(long, short)] + all: bool, + }, +} +/// Function that handles the parsed CLI arguments in one place +pub fn execute(args: Cli) -> anyhow::Result<()> { + let mpm = if let Some(manager) = args.manager { + crate::MetaPackageManager::try_new(manager)? + } else { + crate::MetaPackageManager::new_default()? + }; + + match args.command { + MpmCommands::Managers => crate::print::print_managers(), + MpmCommands::Search { string } => { + let pkgs = mpm.search(&string); + print_pkgs(&pkgs, args.json)?; + } + MpmCommands::List => { + let pkgs = mpm.list_installed(); + print_pkgs(&pkgs, args.json)?; + } + MpmCommands::Install { packages } => { + for pkg in packages { + let s = mpm.install(Package::from_str(&pkg)?); + anyhow::ensure!(s.success(), "Failed to install {pkg}"); + } + } + MpmCommands::Uninstall { packages } => { + for pkg in packages { + let s = mpm.uninstall(Package::from_str(&pkg)?); + anyhow::ensure!(s.success(), "Failed to uninstall pacakge {pkg}"); + } + } + + MpmCommands::Update { packages, all } => { + if all { + mpm.update_all(); + } else { + for pkg in packages { + let s = mpm.update(Package::from_str(&pkg)?); + anyhow::ensure!(s.success(), "Failed to update pacakge {pkg}"); + } + } + } + MpmCommands::Repo { repo } => { + mpm.add_repo(&repo)?; + } + MpmCommands::Sync => { + let s = mpm.sync(); + anyhow::ensure!(s.success(), "Failed to sync repositories"); + } + }; + + Ok(()) +} + +/// Print packages +fn print_pkgs(pkgs: &[Package], json: bool) -> anyhow::Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(pkgs)?); + } else { + println!("{}", tabled::Table::new(pkgs)); + } + Ok(()) +} diff --git a/src/common.rs b/src/common.rs new file mode 100644 index 0000000..123fa37 --- /dev/null +++ b/src/common.rs @@ -0,0 +1,415 @@ +//! Common types and traits. + +use std::{error::Error, fmt::Display}; + +const NO_VERSION: &str = "~"; + +/// Primary interface for implementing a package manager +/// +/// Multiple package managers can be grouped together as dyn PackageManager. +#[ambassador::delegatable_trait] +pub trait PackageManager: Commands + std::fmt::Debug + std::fmt::Display { + /// Defines a delimeter to use while formatting package name and version + /// + /// For example, HomeBrew supports `@` and APT supports + /// `=`. Their appropriate delimiters would be '@' and + /// '=', respectively. For package managers that require additional + /// formatting, overriding the default trait methods would be the way to go. + fn pkg_delimiter(&self) -> char; + + /// Get a formatted string of the package as + /// + /// Note: this functions returns a formatted string only if version + /// information is present. Otherwise, only a borrowed name string is + /// returned. Which is why this function returns a 'Cow' and not a + /// `String`. + fn pkg_format(&self, pkg: &Package) -> String { + if let Some(v) = pkg.version() { + format!("{}{}{}", pkg.name, self.pkg_delimiter(), v) + } else { + pkg.name().into() + } + } + + /// Returns a package after parsing a line of stdout output from the + /// underlying package manager. + /// + /// This method is internally used in other default methods like + /// [``PackageManager::search``] to parse packages from the output. + /// + /// The default implementation merely tries to split the line at the + /// provided delimiter (see [``PackageManager::pkg_delimiter``]) + /// and trims spaces. It returns a package with version information on + /// success, or else it returns a package with only a package name. + /// For package maangers that have unusual or complex output, users are free + /// to override this method. Note: Remember to construct a package with + /// owned values in this method. + fn parse_pkg(&self, line: &str) -> Option { + let pkg = if let Some((name, version)) = line.split_once(self.pkg_delimiter()) { + Package::new(name.trim(), Some(version.trim())) + } else { + Package::new(line.trim(), None) + }; + Some(pkg) + } + + /// Parses output, generally from stdout, to a Vec of Packages. + /// + /// The default implementation uses [``PackageManager::parse_pkg``] for + /// parsing each line into a [`Package`]. + fn parse_output(&self, out: &[u8]) -> Vec { + let outstr = String::from_utf8_lossy(out); + outstr + .lines() + .filter_map(|s| { + let ts = s.trim(); + if !ts.is_empty() { + self.parse_pkg(ts) + } else { + None + } + }) + .collect() + } + + /// General package search + fn search(&self, pack: &str) -> Vec { + let cmds = self.consolidated(Cmd::Search, &[pack.to_string()]); + let out = self.exec_cmds(&cmds); + self.parse_output(&out.stdout) + } + + /// Sync package manaager repositories + fn sync(&self) -> std::process::ExitStatus { + self.exec_cmds_status(&self.consolidated::<&str>(Cmd::Sync, &[])) + } + + /// Update/upgrade all packages + fn update_all(&self) -> std::process::ExitStatus { + self.exec_cmds_status(&self.consolidated::<&str>(Cmd::UpdateAll, &[])) + } + + /// Install a single package + /// + /// For multi-package operations, see [``PackageManager::exec_op``] + fn install(&self, pkg: Package) -> std::process::ExitStatus { + self.exec_op(&[pkg], Operation::Install) + } + + /// Uninstall a single package + /// + /// For multi-package operations, see [``PackageManager::exec_op``] + fn uninstall(&self, pkg: Package) -> std::process::ExitStatus { + self.exec_op(&[pkg], Operation::Uninstall) + } + + /// Update a single package + /// + /// For multi-package operations, see [``PackageManager::exec_op``] + fn update(&self, pkg: Package) -> std::process::ExitStatus { + self.exec_op(&[pkg], Operation::Update) + } + + /// List installed packages + fn list_installed(&self) -> Vec { + let out = self.exec_cmds(&self.consolidated::<&str>(Cmd::List, &[])); + self.parse_output(&out.stdout) + } + + /// Execute an operation on multiple packages, such as install, uninstall + /// and update + fn exec_op(&self, pkgs: &[Package], op: Operation) -> std::process::ExitStatus { + let command = match op { + Operation::Install => Cmd::Install, + Operation::Uninstall => Cmd::Uninstall, + Operation::Update => Cmd::Update, + }; + let fmt: Vec<_> = pkgs + .iter() + .map(|p| self.pkg_format(p).to_string()) + .collect(); + let cmds = self.consolidated(command, &fmt); + self.exec_cmds_status(&cmds) + } + + /// Add third-party repository to the package manager's repository list + /// + /// Since the implementation might greatly vary among different package + /// managers this method returns a `Result` instead of the usual + /// `std::process::ExitStatus`. + fn add_repo(&self, repo: &str) -> anyhow::Result<()> { + let cmds = self.consolidated(Cmd::AddRepo, &[repo.to_string()]); + let s = self.exec_cmds_status(&cmds); + anyhow::ensure!(s.success(), "Error adding repo"); + Ok(()) + } +} + +/// Error type for indicating failure in [``PackageManager::add_repo``] +/// +/// Use [``RepoError::default``] when no meaningful source of the error is +/// available. +#[derive(Default, Debug)] +pub struct RepoError { + pub source: Option>, +} + +impl RepoError { + /// Construct `RepoError` with underlying error source/cause + /// + /// Use [``RepoError::default``] when no meaningful source of the error is + /// available. + pub fn new(source: E) -> Self { + Self { + source: Some(Box::new(source)), + } + } + + /// Construct 'RepoError' with an error message set as its error source + /// + /// Use [``RepoError::new``] to wrap an existing error. + /// Use [``RepoError::default``] when no meaningful source of the error is + /// available. + pub fn with_msg(msg: &'static str) -> Self { + Self { + source: Some(msg.into()), + } + } +} + +impl Display for RepoError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(s) = self.source() { + f.write_fmt(format_args!("failed to add repo: {}", s)) + } else { + f.write_str("failed to add repo") + } + } +} + +impl Error for RepoError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source.as_deref() + } +} + +/// Representation of a package manager command +/// +/// All the variants are the type of commands that a type that imlements +/// [``Commands``] and [``PackageManager``] (should) support. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Cmd { + Install, + Uninstall, + Update, + UpdateAll, + List, + Sync, + AddRepo, + Search, +} + +/// Trait for defining package panager commands in one place +/// +/// Only [``Commands::cmd``] and [``Commands::commands``] are required, the rest +/// are simply conviniece methods that internally call [``Commands::commands``]. +/// The trait [``PackageManager``] depends on this to provide default +/// implementations. +#[ambassador::delegatable_trait] +pub trait Commands { + /// Primary command of the package manager. For example, 'brew', 'apt', and + /// 'dnf', constructed with [``std::process::Command::new``]. + fn cmd(&self) -> std::process::Command; + + /// Returns the appropriate command/s for the given supported command type. + /// Check [``crate::common::Cmd``] enum to see all supported commands. + fn get_cmds(&self, cmd: Cmd) -> Vec; + + /// Returns the appropriate flags for the given command type. Check + /// [``crate::common::Cmd``] enum to see all supported commands. + /// + /// Flags are optional, which is why the default implementation returns an + /// empty slice + fn get_flags(&self, _cmd: Cmd) -> Vec { + vec![] + } + + /// Retreives defined commands and flags for the given + /// [``crate::common::Cmd``] type and returns a Vec of args in the + /// order: `[commands..., user-args..., flags...]` + /// + /// The appropriate commands and flags are determined with the help of the + /// enum [``crate::common::Cmd``] For finer control, a general purpose + /// function [``consolidated_args``] is also provided. + #[inline] + fn consolidated>(&self, cmd: Cmd, args: &[S]) -> Vec { + let mut commands = self.get_cmds(cmd); + commands.append(&mut args.iter().map(|x| x.as_ref().to_string()).collect()); + commands.append(&mut self.get_flags(cmd)); + commands + } + /// Run arbitrary commands against the package manager command and get + /// output + /// + /// # Panics + /// This fn can panic when the defined [``Commands::cmd``] is not found in + /// path. This can be avoided by using [``verified::Verified``] + /// or manually ensuring that the [``Commands::cmd``] is valid. + fn exec_cmds(&self, cmds: &[String]) -> std::process::Output { + tracing::info!("Executing {:?} with args {:?}", self.cmd(), cmds); + self.cmd() + .args(cmds) + .output() + .expect("command executed without a prior check") + } + /// Run arbitrary commands against the package manager command and wait for + /// std::process::ExitStatus + /// + /// # Panics + /// This fn can panic when the defined [``Commands::cmd``] is not found in + /// path. This can be avoided by using [``verified::Verified``] + /// or manually ensuring that the [``Commands::cmd``] is valid. + fn exec_cmds_status>(&self, cmds: &[S]) -> std::process::ExitStatus { + self.cmd() + .args(cmds.iter().map(AsRef::as_ref)) + .status() + .expect("command executed without a prior check") + } + /// Run arbitrary commands against the package manager command and return + /// handle to the spawned process + /// + /// # Panics + /// This fn can panic when the defined [``Commands::cmd``] is not found in + /// path. This can be avoided by using [``verified::Verified``] + /// or manually ensuring that the [``Commands::cmd``] is valid. + fn exec_cmds_spawn(&self, cmds: &[String]) -> std::process::Child { + self.cmd() + .args(cmds) + .spawn() + .expect("command executed without a prior check") + } +} + +/// A representation of a package +/// +/// This struct contains package's name and version information (optional). +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, tabled::Tabled)] +pub struct Package { + /// name of the package + name: String, + // Untyped version, might be replaced with a strongly typed one + version: String, +} + +impl std::str::FromStr for Package { + type Err = anyhow::Error; + + fn from_str(s: &str) -> anyhow::Result { + if let Some((name, version)) = s.split_once('@') { + Ok(Package::new(name, Some(version))) + } else { + Ok(Package::new(s, None)) + } + } +} + +impl Package { + /// Create new Package with name and version. + pub fn new(name: &str, version: Option<&str>) -> Self { + Self { + name: name.to_string(), + version: version.unwrap_or(NO_VERSION).to_string(), + } + } + + /// Package name + pub fn name(&self) -> &str { + &self.name + } + + /// Get version information if present + pub fn version(&self) -> Option<&str> { + if self.version == NO_VERSION { + return None; + } + Some(&self.version) + } +} + +impl Display for Package { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(v) = self.version() { + // might be changed later for a better format + write!(f, "{} - {}", self.name, v) + } else { + write!(f, "{}", self.name) + } + } +} + +/// Available package manager. This is from cli because I can't use +/// MetaPackageManager as `clap::ValueEnum`. +#[derive(Clone, PartialEq, Debug, clap::ValueEnum, strum::EnumIter, strum::EnumCount)] +pub enum AvailablePackageManager { + Apt, + Brew, + Choco, + Dnf, + Yum, + Zypper, +} + +impl AvailablePackageManager { + /// Return the supported pkg format e.g. deb, rpm etc. + pub fn supported_pkg_formats(&self) -> Vec { + match self { + Self::Brew => vec![PkgFormat::Bottle], + Self::Choco => vec![PkgFormat::Exe, PkgFormat::Msi], + Self::Apt => vec![PkgFormat::Deb], + Self::Dnf => vec![PkgFormat::Rpm], + Self::Yum => vec![PkgFormat::Rpm], + Self::Zypper => vec![PkgFormat::Rpm], + } + } +} + +/// Operation type to execute using [``Package::exec_op``] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Operation { + Install, + Uninstall, + Update, +} + +/// General purpose version of [``Commands::consolidated``] for consolidating +/// different types of arguments into a single Vec +#[inline] +pub fn consolidate_args<'a>(cmds: &[&'a str], args: &[&'a str], flags: &[&'a str]) -> Vec<&'a str> { + let mut vec = Vec::with_capacity(cmds.len() + args.len() + flags.len()); + vec.extend(cmds.iter().chain(args.iter()).chain(flags.iter()).copied()); + vec +} + +/// Pkg Format. +#[derive(Clone)] +pub enum PkgFormat { + Bottle, + Exe, + Msi, + Rpm, + Deb, +} + +impl PkgFormat { + /// File extension of package. + pub fn file_extention(&self) -> String { + match self { + Self::Bottle => "tar.gz", + Self::Exe => "exe", + Self::Msi => "msi", + Self::Rpm => "rpm", + Self::Deb => "deb", + } + .to_string() + } +} diff --git a/src/lib.rs b/src/lib.rs index bc8e561..12934db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,391 +1,155 @@ -//! mpm library - -use std::{ - borrow::Cow, - error::Error, - fmt::{Debug, Display}, - process::{Child, Command, ExitStatus, Output}, -}; - -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; - -mod managers; - -#[cfg(feature = "cli")] -pub mod utils; -#[cfg(feature = "cli")] -pub use utils::PkgManagerHandler; - -#[cfg(feature = "verify")] -pub mod verify; +//! meta-package-manager (mpm) library. +//! +//! 1. initialize a package manager +//! +//! ```ignore +//! let pkg_manager = MetaPackageManager::try_default().unwrap(); +//! println!("{}", pkg_manager.about()); +//! ``` +//! +//! or +//! +//! ```ignore +//! let pkg_manager = MetaPackageManager::new("choco").unwrap(); +//! println!("{}", pkg_manager.about()); +//! ``` +//! +//! 2. Install a package with optional version +//! +//! ```ignore +//! pkg_manager.install("firefox", Some("101")).uwnrap(); +//! ``` +//! +//! 3. Search a package +//! +//! ```ignore +//! pkg_manager.search("firefox").uwnrap(); +//! ``` + +#[macro_use] +pub mod common; +pub use common::*; + +pub mod managers; +pub use managers::*; + +#[macro_use] +pub mod print; +pub use print::*; + +pub mod cli; #[cfg(test)] -mod libtests; - -/// Primary interface for implementing a package manager -/// -/// Multiple package managers can be grouped together as dyn PackageManager. -pub trait PackageManager: Commands + Debug + Display { - /// Defines a delimeter to use while formatting package name and version - /// - /// For example, HomeBrew supports `@` and APT supports - /// `=`. Their appropriate delimiters would be '@' and - /// '=', respectively. For package managers that require additional - /// formatting, overriding the default trait methods would be the way to go. - fn pkg_delimiter(&self) -> char; - - /// Get a formatted string of the package as - /// - /// Note: this functions returns a formatted string only if version - /// information is present. Otherwise, only a borrowed name string is - /// returned. Which is why this function returns a 'Cow' and not a - /// `String`. - fn pkg_format<'a>(&self, pkg: &'a Package) -> Cow<'a, str> { - if let Some(v) = pkg.version() { - format!("{}{}{}", pkg.name, self.pkg_delimiter(), v).into() - } else { - pkg.name().into() +mod tests { + + #[cfg(target_family = "unix")] + use std::os::unix::process::ExitStatusExt; + #[cfg(target_family = "windows")] + use std::os::windows::process::ExitStatusExt; + use std::{ + fmt::Display, + process::{Command, ExitStatus, Output}, + str::FromStr, + }; + + use super::{Cmd, Commands}; + use crate::{Package, PackageManager}; + + struct MockCommands; + + impl Commands for MockCommands { + fn cmd(&self) -> Command { + Command::new("") + } + fn get_cmds(&self, _: crate::Cmd) -> Vec { + vec!["command".to_string()] + } + fn get_flags(&self, _: crate::Cmd) -> Vec { + vec!["flag".to_string()] } } - /// Returns a package after parsing a line of stdout output from the - /// underlying package manager. - /// - /// This method is internally used in other default methods like - /// [``PackageManager::search``] to parse packages from the output. - /// - /// The default implementation merely tries to split the line at the - /// provided delimiter (see [``PackageManager::pkg_delimiter``]) - /// and trims spaces. It returns a package with version information on - /// success, or else it returns a package with only a package name. - /// For package maangers that have unusual or complex output, users are free - /// to override this method. Note: Remember to construct a package with - /// owned values in this method. - fn parse_pkg<'a>(&self, line: &str) -> Option> { - let pkg = if let Some((name, version)) = line.split_once(self.pkg_delimiter()) { - Package::from(name.trim().to_owned()).with_version(version.trim().to_owned()) - } else { - Package::from(line.trim().to_owned()) - }; - Some(pkg) - } - - /// Parses output, generally from stdout, to a Vec of Packages. - /// - /// The default implementation uses [``PackageManager::parse_pkg``] for - /// parsing each line into a [`Package`]. - fn parse_output(&self, out: &[u8]) -> Vec { - let outstr = String::from_utf8_lossy(out); - outstr - .lines() - .filter_map(|s| { - let ts = s.trim(); - if !ts.is_empty() { - self.parse_pkg(ts) - } else { - None - } - }) - .collect() - } - - /// General package search - fn search(&self, pack: &str) -> Vec { - let cmds = self.consolidated(Cmd::Search, &[pack]); - let out = self.exec_cmds(&cmds); - self.parse_output(&out.stdout) - } - - /// Sync package manaager repositories - fn sync(&self) -> ExitStatus { - self.exec_cmds_status(&self.consolidated(Cmd::Sync, &[])) - } - - /// Update/upgrade all packages - fn update_all(&self) -> ExitStatus { - self.exec_cmds_status(&self.consolidated(Cmd::UpdateAll, &[])) - } - - /// Install a single package - /// - /// For multi-package operations, see [``PackageManager::exec_op``] - fn install(&self, pkg: Package) -> ExitStatus { - self.exec_op(&[pkg], Operation::Install) - } - - /// Uninstall a single package - /// - /// For multi-package operations, see [``PackageManager::exec_op``] - fn uninstall(&self, pkg: Package) -> ExitStatus { - self.exec_op(&[pkg], Operation::Uninstall) - } - - /// Update a single package - /// - /// For multi-package operations, see [``PackageManager::exec_op``] - fn update(&self, pkg: Package) -> ExitStatus { - self.exec_op(&[pkg], Operation::Update) - } - - /// List installed packages - fn list_installed(&self) -> Vec { - let out = self.exec_cmds(&self.consolidated(Cmd::List, &[])); - self.parse_output(&out.stdout) - } - - /// Execute an operation on multiple packages, such as install, uninstall - /// and update - fn exec_op(&self, pkgs: &[Package], op: Operation) -> ExitStatus { - let command = match op { - Operation::Install => Cmd::Install, - Operation::Uninstall => Cmd::Uninstall, - Operation::Update => Cmd::Update, - }; - let fmt: Vec<_> = pkgs.iter().map(|p| self.pkg_format(p)).collect(); - let cmds = self.consolidated( - command, - &fmt.iter().map(|v| v.as_ref()).collect::>(), - ); - self.exec_cmds_status(&cmds) - } - - /// Add third-party repository to the package manager's repository list - /// - /// Since the implementation might greatly vary among different package - /// managers this method returns a `Result` instead of the usual - /// `ExitStatus`. - fn add_repo(&self, repo: &str) -> Result<(), RepoError> { - let cmds = self.consolidated(Cmd::AddRepo, &[repo]); - self.exec_cmds_status(&cmds) - .success() - .then_some(()) - .ok_or(RepoError::default()) - } -} -/// Error type for indicating failure in [``PackageManager::add_repo``] -/// -/// Use [``RepoError::default``] when no meaningful source of the error is -/// available. -#[derive(Default, Debug)] -pub struct RepoError { - pub source: Option>, -} + #[derive(Debug)] + struct MockPackageManager; -impl RepoError { - /// Construct `RepoError` with underlying error source/cause - /// - /// Use [``RepoError::default``] when no meaningful source of the error is - /// available. - pub fn new(source: E) -> Self { - Self { - source: Some(Box::new(source)), + impl Display for MockPackageManager { + fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + unimplemented!() } } - /// Construct 'RepoError' with an error message set as its error source - /// - /// Use [``RepoError::new``] to wrap an existing error. - /// Use [``RepoError::default``] when no meaningful source of the error is - /// available. - pub fn with_msg(msg: &'static str) -> Self { - Self { - source: Some(msg.into()), + impl PackageManager for MockPackageManager { + fn pkg_delimiter(&self) -> char { + '+' } } -} -impl Display for RepoError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(s) = self.source() { - f.write_fmt(format_args!("failed to add repo: {}", s)) - } else { - f.write_str("failed to add repo") + impl Commands for MockPackageManager { + fn cmd(&self) -> Command { + Command::new("") + } + fn get_cmds(&self, _: Cmd) -> Vec { + vec!["".to_string()] + } + fn exec_cmds(&self, _: &[String]) -> Output { + let out = br#" + package1 + package2+1.1.0 + package3 + "#; + Output { + status: ExitStatus::from_raw(0), + stdout: out.to_vec(), + stderr: vec![], + } } } -} -impl Error for RepoError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - self.source.as_deref() + #[test] + fn default_cmd_consolidated_order() { + let mock = MockCommands; + let con = mock.consolidated(Cmd::Install, &["arg"]); + let mut coniter = con.into_iter(); + assert_eq!(coniter.next(), Some("command".to_string())); + assert_eq!(coniter.next(), Some("arg".to_string())); + assert_eq!(coniter.next(), Some("flag".to_string())); } -} -/// Trait for defining package panager commands in one place -/// -/// Only [``Commands::cmd``] and [``Commands::commands``] are required, the rest -/// are simply conviniece methods that internally call [``Commands::commands``]. -/// The trait [``PackageManager``] depends on this to provide default -/// implementations. -pub trait Commands { - /// Primary command of the package manager. For example, 'brew', 'apt', and - /// 'dnf', constructed with [``std::process::Command::new``]. - fn cmd(&self) -> Command; - /// Returns the appropriate command/s for the given supported command type. - /// Check [``Cmd``] enum to see all supported commands. - fn get_cmds(&self, cmd: Cmd) -> &'static [&'static str]; - /// Returns the appropriate flags for the given command type. Check - /// [``Cmd``] enum to see all supported commands. - /// - /// Flags are optional, which is why the default implementation returns an - /// empty slice - fn get_flags(&self, _cmd: Cmd) -> &'static [&'static str] { - &[] - } - /// Retreives defined commands and flags for the given [``Cmd``] type and - /// returns a Vec of args in the order: `[commands..., user-args..., - /// flags...]` - /// - /// The appropriate commands and flags are determined with the help of the - /// enum [``Cmd``] For finer control, a general purpose function - /// [``consolidated_args``] is also provided. - #[inline] - fn consolidated<'a>(&self, cmd: Cmd, args: &[&'a str]) -> Vec<&'a str> { - let commands = self.get_cmds(cmd); - let flags = self.get_flags(cmd); - let mut vec = Vec::with_capacity(commands.len() + flags.len() + args.len()); - vec.extend( - commands - .iter() - .chain(args.iter()) - .chain(flags.iter()) - .copied(), - ); - vec - } - /// Run arbitrary commands against the package manager command and get - /// output - /// - /// # Panics - /// This fn can panic when the defined [``Commands::cmd``] is not found in - /// path. This can be avoided by using [``verified::Verified``] - /// or manually ensuring that the [``Commands::cmd``] is valid. - fn exec_cmds(&self, cmds: &[&str]) -> Output { - tracing::info!("Executing {:?} with args {:?}", self.cmd(), cmds); - self.cmd() - .args(cmds) - .output() - .expect("command executed without a prior check") - } - /// Run arbitrary commands against the package manager command and wait for - /// ExitStatus - /// - /// # Panics - /// This fn can panic when the defined [``Commands::cmd``] is not found in - /// path. This can be avoided by using [``verified::Verified``] - /// or manually ensuring that the [``Commands::cmd``] is valid. - fn exec_cmds_status(&self, cmds: &[&str]) -> ExitStatus { - self.cmd() - .args(cmds) - .status() - .expect("command executed without a prior check") + #[test] + fn default_pm_package_parsing() { + let pm = MockPackageManager; + package_assertions(pm.list_installed().into_iter()); + package_assertions(pm.search("").into_iter()); } - /// Run arbitrary commands against the package manager command and return - /// handle to the spawned process - /// - /// # Panics - /// This fn can panic when the defined [``Commands::cmd``] is not found in - /// path. This can be avoided by using [``verified::Verified``] - /// or manually ensuring that the [``Commands::cmd``] is valid. - fn exec_cmds_spawn(&self, cmds: &[&str]) -> Child { - self.cmd() - .args(cmds) - .spawn() - .expect("command executed without a prior check") - } -} - -/// Representation of a package manager command -/// -/// All the variants are the type of commands that a type that imlements -/// [``Commands``] and [``PackageManager``] (should) support. -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Cmd { - Install, - Uninstall, - Update, - UpdateAll, - List, - Sync, - AddRepo, - Search, -} - -/// A representation of a package -/// -/// This struct contains package's name and version information (optional). -/// It can be constructed with any type that implements `Into>`, for -/// example, `&str` and `String`. `Package::from("python")` or with version, -/// `Package::from("python").with_version("3.10.0")`. -#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Package<'a> { - name: Cow<'a, str>, - // Untyped version, might be replaced with a strongly typed one - version: Option>, -} -impl<'a> Package<'a> { - /// Package name - pub fn name(&self) -> &str { - &self.name - } - /// Check if package has version information. - pub fn has_version(&self) -> bool { - self.version.is_some() + fn package_assertions(mut listiter: impl Iterator) { + assert_eq!(listiter.next(), Package::from_str("package1").ok()); + assert_eq!(listiter.next(), Package::from_str("package2@1.1.0").ok()); + assert_eq!(listiter.next(), Package::from_str("package3").ok()); + assert_eq!(listiter.next(), None); } - /// Get version information if present - pub fn version(&self) -> Option<&str> { - self.version.as_deref() - } - /// Add or replace package's version - pub fn with_version(mut self, ver: V) -> Self - where - V: Into>, - { - self.version.replace(ver.into()); - self - } -} -impl<'a, T> From for Package<'a> -where - T: Into>, -{ - fn from(value: T) -> Self { - Self { - name: value.into(), - version: None, - } + #[test] + fn package_formatting() { + let pkg = Package::from_str("package").unwrap(); + assert_eq!(MockPackageManager.pkg_format(&pkg), "package"); + let pkg = Package::from_str("package@0.1.0").unwrap(); + assert_eq!(MockPackageManager.pkg_format(&pkg), "package+0.1.0"); } -} -impl Display for Package<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(v) = self.version.as_ref() { - // might be changed later for a better format - write!(f, "{} - {}", self.name, v) - } else { - write!(f, "{}", self.name) - } + #[test] + fn package_version() { + let pkg = Package::from_str("test").unwrap(); + assert!(pkg.version().is_none()); + let pkg = Package::from_str("test@1.1").unwrap(); + assert!(pkg.version().is_some()); } -} -/// Operation type to execute using [``Package::exec_op``] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Operation { - Install, - Uninstall, - Update, -} - -/// General purpose version of [``Commands::consolidated``] for consolidating -/// different types of arguments into a single Vec -#[inline] -pub fn consolidate_args<'a>(cmds: &[&'a str], args: &[&'a str], flags: &[&'a str]) -> Vec<&'a str> { - let mut vec = Vec::with_capacity(cmds.len() + args.len() + flags.len()); - vec.extend(cmds.iter().chain(args.iter()).chain(flags.iter()).copied()); - vec + #[test] + fn package_version_replace() { + let pkg = Package::from_str("test@1.0").unwrap(); + assert_eq!(pkg.version(), Some("1.0")); + let pkg = Package::from_str("test@2.0").unwrap(); + assert_eq!(pkg.version(), Some("2.0")); + } } diff --git a/src/libtests.rs b/src/libtests.rs deleted file mode 100644 index cd3e5db..0000000 --- a/src/libtests.rs +++ /dev/null @@ -1,112 +0,0 @@ -#[cfg(target_family = "unix")] -use std::os::unix::process::ExitStatusExt; -#[cfg(target_family = "windows")] -use std::os::windows::process::ExitStatusExt; -use std::{ - fmt::Display, - process::{Command, ExitStatus, Output}, -}; - -use super::{Cmd, Commands}; -use crate::{Package, PackageManager}; - -struct MockCommands; - -impl Commands for MockCommands { - fn cmd(&self) -> Command { - Command::new("") - } - fn get_cmds(&self, _: crate::Cmd) -> &'static [&'static str] { - &["command"] - } - fn get_flags(&self, _: crate::Cmd) -> &'static [&'static str] { - &["flag"] - } -} - -#[derive(Debug)] -struct MockPackageManager; - -impl Display for MockPackageManager { - fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - unimplemented!() - } -} - -impl PackageManager for MockPackageManager { - fn pkg_delimiter(&self) -> char { - '+' - } -} - -impl Commands for MockPackageManager { - fn cmd(&self) -> Command { - Command::new("") - } - fn get_cmds(&self, _: Cmd) -> &'static [&'static str] { - &[""] - } - fn exec_cmds(&self, _: &[&str]) -> Output { - let out = br#" - package1 - package2+1.1.0 - package3 - "#; - Output { - status: ExitStatus::from_raw(0), - stdout: out.to_vec(), - stderr: vec![], - } - } -} - -#[test] -fn default_cmd_consolidated_order() { - let mock = MockCommands; - let con = mock.consolidated(Cmd::Install, &["arg"]); - let mut coniter = con.into_iter(); - assert_eq!(coniter.next(), Some("command")); - assert_eq!(coniter.next(), Some("arg")); - assert_eq!(coniter.next(), Some("flag")); -} - -#[test] -fn default_pm_package_parsing() { - let pm = MockPackageManager; - package_assertions(pm.list_installed().into_iter()); - package_assertions(pm.search("").into_iter()); -} - -fn package_assertions<'a>(mut listiter: impl Iterator>) { - assert_eq!(listiter.next(), Some(Package::from("package1"))); - assert_eq!( - listiter.next(), - Some(Package::from("package2").with_version("1.1.0")) - ); - assert_eq!(listiter.next(), Some(Package::from("package3"))); - assert_eq!(listiter.next(), None); -} - -#[test] -fn package_formatting() { - let pkg = Package::from("package"); - assert_eq!(MockPackageManager.pkg_format(&pkg), "package"); - let pkg = pkg.with_version("0.1.0"); - assert_eq!(MockPackageManager.pkg_format(&pkg), "package+0.1.0"); -} - -#[test] -fn package_version() { - let pkg = Package::from("test"); - assert!(!pkg.has_version()); - let pkg = pkg.with_version("1.1"); - assert!(pkg.has_version()); -} - -#[test] -fn package_version_replace() { - let pkg = Package::from("test").with_version("1.0"); - assert_eq!(pkg.version(), Some("1.0")); - let pkg = pkg.with_version("2.0"); - assert_eq!(pkg.version(), Some("2.0")); -} diff --git a/src/main.rs b/src/main.rs index 5f3198a..06342c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,12 @@ //! Meta Package Manager (MPM) binary use clap::Parser; -use mpm::utils; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; fn main() { // elevate to sudo - if let Err(e) = elevate::with_env(&["CARGO_", "RUST_LOG"]) { + #[cfg(target_os = "linux")] + if let Err(e) = sudo::with_env(&["CARGO_", "RUST_LOG"]) { tracing::warn!("Failed to elevate to sudo: {e}."); } @@ -18,8 +18,8 @@ fn main() { let info = os_info::get(); tracing::info!("Detected OS {:?}", info.os_type()); - if let Err(err) = utils::execute(utils::parser::Cli::parse()) { - utils::print::log_error(err); + if let Err(err) = mpm::cli::execute(mpm::cli::Cli::parse()) { + mpm::print::log_error(err); std::process::exit(1); } } diff --git a/src/managers/apt.rs b/src/managers/apt.rs index b9adb19..4efb3cb 100644 --- a/src/managers/apt.rs +++ b/src/managers/apt.rs @@ -1,8 +1,6 @@ -use crate::{Cmd, Commands, Package, PackageManager, RepoError}; use std::{fmt::Display, fs, io::Write, process::Command}; -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; +use crate::{common::Package, Cmd, Commands, PackageManager}; /// Wrapper for Advanced Pacakge Tool (APT), the default package management /// user-facing utilities in Debian and Debian-based distributions. @@ -16,18 +14,20 @@ use serde::{Deserialize, Serialize}; /// Another notable point is that the [``AdvancedPackageTool::add_repo``] /// implementation doesn't execute commands, but it writes to /// "/etc/apt/sources.list". -#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] -#[derive(Debug)] +#[derive(Debug, Default)] pub struct AdvancedPackageTool; impl AdvancedPackageTool { const SOURCES: &'static str = "/etc/apt/sources.list"; - fn alt_cmd(cmds: &[&str]) -> Command { - if matches!(cmds.first(), Some(&"list") | Some(&"search")) { + fn alt_cmd>(&self, cmds: &[S]) -> Command { + if matches!( + cmds.first().map(AsRef::as_ref), + Some("list") | Some("search") + ) { Command::new("apt") } else { - Self.cmd() + self.cmd() } } } @@ -37,26 +37,20 @@ impl PackageManager for AdvancedPackageTool { '=' } - fn parse_pkg<'a>(&self, line: &str) -> Option> { - let Some((name, info)) = line.split_once('/') else { - return None; - }; + fn parse_pkg<'a>(&self, line: &str) -> Option { + let (name, info) = line.split_once('/')?; if matches!(info.split_whitespace().count(), 3 | 4) { let ver = info.split_whitespace().nth(1)?; - Some(Package::from(name.to_owned()).with_version(ver.to_owned())) + Some(Package::new(name, Some(ver))) } else { None } } - fn add_repo(&self, repo: &str) -> Result<(), RepoError> { - let mut sources = fs::File::options() - .append(true) - .open(Self::SOURCES) - .map_err(RepoError::new)?; - sources - .write_fmt(format_args!("\n{}", repo)) - .map_err(RepoError::new) + fn add_repo(&self, repo: &str) -> anyhow::Result<()> { + let mut sources = fs::File::options().append(true).open(Self::SOURCES)?; + sources.write_fmt(format_args!("\n{}", repo))?; + Ok(()) } } @@ -70,46 +64,59 @@ impl Commands for AdvancedPackageTool { fn cmd(&self) -> Command { Command::new("apt-get") } - fn get_cmds(&self, cmd: Cmd) -> &'static [&'static str] { + fn get_cmds(&self, cmd: Cmd) -> Vec { match cmd { - Cmd::Install => &["install"], - Cmd::Uninstall => &["remove"], - Cmd::Update => &["install"], - Cmd::UpdateAll => &["upgrade"], - Cmd::List => &["list"], - Cmd::Sync => &["update"], - Cmd::AddRepo => &[], - Cmd::Search => &["search"], + Cmd::Install => vec!["install"], + Cmd::Uninstall => vec!["remove"], + Cmd::Update => vec!["install"], + Cmd::UpdateAll => vec!["upgrade"], + Cmd::List => vec!["list"], + Cmd::Sync => vec!["update"], + Cmd::AddRepo => vec![], + Cmd::Search => vec!["search"], } + .iter() + .map(|x| x.to_string()) + .collect() } - fn get_flags(&self, cmd: Cmd) -> &'static [&'static str] { + + fn get_flags(&self, cmd: Cmd) -> Vec { match cmd { - Cmd::Install | Cmd::Uninstall | Cmd::UpdateAll => &["--yes"], - Cmd::Update => &["--yes", "--only-upgrade"], - Cmd::List => &["--installed"], - _ => &[], + Cmd::Install | Cmd::Uninstall | Cmd::UpdateAll => vec!["--yes"], + Cmd::Update => vec!["--yes", "--only-upgrade"], + Cmd::List => vec!["--installed"], + _ => vec![], } + .iter() + .map(|x| x.to_string()) + .collect() } - fn exec_cmds(&self, cmds: &[&str]) -> std::process::Output { - Self::alt_cmd(cmds).args(cmds).output().unwrap() + fn exec_cmds(&self, cmds: &[String]) -> std::process::Output { + self.alt_cmd(cmds).args(cmds).output().unwrap() } - fn exec_cmds_status(&self, cmds: &[&str]) -> std::process::ExitStatus { - Self::alt_cmd(cmds).args(cmds).status().unwrap() + fn exec_cmds_status>(&self, cmds: &[S]) -> std::process::ExitStatus { + self.alt_cmd(cmds) + .args(cmds.iter().map(AsRef::as_ref)) + .status() + .unwrap() } - fn exec_cmds_spawn(&self, cmds: &[&str]) -> std::process::Child { - Self::alt_cmd(cmds).args(cmds).spawn().unwrap() + fn exec_cmds_spawn(&self, cmds: &[String]) -> std::process::Child { + self.alt_cmd(cmds).args(cmds).spawn().unwrap() } } #[cfg(test)] mod tests { + use std::str::FromStr; + use super::AdvancedPackageTool; use crate::{Cmd, Commands, Package, PackageManager}; + #[test] - fn parse_pkg() { + fn test_parse_pkg() { let input = r#" hello/stable 2.10-3 amd64 example package based on GNU hello @@ -121,21 +128,12 @@ mount/now 2.38.1-5+b1 amd64 [installed,local] mysql-common/now 5.8+1.1.0 all [installed,local]"#; let apt = AdvancedPackageTool; let mut iter = input.lines().filter_map(|l| apt.parse_pkg(l)); + assert_eq!(iter.next(), Package::from_str("hello@2.10-3").ok()); + assert_eq!(iter.next(), Package::from_str("iagno@1:3.38.1-2").ok()); + assert_eq!(iter.next(), Package::from_str("mount@2.38.1-5+b1").ok()); assert_eq!( iter.next(), - Some(Package::from("hello").with_version("2.10-3")) - ); - assert_eq!( - iter.next(), - Some(Package::from("iagno").with_version("1:3.38.1-2")) - ); - assert_eq!( - iter.next(), - Some(Package::from("mount").with_version("2.38.1-5+b1")) - ); - assert_eq!( - iter.next(), - Some(Package::from("mysql-common").with_version("5.8+1.1.0")) + Package::from_str("mysql-common@5.8+1.1.0").ok() ); } @@ -144,7 +142,6 @@ mysql-common/now 5.8+1.1.0 all [installed,local]"#; let apt = AdvancedPackageTool; let alt = "apt"; let reg = "apt-get"; - let func = AdvancedPackageTool::alt_cmd; let cmds = &[ Cmd::Install, Cmd::Uninstall, @@ -155,12 +152,13 @@ mysql-common/now 5.8+1.1.0 all [installed,local]"#; Cmd::AddRepo, Cmd::Search, ]; - for cmd in cmds { + + for cmd in cmds.iter() { let should_match = match cmd { Cmd::Search | Cmd::List => alt, _ => reg, }; - assert_eq!(func(apt.get_cmds(*cmd)).get_program(), should_match); + assert_eq!(apt.alt_cmd(&apt.get_cmds(*cmd)).get_program(), should_match); } } } diff --git a/src/managers/brew.rs b/src/managers/brew.rs index cb68253..bd600e1 100644 --- a/src/managers/brew.rs +++ b/src/managers/brew.rs @@ -5,7 +5,7 @@ use crate::{Cmd, Commands, PackageManager}; /// Wrapper for the Homebrew package manager. /// /// [Homebrew — The Missing Package Manager for macOS (or Linux)](https://brew.sh/) -#[derive(Debug)] +#[derive(Debug, Default)] pub struct Homebrew; impl PackageManager for Homebrew { @@ -18,16 +18,20 @@ impl Commands for Homebrew { fn cmd(&self) -> Command { Command::new("brew") } - fn get_cmds(&self, cmd: Cmd) -> &'static [&'static str] { + + fn get_cmds(&self, cmd: Cmd) -> Vec { match cmd { - Cmd::Install => &["install"], - Cmd::Uninstall => &["uninstall"], - Cmd::Update | Cmd::UpdateAll => &["upgrade"], - Cmd::List => &["list"], - Cmd::Sync => &["update"], - Cmd::AddRepo => &["tap"], - Cmd::Search => &["search"], + Cmd::Install => vec!["install"], + Cmd::Uninstall => vec!["uninstall"], + Cmd::Update | Cmd::UpdateAll => vec!["upgrade"], + Cmd::List => vec!["list"], + Cmd::Sync => vec!["update"], + Cmd::AddRepo => vec!["tap"], + Cmd::Search => vec!["search"], } + .iter() + .map(|x| x.to_string()) + .collect() } } diff --git a/src/managers/choco.rs b/src/managers/choco.rs index 3c7d578..633ddad 100644 --- a/src/managers/choco.rs +++ b/src/managers/choco.rs @@ -1,20 +1,20 @@ -use std::{borrow::Cow, fmt::Display, process::Command}; +use std::{fmt::Display, process::Command}; -use crate::{Cmd, Commands, Package, PackageManager}; +use crate::{common::Package, Cmd, Commands, PackageManager}; /// Wrapper for the Chocolatey package manager for windows /// /// [Chocolatey Software | Chocolatey - The package manager for Windows](https://chocolatey.org/) -#[derive(Debug)] +#[derive(Debug, Default)] pub struct Chocolatey; impl PackageManager for Chocolatey { fn pkg_delimiter(&self) -> char { '|' } - fn pkg_format<'a>(&self, pkg: &'a Package) -> Cow<'a, str> { + fn pkg_format(&self, pkg: &Package) -> String { if let Some(v) = pkg.version() { - format!("{} --version {}", pkg.name, v).into() + format!("{} --version {}", pkg.name(), v) } else { pkg.name().into() } @@ -25,26 +25,32 @@ impl Commands for Chocolatey { fn cmd(&self) -> Command { Command::new("choco") } - fn get_cmds(&self, cmd: Cmd) -> &'static [&'static str] { + fn get_cmds(&self, cmd: Cmd) -> Vec { match cmd { - Cmd::Install => &["install"], - Cmd::Uninstall => &["uninstall"], - Cmd::Update => &["upgrade"], - Cmd::UpdateAll => &["upgrade", "all"], - Cmd::List => &["list"], + Cmd::Install => vec!["install"], + Cmd::Uninstall => vec!["uninstall"], + Cmd::Update => vec!["upgrade"], + Cmd::UpdateAll => vec!["upgrade", "all"], + Cmd::List => vec!["list"], // Since chocolatey does not have an analogue for sync command // updating chocolatey was chosen as an alternative - Cmd::Sync => &["upgrade", "chocolatey"], - Cmd::AddRepo => &["source", "add"], - Cmd::Search => &["search"], + Cmd::Sync => vec!["upgrade", "chocolatey"], + Cmd::AddRepo => vec!["source", "add"], + Cmd::Search => vec!["search"], } + .iter() + .map(|x| x.to_string()) + .collect() } - fn get_flags(&self, cmd: Cmd) -> &'static [&'static str] { + fn get_flags(&self, cmd: Cmd) -> Vec { match cmd { - Cmd::List | Cmd::Search => &["--limit-output"], - Cmd::Install | Cmd::Update | Cmd::UpdateAll => &["--yes"], - _ => &[], + Cmd::List | Cmd::Search => vec!["--limit-output"], + Cmd::Install | Cmd::Update | Cmd::UpdateAll => vec!["--yes"], + _ => vec![], } + .iter() + .map(|x| x.to_string()) + .collect() } } @@ -56,15 +62,15 @@ impl Display for Chocolatey { #[cfg(test)] mod tests { + use std::str::FromStr; + use super::*; + #[test] - fn choco_pkg_fmt() { - let pkg = Package::from("package"); - assert_eq!(Chocolatey.pkg_format(&pkg), Cow::from("package")); - let pkg = pkg.with_version("0.1.0"); - assert_eq!( - Chocolatey.pkg_format(&pkg), - Cow::from("package --version 0.1.0") - ); + fn test_choco_pkg_fmt() { + let pkg = Package::from_str("package").unwrap(); + assert_eq!(Chocolatey.pkg_format(&pkg), "package".to_string()); + let pkg = Package::from_str("package@0.1.0").unwrap(); + assert_eq!(&Chocolatey.pkg_format(&pkg), "package --version 0.1.0"); } } diff --git a/src/managers/dnf.rs b/src/managers/dnf.rs index 3b363d0..b4c1a76 100644 --- a/src/managers/dnf.rs +++ b/src/managers/dnf.rs @@ -1,9 +1,6 @@ use std::{fmt::Display, process::Command}; -use crate::{Cmd, Commands, Package, PackageManager, RepoError}; - -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; +use crate::{Cmd, Commands, Package, PackageManager}; /// Wrapper for DandifiedYUM or DNF, the next upcoming major version of YUM /// @@ -11,38 +8,38 @@ use serde::{Deserialize, Serialize}; /// # Idiosyncracies /// The [``DandifiedYUM::add_repo``] method also installs `config-manager` /// plugin for DNF before attempting to add a repo. -#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] -#[derive(Debug)] +#[derive(Debug, Default)] pub struct DandifiedYUM; impl PackageManager for DandifiedYUM { fn pkg_delimiter(&self) -> char { '-' } - fn parse_pkg<'a>(&self, line: &str) -> Option> { + + fn parse_pkg<'a>(&self, line: &str) -> Option { if line.contains('@') { let mut splt = line.split_whitespace(); let name = splt.next()?; let ver = splt.next()?; - return Some(Package::from(name.trim().to_owned()).with_version(ver.trim().to_owned())); + return Some(Package::new(name.trim(), Some(ver.trim()))); } if !line.contains("====") { - Some(Package::from(line.split_once(':')?.0.trim().to_owned())) + Some(Package::new(line.split_once(':')?.0.trim(), None)) } else { None } } - fn add_repo(&self, repo: &str) -> Result<(), RepoError> { - if !self.install("dnf-command(config-manager)".into()).success() { - return Err(RepoError::with_msg( - "failed to install config-manager plugin", - )); - } - self.exec_cmds_status(&self.consolidated(Cmd::AddRepo, &[repo])) - .success() - .then_some(()) - .ok_or(RepoError::default()) + fn add_repo(&self, repo: &str) -> anyhow::Result<()> { + anyhow::ensure!( + self.install(Package::new("dnf-command(config-manager)", None)) + .success(), + "failed to install config-manager plugin" + ); + + let s = self.exec_cmds_status(&self.consolidated(Cmd::AddRepo, &[repo])); + anyhow::ensure!(s.success(), "failed to add repo"); + Ok(()) } } @@ -56,37 +53,46 @@ impl Commands for DandifiedYUM { fn cmd(&self) -> Command { Command::new("dnf") } - fn get_cmds(&self, cmd: Cmd) -> &'static [&'static str] { + + fn get_cmds(&self, cmd: Cmd) -> Vec { match cmd { - Cmd::Install => &["install"], - Cmd::Uninstall => &["remove"], - Cmd::Update => &["upgrade"], - Cmd::UpdateAll => &["distro-sync"], - Cmd::List => &["list"], - Cmd::Sync => &["makecache"], + Cmd::Install => vec!["install"], + Cmd::Uninstall => vec!["remove"], + Cmd::Update => vec!["upgrade"], + Cmd::UpdateAll => vec!["distro-sync"], + Cmd::List => vec!["list"], + Cmd::Sync => vec!["makecache"], // depends on config-manager plugin (handled in add_repo method) - Cmd::AddRepo => &["config-manager", "--add-repo"], // flag must come before repo - Cmd::Search => &["search"], + Cmd::AddRepo => vec!["config-manager", "--add-repo"], // flag must come before repo + Cmd::Search => vec!["search"], } + .iter() + .map(|x| x.to_string()) + .collect() } - fn get_flags(&self, cmd: Cmd) -> &'static [&'static str] { + fn get_flags(&self, cmd: Cmd) -> Vec { match cmd { - Cmd::Install | Cmd::Uninstall | Cmd::Update | Cmd::UpdateAll => &["-y"], - Cmd::List => &["--installed"], - Cmd::Search => &["-q"], - _ => &[], + Cmd::Install | Cmd::Uninstall | Cmd::Update | Cmd::UpdateAll => vec!["-y"], + Cmd::List => vec!["--installed"], + Cmd::Search => vec!["-q"], + _ => vec![], } + .iter() + .map(|x| x.to_string()) + .collect() } } #[cfg(test)] mod tests { + use std::str::FromStr; + use super::DandifiedYUM; use crate::{Package, PackageManager}; #[test] - fn parse_pkg() { + fn test_parse_pkg() { let dnf = DandifiedYUM; let input = r#" sudo.x86_64 1.9.13-2.p2.fc38 @koji-override-0 @@ -99,16 +105,17 @@ rubygem-mixlib-shellout-doc.noarch : Documentation for rubygem-mixlib-shellout"# let mut iter = input.lines().filter_map(|l| dnf.parse_pkg(l)); assert_eq!( iter.next(), - Some(Package::from("sudo.x86_64").with_version("1.9.13-2.p2.fc38")) + Package::from_str("sudo.x86_64@1.9.13-2.p2.fc38").ok() ); assert_eq!( iter.next(), - Some(Package::from("systemd-libs.x86_64").with_version("253.10-1.fc38")) + Package::from_str("systemd-libs.x86_64@253.10-1.fc38").ok() ); - assert_eq!(iter.next(), Some(Package::from("hello.x86_64"))); + + assert_eq!(iter.next(), Package::from_str("hello.x86_64").ok()); assert_eq!( iter.next(), - Some(Package::from("rubygem-mixlib-shellout-doc.noarch")) + Package::from_str("rubygem-mixlib-shellout-doc.noarch").ok() ); } } diff --git a/src/managers/mod.rs b/src/managers/mod.rs index e6942b1..6f9d895 100644 --- a/src/managers/mod.rs +++ b/src/managers/mod.rs @@ -4,6 +4,10 @@ //! If the module is empty, it means that no package manager feature flag is //! enabled. +use ambassador::Delegate; +use anyhow::Context; +use strum::IntoEnumIterator; + pub mod apt; pub mod brew; pub mod choco; @@ -11,17 +15,87 @@ pub mod dnf; pub mod yum; pub mod zypper; -pub use apt::AdvancedPackageTool; -pub use brew::Homebrew; -pub use choco::Chocolatey; -pub use dnf::DandifiedYUM; -pub use yum::YellowdogUpdaterModified; -pub use zypper::Zypper; +use apt::AdvancedPackageTool; +use brew::Homebrew; +use choco::Chocolatey; +use dnf::DandifiedYUM; +use yum::YellowdogUpdaterModified; +use zypper::Zypper; + +use crate::common::*; + +/// Enum of all supported package managers. +#[derive(Debug, Delegate, strum::EnumIter, strum::EnumCount)] +#[delegate(crate::Commands)] +#[delegate(crate::PackageManager)] +pub enum MetaPackageManager { + Apt(AdvancedPackageTool), + Brew(Homebrew), + Choco(Chocolatey), + Dnf(DandifiedYUM), + Yum(YellowdogUpdaterModified), + Zypper(Zypper), +} + +impl Default for MetaPackageManager { + fn default() -> Self { + Self::Choco(Chocolatey) + } +} + +impl MetaPackageManager { + /// Construct a new `MetaPackageManager` from a given package manager. + pub fn try_new(manager: AvailablePackageManager) -> anyhow::Result { + tracing::info!("Creating meta package manager interface for {manager:?}"); + let mpm = match manager { + AvailablePackageManager::Apt => Self::Apt(AdvancedPackageTool), + AvailablePackageManager::Brew => Self::Brew(Homebrew), + AvailablePackageManager::Choco => Self::Choco(Chocolatey), + AvailablePackageManager::Dnf => Self::Dnf(DandifiedYUM), + AvailablePackageManager::Yum => Self::Yum(YellowdogUpdaterModified::default()), + AvailablePackageManager::Zypper => Self::Zypper(Zypper), + }; + + match mpm.cmd().arg("--version").output() { + Ok(output) => { + if output.status.success() { + Ok(mpm) + } else { + anyhow::bail!("failed to run {mpm} command") + } + } + Err(e) => anyhow::bail!("{mpm} not found on this system: {e}"), + } + } + + /// Try to find the system package manager. + /// + /// First enum variant is given the highest priority, second, the second + /// highest, and so on. + pub fn new_default() -> anyhow::Result { + AvailablePackageManager::iter() + .find_map(|m| Self::try_new(m).ok()) + .context("no supported package manager found") + } +} + +impl std::fmt::Display for MetaPackageManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MetaPackageManager::Brew(_) => Homebrew.fmt(f), + MetaPackageManager::Choco(_) => Chocolatey.fmt(f), + MetaPackageManager::Apt(_) => AdvancedPackageTool.fmt(f), + MetaPackageManager::Dnf(_) => DandifiedYUM.fmt(f), + MetaPackageManager::Zypper(_) => Zypper.fmt(f), + MetaPackageManager::Yum(_) => YellowdogUpdaterModified::default().fmt(f), + } + } +} #[cfg(test)] mod tests { - use crate::{verify::Verify, PackageManager}; + use crate::PackageManager; #[cfg(target_os = "osx")] #[test] @@ -33,14 +107,18 @@ mod tests { // search assert!(hb.search("hello").iter().any(|p| p.name() == "hello")); // install - assert!(hb.exec_op(&["hello".into()], Operation::Install).success()); + assert!(hb + .exec_op(&["hello".parse().unwrap()], Operation::Install) + .success()); // list assert!(hb.list_installed().iter().any(|p| p.name() == "hello")); // update - assert!(hb.exec_op(&["hello".into()], Operation::Update).success()); + assert!(hb + .exec_op(&["hello".parse().unwrap()], Operation::Update) + .success()); // uninstall assert!(hb - .exec_op(&["hello".into()], Operation::Uninstall) + .exec_op(&["hello".parse().unwrap()], Operation::Uninstall) .success()); // TODO: Test AddRepo } @@ -49,20 +127,19 @@ mod tests { #[test] fn test_chocolatey() { let choco = crate::managers::Chocolatey; - let choco = choco.verify().expect("Chocolatey not found in path"); let pkg = "tac"; // sync assert!(choco.sync().success()); // search assert!(choco.search(pkg).iter().any(|p| p.name() == pkg)); // install - assert!(choco.install(pkg.into()).success()); + assert!(choco.install(pkg.parse().unwrap()).success()); // list assert!(choco.list_installed().iter().any(|p| p.name() == pkg)); // update - assert!(choco.update(pkg.into()).success()); + assert!(choco.update(pkg.parse().unwrap()).success()); // uninstall - assert!(choco.uninstall(pkg.into()).success()); + assert!(choco.uninstall(pkg.parse().unwrap()).success()); // TODO: Test AddRepo } @@ -105,27 +182,24 @@ mod tests { dnf_yum_cases(crate::managers::YellowdogUpdaterModified::default()) } + #[cfg(target_os = "linux")] fn dnf_yum_cases(man: impl crate::PackageManager) { - if let Some(man) = man.verify() { - let pkg = "hello"; - // sync - assert!(man.sync().success()); - // search - assert!(man.search(pkg).iter().any(|p| p.name() == "hello.x86_64")); - // install - assert!(man.install(pkg.into()).success()); - // list - assert!(man - .list_installed() - .iter() - .any(|p| p.name() == "hello.x86_64")); - // update - assert!(man.update(pkg.into()).success()); - // uninstall - assert!(man.uninstall(pkg.into()).success()); - // TODO: Test AddRepo - } else { - eprintln!("dnf not found"); - } + let pkg = "hello"; + // sync + assert!(man.sync().success()); + // search + assert!(man.search(pkg).iter().any(|p| p.name() == "hello.x86_64")); + // install + assert!(man.install(pkg.parse().unwrap()).success()); + // list + assert!(man + .list_installed() + .iter() + .any(|p| p.name() == "hello.x86_64")); + // update + assert!(man.update(pkg.parse().unwrap()).success()); + // uninstall + assert!(man.uninstall(pkg.parse().unwrap()).success()); + // TODO: Test AddRepo } } diff --git a/src/managers/yum.rs b/src/managers/yum.rs index d9c5518..be2947f 100644 --- a/src/managers/yum.rs +++ b/src/managers/yum.rs @@ -2,9 +2,6 @@ use std::{fmt::Display, process::Command}; use crate::{managers::DandifiedYUM, Cmd, Commands, PackageManager}; -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; - /// Wrapper for Yellowdog Updater Modified (YUM) package manager. /// /// [Chapter 14. YUM (Yellowdog Updater Modified) Red Hat Enterprise Linux 5 | Red Hat Customer Portal](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/5/html/deployment_guide/c1-yum) @@ -12,15 +9,12 @@ use serde::{Deserialize, Serialize}; /// Note: The current YUM implementation uses [``DandifiedYUM``]'s /// implementation under the hood, which is why this struct is required to be /// constructed by calling [``YellowdogUpdaterModified::default()``]. -#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] #[derive(Debug)] -pub struct YellowdogUpdaterModified { - dnf: DandifiedYUM, -} +pub struct YellowdogUpdaterModified(DandifiedYUM); impl Default for YellowdogUpdaterModified { fn default() -> Self { - Self { dnf: DandifiedYUM } + Self(DandifiedYUM) } } @@ -32,13 +26,13 @@ impl Display for YellowdogUpdaterModified { impl PackageManager for YellowdogUpdaterModified { fn pkg_delimiter(&self) -> char { - self.dnf.pkg_delimiter() + self.0.pkg_delimiter() } - fn parse_pkg<'a>(&self, line: &str) -> Option> { - self.dnf.parse_pkg(line) + fn parse_pkg<'a>(&self, line: &str) -> Option { + self.0.parse_pkg(line) } - fn add_repo(&self, repo: &str) -> Result<(), crate::RepoError> { - self.dnf.add_repo(repo) + fn add_repo(&self, repo: &str) -> anyhow::Result<()> { + self.0.add_repo(repo) } } @@ -46,10 +40,10 @@ impl Commands for YellowdogUpdaterModified { fn cmd(&self) -> Command { Command::new("yum") } - fn get_cmds(&self, cmd: crate::Cmd) -> &'static [&'static str] { - self.dnf.get_cmds(cmd) + fn get_cmds(&self, cmd: crate::Cmd) -> Vec { + self.0.get_cmds(cmd) } - fn get_flags(&self, cmd: Cmd) -> &'static [&'static str] { - self.dnf.get_flags(cmd) + fn get_flags(&self, cmd: Cmd) -> Vec { + self.0.get_flags(cmd) } } diff --git a/src/managers/zypper.rs b/src/managers/zypper.rs index 9814f4d..e998c1c 100644 --- a/src/managers/zypper.rs +++ b/src/managers/zypper.rs @@ -2,14 +2,10 @@ use std::{fmt::Display, process::Command}; -use crate::{Cmd, Commands, Package, PackageManager, RepoError}; - -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; +use crate::{Cmd, Commands, Package, PackageManager}; /// Wrapper for Zypper package manager. Some openSUSE might support dnf as well. -#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] -#[derive(Debug)] +#[derive(Debug, Default)] pub struct Zypper; impl PackageManager for Zypper { @@ -35,45 +31,42 @@ impl PackageManager for Zypper { let mut packages = vec![]; for p in &list.children { if let Some(p) = p.as_element() { - packages.push(Package { - name: p - .attributes - .get("name") - .expect("must have name") - .to_string() - .into(), - version: None, - }) + packages.push(Package::new( + p.attributes.get("name").expect("must have name"), + None, + )); } } packages } - fn parse_pkg<'a>(&self, line: &str) -> Option> { + fn parse_pkg<'a>(&self, line: &str) -> Option { if line.contains('@') { let mut splt = line.split_whitespace(); let name = splt.next()?; let ver = splt.next()?; - return Some(Package::from(name.trim().to_owned()).with_version(ver.trim().to_owned())); + return Some(Package::new(name.trim(), Some(ver.trim()))); } if !line.contains("====") { - Some(Package::from(line.split_once(':')?.0.trim().to_owned())) + Some(Package::new(line.split_once(':')?.0.trim(), None)) } else { None } } - fn add_repo(&self, repo: &str) -> Result<(), RepoError> { - if !self.install("dnf-command(config-manager)".into()).success() { - return Err(RepoError::with_msg( - "failed to install config-manager plugin", - )); - } - - self.exec_cmds_status(&self.consolidated(Cmd::AddRepo, &[repo])) - .success() - .then_some(()) - .ok_or(RepoError::default()) + fn add_repo(&self, repo: &str) -> anyhow::Result<()> { + anyhow::ensure!( + self.install(Package::new("dnf-command(config-manager)", None)) + .success(), + "failed to install config-manager plugin", + ); + + anyhow::ensure!( + self.exec_cmds_status(&self.consolidated(Cmd::AddRepo, &[repo])) + .success(), + "Failed to add repo" + ); + Ok(()) } } @@ -89,27 +82,33 @@ impl Commands for Zypper { Command::new("zypper") } - fn get_cmds(&self, cmd: Cmd) -> &'static [&'static str] { + fn get_cmds(&self, cmd: Cmd) -> Vec { match cmd { - Cmd::Install => &["install"], - Cmd::Uninstall => &["remove"], - Cmd::Update => &["update"], - Cmd::UpdateAll => &["dist-upgrade"], - Cmd::List => &["--xmlout", "search"], - Cmd::Sync => &["refresh"], - Cmd::AddRepo => &["addrepo"], - Cmd::Search => &["--xmlout", "search"], + Cmd::Install => vec!["install"], + Cmd::Uninstall => vec!["remove"], + Cmd::Update => vec!["update"], + Cmd::UpdateAll => vec!["dist-upgrade"], + Cmd::List => vec!["--xmlout", "search"], + Cmd::Sync => vec!["refresh"], + Cmd::AddRepo => vec!["addrepo"], + Cmd::Search => vec!["--xmlout", "search"], } + .iter() + .map(|x| x.to_string()) + .collect() } - fn get_flags(&self, cmd: Cmd) -> &'static [&'static str] { + fn get_flags(&self, cmd: Cmd) -> Vec { match cmd { - Cmd::Install | Cmd::Uninstall | Cmd::Update | Cmd::UpdateAll => &["-n"], - Cmd::List => &["-i"], - Cmd::Search => &["--no-refresh", "-q"], - Cmd::AddRepo => &["-f"], - _ => &["-n"], + Cmd::Install | Cmd::Uninstall | Cmd::Update | Cmd::UpdateAll => vec!["-n"], + Cmd::List => vec!["-i"], + Cmd::Search => vec!["--no-refresh", "-q"], + Cmd::AddRepo => vec!["-f"], + _ => vec!["-n"], } + .iter() + .map(|x| x.to_string()) + .collect() } } diff --git a/src/print.rs b/src/print.rs new file mode 100644 index 0000000..afea7e3 --- /dev/null +++ b/src/print.rs @@ -0,0 +1,78 @@ +use colored::{ColoredString, Colorize}; +use strum::{EnumCount, IntoEnumIterator}; +use tabled::{ + settings::{object::Rows, themes::Colorization, Color, Style}, + Table, Tabled, +}; + +use crate::{common::AvailablePackageManager, managers::MetaPackageManager}; + +/// Takes a format string and prints it in the format "Info {format_str}" +#[macro_export] +macro_rules! notify { + ($($fmt:tt)+) => { + { + println!("{args}", args = format_args!($($fmt)+)) + } + }; +} + +/// Struct used for printing supported package managers in a table +#[derive(Tabled)] +#[tabled(rename_all = "PascalCase")] +struct Listing { + supported: ColoredString, + /// CSV of support formats. + file_extensions: ColoredString, + available: ColoredString, +} + +impl Listing { + pub(crate) fn new(pm: AvailablePackageManager) -> Self { + Listing { + supported: format!("{pm:?}").green(), + file_extensions: pm + .supported_pkg_formats() + .iter() + .map(|pkg| pkg.file_extention()) + .collect::>() + .join(", ") + .green(), + available: if MetaPackageManager::try_new(pm).is_ok() { + "Yes".green() + } else { + "No".red() + }, + } + } +} + +/// Creates a table and prints supported package managers with availability +/// information +pub fn print_managers() { + notify!( + "a total of {} package managers are supported", + AvailablePackageManager::COUNT + ); + let table = Table::new(AvailablePackageManager::iter().map(Listing::new)); + print_table(table); +} + +/// Takes a `Table` type and sets appropriate styling options, then prints in +pub fn print_table(mut table: Table) { + table + .with(Style::rounded().remove_horizontals()) + .with(Colorization::exact([Color::FG_CYAN], Rows::first())); + println!("{table}"); +} + +/// Log error +pub fn log_error(err: anyhow::Error) { + eprintln!("{} {err}", "Error:".red().bold()); + for (i, cause) in err.chain().skip(1).enumerate() { + if i == 0 { + eprintln!("\n{}", "Caused by:".yellow().bold()); + } + eprintln!("({}) {cause}", i + 1); + } +} diff --git a/src/utils/manager.rs b/src/utils/manager.rs deleted file mode 100644 index b1a4319..0000000 --- a/src/utils/manager.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::fmt::Display; - -use clap::ValueEnum; -use strum::{EnumCount, EnumIter}; - -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; - -use crate::{ - managers::{ - AdvancedPackageTool, Chocolatey, DandifiedYUM, Homebrew, YellowdogUpdaterModified, Zypper, - }, - verify::{DynVerified, Verify}, -}; - -/// Declarative macro for initializing a package manager based on the cfg -/// predicate -/// -/// Takes a package manager instance and a cfg predicate (same as the cfg -/// attribute or macro) and attempts to constructs a [``DynVerified``] instance -/// if the cfg predicate evaluates to true, otherwise returns None -macro_rules! if_cfg { - ($pm:expr, $($cfg:tt)+) => { - if cfg!($($cfg)+) { - $pm.verify_dyn() - } else { - None - } - }; -} - -/// The enum lists all the supported package managers in one place -/// -/// The same enum is used in the Cli command parser. -/// Any package manager names that are too long should have an alias, which will -/// let the users of the CLI access the package manager without having to write -/// the full name. -/// -/// The order of the listing is also important. The order dictates priority -/// during selection of the default package manager. -/// -/// Adding support to a new package manager involves creating a new variant of -/// its name and writing the appropriate [``Manager::init``] implementation. -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, EnumCount, ValueEnum)] -#[value(rename_all = "lower")] -#[non_exhaustive] -pub enum Manager { - Brew, - Choco, - Apt, - Dnf, - Yum, - Zypper, -} - -impl Manager { - /// Initialize the corresponding package maanger from genpack library into a - /// [``DynVerified``] type - pub fn init(&self) -> Option { - match self { - Manager::Brew => { - if_cfg!(Homebrew, target_family = "unix") - } - - Manager::Choco => { - if_cfg!(Chocolatey, target_os = "windows") - } - - Manager::Apt => { - if_cfg!( - AdvancedPackageTool, - any(target_os = "linux", target_os = "android") - ) - } - Manager::Dnf => { - if_cfg!(DandifiedYUM, target_os = "linux") - } - Manager::Yum => { - if_cfg!(YellowdogUpdaterModified::default(), target_os = "linux") - } - Manager::Zypper => { - if_cfg!(Zypper, target_os = "linux") - } - } - } - - /// Return the supported pkg format e.g. deb, rpm etc. - pub fn supported_pkg_formats(&self) -> Vec { - match self { - Self::Brew => vec![PkgFormat::Bottle], - Self::Choco => vec![PkgFormat::Exe, PkgFormat::Msi], - Self::Apt => vec![PkgFormat::Deb], - Self::Dnf => vec![PkgFormat::Rpm], - Self::Yum => vec![PkgFormat::Rpm], - Self::Zypper => vec![PkgFormat::Rpm], - } - } -} - -impl Display for Manager { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Manager::Brew => Homebrew.fmt(f), - Manager::Choco => Chocolatey.fmt(f), - Manager::Apt => AdvancedPackageTool.fmt(f), - Manager::Dnf => DandifiedYUM.fmt(f), - Manager::Zypper => Zypper.fmt(f), - Manager::Yum => YellowdogUpdaterModified::default().fmt(f), - } - } -} - -/// Pkg Format. -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Clone, Debug)] -pub enum PkgFormat { - Bottle, - Exe, - Msi, - Rpm, - Deb, -} - -impl PkgFormat { - /// File extension of package. - pub fn file_extention(&self) -> String { - match self { - Self::Bottle => "tar.gz", - Self::Exe => "exe", - Self::Msi => "msi", - Self::Rpm => "rpm", - Self::Deb => "deb", - } - .to_string() - } -} diff --git a/src/utils/mod.rs b/src/utils/mod.rs deleted file mode 100644 index 1ce985d..0000000 --- a/src/utils/mod.rs +++ /dev/null @@ -1,194 +0,0 @@ -use anyhow::{bail, Context, Result}; -use manager::Manager; -use strum::IntoEnumIterator; - -mod manager; - -pub mod parser; -use parser::{Cli, Commands}; - -#[macro_use] -pub mod print; - -use crate::{verify::DynVerified, Operation}; - -/// Primary interface to executing the CLI commands -/// "PkgManagerHandler" because it handles Package Managers, and it's funny -pub struct PkgManagerHandler(Option); - -impl Default for PkgManagerHandler { - fn default() -> Self { - Self::new(None) - } -} - -impl PkgManagerHandler { - /// Create Self with an optional uninitialised package manager. - pub fn new(man: Option) -> Self { - Self(man) - } - - /// Try to find the system package manager. - /// - /// First enum variant is given the highest priority, second, the second - /// highest, and so on. - pub fn default_pkg_manager() -> Result { - Manager::iter() - .find_map(|m| m.init()) - .context("no supported package manager found") - } - - /// Get the inner package manager, if `None` then return the system's default pacakge manager. - fn get_pkg_manager(&self) -> Result { - let man = if let Some(m) = &self.0 { - let userm = m - .init() - .context("requested package manager is unavailable")?; - notify!("running command(s) through {userm}"); - userm - } else { - let defm = Self::default_pkg_manager()?; - notify!("defaulting to {defm}"); - defm - }; - Ok(man) - } - - /// Wrapper for [``Self::execute_op``] - pub fn install(&self, pkgs: Vec) -> Result<()> { - self.execute_op(pkgs, Operation::Install) - } - - /// Wrapper for [``Self::execute_op``] - pub fn uninstall(&self, pkgs: Vec) -> Result<()> { - self.execute_op(pkgs, Operation::Uninstall) - } - - /// Wrapper for [``Self::execute_op``] - pub fn update(&self, pkgs: Vec) -> Result<()> { - self.execute_op(pkgs, Operation::Update) - } - - /// Execute the update_all operation on the package manager - pub fn update_all(&self) -> Result<()> { - let man = self.get_pkg_manager()?; - let status = man.update_all(); - if !status.success() { - bail!("failed to update all packages using {man} with {status}"); - } - Ok(()) - } - - /// Handles three different types of [``Operation``]s on packages: Install, - /// Uninstall and Update - fn execute_op(&self, raw_pkgs: Vec, op: Operation) -> Result<()> { - let pkgs: Vec<_> = raw_pkgs.iter().map(|p| parser::pkg_parse(p)).collect(); - let man = self.get_pkg_manager()?; - let status = man.exec_op(&pkgs, op); - if !status.success() { - bail!( - "failed to execute {:?} operation using {man} with {status}", - op, - ); - } - Ok(()) - } - - /// Does the same as the [``PackageManager::add_repo``] fn - pub fn add_repo(&self, repo: &str) -> Result<()> { - let man = self.get_pkg_manager()?; - if let Err(err) = man.add_repo(repo) { - bail!("{err}") - } - Ok(()) - } - - /// Does the same as the [``PackageManager::sync``] fn - pub fn sync(&self) -> Result<()> { - let man = self.get_pkg_manager()?; - let status = man.sync(); - if !status.success() { - bail!("failed to sync {man} due to {status}") - } - Ok(()) - } - - /// Does the same as the [``PackageManager::list``] fn - pub fn list(&self) -> Result<()> { - let man = self.get_pkg_manager()?; - let pkgs = man.list_installed(); - notify!("{} packages found", pkgs.len()); - if !pkgs.is_empty() { - print::print_packages(pkgs); - } - Ok(()) - } - - #[cfg(feature = "json")] - /// Does the same as the [``PkgManagerHandler::list``] fn, except the output will be in JSON - pub fn list_json(&self) -> Result<()> { - let man = self.get_pkg_manager()?; - let pkgs = man.list_installed(); - print::print_packages_json(pkgs) - } - - /// Does the same as the [``PackageManager::search``] fn - pub fn search(&self, query: &str) -> Result<()> { - tracing::debug!("Searching for {query}"); - let man = self.get_pkg_manager()?; - let pkgs = man.search(query); - if pkgs.is_empty() { - bail!("no packages found that match the query: {query}") - } - notify!("{} packages found for query {query}", pkgs.len()); - print::print_packages(pkgs); - Ok(()) - } - - #[cfg(feature = "json")] - /// Does the same as the [``PkgManagerHandler::search``] fn, except the output will be in JSON - pub fn search_json(&self, query: &str) -> Result<()> { - tracing::debug!("Searching for {query}"); - let man = self.get_pkg_manager()?; - let pkgs = man.search(query); - print::print_packages_json(pkgs) - } -} - -/// Function that handles the parsed CLI arguments in one place -pub fn execute(args: Cli) -> Result<()> { - let handler = PkgManagerHandler::new(args.manager); - match args.command { - Commands::Managers => { - if cfg!(feature = "json") && args.json { - return print::print_managers_json(); - } - print::print_managers() - } - Commands::Search { string } => { - if cfg!(feature = "json") && args.json { - return handler.search_json(&string); - } - handler.search(&string)? - } - Commands::List => { - if cfg!(feature = "json") && args.json { - return handler.list_json(); - } - handler.list()? - } - Commands::Install { packages } => handler.install(packages)?, - Commands::Uninstall { packages } => handler.uninstall(packages)?, - Commands::Update { packages, all } => { - if all { - handler.update_all()? - } else { - handler.update(packages)?; - } - } - Commands::Repo { repo } => handler.add_repo(&repo)?, - Commands::Sync => handler.sync()?, - }; - - Ok(()) -} diff --git a/src/utils/parser.rs b/src/utils/parser.rs deleted file mode 100644 index 4d57e87..0000000 --- a/src/utils/parser.rs +++ /dev/null @@ -1,90 +0,0 @@ -use clap::{Parser, Subcommand}; - -use crate::{utils::manager::Manager, Package}; - -#[derive(Parser)] -#[command( - author, - version, - about = "A generic package manager.", - long_about = "A generic package manager for interfacing with multiple distro and platform specific package managers." -)] -pub struct Cli { - #[command(subcommand)] - pub command: Commands, - #[arg( - long, - short, - help = "Specify a package manager mpm should use", - long_help = "Optionally specify a package manager mpm should use. When no package manager is provided, a default available one is picked automatically." - )] - pub manager: Option, - #[cfg(feature = "json")] - #[arg( - short, - long, - help = "Print JSON to stdout instead of formatted text", - long_help = "Use JSON output instead of formatted text. Note: the flag will be ignored when used with unsupported commands." - )] - pub json: bool, -} - -#[derive(Subcommand)] -pub enum Commands { - #[command(about = "List supported package managers and display their availability")] - Managers, - - #[command(about = "Search for a given sub-string and list matching packages")] - Search { string: String }, - - #[command(about = "List all packages that are installed")] - List, - - #[command( - about = "Install the given package(s)", - long_about = "Install the given package(s).\nIf a specific version of the package is desired, it can be specified using the format @.\nNote: version information is optional." - )] - Install { - #[clap(required = true)] - packages: Vec, - }, - - #[command( - about = "Uninstall the given package(s)", - long_about = "Uninstall the given package(s).\nIf a specific version of the package is desired, it can be specified using the format @.\nNote: version information is optional." - )] - Uninstall { - #[clap(required = true)] - packages: Vec, - }, - - #[command( - about = "Add the provided third-party repo location to the package manager", - long_about = "Provide a repo in the form of a URL or package manager specific repo format to add it to the list of repositories of the package manager" - )] - Repo { repo: String }, - - #[command( - about = "Updates the cached package repository data", - long_about = "Sync the cached package repository data.\nNote: this behavior might not be consistent among package managers; when sync is not supported, the package manager might simply update itself." - )] - Sync, - - #[command(about = "Update/upgrade the given package(s) or (--)all of them")] - #[group(required = true)] - Update { - packages: Vec, - #[arg(long, short)] - all: bool, - }, -} - -/// Parse user given string into package name and version. The string must have -/// `@` for version information to be extracted. -pub fn pkg_parse(pkg: &str) -> Package { - if let Some((name, version)) = pkg.split_once('@') { - Package::from(name).with_version(version) - } else { - pkg.into() - } -} diff --git a/src/utils/print.rs b/src/utils/print.rs deleted file mode 100644 index 0cfbe8c..0000000 --- a/src/utils/print.rs +++ /dev/null @@ -1,151 +0,0 @@ -use anyhow::Error; -use colored::{ColoredString, Colorize}; -use strum::{EnumCount, IntoEnumIterator}; -use tabled::{ - settings::{object::Rows, themes::Colorization, Color, Style}, - Table, Tabled, -}; - -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; - -use crate::{utils::manager::Manager, Package}; - -use super::manager::PkgFormat; - -/// Takes a format string and prints it in the format "Info {format_str}" -macro_rules! notify { - ($($fmt:tt)+) => { - { - println!("{args}", args = format_args!($($fmt)+)) - } - }; -} - -/// Struct used for printing supported package managers in a table -#[derive(Tabled)] -#[tabled(rename_all = "PascalCase")] -struct Listing { - supported: Manager, - /// CSV of support formats. - file_extensions: ColoredString, - available: ColoredString, -} - -impl Listing { - fn new(pm: Manager) -> Self { - Listing { - supported: pm, - file_extensions: pm - .supported_pkg_formats() - .iter() - .map(|pkg| pkg.file_extention()) - .collect::>() - .join(", ") - .green(), - available: if pm.init().is_some() { - "Yes".green() - } else { - "No".red() - }, - } - } -} - -#[cfg(feature = "json")] -/// Type used for printing a list of supported package managers as JSON -#[derive(Serialize, Deserialize, Debug)] -struct PkgManangerInfo { - name: Manager, - available: bool, - file_extensions: Vec, -} - -#[cfg(feature = "json")] -impl PkgManangerInfo { - fn new(pm: Manager) -> Self { - PkgManangerInfo { - name: pm, - file_extensions: pm.supported_pkg_formats(), - available: pm.init().is_some(), - } - } -} - -/// Struct used for printing packages in a table -#[derive(Tabled)] -#[allow(non_snake_case)] -struct PkgListing<'a> { - Package: &'a str, - Version: ColoredString, -} - -impl<'a> PkgListing<'a> { - fn new(pkg: &'a Package<'a>) -> Self { - PkgListing { - Package: pkg.name(), - Version: if let Some(v) = pkg.version() { - v.green() - } else { - "~".white() // no version info is present - }, - } - } -} - -#[cfg(feature = "json")] -/// Prints list of packages with version information in JSON format to stdout -pub fn print_packages_json(pkgs: Vec) -> anyhow::Result<()> { - use anyhow::Context; - - let stdout = std::io::stdout(); - serde_json::to_writer_pretty(stdout, &pkgs).context("failed to package list in JSON")?; - Ok(()) -} - -/// Creates a table and prints list of packages with version information -pub fn print_packages(pkgs: Vec) { - let table = Table::new(pkgs.iter().map(PkgListing::new)); - print_table(table); -} - -/// Creates a table and prints supported package managers with availability -/// information -pub fn print_managers() { - notify!( - "a total of {} package managers are supported", - Manager::COUNT - ); - let table = Table::new(Manager::iter().map(Listing::new)); - print_table(table); -} - -/// Prints list of supported package managers in JSON format to stdout -#[cfg(feature = "json")] -pub fn print_managers_json() -> anyhow::Result<()> { - use anyhow::Context; - - let managers: Vec<_> = Manager::iter().map(PkgManangerInfo::new).collect(); - let stdout = std::io::stdout(); - serde_json::to_writer_pretty(stdout, &managers) - .context("failed to print support package managers in JSON")?; - Ok(()) -} - -/// Takes a `Table` type and sets appropriate styling options, then prints in -pub fn print_table(mut table: Table) { - table - .with(Style::rounded().remove_horizontals()) - .with(Colorization::exact([Color::FG_CYAN], Rows::first())); - println!("{table}"); -} - -pub fn log_error(err: Error) { - eprintln!("{} {err}", "Error:".red().bold()); - for (i, cause) in err.chain().skip(1).enumerate() { - if i == 0 { - eprintln!("\n{}", "Caused by:".yellow().bold()); - } - eprintln!("({}) {cause}", i + 1); - } -} diff --git a/src/verify.rs b/src/verify.rs deleted file mode 100644 index a5e9b62..0000000 --- a/src/verify.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! This module contains types that are used to signify that a package manager -//! is installed / is in path and is safe to use. -//! -//! The [``crate::Commands``] trait (which the [``PackageManager``] trait relies -//! on) internally uses `unwrap` on [``std::process::Command``] when it is -//! executed to avoid extra error-handling on the user side. This is safe and -//! won't cause a panic when the primary command of the given package maanger -//! works (i.e. is in path), which is the assumption the API makes. -//! However, the user cannot always be trusted to ensure this assumption holds -//! true. Therefore, one can rely on the functionality provided by this -//! module to check if a package manager is installed / is in path, and then -//! construct a type that marks that it is now safe to use. This is done by -//! leveraging the type system: the verified types can only be constructed using -//! their provided constructor functions. They internally use the -//! [``is_installed``] function and return `Some(Self)` only if it returns -//! `true`. -//! -//! The type [``Verified``] is a generic wrapper that works with any `T` that -//! implements the [``PackageManager``] trait. [``DynVerified``] is similar, -//! except that it internally stores the type `T` as `Box` -//! to allow dynamic dispatch with ease. These types can be constructed manaully -//! by using their respective constructors or use the [``Verify``] trait, which -//! provides a blanket implementation and lets you construct them directly from -//! any instance of a type that implements [``PackageManager``]. For example, -//! `MyPackageMan::new().verify()` or `MyPackageMan::new().verify_dyn()`. -use std::{fmt::Display, ops::Deref, process::Stdio}; - -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; - -use crate::PackageManager; - -/// Wraps `T` that implements [``PackageManager``] and only constructs an -/// instance if the given package manager is installed / is in path. -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug)] -pub struct Verified { - inner: T, -} - -impl Verified { - pub fn new(pm: T) -> Option { - is_installed(&pm).then_some(Self { inner: pm }) - } -} - -impl Deref for Verified { - type Target = T; - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl Display for Verified { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!("{}", self.inner)) - } -} - -/// Converts `T` that implements [``PackageManager``] into `Box` and only constructs an instance if the given package -/// manager is installed / is in path. -#[derive(Debug)] -pub struct DynVerified { - inner: Box, -} - -impl DynVerified { - pub fn new(pm: P) -> Option { - is_installed(&pm).then_some(Self { - inner: Box::new(pm), - }) - } -} - -impl Deref for DynVerified { - type Target = dyn PackageManager; - fn deref(&self) -> &Self::Target { - self.inner.as_ref() - } -} - -impl Display for DynVerified { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!("{}", self.inner)) - } -} - -/// Check if package manager is installed on the system -/// -/// The current implementation merely runs the primary package-manager command -/// and checks if it returns an error or not. -pub fn is_installed(pm: &P) -> bool { - tracing::trace!("Checking if {pm:?} is installed: {:?}", pm.cmd()); - pm.cmd() - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .is_ok() -} - -/// Helper trait that lets you construct a verified package manager instance -/// that is known to be installed or in path, and is safe to be interacted with. -/// -/// This trait has a blanket implementation for all T that implement -/// PackageManager -pub trait Verify: PackageManager -where - Self: Sized, -{ - /// Creates an instance of [``Verified``], which signifies that the package - /// manager is installed and is safe to be interacted with. - fn verify(self) -> Option> { - Verified::new(self) - } - - /// Creates an instance of [``DynVerified``], which signifies that the - /// package manager is installed and is safe to be interacted with. - /// - /// Note: This internally converts and stores `Self` as `dyn PackageManager` - fn verify_dyn(self) -> Option - where - Self: 'static, - { - DynVerified::new(self) - } -} - -impl Verify for T where T: PackageManager {}