refact: remove virtual package

This commit is contained in:
steven-omaha
2025-08-05 12:20:09 +02:00
parent 9a7fd77e99
commit 83275576fb
38 changed files with 34 additions and 48 deletions
+122
View File
@@ -0,0 +1,122 @@
use std::collections::HashSet;
use std::process::Command;
use alpm::Alpm;
use alpm::PackageReason::Explicit;
use anyhow::{Context, Result};
use crate::cmd::run_external_command;
use crate::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Arch {
pub binary: String,
pub aur_rm_args: Vec<String>,
}
impl Arch {
pub fn new(config: &Config) -> Self {
Self {
binary: config.aur_helper.clone(),
aur_rm_args: config.aur_rm_args.clone(),
}
}
}
impl Backend for Arch {
fn backend_info(&self) -> BackendInfo {
BackendInfo {
binary: self.binary.clone(),
section: "arch",
switches_info: &["--query", "--info"],
switches_install: &["--sync"],
switches_noconfirm: &["--noconfirm"],
switches_remove: &["--remove", "--recursive"],
switches_make_dependency: Some(&["--database", "--asdeps"]),
}
}
fn get_all_installed_packages(&self) -> Result<Packages> {
let alpm_packages = get_all_installed_packages_from_alpm()
.context("getting all installed packages from alpm")?;
let result = convert_to_pacdef_packages(alpm_packages);
Ok(result)
}
fn get_explicitly_installed_packages(&self) -> Result<Packages> {
let alpm_packages = get_explicitly_installed_packages_from_alpm()
.context("getting all installed packages from alpm")?;
let result = convert_to_pacdef_packages(alpm_packages);
Ok(result)
}
/// Install the specified packages.
fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(&self.binary);
cmd.args(backend_info.switches_install);
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Remove the specified packages.
fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(&self.binary);
cmd.args(backend_info.switches_remove);
cmd.args(&self.aur_rm_args);
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
}
fn get_all_installed_packages_from_alpm() -> Result<HashSet<String>> {
let db = get_db_handle().context("getting DB handle")?;
let result = db
.localdb()
.pkgs()
.iter()
.map(|p| p.name().to_string())
.collect();
Ok(result)
}
fn get_explicitly_installed_packages_from_alpm() -> Result<HashSet<String>> {
let db = get_db_handle().context("getting DB handle")?;
let result = db
.localdb()
.pkgs()
.iter()
.filter(|p| p.reason() == Explicit)
.map(|p| p.name().to_string())
.collect();
Ok(result)
}
fn convert_to_pacdef_packages(packages: HashSet<String>) -> Packages {
packages.into_iter().map(Package::from).collect()
}
fn get_db_handle() -> Result<Alpm> {
Alpm::new("/", "/var/lib/pacman").context("connecting to DB using expected default values")
}
+103
View File
@@ -0,0 +1,103 @@
use anyhow::Result;
use rust_apt::cache::PackageSort;
use rust_apt::new_cache;
use crate::backend::root::build_base_command_with_privileges;
use crate::cmd::run_external_command;
use crate::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Debian {}
impl Debian {
pub fn new() -> Self {
Self {}
}
}
impl Default for Debian {
fn default() -> Self {
Self::new()
}
}
impl Backend for Debian {
fn backend_info(&self) -> BackendInfo {
BackendInfo {
binary: "apt".to_string(),
section: "debian",
switches_info: &["show"],
switches_install: &["install"],
switches_noconfirm: &["--yes"],
switches_remove: &["remove"],
switches_make_dependency: Some(&[]),
}
}
fn get_all_installed_packages(&self) -> Result<Packages> {
let cache = new_cache!()?;
let sort = PackageSort::default().installed();
let mut result = Packages::new();
for pkg in cache.packages(&sort)? {
result.insert(Package::from(pkg.name().to_string()));
}
Ok(result)
}
fn get_explicitly_installed_packages(&self) -> Result<Packages> {
let cache = new_cache!()?;
let sort = PackageSort::default().installed().manually_installed();
let mut result = Packages::new();
for pkg in cache.packages(&sort)? {
result.insert(Package::from(pkg.name().to_string()));
}
Ok(result)
}
fn make_dependency(&self, packages: &Packages) -> Result<()> {
let mut cmd = build_base_command_with_privileges("apt-mark");
cmd.arg("auto");
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Install the specified packages.
fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = build_base_command_with_privileges(&backend_info.binary);
cmd.args(backend_info.switches_install);
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Remove the specified packages.
fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = build_base_command_with_privileges(&backend_info.binary);
cmd.args(backend_info.switches_remove);
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
}
+143
View File
@@ -0,0 +1,143 @@
use std::process::Command;
use anyhow::Result;
use crate::cmd::run_external_command;
use crate::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Fedora {}
impl Fedora {
pub fn new() -> Self {
Self {}
}
}
impl Default for Fedora {
fn default() -> Self {
Self::new()
}
}
/// These repositories are ignored when storing the packages
/// as these are present by default on any sane fedora system
const DEFAULT_REPOS: [&str; 5] = ["koji", "fedora", "updates", "anaconda", "@"];
/// These switches are responsible for
/// getting the packages explicitly installed by the user
const SWITCHES_FETCH_USER: Switches = &[
"repoquery",
"--userinstalled",
"--queryformat",
"%{from_repo}/%{name}",
];
/// These switches are responsible for
/// getting all the packages installed on the system
const SWITCHES_FETCH_GLOBAL: Switches = &[
"repoquery",
"--installed",
"--queryformat",
"%{from_repo}/%{name}",
];
impl Backend for Fedora {
fn backend_info(&self) -> BackendInfo {
BackendInfo {
binary: "dnf".to_string(),
section: "fedora",
switches_info: &["info"],
switches_install: &["install"],
switches_noconfirm: &["--assumeyes"],
switches_remove: &["remove"],
switches_make_dependency: None,
}
}
fn get_all_installed_packages(&self) -> Result<Packages> {
let mut cmd = Command::new(self.backend_info().binary);
cmd.args(SWITCHES_FETCH_GLOBAL);
let output = String::from_utf8(cmd.output()?.stdout)?;
let packages = output.lines().map(create_package).collect();
Ok(packages)
}
fn get_explicitly_installed_packages(&self) -> Result<Packages> {
let mut cmd = Command::new(self.backend_info().binary);
cmd.args(SWITCHES_FETCH_USER);
let output = String::from_utf8(cmd.output()?.stdout)?;
let packages = output.lines().map(create_package).collect();
Ok(packages)
}
/// Install the specified packages.
fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new("sudo");
cmd.arg(backend_info.binary);
cmd.args(backend_info.switches_install);
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(&p.name);
if let Some(repo) = p.repo.as_ref() {
cmd.args(["--repo", repo]);
}
}
// add these two repositories as these are needed for many dependencies
cmd.args(["--repo", "updates"]);
cmd.args(["--repo", "fedora"]);
run_external_command(cmd)
}
/// Show information from package manager for package.
fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new("sudo");
cmd.arg(backend_info.binary);
cmd.args(backend_info.switches_remove);
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(&p.name);
}
run_external_command(cmd)
}
fn show_package_info(&self, package: &Package) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(backend_info.binary);
cmd.args(backend_info.switches_info);
cmd.arg(&package.name);
run_external_command(cmd)
}
fn make_dependency(&self, _: &Packages) -> Result<()> {
panic!("Not supported by the package manager!")
}
}
fn create_package(package: &str) -> Package {
if DEFAULT_REPOS.iter().any(|repo| package.contains(repo)) && !package.contains("copr") {
let package = package.split('/').nth(1).expect("Cannot be empty!");
package.into()
} else {
package.into()
}
}
+116
View File
@@ -0,0 +1,116 @@
use std::process::Command;
use anyhow::Result;
use crate::cmd::run_external_command;
use crate::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Flatpak {
pub systemwide: bool,
}
impl Flatpak {
pub fn new(config: &Config) -> Self {
Self {
systemwide: config.flatpak_systemwide,
}
}
fn get_switches_runtime(&self) -> Switches {
if self.systemwide {
&[]
} else {
&["--user"]
}
}
fn get_installed_packages(&self, include_implicit: bool) -> Result<Packages> {
let mut cmd = Command::new(self.backend_info().binary);
cmd.args(["list", "--columns=application"]);
if !include_implicit {
cmd.arg("--app");
}
if !self.systemwide {
cmd.arg("--user");
}
let output = String::from_utf8(cmd.output()?.stdout)?;
Ok(output.lines().map(Package::from).collect::<Packages>())
}
}
impl Backend for Flatpak {
fn backend_info(&self) -> BackendInfo {
BackendInfo {
binary: "flatpak".to_string(),
section: "flatpak",
switches_info: &["info"],
switches_install: &["install"],
switches_noconfirm: &["--assumeyes"],
switches_remove: &["uninstall"],
switches_make_dependency: None,
}
}
fn get_all_installed_packages(&self) -> Result<Packages> {
self.get_installed_packages(true)
}
fn get_explicitly_installed_packages(&self) -> Result<Packages> {
self.get_installed_packages(false)
}
/// Install the specified packages.
fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(backend_info.binary);
cmd.args(backend_info.switches_install);
cmd.args(self.get_switches_runtime());
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
fn make_dependency(&self, _: &Packages) -> Result<()> {
panic!("not supported by {}", self.backend_info().binary)
}
/// Remove the specified packages.
fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(backend_info.binary);
cmd.args(backend_info.switches_remove);
cmd.args(self.get_switches_runtime());
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Show information from package manager for package.
fn show_package_info(&self, package: &Package) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(backend_info.binary);
cmd.args(backend_info.switches_info);
cmd.args(self.get_switches_runtime());
cmd.arg(format!("{package}"));
run_external_command(cmd)
}
}
+10
View File
@@ -0,0 +1,10 @@
#[cfg(feature = "arch")]
pub mod arch;
#[cfg(feature = "debian")]
pub mod debian;
pub mod fedora;
pub mod flatpak;
pub mod python;
pub mod rust;
pub mod rustup;
pub mod void;
+106
View File
@@ -0,0 +1,106 @@
use std::process::Command;
use anyhow::Context;
use anyhow::Result;
use serde_json::Value;
use crate::prelude::*;
macro_rules! ERROR{
($bin:expr) => {
panic!("Cannot use {} for package management in python. Please use a valid package manager like pip or pipx.", $bin)
};
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Python {
pub binary: String,
}
impl Python {
pub fn new(config: &Config) -> Self {
Self {
binary: config.pip_binary.to_string(),
}
}
fn get_switches_runtime(&self) -> Switches {
match self.backend_info().binary.as_str() {
"pip" => &["list", "--format", "json", "--not-required", "--user"],
"pipx" => &["list", "--json"],
_ => ERROR!(self.backend_info().binary),
}
}
fn get_switches_explicit(&self) -> Switches {
match self.backend_info().binary.as_str() {
"pip" => &["list", "--format", "json", "--user"],
"pipx" => &["list", "--json"],
_ => ERROR!(self.backend_info().binary),
}
}
fn extract_packages(&self, output: Value) -> Result<Packages> {
match self.backend_info().binary.as_str() {
"pip" => extract_pacdef_packages(output),
"pipx" => extract_pacdef_packages_pipx(output),
_ => ERROR!(self.backend_info().binary),
}
}
}
impl Backend for Python {
fn backend_info(&self) -> BackendInfo {
BackendInfo {
binary: self.binary.clone(),
section: "python",
switches_info: &["show"],
switches_install: &["install"],
switches_noconfirm: &[],
switches_remove: &["uninstall"],
switches_make_dependency: None,
}
}
fn get_all_installed_packages(&self) -> Result<Packages> {
let mut cmd = Command::new(self.backend_info().binary);
let output = run_pip_command(&mut cmd, self.get_switches_runtime())?;
self.extract_packages(output)
}
fn get_explicitly_installed_packages(&self) -> Result<Packages> {
let mut cmd = Command::new(self.backend_info().binary);
let output = run_pip_command(&mut cmd, self.get_switches_explicit())?;
self.extract_packages(output)
}
fn make_dependency(&self, _packages: &Packages) -> Result<()> {
panic!("not supported by {}", self.binary)
}
}
fn run_pip_command(cmd: &mut Command, args: &[&str]) -> Result<Value> {
cmd.args(args);
let output = String::from_utf8(cmd.output()?.stdout)?;
let val: Value = serde_json::from_str(&output)?;
Ok(val)
}
fn extract_pacdef_packages(value: Value) -> Result<Packages> {
let result = value
.as_array()
.context("getting inner json array")?
.iter()
.map(|node| node["name"].as_str().expect("should always be a string"))
.map(Package::from)
.collect();
Ok(result)
}
fn extract_pacdef_packages_pipx(value: Value) -> Result<Packages> {
let result = value["venvs"]
.as_object()
.context("getting inner json object")?
.iter()
.map(|(name, _)| Package::from(name.as_str()))
.collect();
Ok(result)
}
+86
View File
@@ -0,0 +1,86 @@
use std::fs::read_to_string;
use std::io::ErrorKind::NotFound;
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use serde_json::Value;
use crate::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Rust {}
impl Rust {
pub fn new() -> Self {
Self {}
}
}
impl Default for Rust {
fn default() -> Self {
Self::new()
}
}
impl Backend for Rust {
fn backend_info(&self) -> BackendInfo {
BackendInfo {
binary: "cargo".to_string(),
section: "rust",
switches_info: &["search", "--limit", "1"],
switches_install: &["install"],
switches_noconfirm: &[],
switches_remove: &["uninstall"],
switches_make_dependency: None,
}
}
fn get_all_installed_packages(&self) -> Result<Packages> {
let file = get_crates_file().context("getting path to crates file")?;
let content = match read_to_string(file) {
Ok(string) => string,
Err(err) if err.kind() == NotFound => {
log::warn!("no crates file found for cargo. Assuming no crates installed yet.");
return Ok(Packages::new());
}
Err(err) => bail!(err),
};
let json: Value =
serde_json::from_str(&content).context("parsing JSON from crates file")?;
extract_packages(&json).context("extracting packages from crates file")
}
fn get_explicitly_installed_packages(&self) -> Result<Packages> {
self.get_all_installed_packages()
.context("getting all installed packages")
}
fn make_dependency(&self, _: &Packages) -> Result<()> {
panic!("not supported by {}", self.backend_info().binary)
}
}
fn extract_packages(json: &Value) -> Result<Packages> {
let result: Packages = json
.get("installs")
.context("get 'installs' field from json")?
.as_object()
.context("getting object")?
.into_iter()
.map(|(name, _)| name)
.map(|name| {
name.split_whitespace()
.next()
.expect("identifier is whitespace-delimited")
})
.map(|name| Package::try_from(name).expect("name is valid"))
.collect();
Ok(result)
}
fn get_crates_file() -> Result<PathBuf> {
let mut result = crate::path::get_cargo_home().context("getting cargo home dir")?;
result.push(".crates2.json");
Ok(result)
}
+58
View File
@@ -0,0 +1,58 @@
use super::types::RustupPackage;
pub fn toolchain_of_component_was_already_removed(
removed_toolchains: &[String],
component: &RustupPackage,
) -> bool {
removed_toolchains.contains(&component.toolchain)
}
pub fn install_components(line: &str, toolchain: &str, val: &mut Vec<String>) {
let mut chunks = line.splitn(3, '-');
let component = chunks.next().expect("Component name is empty!");
match component {
// these are the only components that have a single word name
"cargo" | "rustfmt" | "clippy" | "miri" | "rls" | "rustc" => {
val.push([toolchain, component].join("/"));
}
// all the others have two words hyphenated as component names
_ => {
let component = [
component,
chunks
.next()
.expect("No such component is managed by rustup"),
]
.join("-");
val.push([toolchain, component.as_str()].join("/"));
}
}
}
pub fn group_components_by_toolchains(components: Vec<RustupPackage>) -> Vec<Vec<RustupPackage>> {
let mut result = vec![];
let mut toolchains: Vec<String> = vec![];
for component in components {
let index = toolchains
.iter()
.enumerate()
.find(|(_, toolchain)| toolchain == &&component.toolchain)
.map(|(idx, _)| idx)
.unwrap_or_else(|| {
toolchains.push(component.toolchain.clone());
result.push(vec![]);
toolchains.len() - 1
});
result
.get_mut(index)
.expect(
"either the index already existed or we just pushed the element with that index",
)
.push(component);
}
result
}
+231
View File
@@ -0,0 +1,231 @@
mod helpers;
mod types;
use crate::cmd::run_external_command;
use crate::prelude::*;
use anyhow::{bail, Context, Result};
use std::process::Command;
use self::helpers::{
group_components_by_toolchains, install_components, toolchain_of_component_was_already_removed,
};
use self::types::{Repotype, RustupPackage};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Rustup {}
impl Rustup {
pub fn new() -> Self {
Self {}
}
}
impl Default for Rustup {
fn default() -> Self {
Self::new()
}
}
impl Backend for Rustup {
fn backend_info(&self) -> BackendInfo {
BackendInfo {
binary: "rustup".to_string(),
section: "rustup",
switches_install: &["component", "add"],
switches_info: &["component", "list", "--installed"],
switches_noconfirm: &[],
switches_remove: &["component", "remove"],
switches_make_dependency: None,
}
}
fn get_all_installed_packages(&self) -> Result<Packages> {
let toolchains_vec = self
.run_toolchain_command(Repotype::Toolchain.get_info_switches())
.context("Getting installed toolchains")?;
let toolchains: Packages = toolchains_vec
.iter()
.map(|name| ["toolchain", name].join("/").into())
.collect();
let components: Packages = self
.run_component_command(Repotype::Component.get_info_switches(), &toolchains_vec)
.context("Getting installed components")?
.iter()
.map(|name| ["component", name].join("/").into())
.collect();
let mut packages = Packages::new();
packages.extend(toolchains);
packages.extend(components);
Ok(packages)
}
fn get_explicitly_installed_packages(&self) -> Result<Packages> {
self.get_all_installed_packages()
.context("Getting all installed packages")
}
fn make_dependency(&self, _: &Packages) -> Result<()> {
panic!("Not supported by {}", self.backend_info().binary)
}
fn install_packages(&self, packages: &Packages, _: bool) -> Result<()> {
let packages = RustupPackage::from_pacdef_packages(packages)?;
let (toolchains, components) =
RustupPackage::sort_packages_into_toolchains_and_components(packages);
self.install_toolchains(toolchains)?;
self.install_components(components)?;
Ok(())
}
fn remove_packages(&self, packages: &Packages, _: bool) -> Result<()> {
let rustup_packages = RustupPackage::from_pacdef_packages(packages)?;
let (toolchains, components) =
RustupPackage::sort_packages_into_toolchains_and_components(rustup_packages);
let removed_toolchains = self.remove_toolchains(toolchains)?;
self.remove_components(components, removed_toolchains)?;
Ok(())
}
}
impl Rustup {
fn run_component_command(&self, args: &[&str], toolchains: &[String]) -> Result<Vec<String>> {
let mut val = Vec::new();
for toolchain in toolchains {
let mut cmd = Command::new(self.backend_info().binary);
cmd.args(args).arg(toolchain);
let output = String::from_utf8(cmd.output()?.stdout)?;
for component in output.lines() {
install_components(component, toolchain, &mut val);
}
}
Ok(val)
}
fn run_toolchain_command(&self, args: &[&str]) -> Result<Vec<String>> {
let mut cmd = Command::new(self.backend_info().binary);
cmd.args(args);
let output = String::from_utf8(cmd.output()?.stdout)?;
let mut val = Vec::new();
for line in output.lines() {
let toolchain = line.split('-').next();
match toolchain {
Some(name) => val.push(name.to_string()),
None => bail!("Toolchain name not provided!"),
}
}
Ok(val)
}
fn install_toolchains(&self, toolchains: Vec<RustupPackage>) -> Result<()> {
if toolchains.is_empty() {
return Ok(());
}
let mut cmd = Command::new(self.backend_info().binary);
cmd.args(Repotype::Toolchain.get_install_switches());
for toolchain in toolchains {
cmd.arg(&toolchain.toolchain);
}
run_external_command(cmd).context("installing toolchains")?;
Ok(())
}
fn install_components(&self, components: Vec<RustupPackage>) -> Result<()> {
if components.is_empty() {
return Ok(());
}
let components_by_toolchain = group_components_by_toolchains(components);
for components_for_one_toolchain in components_by_toolchain {
let mut cmd = Command::new(self.backend_info().binary);
cmd.args(Repotype::Component.get_install_switches());
let the_toolchain = &components_for_one_toolchain
.first()
.expect("will have at least one element")
.toolchain;
cmd.arg(the_toolchain);
for component_package in &components_for_one_toolchain {
let actual_component = component_package
.component
.as_ref()
.expect("constructor makes sure this is Some");
cmd.arg(actual_component);
}
run_external_command(cmd)
.with_context(|| format!("installing [{components_for_one_toolchain:?}]"))?;
}
Ok(())
}
fn remove_toolchains(&self, toolchains: Vec<RustupPackage>) -> Result<Vec<String>> {
let mut removed_toolchains = vec![];
if !toolchains.is_empty() {
let mut cmd = Command::new(self.backend_info().binary);
cmd.args(Repotype::Toolchain.get_remove_switches());
for toolchain_package in &toolchains {
let name = toolchain_package.toolchain.as_str();
cmd.arg(name);
removed_toolchains.push(name.to_string());
}
run_external_command(cmd)
.with_context(|| format!("removing toolchains [{toolchains:?}]"))?;
}
Ok(removed_toolchains)
}
fn remove_components(
&self,
components: Vec<RustupPackage>,
removed_toolchains: Vec<String>,
) -> Result<()> {
for component_package in components {
let mut cmd = Command::new(self.backend_info().binary);
cmd.args(Repotype::Component.get_remove_switches());
if toolchain_of_component_was_already_removed(&removed_toolchains, &component_package) {
continue;
}
cmd.arg(&component_package.toolchain);
cmd.arg(
component_package
.component
.as_ref()
.expect("the constructor ensures this cannot be None"),
);
run_external_command(cmd)
.with_context(|| format!("removing component {component_package:?}"))?;
}
Ok(())
}
}
+135
View File
@@ -0,0 +1,135 @@
use anyhow::{bail, Context, Result};
use crate::prelude::*;
#[derive(Debug)]
pub enum Repotype {
Toolchain,
Component,
}
/// A package as used exclusively in the rustup backend. Contrary to other packages, this does not
/// have an (optional) repository and a name, but is either a component or a toolchain, has a
/// toolchain version, and if it is a toolchain also a name.
#[derive(Debug)]
pub struct RustupPackage {
/// Whether it is a toolchain or a component.
pub repotype: Repotype,
/// The name of the toolchain this belongs to (stable, nightly, a pinned version)
pub toolchain: String,
/// If it is a toolchain, it will not have a component name.
/// If it is a component, this will be its name.
pub component: Option<String>,
}
impl Repotype {
fn try_from<T>(value: T) -> Result<Self>
where
T: AsRef<str>,
{
let value = value.as_ref();
let result = match value {
"toolchain" => Self::Toolchain,
"component" => Self::Component,
_ => bail!("{} is neither toolchain nor component", value),
};
Ok(result)
}
pub fn get_install_switches(self) -> Switches {
match self {
Self::Toolchain => &["toolchain", "install"],
Self::Component => &["component", "add", "--toolchain"],
}
}
pub fn get_remove_switches(self) -> Switches {
match self {
Self::Toolchain => &["toolchain", "uninstall"],
Self::Component => &["component", "remove", "--toolchain"],
}
}
pub fn get_info_switches(self) -> Switches {
match self {
Self::Toolchain => &["toolchain", "list"],
Self::Component => &["component", "list", "--installed", "--toolchain"],
}
}
}
impl RustupPackage {
/// Creates a new [`RustupPackage`].
///
/// # Panics
///
/// Panics if
/// - repotype is Toolchain and component is Some, or
/// - repotype is Component and component is None.
fn new(repotype: Repotype, toolchain: String, component: Option<String>) -> Self {
match repotype {
Repotype::Toolchain => assert!(component.is_none()),
Repotype::Component => assert!(component.is_some()),
};
Self {
repotype,
toolchain,
component,
}
}
pub fn sort_packages_into_toolchains_and_components(
packages: Vec<Self>,
) -> (Vec<Self>, Vec<Self>) {
let mut toolchains = vec![];
let mut components = vec![];
for package in packages {
match package.repotype {
Repotype::Toolchain => toolchains.push(package),
Repotype::Component => components.push(package),
}
}
(toolchains, components)
}
pub fn from_pacdef_packages(packages: &Packages) -> Result<Vec<Self>> {
let mut result = vec![];
for package in packages {
let rustup_package = Self::try_from(package).with_context(|| {
format!(
"converting pacdef package {} to rustup package",
package.name
)
})?;
result.push(rustup_package);
}
Ok(result)
}
}
impl TryFrom<&Package> for RustupPackage {
type Error = anyhow::Error;
fn try_from(package: &Package) -> Result<Self> {
let repo = package.repo.as_ref().context("getting repo from package")?;
let repotype = Repotype::try_from(repo).context("getting repotype")?;
let (toolchain, component) = match repotype {
Repotype::Toolchain => (package.name.to_string(), None),
Repotype::Component => {
let (toolchain, component) = package
.name
.split_once('/')
.context("splitting package into toolchain and component")?;
(toolchain.to_string(), Some(component.into()))
}
};
Ok(Self::new(repotype, toolchain, component))
}
}
+143
View File
@@ -0,0 +1,143 @@
use std::process::Command;
use anyhow::Result;
use regex::Regex;
use crate::backend::root::build_base_command_with_privileges;
use crate::cmd::run_external_command;
use crate::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Void {}
impl Void {
pub fn new() -> Self {
Self {}
}
}
impl Default for Void {
fn default() -> Self {
Self::new()
}
}
const INSTALL_BINARY: Text = "xbps-install";
const REMOVE_BINARY: Text = "xbps-remove";
const QUERY_BINARY: Text = "xbps-query";
const PKGDB_BINARY: Text = "xbps-pkgdb";
impl Backend for Void {
fn backend_info(&self) -> BackendInfo {
BackendInfo {
binary: "xbps-install".to_string(),
section: "void",
switches_info: &[],
switches_install: &["-S"],
switches_noconfirm: &["-y"],
switches_remove: &["-R"],
switches_make_dependency: Some(&["-m", "auto"]),
}
}
fn get_all_installed_packages(&self) -> Result<Packages> {
// Removes the package status and description from output
let re_str_1 = r"^ii |^uu |^hr |^\?\? | .*";
// Removes the package version from output
let re_str_2 = r"-[^-]*$";
let re1 = Regex::new(re_str_1)?;
let re2 = Regex::new(re_str_2)?;
let mut cmd = Command::new(QUERY_BINARY);
cmd.args(["-l"]);
let output = String::from_utf8(cmd.output()?.stdout)?;
let packages = output
.lines()
.map(|line| {
let result = re1.replace_all(line, "");
let result = re2.replace_all(&result, "");
result.to_string().into()
})
.collect();
Ok(packages)
}
fn get_explicitly_installed_packages(&self) -> Result<Packages> {
// Removes the package version from output
let re_str = r"-[^-]*$";
let re = Regex::new(re_str)?;
let mut cmd = Command::new(QUERY_BINARY);
cmd.args(["-m"]);
let output = String::from_utf8(cmd.output()?.stdout)?;
let packages = output
.lines()
.map(|line| {
let result = re.replace_all(line, "").to_string();
result.into()
})
.collect();
Ok(packages)
}
/// Install the specified packages.
fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = build_base_command_with_privileges(INSTALL_BINARY);
cmd.args(backend_info.switches_install);
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = build_base_command_with_privileges(REMOVE_BINARY);
cmd.args(backend_info.switches_remove);
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
fn make_dependency(&self, packages: &Packages) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = build_base_command_with_privileges(PKGDB_BINARY);
cmd.args(
backend_info
.switches_make_dependency
.expect("void should support make make dependency"),
);
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Show information from package manager for package.
fn show_package_info(&self, package: &Package) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(QUERY_BINARY);
cmd.args(backend_info.switches_info);
cmd.arg(format!("{package}"));
run_external_command(cmd)
}
}
+167
View File
@@ -0,0 +1,167 @@
use std::collections::BTreeMap;
use std::process::Command;
use anyhow::Result;
use crate::cmd::run_external_command;
use crate::prelude::*;
pub type Switches = &'static [&'static str];
pub type Text = &'static str;
/// A bundle of small of bits of info associated with a backend.
pub struct BackendInfo {
/// The binary name when calling the backend.
pub binary: String,
/// The name of the section in the group files.
pub section: Text,
/// CLI switches for the package manager to show information for
/// packages.
pub switches_info: Switches,
/// CLI switches for the package manager to install packages.
pub switches_install: Switches,
/// CLI switches for the package manager to perform `sync` and `clean` without
/// confirmation.
pub switches_noconfirm: Switches,
/// CLI switches for the package manager to remove packages.
pub switches_remove: Switches,
/// CLI switches for the package manager to mark packages as
/// dependency. This is not supported by all package managers.
pub switches_make_dependency: Option<Switches>,
}
/// The trait of a struct that is used as a backend.
#[enum_dispatch::enum_dispatch]
pub trait Backend {
/// Return the [`BackendInfo`] associated with this backend.
fn backend_info(&self) -> BackendInfo;
fn supports_as_dependency(&self) -> bool {
self.backend_info().switches_make_dependency.is_some()
}
/// Get all packages that are installed in the system.
///
/// # Errors
///
/// This function shall return an error if the installed packages cannot be
/// determined.
fn get_all_installed_packages(&self) -> Result<Packages>;
/// Get all packages that were installed in the system explicitly.
///
/// # Errors
///
/// This function shall return an error if the explicitly installed packages
/// cannot be determined.
fn get_explicitly_installed_packages(&self) -> Result<Packages>;
/// Assign each of the packages to an individual group by editing the
/// group files.
///
/// # Errors
///
/// Returns an Error if any of the groups fails to save their given packages.
fn assign_group(&self, to_assign: Vec<(Package, Group)>) -> Result<()> {
let mut group_package_map: BTreeMap<Group, Packages> = BTreeMap::new();
for (package, group) in to_assign {
group_package_map.entry(group).or_default().insert(package);
}
let section_header = format!("[{}]", self.backend_info().section);
for (group, packages) in group_package_map {
group.save_packages(&section_header, &packages)?;
}
Ok(())
}
/// Install the specified packages. If `noconfirm` is `true`, pass the corresponding
/// switch to the package manager. Return the [`ExitStatus`] from the package manager.
///
/// # Errors
///
/// This function will return an error if the package manager cannot be run or it
/// returns an error.
fn install_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(self.backend_info().binary);
cmd.args(backend_info.switches_install);
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Mark the packages as non-explicit / dependency using the underlying
/// package manager.
///
/// # Panics
///
/// This method shall panic when the backend does not support dependent packages.
///
/// # Errors
///
/// Returns an error if the external command fails.
fn make_dependency(&self, packages: &Packages) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(backend_info.binary);
if let Some(switches_make_dependency) = backend_info.switches_make_dependency {
cmd.args(switches_make_dependency);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Remove the specified packages.
///
/// # Errors
///
/// Returns an error if the external command fails.
fn remove_packages(&self, packages: &Packages, noconfirm: bool) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(backend_info.binary);
cmd.args(backend_info.switches_remove);
if noconfirm {
cmd.args(backend_info.switches_noconfirm);
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Show information from package manager for package.
///
/// # Errors
///
/// Returns an error if the external command fails.
fn show_package_info(&self, package: &Package) -> Result<()> {
let backend_info = self.backend_info();
let mut cmd = Command::new(backend_info.binary);
cmd.args(backend_info.switches_info);
cmd.arg(format!("{package}"));
run_external_command(cmd)
}
}
+107
View File
@@ -0,0 +1,107 @@
pub mod actual;
pub mod backend_trait;
mod root;
pub mod todo_per_backend;
use std::fmt::Display;
use crate::prelude::*;
use anyhow::{Context, Result};
/// A backend with its associated managed packages
pub struct ManagedBackend {
/// All managed packages for this backend, i.e. all packages
/// under the corresponding section in all group files.
pub packages: Packages,
pub any_backend: AnyBackend,
}
impl ManagedBackend {
/// Get unmanaged packages
///
/// # Errors
///
/// Returns an error if the backend fails to get the explicitly installed packages.
pub fn get_unmanaged_packages_sorted(&self) -> Result<Packages> {
let installed = self
.any_backend
.get_explicitly_installed_packages()
.context("could not get explicitly installed packages")?;
let diff = installed.difference(&self.packages).cloned().collect();
Ok(diff)
}
/// Get missing packages
///
/// # Errors
///
/// Returns an error if the backend fails to get the installed packages.
pub fn get_missing_packages_sorted(&self) -> Result<Packages> {
let installed = self
.any_backend
.get_all_installed_packages()
.context("could not get installed packages")?;
let diff = self.packages.difference(&installed).cloned().collect();
Ok(diff)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[enum_dispatch::enum_dispatch(Backend)]
pub enum AnyBackend {
#[cfg(feature = "arch")]
Arch(actual::arch::Arch),
#[cfg(feature = "debian")]
Debian(actual::debian::Debian),
Flatpak(Flatpak),
Fedora(Fedora),
Python(Python),
Rust(Rust),
Rustup(Rustup),
Void(Void),
}
impl AnyBackend {
/// Returns an iterator of every variant of backend.
pub fn all(config: &Config) -> impl Iterator<Item = Self> {
vec![
#[cfg(feature = "arch")]
Self::Arch(actual::arch::Arch::new(config)),
#[cfg(feature = "debian")]
Self::Debian(actual::debian::Debian::new()),
Self::Flatpak(Flatpak::new(config)),
Self::Fedora(Fedora::new()),
Self::Python(Python::new(config)),
Self::Rust(Rust::new()),
Self::Rustup(Rustup::new()),
Self::Void(Void::new()),
]
.into_iter()
}
pub fn from_section(section: &str, config: &Config) -> Result<Self> {
match section {
#[cfg(feature = "arch")]
"arch" => Ok(Self::Arch(actual::arch::Arch::new(config))),
#[cfg(feature = "debian")]
"debian" => Ok(Self::Debian(actual::debian::Debian::new())),
"flatpak" => Ok(Self::Flatpak(Flatpak::new(config))),
"fedora" => Ok(Self::Fedora(Fedora::new())),
"python" => Ok(Self::Python(Python::new(config))),
"rust" => Ok(Self::Rust(Rust::new())),
"rustup" => Ok(Self::Rustup(Rustup::new())),
"void" => Ok(Self::Void(Void::new())),
_ => Err(anyhow::anyhow!(
"no matching backend for the section: {section}"
)),
}
}
}
impl Display for AnyBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.backend_info().section)
}
}
+17
View File
@@ -0,0 +1,17 @@
use std::process::Command;
pub fn we_are_root() -> bool {
let uid = unsafe { libc::geteuid() };
uid == 0
}
pub fn build_base_command_with_privileges(binary: &str) -> Command {
let cmd = if we_are_root() {
Command::new(binary)
} else {
let mut cmd = Command::new("sudo");
cmd.arg(binary);
cmd
};
cmd
}
+104
View File
@@ -0,0 +1,104 @@
use std::fmt::Write;
use anyhow::{Context, Result};
use crate::prelude::*;
/// A vector of tuples containing a Backends and a vector of unmanaged packages
/// for that backend.
///
/// This struct is used to store a list of unmanaged packages or missing packages
/// for all backends.
#[derive(Debug)]
pub struct ToDoPerBackend(Vec<(AnyBackend, Packages)>);
impl ToDoPerBackend {
pub fn new() -> Self {
Self(vec![])
}
pub fn push(&mut self, item: (AnyBackend, Packages)) {
self.0.push(item);
}
pub fn iter(&self) -> impl Iterator<Item = &(AnyBackend, Packages)> {
self.0.iter()
}
pub fn nothing_to_do_for_all_backends(&self) -> bool {
self.0.iter().all(|(_, diff)| diff.is_empty())
}
pub fn install_missing_packages(&self, noconfirm: bool) -> Result<()> {
for (backend, packages) in &self.0 {
if packages.is_empty() {
continue;
}
backend
.install_packages(packages, noconfirm)
.with_context(|| format!("installing packages for {backend}"))?;
}
Ok(())
}
pub fn remove_unmanaged_packages(&self, noconfirm: bool) -> Result<()> {
for (backend, packages) in &self.0 {
if packages.is_empty() {
continue;
}
backend
.remove_packages(packages, noconfirm)
.with_context(|| format!("removing packages for {backend}"))?;
}
Ok(())
}
pub fn show(&self) -> Result<()> {
let mut parts = vec![];
for (backend, packages) in self.iter() {
if packages.is_empty() {
continue;
}
let mut segment = String::new();
segment.write_str(&format!("[{backend}]"))?;
for package in packages {
segment.write_str(&format!("\n{package}"))?;
}
parts.push(segment);
}
let mut output = String::new();
let mut iter = parts.iter().peekable();
while let Some(part) = iter.next() {
output.write_str(part)?;
if iter.peek().is_some() {
output.write_str("\n\n")?;
}
}
println!("{output}");
Ok(())
}
}
impl Default for ToDoPerBackend {
fn default() -> Self {
Self::new()
}
}
impl IntoIterator for ToDoPerBackend {
type Item = (AnyBackend, Packages);
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
+182
View File
@@ -0,0 +1,182 @@
//! The clap declarative command line interface
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[command(
version,
author,
arg_required_else_help(true),
subcommand_required(true),
disable_help_subcommand(true),
disable_version_flag(true)
)]
/// multi-backend declarative package manager for Linux
pub struct MainArguments {
#[command(subcommand)]
pub subcommand: MainSubcommand,
}
#[derive(Subcommand)]
pub enum MainSubcommand {
Group(GroupArguments),
Package(PackageArguments),
Version(VersionArguments),
}
#[derive(Args)]
#[command(
arg_required_else_help(true),
visible_alias("g"),
subcommand_required(true)
)]
/// manage groups
pub struct GroupArguments {
#[command(subcommand)]
pub group_action: GroupAction,
}
#[derive(Subcommand)]
pub enum GroupAction {
Edit(EditGroupAction),
Export(ExportGroupAction),
Import(ImportGroupAction),
List(ListGroupAction),
New(NewGroupAction),
Remove(RemoveGroupAction),
Show(ShowGroupAction),
}
#[derive(Args)]
#[command(arg_required_else_help(true), visible_alias("ed"))]
/// edit one or more existing group
pub struct EditGroupAction {
#[arg(required(true), num_args(1..))]
/// a previously imported group
pub edit_groups: Vec<String>,
}
#[derive(Args)]
#[command(arg_required_else_help(true), visible_alias("ex"))]
/// export one or more group files
pub struct ExportGroupAction {
#[arg(required(true), num_args(1..))]
/// the file to export as group
pub export_groups: Vec<String>,
#[arg(short, long)]
/// (optional) the directory under which to save the group
pub output_dir: Option<PathBuf>,
#[arg(short, long)]
/// overwrite output files if they exist
pub force: bool,
}
#[derive(Args)]
#[command(arg_required_else_help(true), visible_alias("i"))]
/// import one or more group files
pub struct ImportGroupAction {
#[arg(required(true), num_args(1..))]
/// the file to import as group
pub import_groups: Vec<String>,
}
#[derive(Args)]
#[command(visible_alias("l"))]
/// list names of imported groups
pub struct ListGroupAction {}
#[derive(Args)]
#[command(arg_required_else_help(true), visible_alias("n"))]
/// create new group files
pub struct NewGroupAction {
#[arg(required(true), num_args(1..))]
/// the groups to create
pub new_groups: Vec<String>,
#[arg(short, long)]
/// edit the new group files after creation
pub edit: bool,
}
#[derive(Args)]
#[command(arg_required_else_help(true), visible_alias("r"))]
/// remove one or more previously imported groups
pub struct RemoveGroupAction {
#[arg(required(true), num_args(1..))]
/// a previously imported group that will be removed
pub remove_groups: Vec<String>,
}
#[derive(Args)]
#[command(arg_required_else_help(true), visible_alias("s"))]
/// show packages under an imported group
pub struct ShowGroupAction {
#[arg(required(true), num_args(1..))]
/// group file(s) to show
pub show_groups: Vec<String>,
}
#[derive(Args)]
#[command(
arg_required_else_help(true),
subcommand_required(true),
visible_alias("p")
)]
/// manage packages
pub struct PackageArguments {
#[command(subcommand)]
pub package_action: PackageAction,
}
#[derive(Subcommand)]
pub enum PackageAction {
Clean(CleanPackageAction),
Review(ReviewPackageAction),
Search(SearchPackageAction),
Sync(SyncPackageAction),
Unmanaged(UnmanagedPackageAction),
}
#[derive(Args)]
#[command(visible_alias("c"))]
/// remove unmanaged packages
pub struct CleanPackageAction {
#[arg(long)]
/// do not ask for any confirmation
pub no_confirm: bool,
}
#[derive(Args)]
#[command(visible_alias("r"))]
/// review unmanaged packages
pub struct ReviewPackageAction {}
#[derive(Args)]
#[command(arg_required_else_help(true), visible_alias("se"))]
/// search for packages which match a provided regex
pub struct SearchPackageAction {
#[arg(required(true))]
/// the regular expression the package must match
pub regex: String,
}
#[derive(Args)]
#[command(visible_alias("sy"))]
/// install packages from all imported groups
pub struct SyncPackageAction {
#[arg(long)]
/// do not ask for any confirmation
pub no_confirm: bool,
}
#[derive(Args)]
#[command(visible_alias("u"))]
/// show explicitly installed packages not managed by pacdef
pub struct UnmanagedPackageAction {}
#[derive(Args)]
pub struct VersionArguments {}
+54
View File
@@ -0,0 +1,54 @@
use std::path::Path;
use std::process::Command;
use anyhow::{ensure, Context, Result};
use crate::env::{get_editor, should_print_debug_info};
/// Run the editor and pass the provided files as arguments. The workdir is set
/// to the parent of the first file.
pub fn run_edit_command<P>(files: &[P]) -> Result<()>
where
P: AsRef<Path>,
{
fn inner(files: &[&Path]) -> Result<()> {
let mut cmd = Command::new(get_editor().context("getting suitable editor")?);
cmd.current_dir(
files[0]
.parent()
.context("getting parent dir of first file argument")?,
);
for f in files {
cmd.arg(f.to_string_lossy().to_string());
}
run_external_command(cmd)
}
let files: Vec<_> = files.iter().map(|p| p.as_ref()).collect();
inner(&files)
}
/// Run an external command. Use the anyhow framework to bubble up errors if they occur. Will print
/// the full command to be executed when pacdef is in debug mode.
///
/// # Errors
///
/// This function will return an error if the command cannot be run or if it returns a non-zero
/// exit status. In case of an error the full command will be part of the error message.
pub fn run_external_command(mut cmd: Command) -> Result<()> {
if should_print_debug_info() {
println!("will run the following command");
dbg!(&cmd);
}
let exit_status = cmd
.status()
.with_context(|| format!("running command [{cmd:?}]"))?;
let success = exit_status.success();
ensure!(
success,
"command [{cmd:?}] returned non-zero exit status {success}"
);
Ok(())
}
+101
View File
@@ -0,0 +1,101 @@
use std::fs::{create_dir_all, read_to_string, File};
use std::io::{ErrorKind, Write};
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::prelude::*;
// Update the master README if fields change.
/// Config for the program, as listed in `$XDG_CONFIG_HOME/pacdef/pacdef.toml`.
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
/// The AUR helper to use for Arch Linux.
#[serde(default = "aur_helper")]
pub aur_helper: String,
/// Additional arguments to pass to `aur_helper` when removing a package.
#[serde(default)]
pub aur_rm_args: Vec<String>,
/// Install Flatpak packages system-wide
#[serde(default = "yes")]
pub flatpak_systemwide: bool,
/// Warn the user when a group is not a symlink.
#[serde(default = "yes")]
pub warn_not_symlinks: bool,
/// Backends the user does not want to use even though the binary exists.
#[serde(default)]
pub disabled_backends: Vec<String>,
/// Choose whether to use pipx instead of pip for python package management
#[serde(default = "pip")]
pub pip_binary: String,
}
fn yes() -> bool {
true
}
fn aur_helper() -> String {
"paru".into()
}
fn pip() -> String {
"pip".into()
}
impl Config {
/// Load the config from the associated file.
///
/// # Errors
///
/// This function will return an error if the config file exists but cannot be
/// read, its contents are not UTF-8, or the file is malformed.
pub fn load(config_file: &Path) -> Result<Self> {
let from_file = read_to_string(config_file);
let content = match from_file {
Ok(content) => content,
Err(e) => {
if e.kind() == ErrorKind::NotFound {
bail!(Error::ConfigFileNotFound)
}
bail!("unexpected error occurred: {e:?}");
}
};
toml::from_str(&content).context("parsing toml config")
}
/// Save the instance of [`Config`] to disk.
///
/// # Errors
///
/// This function will return an error if the config file cannot be saved to disk.
pub fn save(&self, file: &Path) -> Result<()> {
let content = toml::to_string(&self).context("converting Config to toml")?;
let parent = file.parent().context("getting parent of config dir")?;
if !parent.is_dir() {
create_dir_all(parent)
.with_context(|| format!("creating dir {}", parent.to_string_lossy()))?;
}
let mut output = File::create(file).context("creating default config file")?;
write!(output, "{content}").context("writing default config")?;
Ok(())
}
}
impl Default for Config {
fn default() -> Self {
Self {
aur_helper: "paru".into(),
aur_rm_args: vec![],
flatpak_systemwide: true,
warn_not_symlinks: true,
disabled_backends: vec![],
pip_binary: "pip".into(),
}
}
}
+566
View File
@@ -0,0 +1,566 @@
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::{copy, create_dir_all, remove_file, rename, File};
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, ensure, Context, Result};
use const_format::formatcp;
use crate::cmd::{run_edit_command, run_external_command};
use crate::env::{get_editor, should_print_debug_info};
use crate::grouping::group::groups_to_backend_packages;
use crate::path::{binary_in_path, get_absolutized_file_paths, get_group_dir};
use crate::prelude::*;
use crate::review::review;
use crate::search::search_packages;
use crate::ui::get_user_confirmation;
impl MainArguments {
/// Run the action that was provided by the user as first argument.
///
/// For convenience sake, all called functions take a `&self` argument, even if
/// these are not strictly required.
///
/// # Errors
///
/// This function propagates errors from the underlying functions.
pub fn run(self, groups: &Groups, config: &Config) -> Result<()> {
match self.subcommand {
MainSubcommand::Group(group) => group.run(groups),
MainSubcommand::Package(package) => package.run(groups, config),
MainSubcommand::Version(version) => version.run(config),
}
}
}
impl VersionArguments {
/// If the crate was compiled from git, return `pacdef, <version> (<hash>)`.
/// Otherwise return `pacdef, <version>`.
fn run(self, config: &Config) -> Result<()> {
let backends = get_included_backends(config);
let mut result = format!("pacdef, version: {}\n", get_version_string());
result.push_str("supported backends:");
for b in backends {
result.push_str("\n ");
result.push_str(b);
}
println!("{}", result);
Ok(())
}
}
impl GroupArguments {
fn run(self, groups: &Groups) -> Result<()> {
match self.group_action {
GroupAction::Edit(edit) => edit.run(groups),
GroupAction::Export(export) => export.run(groups),
GroupAction::Import(import) => import.run(),
GroupAction::List(list) => list.run(groups),
GroupAction::New(new) => new.run(),
GroupAction::Remove(remove) => remove.run(groups),
GroupAction::Show(show) => show.run(groups),
}
}
}
impl EditGroupAction {
fn run(self, groups: &Groups) -> Result<()> {
let group_files: Vec<_> = find_groups_by_name(&self.edit_groups, groups)
.context("getting group files for args")?
.into_iter()
.map(|g| g.path.as_path())
.collect();
let mut cmd = Command::new(get_editor().context("getting suitable editor")?);
cmd.current_dir(
group_files[0]
.parent()
.context("getting parent dir of first file argument")?,
);
for group_file in group_files {
cmd.arg(group_file.to_string_lossy().to_string());
}
run_external_command(cmd)?;
Ok(())
}
}
impl ExportGroupAction {
/// Export pacdef groups by moving a group file to an output dir. The path of the
/// group file relative to the group base dir will be replicated under the output
/// directory.
///
/// By default, the output dir is the current working directory. `output_dir` may be
/// specified to the path of another directory, in which case `output_dir` must
/// exist.
///
/// If `force` is `true`, the output file will be overwritten if it exists.
///
/// # Errors
///
/// This function will return an error if
/// - the group file is a symlink (in which case exporting makes no sense),
/// - the output file exists and `force` is not `true`, or
/// - the user does not have permission to write to the output dir.
///
/// # Limitations
///
/// At the moment we cannot export nested group dirs. The user would have to
/// export every group file individually, or use a shell glob.
fn run(self, groups: &Groups) -> Result<()> {
let groups = find_groups_by_name(&self.export_groups, groups)?;
let output_dir = match self.output_dir {
Some(p) => p,
None => current_dir().context("no output dir specified, getting current directory")?,
};
ensure!(
output_dir.exists() && output_dir.is_dir(),
"output must be a directory and exist"
);
for group in &groups {
ensure!(!&group.path.is_symlink(), "cannot export symlinks");
let mut exported_path = output_dir.clone();
exported_path.push(PathBuf::from(&group.name));
ensure!(
!self.force && !exported_path.exists(),
"{exported_path:?} already exists"
);
create_parent(&exported_path)
.with_context(|| format!("creating parent dir of {exported_path:?}"))?;
move_file(&group.path, &exported_path).context("moving file")?;
symlink(&exported_path, &group.path).context("creating symlink to exported file")?;
}
Ok(())
}
}
impl ImportGroupAction {
fn run(self) -> Result<()> {
let files = get_absolutized_file_paths(&self.import_groups)?;
let groups_dir = get_group_dir()?;
for target in files {
let target_name = target
.file_name()
.context("path should not end in '..'")?
.to_str()
.context("filename is not valid UTF-8")?;
if !target.exists() {
log::warn!("file {target_name} does not exist, skipping");
continue;
}
let mut link = groups_dir.clone();
link.push(target_name);
if link.exists() {
log::warn!("group {target_name} already exists, skipping");
} else {
symlink(target, link)?;
}
}
Ok(())
}
}
impl ListGroupAction {
/// Print the alphabetically sorted names of all groups to stdout.
///
/// This methods cannot return an error. It returns a `Result` to be consistent
/// with other methods.
fn run(self, groups: &Groups) -> Result<()> {
let mut vec: Vec<_> = groups.iter().collect();
vec.sort_unstable();
for g in vec {
println!("{}", g.name);
}
Ok(())
}
}
impl NewGroupAction {
/// Create empty group files.
///
/// If `edit` is `true`, the editor will be run to edit the files after they are
/// created.
///
/// # Errors
///
/// This function will return an error if
/// - a group name is `.` or `..`,
/// - a group with the same name already exists,
/// - the editor cannot be run, or
/// - if we do not have permission to write to the group dir.
fn run(&self) -> Result<()> {
let group_path = get_group_dir()?;
// prevent group names that resolve to directories
for new_group in &self.new_groups {
ensure!(
new_group != "." && new_group != "..",
Error::InvalidGroupName(new_group.clone())
);
}
let paths: Vec<_> = self
.new_groups
.iter()
.map(|name| {
let mut base = group_path.clone();
base.push(name);
base
})
.collect();
for file in &paths {
ensure!(!file.exists(), Error::GroupAlreadyExists(file.clone()));
}
for file in &paths {
File::create(file)?;
}
if self.edit {
run_edit_command(&paths).context("running editor")?;
}
Ok(())
}
}
impl RemoveGroupAction {
fn run(self, groups: &Groups) -> Result<()> {
let found = find_groups_by_name(&self.remove_groups, groups)?;
for group in found {
remove_file(&group.path)?;
}
Ok(())
}
}
impl ShowGroupAction {
fn run(self, groups: &Groups) -> Result<()> {
let mut errors = vec![];
let mut found_groups = vec![];
// make sure all args exist before doing anything
for show_group in &self.show_groups {
let possible_group = groups.iter().find(|group| group.name == *show_group);
let Some(group) = possible_group else {
errors.push(show_group.to_string());
continue;
};
found_groups.push(group);
}
// return an error if any arg was not found
ensure!(errors.is_empty(), Error::MultipleGroupsNotFound(errors));
let show_more_than_one_group = self.show_groups.len() > 1;
let mut iter = found_groups.into_iter().peekable();
while let Some(group) = iter.next() {
if show_more_than_one_group {
let name = &group.name;
println!("{name}");
for _ in 0..name.len() {
print!("-");
}
println!();
}
println!("{group}");
if iter.peek().is_some() {
println!();
}
}
Ok(())
}
}
impl PackageArguments {
fn run(self, groups: &Groups, config: &Config) -> Result<()> {
match self.package_action {
PackageAction::Clean(clean) => clean.run(groups, config),
PackageAction::Review(review) => review.run(groups, config),
PackageAction::Search(search) => search.run(groups),
PackageAction::Sync(sync) => sync.run(groups, config),
PackageAction::Unmanaged(unmanaged) => unmanaged.run(groups, config),
}
}
}
impl CleanPackageAction {
fn run(self, groups: &Groups, config: &Config) -> Result<()> {
let to_remove = get_unmanaged_packages(groups, config)?;
if to_remove.nothing_to_do_for_all_backends() {
println!("nothing to do");
return Ok(());
}
println!("Would remove the following packages:\n");
to_remove.show().context("printing things to do")?;
println!();
if self.no_confirm {
println!("proceeding without confirmation");
} else if !get_user_confirmation()? {
return Ok(());
}
to_remove.remove_unmanaged_packages(self.no_confirm)
}
}
impl ReviewPackageAction {
fn run(self, groups: &Groups, config: &Config) -> Result<()> {
review(get_unmanaged_packages(groups, config)?, groups)
}
}
impl SearchPackageAction {
fn run(self, groups: &Groups) -> Result<()> {
search_packages(&self.regex, groups)
}
}
impl SyncPackageAction {
fn run(self, groups: &Groups, config: &Config) -> Result<()> {
let to_install = get_missing_packages(groups, config)?;
if to_install.nothing_to_do_for_all_backends() {
println!("nothing to do");
return Ok(());
}
println!("Would install the following packages:\n");
to_install.show().context("printing things to do")?;
println!();
if self.no_confirm {
println!("proceeding without confirmation");
} else if !get_user_confirmation()? {
return Ok(());
}
to_install.install_missing_packages(self.no_confirm)
}
}
impl UnmanagedPackageAction {
fn run(self, groups: &Groups, config: &Config) -> Result<()> {
let unmanaged_per_backend = &get_unmanaged_packages(groups, config)?;
if unmanaged_per_backend.nothing_to_do_for_all_backends() {
return Ok(());
}
unmanaged_per_backend
.show()
.context("printing things to do")
}
}
fn get_missing_packages(groups: &Groups, config: &Config) -> Result<ToDoPerBackend> {
let backend_packages = groups_to_backend_packages(groups, config)?;
let mut to_install = ToDoPerBackend::new();
for (any_backend, packages) in &backend_packages {
let backend_info = any_backend.backend_info();
if config
.disabled_backends
.contains(&backend_info.section.to_string())
{
continue;
}
if !binary_in_path(&backend_info.binary)? {
continue;
}
let managed_backend = ManagedBackend {
packages: packages.clone(),
any_backend: any_backend.clone(),
};
match managed_backend.get_missing_packages_sorted() {
Ok(diff) => to_install.push((any_backend.clone(), diff)),
Err(error) => show_backend_query_error(&error, any_backend),
};
}
Ok(to_install)
}
/// Get a list of unmanaged packages per backend.
///
/// This method loops through all enabled `Backend`s whose binary is in `PATH`.
///
/// # Errors
///
/// This function will propagate errors from the individual backends.
fn get_unmanaged_packages(groups: &Groups, config: &Config) -> Result<ToDoPerBackend> {
let backend_packages = groups_to_backend_packages(groups, config)?;
let mut todo_unmanaged = ToDoPerBackend::new();
for (any_backend, packages) in &backend_packages {
let backend_info = any_backend.backend_info();
if config
.disabled_backends
.contains(&backend_info.section.to_string())
{
continue;
}
if !binary_in_path(&backend_info.binary)? {
continue;
}
let managed_backend = ManagedBackend {
packages: packages.clone(),
any_backend: any_backend.clone(),
};
match managed_backend.get_unmanaged_packages_sorted() {
Ok(unmanaged) => todo_unmanaged.push((any_backend.clone(), unmanaged)),
Err(error) => show_backend_query_error(&error, any_backend),
};
}
Ok(todo_unmanaged)
}
/// Create the parent directory of the `path` if that directory does not exist.
///
/// Do nothing otherwise.
///
/// # Panics
///
/// Panics if the path does not have a parent.
///
/// # Errors
///
/// This function will propagate errors from [`std::fs::create_dir_all`].
fn create_parent(path: &Path) -> Result<()> {
let parent = &path.parent().expect("this should never be /");
if !parent.is_dir() {
create_dir_all(parent).context("creating parent dir")?;
}
Ok(())
}
/// Move a file from one place to another.
///
/// At first [`std::fs::rename`] is used, which fails if `from` and `to` reside under
/// different filesystems. In case that happens, we will resort to copying the files
/// and then removing `from`.
///
/// # Errors
///
/// This function will return an error if we lack permission to write the file.
fn move_file<P, Q>(from: P, to: Q) -> Result<()>
where
P: AsRef<Path>,
Q: AsRef<Path>,
{
let from = from.as_ref();
let to = to.as_ref();
match rename(from, to) {
Ok(_) => (),
Err(e) => {
// CrossesDevices is nightly. See rust #86442.
// We cannot check that here, so we just assume that
// that would be the error if permissions are okay.
if e.kind() == std::io::ErrorKind::PermissionDenied {
bail!(e);
}
copy(from, to).with_context(|| format!("copying {from:?} to {to:?}"))?;
remove_file(from).with_context(|| format!("deleting {from:?}"))?;
}
};
Ok(())
}
/// For the provided names, get the group with the same name.
///
/// # Errors
///
/// This function will return an error if any of the file names do not match one
/// of group names.
fn find_groups_by_name<'a>(names: &[String], groups: &'a Groups) -> Result<Vec<&'a Group>> {
let name_group_map: HashMap<&str, &Group> =
groups.iter().map(|g| (g.name.as_str(), g)).collect();
let mut result = Vec::new();
for file in names {
match name_group_map.get(file.as_str()) {
Some(group) => {
result.push(*group);
}
None => bail!(Error::GroupFileNotFound(file.clone())),
}
}
Ok(result)
}
/// Show the error chain for an error that has occurred when a backend was queried
/// if the `RUST_BACKTRACE` env variable is set to `1` or `full`.
fn show_backend_query_error(error: &anyhow::Error, backend: &AnyBackend) {
if should_print_debug_info() {
log::warn!(
"skipping backend '{backend}': {}",
error.chain().map(|x| x.to_string()).collect::<String>()
);
} else {
log::warn!("skipping backend '{backend}': {error}");
}
}
/// If the crate was compiled from git, return `<version> (<hash>)`. Otherwise
/// return `<version>`.
pub const fn get_version_string() -> &'static str {
const VERSION: &str = env!("CARGO_PKG_VERSION");
const HASH: &str = env!("GIT_HASH");
if HASH.is_empty() {
VERSION
} else {
formatcp!("{VERSION} ({HASH})")
}
}
/// Get a vector with the names of all backends, sorted alphabetically.
fn get_included_backends(config: &Config) -> Vec<&'static str> {
let mut result = vec![];
for backend in AnyBackend::all(config) {
result.push(backend.backend_info().section);
}
result.sort_unstable();
result
}
+25
View File
@@ -0,0 +1,25 @@
use std::env::var;
use anyhow::{anyhow, Result};
pub fn get_editor() -> Result<String> {
check_vars_in_order(&["EDITOR", "VISUAL"]).ok_or_else(|| anyhow!("could not find editor"))
}
fn check_vars_in_order(vars: &[&str]) -> Option<String> {
vars.iter().find_map(|v| var(v).ok())
}
fn get_single_var(variable: &str) -> Option<String> {
var(variable).ok()
}
/// Determine if debug information should be printed. Will return `true` if RUST_BACKTRACE equals
/// "s" or "full".
pub fn should_print_debug_info() -> bool {
match get_single_var("RUST_BACKTRACE") {
Some(value) if ["s", "full"].contains(&value.as_str()) => true,
Some(_) => false,
None => false,
}
}
+44
View File
@@ -0,0 +1,44 @@
use std::error::Error as ErrorTrait;
use std::fmt::Display;
use std::path::PathBuf;
/// Error types for pacdef.
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
/// Package search yields no results.
NoPackagesFound,
/// Config file not found.
ConfigFileNotFound,
/// Group file not found.
GroupFileNotFound(String),
/// Group already exists.
GroupAlreadyExists(PathBuf),
/// Invalid group name ('.' or '..')
InvalidGroupName(String),
/// Multiple groups not found.
MultipleGroupsNotFound(Vec<String>),
}
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoPackagesFound => write!(f, "no packages matching query"),
Self::ConfigFileNotFound => write!(f, "config file not found"),
Self::GroupFileNotFound(name) => write!(f, "group file '{name}' not found"),
Self::GroupAlreadyExists(path) => {
write!(f, "group file '{}' already exists", path.to_string_lossy())
}
Self::InvalidGroupName(name) => write!(f, "group name '{name}' is not valid"),
Self::MultipleGroupsNotFound(vec) => {
write!(
f,
"could not find the following groups: [{}]",
vec.join(", ")
)
}
}
}
}
impl ErrorTrait for Error {}
+347
View File
@@ -0,0 +1,347 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Display;
use std::fs::{create_dir, read_to_string, File};
use std::hash::Hash;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use path_absolutize::Absolutize;
use walkdir::WalkDir;
use crate::path::get_relative_path;
use crate::prelude::*;
/// A set of groups
pub type Groups = BTreeSet<Group>;
pub type BackendPackages = BTreeMap<AnyBackend, Packages>;
pub fn groups_to_backend_packages(groups: &Groups, config: &Config) -> Result<BackendPackages> {
let mut backend_packages = BackendPackages::new();
for group in groups {
for section in &group.sections {
backend_packages
.entry(AnyBackend::from_section(&section.name, config)?)
.or_default()
.extend(section.packages.iter().cloned());
}
}
Ok(backend_packages)
}
/// Representation of a group file.
#[derive(Debug, Clone)]
pub struct Group {
/// Name of the group (file name from which it was read, relative to the group
/// base dir).
pub name: String,
/// The sections in the file which in turn hold the packages.
pub sections: Sections,
/// The absolute path of the original file.
pub path: PathBuf,
/// Whether the main program should warn this group being loaded from a symlink.
pub warn_symlink: bool,
}
impl Group {
/// Load all group files from the pacdef group dir by traversing through the group dir.
///
/// This method will print a warning if `warn_not_symlinks` is true and a group
/// file is not a symlink or does not reside under a symlink dir.
///
/// # Errors
///
/// This function will return an error if any of the files under `group_dir` cannot
/// be accessed.
pub fn load(group_dir: &Path, warn_not_symlinks: bool) -> Result<Groups> {
let mut result = Groups::new();
if !group_dir.is_dir() {
// we only need to create the innermost dir. The rest was already created from when
// we loaded the config
create_dir(group_dir).context("group dir does not exist, creating")?;
}
let mut symlink_dirs = Vec::new();
for entry in WalkDir::new(group_dir).follow_links(true).min_depth(1) {
let file = entry?;
let path = file.path().absolutize_from(group_dir)?.to_path_buf();
if path.is_dir() {
if warn_not_symlinks && path.is_symlink() {
symlink_dirs.push(path);
}
continue;
}
let should_warn_about_symlinks = warn_not_symlinks
&& !path.is_symlink()
&& !is_child_of_any_dir(&path, &symlink_dirs);
let group = Self::try_from(path.as_path(), group_dir, should_warn_about_symlinks)
.with_context(|| format!("reading group file {path:?}"))?;
result.insert(group);
}
Ok(result)
}
}
/// Check if `path` is a child of any of the [`PathBuf`] in `dirs`. All paths should be
/// absolute.
fn is_child_of_any_dir(path: &Path, dirs: &[PathBuf]) -> bool {
dirs.iter()
// pair `path` with every item from `symlink_dirs`
.zip([path].iter().cycle())
// for every pair, test if all path elements of the dir are present in the file path
.map(|(dir, file)| {
dir.iter()
.zip(file.iter())
.map(|(dir_elem, file_elem)| dir_elem == file_elem)
.all(|path_element_equal| path_element_equal)
})
// it suffices if that holds for any of the generated pairs
.any(|is_child| is_child)
}
impl PartialOrd for Group {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Group {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.name.cmp(&other.name)
}
}
impl Hash for Group {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.name.hash(state);
}
}
impl PartialEq for Group {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
}
}
impl Eq for Group {
fn assert_receiver_is_total_eq(&self) {}
}
impl Group {
/// Load the group from `path`. Determine the name from the path relative to the
/// `group_dir`.
///
/// # Warnings
///
/// This function will print a warning if any section in the group file cannot
/// be processed, or the file contains no sections.
///
/// # Errors
///
/// This function will return an error if the group file cannot be read.
fn try_from<P>(path: P, group_dir: P, warn_symlink: bool) -> Result<Self>
where
P: AsRef<Path>,
{
let path = path.as_ref();
let content = read_to_string(path).context("reading file content")?;
let name = extract_group_name(path, group_dir.as_ref());
let mut lines = content.lines().peekable();
let mut sections = Sections::new();
while lines.peek().is_some() {
let result = Section::try_from_lines(&mut lines).context("reading section");
match result {
Ok(section) => {
sections.insert(section);
}
Err(e) => {
let err = e.root_cause();
log::warn!("could not process a section under group '{name}': {err}");
}
}
}
if sections.is_empty() {
log::warn!("no sections found in group '{name}'");
}
let path = path.into();
Ok(Self {
name,
sections,
path,
warn_symlink,
})
}
/// Add the new `packages` to the group file under the section `section_header`. If
/// the section header does not yet exist, it is created. The packages are written
/// in the provided order immediately after the header.
///
/// # Errors
///
/// This function returns an error if the group file cannot be read, or if the
/// file cannot be written to.
pub fn save_packages(&self, section_header: &str, packages: &Packages) -> Result<()> {
let mut content = read_to_string(&self.path)
.with_context(|| format!("reading existing file contents from {:?}", &self.path))?;
if content.contains(section_header) {
write_packages_to_existing_section(&mut content, section_header, packages)
.context("existing section")?;
} else {
add_new_section_with_packages(&mut content, section_header, packages);
}
let mut file = File::create(&self.path)
.with_context(|| format!("creating descriptor to output file {:?}", &self.path))?;
write!(file, "{content}").with_context(|| format!("writing file {:?}", &self.path))
}
}
/// Extract the group name from its path relative to the group path.
/// All subdirectories are concatenated using `'/'`.
///
/// # Example
///
/// If the group dir is `~/.config/pacdef/groups`, and the group file is
/// `~/.config/pacdef/groups/generic/base`, then the group name is
/// `"generic/base"`.
///
/// # Panics
///
/// Panics if `path` and `group_path` are identical.
fn extract_group_name(path: &Path, group_path: &Path) -> String {
get_relative_path(path, group_path)
.iter()
.map(|p| p.to_string_lossy().to_string())
.reduce(|mut a, b| {
a.push('/');
a.push_str(&b);
a
})
.expect("must have at least one element")
}
impl Display for Group {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut sections: Vec<_> = self.sections.iter().collect();
sections.sort_unstable();
let mut iter = sections.into_iter().peekable();
while let Some(section) = iter.next() {
section.fmt(f)?;
if iter.peek().is_some() {
f.write_str("\n\n")?;
}
}
Ok(())
}
}
/// Add some packages to an existing section in the content of a group file.
///
/// # Errors
///
/// This function will return an error if the header cannot be found in
/// the file content.
fn write_packages_to_existing_section(
group_file_content: &mut String,
section_header: &str,
packages: &Packages,
) -> Result<()> {
let idx_of_first_package_line_in_section =
find_first_package_line_in_section(group_file_content, section_header)?;
let after = group_file_content.split_off(idx_of_first_package_line_in_section);
for p in packages {
group_file_content.push_str(&format!("{p}\n"));
}
group_file_content.push_str(&after);
Ok(())
}
/// Find the index to the first line in `group_file_content` after the
/// given `section_header`.
///
/// # Errors
///
/// This function will return an error if the `section_header` does not
/// exist in `group_file_content`, or if the line containing the
/// `section_header` is not newline-terminated.
fn find_first_package_line_in_section(
group_file_content: &str,
section_header: &str,
) -> Result<usize> {
let section_start = group_file_content
.find(section_header)
.context("finding first package after section header")?;
let distance_to_next_newline = group_file_content[section_start..]
.find('\n')
.context("getting next newline")?;
Ok(section_start + distance_to_next_newline + 1) // + 1 to be after the newline
}
/// Append a new section with some packages to the content of a group file.
fn add_new_section_with_packages(
group_file_content: &mut String,
section_header: &str,
packages: &Packages,
) {
group_file_content.push('\n');
group_file_content.push_str(section_header);
group_file_content.push('\n');
for p in packages {
group_file_content.push_str(&format!("{p}\n"));
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
#[test]
fn extract_group_name() {
let path = PathBuf::from("/a/b/c/d/e");
let group_path = PathBuf::from("/a/b/c");
let expected = String::from("d/e");
let result = super::extract_group_name(&path, &group_path);
assert_eq!(result, expected);
}
#[test]
fn is_child_of_any_symlink_dir() {
let path = PathBuf::from("/a/b/c/d/e");
let dir = PathBuf::from("/z");
let mut symlink_dirs = vec![dir];
let result = super::is_child_of_any_dir(&path, &symlink_dirs);
assert!(!result);
symlink_dirs.push(PathBuf::from("/a/b/c"));
let result = super::is_child_of_any_dir(&path, &symlink_dirs);
assert!(result);
}
}
+14
View File
@@ -0,0 +1,14 @@
/*!
This module reflects the relationship between groups, sections / backends and
packages.
A [`Group`] contains one (strictly spoken zero, but this doesn't make sense) or
more [`Section`]s, which relate to individual backends. Each section contains
one (strictly spoken zero) or more [`Package`]s. On start-up `pacdef` will load
all groups using [`Group::load`], which in turn will get all packages from all
sections.
*/
pub mod group;
pub mod package;
pub mod section;
+134
View File
@@ -0,0 +1,134 @@
use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::fmt::{Display, Write};
pub type Packages = BTreeSet<Package>;
/// A struct to represent a single package, consisting of a `name`, and
/// optionally a `repo`.
#[derive(Debug, Clone)]
pub struct Package {
/// The name of the package
pub name: String,
/// Optionally, which repository the package belongs to
pub repo: Option<String>,
}
fn remove_comment_and_trim_whitespace(s: &str) -> &str {
s.split('#') // remove comment
.next()
.expect("line contains something")
.trim() // remove whitespace
}
impl From<String> for Package {
fn from(value: String) -> Self {
let trimmed = remove_comment_and_trim_whitespace(&value);
debug_assert!(!trimmed.is_empty(), "empty package names are not allowed");
let (name, repo) = Self::split_into_name_and_repo(trimmed);
Self { name, repo }
}
}
impl From<&str> for Package {
fn from(value: &str) -> Self {
Self::from(value.to_string())
}
}
impl Package {
/// From a string that contains a package name, optionally prefixed by a
/// repository, return the package name as well as the repository if it
/// exists.
///
/// # Panics
///
/// Panics if `string` is empty.
fn split_into_name_and_repo(string: &str) -> (String, Option<String>) {
if let Some((before, after)) = string.split_once('/') {
(after.to_string(), Some(before.to_string()))
} else {
(string.to_string(), None)
}
}
/// Try to parse a string (from a line in a group file) and return a package.
/// From the string, any possible comment is removed and whitespace is trimmed.package
/// Returns `None` if there is nothing left after trimming.
pub fn try_from<S>(s: S) -> Option<Self>
where
S: AsRef<str>,
{
let trimmed = remove_comment_and_trim_whitespace(s.as_ref());
if trimmed.is_empty() {
return None;
}
let (name, repo) = Self::split_into_name_and_repo(trimmed);
Some(Self { name, repo })
}
}
impl PartialEq for Package {
fn eq(&self, other: &Self) -> bool {
self.cmp(other).is_eq()
}
}
impl Eq for Package {}
impl PartialOrd for Package {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Package {
fn cmp(&self, other: &Self) -> Ordering {
self.name
.cmp(&other.name)
.then(self.repo.as_ref().map_or(Ordering::Equal, |self_repo| {
other
.repo
.as_ref()
.map_or(Ordering::Equal, |other_repo| self_repo.cmp(other_repo))
}))
}
}
impl Display for Package {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.repo {
None => (),
Some(repo) => {
f.write_str(repo)?;
f.write_char('/')?;
}
}
f.write_str(&self.name)
}
}
#[cfg(test)]
mod tests {
use super::Package;
#[test]
fn split_into_name_and_repo() {
let x = "repo/name".to_string();
let (name, repo) = Package::split_into_name_and_repo(&x);
assert_eq!(name, "name");
assert_eq!(repo, Some("repo".to_string()));
let x = "something".to_string();
let (name, repo) = super::Package::split_into_name_and_repo(&x);
assert_eq!(name, "something");
assert_eq!(repo, None);
}
#[test]
fn from() {
let x = "myrepo/somepackage # ".to_string();
let p = Package::try_from(x).expect("this should be a valid package line");
assert_eq!(p.name, "somepackage");
assert_eq!(p.repo, Some("myrepo".to_string()));
}
}
+118
View File
@@ -0,0 +1,118 @@
use std::collections::BTreeSet;
use std::fmt::{Display, Write};
use std::hash::Hash;
use std::iter::Peekable;
use anyhow::{ensure, Context, Result};
use crate::prelude::*;
pub type Sections = BTreeSet<Section>;
#[derive(Debug, Clone)]
pub struct Section {
pub name: String,
pub packages: Packages,
}
impl Section {
pub fn new(name: String, packages: Packages) -> Self {
Self { name, packages }
}
pub fn try_from_lines<'a>(iter: &mut Peekable<impl Iterator<Item = &'a str>>) -> Result<Self> {
let name = find_next_section_name(iter)?;
let mut packages = Packages::new();
while next_line_might_be_package(iter) {
if let Some(package) = Package::try_from(iter.next().expect("we checked this is some"))
{
insert_package(package, &mut packages);
}
}
ensure!(!packages.is_empty(), "[{name}] is empty");
Ok(Self::new(name, packages))
}
}
fn insert_package(package: Package, packages: &mut Packages) {
let package_name = package.name.clone();
let newly_inserted = packages.insert(package);
if !newly_inserted {
log::warn!("{package_name} occurs twice in the same section");
}
}
fn next_line_might_be_package<'a>(iter: &mut Peekable<impl Iterator<Item = &'a str>>) -> bool {
// `while let` chains are unstable, unfortunately
iter.peek().is_some()
&& !iter
.peek()
.expect("we checked this is some")
.starts_with('[')
}
fn find_next_section_name<'a>(
iter: &mut Peekable<impl Iterator<Item = &'a str>>,
) -> Result<String> {
let name = iter
.find(|line| line.starts_with('['))
.context("finding beginning of next section")?
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.to_string();
Ok(name)
}
impl Hash for Section {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.name.hash(state);
}
}
impl PartialEq for Section {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
}
}
impl Eq for Section {
fn assert_receiver_is_total_eq(&self) {}
}
impl PartialOrd for Section {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Section {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.name.cmp(&other.name)
}
}
impl Display for Section {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("[{}]\n", &self.name))?;
let mut packages: Vec<_> = self.packages.iter().collect();
packages.sort_unstable();
let mut iter = packages.iter().peekable();
while let Some(package) = iter.next() {
package.fmt(f)?;
if iter.peek().is_some() {
f.write_char('\n')?;
}
}
Ok(())
}
}
+43
View File
@@ -0,0 +1,43 @@
//! This library contains all logic that happens in `pacdef`.
#![warn(
clippy::as_conversions,
clippy::cognitive_complexity,
clippy::explicit_iter_loop,
clippy::explicit_into_iter_loop,
clippy::map_entry,
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::option_if_let_else,
clippy::redundant_pub_crate,
clippy::semicolon_if_nothing_returned,
clippy::unnecessary_wraps,
clippy::unused_self,
clippy::unwrap_used,
clippy::use_debug,
clippy::use_self,
clippy::wildcard_dependencies,
missing_docs
)]
pub(crate) mod backend;
#[allow(missing_docs)]
pub mod cli;
mod cmd;
mod config;
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
mod core;
mod env;
mod errors;
mod grouping;
mod review;
mod search;
mod ui;
#[allow(unused_imports)]
mod prelude;
pub mod path;
pub use prelude::{Config, Error, Group};
+131
View File
@@ -0,0 +1,131 @@
//! Main program for `pacdef`.
#![warn(
clippy::as_conversions,
clippy::option_if_let_else,
clippy::redundant_pub_crate,
clippy::semicolon_if_nothing_returned,
clippy::unnecessary_wraps,
clippy::unused_self,
clippy::unwrap_used,
clippy::use_debug,
clippy::use_self,
clippy::wildcard_dependencies,
missing_docs
)]
use std::path::Path;
use std::process::{ExitCode, Termination};
use anyhow::{bail, Context, Result};
use clap::Parser;
use pacdef::cli::MainArguments;
use pacdef::path::{get_config_path, get_config_path_old_version, get_group_dir};
use pacdef::{Config, Error as PacdefError, Group};
const MAJOR_UPDATE_MESSAGE: &str = "VERSION UPGRADE
You seem to have used version 1.x of pacdef before.
In version 2.0 the config file needed to be changed from yaml to toml.
Check out https://github.com/steven-omaha/pacdef/blob/main/README.md#configuration for new syntax information.
This message will not appear again.
------";
struct PacdefLogger;
impl log::Log for PacdefLogger {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
if self.enabled(record.metadata()) {
eprintln!("{} - {}", record.level(), record.args());
}
}
fn flush(&self) {}
}
fn main() -> ExitCode {
log::set_boxed_logger(Box::new(PacdefLogger))
.map(|()| log::set_max_level(log::LevelFilter::Info))
.expect("no other loggers should have been set");
handle_final_result(main_inner())
}
/// Skip printing the error chain when searching packages yields no results,
/// otherwise report error chain.
#[allow(clippy::option_if_let_else)]
fn handle_final_result(result: Result<()>) -> ExitCode {
match result {
Ok(_) => ExitCode::SUCCESS,
Err(ref e) => {
if let Some(root_error) = e.root_cause().downcast_ref::<PacdefError>() {
log::error!("{root_error}");
ExitCode::FAILURE
} else {
result.report()
}
}
}
}
fn main_inner() -> Result<()> {
let main_arguments = MainArguments::parse();
let config_file = get_config_path().context("getting config file")?;
let config = match Config::load(&config_file).context("loading config file") {
Ok(config) => config,
Err(e) => {
if let Some(crate_error) = e.downcast_ref::<PacdefError>() {
match crate_error {
PacdefError::ConfigFileNotFound => load_default_config(&config_file)?,
_ => bail!("unexpected error: {crate_error}"),
}
} else {
bail!("unexpected error: {e:?}");
}
}
};
let group_dir = get_group_dir().context("resolving group dir")?;
let groups = Group::load(&group_dir, config.warn_not_symlinks)
.with_context(|| format!("loading groups under {}", group_dir.to_string_lossy()))?;
if groups.is_empty() {
log::warn!("no group files found");
}
for group in groups.iter() {
if group.warn_symlink {
log::warn!(
"group file {} is not a symlink",
group.path.to_string_lossy()
);
}
}
main_arguments.run(&groups, &config)
}
fn load_default_config(config_file: &Path) -> Result<Config> {
if get_config_path_old_version()?.exists() {
println!("{MAJOR_UPDATE_MESSAGE}");
}
if !config_file.exists() {
create_empty_config_file(config_file)?;
}
Ok(Config::default())
}
fn create_empty_config_file(config_file: &Path) -> Result<()> {
let config_dir = &config_file.parent().context("getting parent dir")?;
std::fs::create_dir_all(config_dir).context("creating parent dir")?;
std::fs::File::create(config_file).context("creating empty config file")?;
Ok(())
}
+181
View File
@@ -0,0 +1,181 @@
/*!
All functions related to `pacdef`'s internal paths.
*/
use std::path::PathBuf;
use std::{env, path::Path};
use anyhow::{Context, Result};
use path_absolutize::Absolutize;
const CONFIG_FILE_NAME: &str = "pacdef.toml";
const CONFIG_FILE_NAME_OLD: &str = "pacdef.yaml";
/// Get the group directory where all group files are located. This is
/// `$XDG_CONFIG_HOME/pacdef/groups`, which defaults to `$HOME/.config/pacdef/groups`.
///
/// # Errors
///
/// This function returns an error if both `$XDG_CONFIG_HOME` and `$HOME` are undefined.
pub fn get_group_dir() -> Result<PathBuf> {
let mut result = get_pacdef_base_dir().context("getting pacdef base dir")?;
result.push("groups");
Ok(result)
}
/// Get the base directory for `pacdef`'s config files.
///
/// # Errors
///
/// This function will return an error if `$XDG_CONFIG_HOME` cannot be determined.
pub fn get_pacdef_base_dir() -> Result<PathBuf> {
let mut dir = get_xdg_config_home().context("getting XDG_CONFIG_HOME")?;
dir.push("pacdef");
Ok(dir)
}
/// Get the path to the cargo home directory.
///
/// # Errors
///
/// This function will return an error if neither the `$CARGO_HOME` nor
/// the `$HOME` environment variables are set.
pub fn get_cargo_home() -> Result<PathBuf> {
if let Ok(config) = env::var("CARGO_HOME") {
Ok(config.into())
} else {
let mut config = get_home_dir().context("falling back to $HOME/.cargo")?;
config.push(".cargo");
Ok(config)
}
}
/// Get the path to the XDG config directory.
///
/// # Errors
///
/// This function will return an error if neither the `$XDG_CONFIG_HOME` nor
/// the `$HOME` environment variables are set.
fn get_xdg_config_home() -> Result<PathBuf> {
if let Ok(config) = env::var("XDG_CONFIG_HOME") {
Ok(config.into())
} else {
let mut config = get_home_dir().context("falling back to $HOME/.config")?;
config.push(".config");
Ok(config)
}
}
/// Get the home directory of the current user from the `$HOME` environment
/// variable.
///
/// # Errors
///
/// This function will return an error if the `$HOME` variable is not set.
pub fn get_home_dir() -> Result<PathBuf> {
Ok(env::var("HOME").context("getting $HOME variable")?.into())
}
/// Get the path to the pacdef config file. This is `$XDG_CONFIG_HOME/pacdef/pacdef.toml`.
///
/// # Errors
///
/// This function returns an error if both `$XDG_CONFIG_HOME` and `$HOME` are
/// undefined.
pub fn get_config_path() -> Result<PathBuf> {
let mut file = get_pacdef_base_dir().context("getting pacdef base dir for config file")?;
file.push(CONFIG_FILE_NAME);
Ok(file)
}
/// Get the path to the pacdef config file from version 0.x. This is
/// `$XDG_CONFIG_HOME/pacdef/pacdef.conf`.
///
/// # Errors
///
/// This function returns an error if both `$XDG_CONFIG_HOME` and `$HOME` are
/// undefined.
pub fn get_config_path_old_version() -> Result<PathBuf> {
let mut file = get_pacdef_base_dir().context("getting pacdef base dir for config file")?;
file.push(CONFIG_FILE_NAME_OLD);
Ok(file)
}
/// Determine if a program `name` exists in the folders in the `$PATH` variable.
///
/// # Errors
///
/// This function returns an error if `$PATH` is not set.
pub fn binary_in_path(name: &str) -> Result<bool> {
let paths = env::var_os("PATH").context("getting $PATH")?;
for dir in env::split_paths(&paths) {
let full_path = dir.join(name);
if full_path.is_file() {
return Ok(true);
}
}
Ok(false)
}
/// Determine the relative path of `full_path` in relation to `base_path`.
///
/// # Panics
///
/// Panics if at least one element in `base_path` does not match the corresponding
/// element in `full_path`.
pub fn get_relative_path<P>(full_path: P, base_path: P) -> PathBuf
where
P: AsRef<Path>,
{
let mut file_iter = full_path.as_ref().iter();
base_path
.as_ref()
.iter()
.zip(&mut file_iter)
.for_each(|(a, b)| assert_eq!(a, b));
let relative_path: PathBuf = file_iter.collect();
relative_path
}
/// For each file argument, return the absolute path to the file.
///
/// # Errors
///
/// Returns an error if any of the files cannot be absolutized.
pub fn get_absolutized_file_paths(arg_match: &[String]) -> Result<Vec<PathBuf>> {
let mut result = vec![];
for item in arg_match {
let path: PathBuf = item.into();
let absolute = path
.absolutize()
.with_context(|| format!("absolutizing {path:?}"))?
.into_owned();
result.push(absolute);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::get_relative_path;
#[test]
fn relative_path() {
let full = PathBuf::from("/a/b/c/d/e");
let base = PathBuf::from("/a/b/c");
let relative = get_relative_path(full, base);
assert_eq!(relative, PathBuf::from("d/e"));
}
#[test]
#[should_panic]
fn relative_path_panic() {
let full = PathBuf::from("/a/b/z/d/e");
let base = PathBuf::from("/a/b/c");
get_relative_path(full, base);
}
}
+46
View File
@@ -0,0 +1,46 @@
#[cfg(feature = "arch")]
pub use crate::backend::actual::arch::Arch;
#[cfg(feature = "debian")]
pub use crate::backend::actual::debian::Debian;
pub use crate::backend::actual::{
fedora::Fedora, flatpak::Flatpak, python::Python, rust::Rust, rustup::Rustup, void::Void,
};
pub use crate::backend::backend_trait::{Backend, BackendInfo, Switches, Text};
pub use crate::backend::todo_per_backend::ToDoPerBackend;
pub use crate::backend::AnyBackend;
pub use crate::backend::ManagedBackend;
pub use crate::cli::CleanPackageAction;
pub use crate::cli::EditGroupAction;
pub use crate::cli::ExportGroupAction;
pub use crate::cli::GroupAction;
pub use crate::cli::GroupArguments;
pub use crate::cli::ImportGroupAction;
pub use crate::cli::ListGroupAction;
pub use crate::cli::MainArguments;
pub use crate::cli::MainSubcommand;
pub use crate::cli::NewGroupAction;
pub use crate::cli::PackageAction;
pub use crate::cli::PackageArguments;
pub use crate::cli::RemoveGroupAction;
pub use crate::cli::ReviewPackageAction;
pub use crate::cli::SearchPackageAction;
pub use crate::cli::ShowGroupAction;
pub use crate::cli::SyncPackageAction;
pub use crate::cli::UnmanagedPackageAction;
pub use crate::cli::VersionArguments;
pub use crate::config::Config;
pub use crate::errors::Error;
pub use crate::grouping::{
group::{Group, Groups},
package::{Package, Packages},
section::{Section, Sections},
};
pub use crate::path::binary_in_path;
pub use crate::path::get_absolutized_file_paths;
pub use crate::path::get_cargo_home;
pub use crate::path::get_config_path;
pub use crate::path::get_config_path_old_version;
pub use crate::path::get_group_dir;
pub use crate::path::get_home_dir;
pub use crate::path::get_pacdef_base_dir;
pub use crate::path::get_relative_path;
+109
View File
@@ -0,0 +1,109 @@
use crate::prelude::*;
use super::strategy::Strategy;
#[derive(Debug, PartialEq)]
pub enum ReviewAction {
AsDependency(Package),
Delete(Package),
AssignGroup(Package, Group),
}
#[derive(Debug)]
pub enum ReviewIntention {
AsDependency,
AssignGroup,
Delete,
Info,
Invalid,
Skip,
Quit,
Apply,
}
#[derive(Debug)]
pub struct ReviewsPerBackend {
items: Vec<(AnyBackend, Vec<ReviewAction>)>,
}
impl ReviewsPerBackend {
pub fn new() -> Self {
Self { items: vec![] }
}
pub fn nothing_to_do(&self) -> bool {
self.items.iter().all(|(_, vec)| vec.is_empty())
}
pub fn push(&mut self, value: (AnyBackend, Vec<ReviewAction>)) {
self.items.push(value);
}
/// Convert the reviews per backend to a vector of [`Strategy`], where one `Strategy` contains
/// all actions that must be executed for a [`Backend`].
///
/// If there are no actions for a `Backend`, then that `Backend` is removed from the return
/// value.
pub fn into_strategies(self) -> Vec<Strategy> {
let mut result = vec![];
for (backend, actions) in self {
let mut to_delete = Packages::new();
let mut assign_group = vec![];
let mut as_dependency = Packages::new();
extract_actions(
actions,
&mut to_delete,
&mut assign_group,
&mut as_dependency,
);
result.push(Strategy::new(
backend,
to_delete,
as_dependency,
assign_group,
));
}
result.retain(|s| !s.nothing_to_do());
result
}
}
impl IntoIterator for ReviewsPerBackend {
type Item = (AnyBackend, Vec<ReviewAction>);
type IntoIter = std::vec::IntoIter<(AnyBackend, Vec<ReviewAction>)>;
fn into_iter(self) -> Self::IntoIter {
self.items.into_iter()
}
}
pub enum ContinueWithReview {
Yes,
No,
NoAndApply,
}
fn extract_actions(
actions: Vec<ReviewAction>,
to_delete: &mut Packages,
assign_group: &mut Vec<(Package, Group)>,
as_dependency: &mut Packages,
) {
for action in actions {
match action {
ReviewAction::Delete(package) => {
to_delete.insert(package);
}
ReviewAction::AssignGroup(package, group) => assign_group.push((package, group)),
ReviewAction::AsDependency(package) => {
as_dependency.insert(package);
}
}
}
}
+177
View File
@@ -0,0 +1,177 @@
mod datastructures;
mod strategy;
use std::io::{stdin, stdout, Write};
use anyhow::Result;
use crate::prelude::*;
use crate::ui::{get_user_confirmation, read_single_char_from_terminal};
use self::datastructures::{ContinueWithReview, ReviewAction, ReviewIntention, ReviewsPerBackend};
use self::strategy::Strategy;
pub fn review(todo_per_backend: ToDoPerBackend, groups: &Groups) -> Result<()> {
let mut reviews = ReviewsPerBackend::new();
if todo_per_backend.nothing_to_do_for_all_backends() {
println!("nothing to do");
return Ok(());
}
'outer: for (backend, packages) in todo_per_backend {
let mut actions = vec![];
for package in packages {
println!("{}: {package}", backend.backend_info().section);
match get_action_for_package(package, groups, &mut actions, &backend)? {
ContinueWithReview::Yes => continue,
ContinueWithReview::No => return Ok(()),
ContinueWithReview::NoAndApply => {
reviews.push((backend, actions));
break 'outer;
}
}
}
reviews.push((backend, actions));
}
if reviews.nothing_to_do() {
println!("nothing to do");
return Ok(());
}
let strategies: Vec<Strategy> = reviews.into_strategies();
println!();
let mut iter = strategies.iter().peekable();
while let Some(strategy) = iter.next() {
strategy.show();
if iter.peek().is_some() {
println!();
}
}
println!();
if !get_user_confirmation()? {
return Ok(());
}
for strategy in strategies {
strategy.execute()?;
}
Ok(())
}
fn get_action_for_package(
package: Package,
groups: &Groups,
reviews: &mut Vec<ReviewAction>,
backend: &dyn Backend,
) -> Result<ContinueWithReview> {
loop {
match ask_user_action_for_package(backend.supports_as_dependency())? {
ReviewIntention::AsDependency => {
assert!(
backend.supports_as_dependency(),
"backend does not support dependencies"
);
reviews.push(ReviewAction::AsDependency(package));
break;
}
ReviewIntention::AssignGroup => {
if let Ok(Some(group)) = ask_group(groups) {
reviews.push(ReviewAction::AssignGroup(package, group.clone()));
break;
};
}
ReviewIntention::Delete => {
reviews.push(ReviewAction::Delete(package));
break;
}
ReviewIntention::Info => {
backend.show_package_info(&package)?;
}
ReviewIntention::Invalid => (),
ReviewIntention::Skip => break,
ReviewIntention::Quit => return Ok(ContinueWithReview::No),
ReviewIntention::Apply => return Ok(ContinueWithReview::NoAndApply),
}
}
Ok(ContinueWithReview::Yes)
}
/// Ask the user for the desired action, and return the associated
/// [`ReviewIntention`]. The query depends on the capabilities of the backend.
///
/// # Errors
///
/// This function will return an error if stdin or stdout cannot be accessed.
fn ask_user_action_for_package(supports_as_dependency: bool) -> Result<ReviewIntention> {
print_query(supports_as_dependency)?;
match read_single_char_from_terminal()?.to_ascii_lowercase() {
'a' if supports_as_dependency => Ok(ReviewIntention::AsDependency),
'd' => Ok(ReviewIntention::Delete),
'g' => Ok(ReviewIntention::AssignGroup),
'i' => Ok(ReviewIntention::Info),
'q' => Ok(ReviewIntention::Quit),
's' => Ok(ReviewIntention::Skip),
'p' => Ok(ReviewIntention::Apply),
_ => Ok(ReviewIntention::Invalid),
}
}
/// Print a space-terminated string that asks the user for the desired action.
/// The items of the string depend on whether the backend supports dependent
/// packages.
///
/// # Errors
///
/// This function will return an error if stdout cannot be flushed.
fn print_query(supports_as_dependency: bool) -> Result<()> {
let mut query = String::from("assign to (g)roup, (d)elete, (s)kip, (i)nfo, ");
if supports_as_dependency {
query.push_str("(a)s dependency, ");
}
query.push_str("a(p)ply, (q)uit? ");
print!("{query}");
stdout().lock().flush()?;
Ok(())
}
fn print_enumerated_groups(groups: &Groups) {
let number_digits = get_amount_of_digits_for_number(groups.len());
for (i, group) in groups.iter().enumerate() {
println!("{i:>number_digits$}: {}", group.name);
}
}
fn get_amount_of_digits_for_number(number: usize) -> usize {
number.to_string().len()
}
fn ask_group(groups: &Groups) -> Result<Option<&Group>> {
print_enumerated_groups(groups);
let mut buf = String::new();
stdin().read_line(&mut buf)?;
let reply = buf.trim();
let idx: usize = if let Ok(idx) = reply.parse() {
idx
} else {
return Ok(None);
};
if idx < groups.len() {
Ok(groups.iter().nth(idx))
} else {
Ok(None)
}
}
+76
View File
@@ -0,0 +1,76 @@
use anyhow::Result;
use crate::prelude::*;
#[derive(Debug)]
pub struct Strategy {
backend: AnyBackend,
delete: Packages,
as_dependency: Packages,
assign_group: Vec<(Package, Group)>,
}
impl Strategy {
pub fn new(
backend: AnyBackend,
delete: Packages,
as_dependency: Packages,
assign_group: Vec<(Package, Group)>,
) -> Self {
Self {
backend,
delete,
as_dependency,
assign_group,
}
}
pub fn execute(self) -> Result<()> {
if !self.delete.is_empty() {
self.backend.remove_packages(&self.delete, false)?;
}
if !self.as_dependency.is_empty() {
self.backend.make_dependency(&self.as_dependency)?;
}
if !self.assign_group.is_empty() {
self.backend.assign_group(self.assign_group)?;
}
Ok(())
}
pub fn show(&self) {
if self.nothing_to_do() {
return;
}
println!("[{}]", self.backend.backend_info().section);
if !self.delete.is_empty() {
println!("delete:");
for p in &self.delete {
println!(" {p}");
}
}
if !self.as_dependency.is_empty() {
println!("as dependency:");
for p in &self.as_dependency {
println!(" {p}");
}
}
if !self.assign_group.is_empty() {
println!("assign groups:");
for (p, g) in &self.assign_group {
println!(" {p} -> {}", g.name);
}
}
}
pub fn nothing_to_do(&self) -> bool {
self.delete.is_empty() && self.as_dependency.is_empty() && self.assign_group.is_empty()
}
}
+97
View File
@@ -0,0 +1,97 @@
use std::iter::Peekable;
use std::vec::IntoIter;
use crate::prelude::*;
use anyhow::{bail, Result};
use regex::Regex;
/// Find all packages in all groups whose name match the regex from the
/// command-line arguments. Print the name of the packages per group and
/// section.
///
/// # Errors
///
/// This function will return an error if
/// - an invalid regex was provided, or
/// - no matching packages could be found.
pub fn search_packages(regex_str: &str, groups: &Groups) -> Result<()> {
if groups.is_empty() {
bail!(crate::errors::Error::NoPackagesFound);
}
let re = Regex::new(regex_str)?;
let mut vec = vec![];
for group in groups {
for section in &group.sections {
for package in &section.packages {
if re.is_match(&package.name) {
vec.push((group, section, package));
}
}
}
}
if vec.is_empty() {
bail!(crate::errors::Error::NoPackagesFound);
}
print_triples(vec);
Ok(())
}
fn print_triples(mut vec: Vec<(&Group, &Section, &Package)>) {
vec.sort_unstable();
let mut g0 = String::new();
let mut s0 = String::new();
let mut iter = vec.into_iter().peekable();
while let Some((g, s, p)) = iter.next() {
print_group_if_changed(g, &g0, &mut s0);
print_section_if_changed(s, &s0);
println!("{p}");
save_group_and_section_name(&mut g0, g, &mut s0, s);
print_separator_unless_exhausted(&mut iter, &g0);
}
}
fn save_group_and_section_name(g0: &mut String, g: &Group, s0: &mut String, s: &Section) {
g0.clone_from(&g.name);
s0.clone_from(&s.name);
}
fn print_separator_unless_exhausted(
iter: &mut Peekable<IntoIter<(&Group, &Section, &Package)>>,
g0: &String,
) {
if let Some((g, _, _)) = iter.peek() {
if g.name != *g0 {
println!();
}
}
}
fn print_section_if_changed(current: &Section, previous_name: &String) {
if current.name != *previous_name {
println!("[{}]", current.name);
}
}
fn print_group_if_changed(
current_group: &Group,
previous_group_name: &String,
previous_section_name: &mut String,
) {
if current_group.name != *previous_group_name {
println!("{}", current_group.name);
for _ in 0..current_group.name.len() {
print!("-");
}
println!();
previous_section_name.clear();
}
}
+49
View File
@@ -0,0 +1,49 @@
use std::io::{self, Read, Write};
use anyhow::{Context, Result};
use termios::*;
pub fn get_user_confirmation() -> Result<bool> {
print!("Continue? [Y/n] ");
std::io::stdout().flush().context("flushing stdout")?;
let mut reply = String::new();
std::io::stdin()
.read_line(&mut reply)
.context("reading stdin")?;
Ok(reply.trim().is_empty() || reply.to_lowercase().contains('y'))
}
/// Read a single byte from stdin and interpret it as `char`. Use the
/// `termios` library to switch the terminal to raw mode before reading,
/// and restore the original terminal mode afterwards.
///
/// # Errors
///
/// This function will return an error if the data cannot be read or the
/// terminal settings cannot be changed.
pub fn read_single_char_from_terminal() -> Result<char> {
// 0 is the file descriptor for stdin
let fd = 0;
let termios = Termios::from_fd(fd).context("getting stdin fd")?;
let mut new_termios = termios;
new_termios.c_lflag &= !(ICANON | ECHO);
new_termios.c_cc[VMIN] = 1;
new_termios.c_cc[VTIME] = 0;
tcsetattr(fd, TCSANOW, &new_termios).context("setting terminal mode")?;
let mut input_buffer = [0u8; 1];
io::stdin()
.read_exact(&mut input_buffer[..])
.context("reading one byte from stdin")?;
let result: char = input_buffer[0].into();
// stdin is not echoed automatically in this terminal mode
println!("{result}");
// restore previous settings
tcsetattr(fd, TCSANOW, &termios).context("restoring terminal mode")?;
Ok(result)
}