restructure project into a virtual manifest

- remove redundant readme
- de-duplicate Cargo.toml information using workspace inheritance
- rename "pacdef_core" to "pacdef"
- move "crates/main/main.rs" to "crates/pacdef/src/main.rs"
This commit is contained in:
ripytide
2024-04-13 14:37:12 +01:00
parent bdfa9b6aeb
commit b473f8e432
48 changed files with 37 additions and 73 deletions
+159
View File
@@ -0,0 +1,159 @@
use crate::core::get_version_string;
use clap::{Arg, ArgAction, Command};
#[must_use]
pub(super) fn build_cli() -> Command {
let package_cmd = get_package_cmd();
let group_cmd = get_group_cmd();
let version_cmd = Command::new("version").about("show version info");
Command::new("pacdef")
.about("multi-backend declarative package manager for Linux")
.version(get_version_string())
.arg_required_else_help(true)
.subcommand_required(true)
.subcommands([group_cmd, package_cmd, version_cmd])
.subcommand_value_name("subcommand")
.disable_help_subcommand(true)
.disable_version_flag(true)
}
/// Build the `pacdef group` subcommand.
fn get_group_cmd() -> Command {
let edit = Command::new("edit")
.about("edit one or more existing group")
.arg_required_else_help(true)
.arg(
Arg::new("groups")
.num_args(1..)
.required(true)
.help("a previously imported group"),
)
.visible_alias("ed");
let export = Command::new("export")
.about("export one or more group files")
.arg_required_else_help(true)
.arg(
Arg::new("force")
.short('f')
.long("force")
.action(clap::ArgAction::SetTrue)
.help("overwrite output files if they exist"),
)
.arg(
Arg::new("output_dir")
.short('o')
.long("output")
.help("(optional) the directory under which to save the group"),
)
.arg(
Arg::new("groups")
.num_args(1..)
.required(true)
.help("the file to export as group"),
)
.visible_alias("ex");
let import = Command::new("import")
.about("import one or more group files")
.arg_required_else_help(true)
.arg(
Arg::new("groups")
.num_args(1..)
.required(true)
.help("the file to import as group"),
)
.visible_alias("i");
let list = Command::new("list")
.about("list names of imported groups")
.visible_alias("l");
let new = Command::new("new")
.about("create new group files")
.arg_required_else_help(true)
.arg(
Arg::new("edit")
.short('e')
.long("edit")
.help("edit the new group files after creation")
.action(clap::ArgAction::SetTrue),
)
.arg(Arg::new("groups").num_args(1..).required(true))
.visible_alias("n");
let remove = Command::new("remove")
.about("remove one or more previously imported groups")
.arg_required_else_help(true)
.arg(
Arg::new("groups")
.num_args(1..)
.required(true)
.help("a previously imported group that will be removed"),
)
.visible_alias("r");
let show = Command::new("show")
.about("show packages under an imported group")
.arg_required_else_help(true)
.arg(
Arg::new("groups")
.num_args(1..)
.required(true)
.help("group file(s) to show"),
)
.visible_alias("s");
Command::new("group")
.arg_required_else_help(true)
.about("manage groups")
.visible_alias("g")
.subcommand_required(true)
.subcommands([edit, export, import, list, new, remove, show])
}
/// Build the `pacdef package` subcommand.
fn get_package_cmd() -> Command {
let sync = Command::new("sync")
.about("install packages from all imported groups")
.visible_alias("sy")
.arg(build_noconfirm_arg());
let clean = Command::new("clean")
.about("remove unmanaged packages")
.visible_alias("c")
.arg(build_noconfirm_arg());
let unmanaged = Command::new("unmanaged")
.about("show explicitly installed packages not managed by pacdef")
.visible_alias("u");
let review = Command::new("review")
.about("review unmanaged packages")
.visible_alias("r");
let search = Command::new("search")
.visible_alias("se")
.about("search for packages which match a provided regex")
.arg_required_else_help(true)
.arg(
Arg::new("regex")
.required(true)
.help("the regular expression the package must match"),
);
Command::new("package")
.arg_required_else_help(true)
.about("manage packages")
.visible_alias("p")
.subcommand_required(true)
.subcommands([clean, review, search, sync, unmanaged])
}
fn build_noconfirm_arg() -> Arg {
Arg::new("noconfirm")
.long("noconfirm")
.help("do not ask for any confirmation")
.action(ArgAction::SetTrue)
}
+47
View File
@@ -0,0 +1,47 @@
#[derive(Debug, PartialEq)]
pub enum Arguments {
Group(GroupAction),
Package(PackageAction),
Version,
}
#[derive(Debug, PartialEq)]
pub enum GroupAction {
Edit(Groups),
Export(Groups, OutputDir, Force),
Import(Groups),
List,
New(Groups, Edit),
Remove(Groups),
Show(Groups),
}
#[derive(Debug, PartialEq)]
pub struct Files(pub Vec<String>);
#[derive(Debug, PartialEq)]
pub struct Groups(pub Vec<String>);
#[derive(Debug, PartialEq)]
pub enum PackageAction {
Clean(Noconfirm),
Review,
Search(Regex),
Sync(Noconfirm),
Unmanaged,
}
#[derive(Debug, PartialEq)]
pub struct Regex(pub String);
#[derive(Debug, PartialEq)]
pub struct Edit(pub bool);
#[derive(Debug, PartialEq)]
pub struct Noconfirm(pub bool);
#[derive(Debug, PartialEq)]
pub struct Force(pub bool);
#[derive(Debug, PartialEq)]
pub struct OutputDir(pub Option<String>);
+14
View File
@@ -0,0 +1,14 @@
mod cli;
mod datastructure;
mod parsing;
#[cfg(test)]
mod tests;
pub use datastructure::*;
/// Get and parse the CLI arguments.
#[must_use]
pub fn get() -> Arguments {
let args = cli::build_cli().get_matches();
parsing::parse(args)
}
+79
View File
@@ -0,0 +1,79 @@
use super::datastructure::*;
const ARGS_CONSISTENT: &str = "argument declaration and parsing must be consistent";
pub(super) fn parse(args: clap::ArgMatches) -> Arguments {
match args.subcommand() {
Some(("group", args)) => Arguments::Group(parse_group_args(args)),
Some(("package", args)) => Arguments::Package(parse_package_args(args)),
Some(("version", _)) => Arguments::Version,
Some(value) => panic!("main subcommand was not matched: {value:?}"),
None => unreachable!("prevented by clap"),
}
}
fn parse_group_args(args: &clap::ArgMatches) -> GroupAction {
use GroupAction::*;
match args.subcommand() {
Some(("edit", args)) => Edit(get_groups(args)),
Some(("export", args)) => Export(get_groups(args), get_output_dir(args), get_force(args)),
Some(("import", args)) => Import(get_groups(args)),
Some(("list", _)) => List,
Some(("new", args)) => New(get_groups(args), get_edit(args)),
Some(("remove", args)) => Remove(get_groups(args)),
Some(("show", args)) => Show(get_groups(args)),
Some(value) => panic!("group subcommand was not matched: {value:?}"),
None => unreachable!("prevented by clap"),
}
}
fn parse_package_args(args: &clap::ArgMatches) -> PackageAction {
use PackageAction::*;
match args.subcommand() {
Some(("clean", args)) => Clean(get_noconfirm(args)),
Some(("review", _)) => Review,
Some(("search", args)) => Search(get_regex(args)),
Some(("sync", args)) => Sync(get_noconfirm(args)),
Some(("unmanaged", _)) => Unmanaged,
Some(value) => panic!("package subcommand was not matched: {value:?}"),
None => unreachable!("prevented by clap"),
}
}
fn get_one_arg<T>(args: &clap::ArgMatches, id: &str) -> T
where
T: std::any::Any + Clone + Sync + Send + 'static,
{
args.get_one::<T>(id).expect(ARGS_CONSISTENT).to_owned()
}
fn get_regex(args: &clap::ArgMatches) -> Regex {
Regex(get_one_arg(args, "regex"))
}
fn get_noconfirm(args: &clap::ArgMatches) -> Noconfirm {
Noconfirm(get_one_arg(args, "noconfirm"))
}
fn get_edit(args: &clap::ArgMatches) -> Edit {
Edit(get_one_arg(args, "edit"))
}
fn get_groups(args: &clap::ArgMatches) -> Groups {
Groups(
args.get_many::<String>("groups")
.expect(ARGS_CONSISTENT)
.cloned()
.collect(),
)
}
fn get_output_dir(args: &clap::ArgMatches) -> OutputDir {
OutputDir(args.get_one::<String>("output_dir").cloned())
}
fn get_force(args: &clap::ArgMatches) -> Force {
Force(get_one_arg(args, "force"))
}
+38
View File
@@ -0,0 +1,38 @@
use super::cli::build_cli;
use super::datastructure::*;
use super::parsing::parse;
use rstest::rstest;
#[rstest]
#[case(vec!["pacdef", "group", "list"],
Arguments::Group(GroupAction::List))]
#[case(vec!["pacdef", "g", "l"],
Arguments::Group(GroupAction::List))]
#[case(vec!["pacdef", "version"],
Arguments::Version)]
#[case(vec!["pacdef", "group", "export", "-o", "/tmp", "--force", "base"],
Arguments::Group(GroupAction::Export(Groups(vec!["base".to_string()]), OutputDir(Some("/tmp".to_string())), Force(true))))]
#[case(vec!["pacdef", "package", "sync"],
Arguments::Package(PackageAction::Sync(Noconfirm(false))))]
#[case(vec!["pacdef", "package", "sync", "--noconfirm"],
Arguments::Package(PackageAction::Sync(Noconfirm(true))))]
#[case(vec!["pacdef", "p", "se", "myregex"],
Arguments::Package(PackageAction::Search(Regex("myregex".to_string()))))]
fn arg_parsing(#[case] input: Vec<&str>, #[case] expected: Arguments) {
let args = build_cli().get_matches_from(input);
let parsed = parse(args);
assert_eq!(parsed, expected);
}
#[rstest]
#[should_panic]
#[case(vec!["pacdef", "package", "search"], "regex missing")]
#[should_panic]
#[case(vec!["pacdef", "p", "s"], "can be either 'sync' or 'search'")]
#[should_panic]
#[case(vec!["pacdef", "group", "edit"], "group missing")]
fn arg_parsing_invalid(#[case] input: Vec<&str>, #[case] err_msg: &str) {
build_cli().try_get_matches_from(input).expect(err_msg);
}
+129
View File
@@ -0,0 +1,129 @@
use std::collections::HashSet;
use std::process::Command;
use alpm::Alpm;
use alpm::PackageReason::Explicit;
use anyhow::{Context, Result};
use crate::backend::backend_trait::{Backend, Switches, Text};
use crate::backend::macros::impl_backend_constants;
use crate::cmd::run_external_command;
use crate::{Group, Package};
#[derive(Debug, Clone)]
pub struct Arch {
pub(crate) binary: String,
pub(crate) aur_rm_args: Vec<String>,
pub(crate) packages: HashSet<Package>,
}
const BINARY: Text = "pacman";
const SECTION: Text = "arch";
const SWITCHES_INFO: Switches = &["--query", "--info"];
const SWITCHES_INSTALL: Switches = &["--sync"];
const SWITCHES_MAKE_DEPENDENCY: Switches = &["--database", "--asdeps"];
const SWITCHES_NOCONFIRM: Switches = &["--noconfirm"];
const SWITCHES_REMOVE: Switches = &["--remove", "--recursive"];
const SUPPORTS_AS_DEPENDENCY: bool = true;
impl Backend for Arch {
impl_backend_constants!();
fn get_binary(&self) -> Text {
let r#box = self.binary.clone().into_boxed_str();
Box::leak(r#box)
}
fn get_all_installed_packages(&self) -> Result<HashSet<Package>> {
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<HashSet<Package>> {
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: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = Command::new(&self.binary);
cmd.args(self.get_switches_install());
if noconfirm {
cmd.args(self.get_switches_noconfirm());
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Remove the specified packages.
fn remove_packages(&self, packages: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = Command::new(&self.binary);
cmd.args(self.get_switches_remove());
cmd.args(&self.aur_rm_args);
if noconfirm {
cmd.args(self.get_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>) -> HashSet<Package> {
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")
}
impl Arch {
pub(crate) fn new() -> Self {
Self {
binary: BINARY.to_string(),
aur_rm_args: vec![],
packages: HashSet::new(),
}
}
}
+104
View File
@@ -0,0 +1,104 @@
use std::collections::HashSet;
use anyhow::Result;
use rust_apt::cache::PackageSort;
use rust_apt::new_cache;
use crate::backend::backend_trait::{Backend, Switches, Text};
use crate::backend::macros::impl_backend_constants;
use crate::backend::root::build_base_command_with_privileges;
use crate::cmd::run_external_command;
use crate::{Group, Package};
#[derive(Debug, Clone)]
pub struct Debian {
pub(crate) packages: HashSet<Package>,
}
const BINARY: Text = "apt";
const SECTION: Text = "debian";
const SWITCHES_INFO: Switches = &["show"];
const SWITCHES_INSTALL: Switches = &["install"];
const SWITCHES_MAKE_DEPENDENCY: Switches = &[]; // not needed
const SWITCHES_NOCONFIRM: Switches = &["--yes"];
const SWITCHES_REMOVE: Switches = &["remove"];
const SUPPORTS_AS_DEPENDENCY: bool = true;
impl Backend for Debian {
impl_backend_constants!();
fn get_all_installed_packages(&self) -> Result<HashSet<Package>> {
let cache = new_cache!()?;
let sort = PackageSort::default().installed();
let mut result = HashSet::new();
for pkg in cache.packages(&sort)? {
result.insert(Package::from(pkg.name().to_string()));
}
Ok(result)
}
fn get_explicitly_installed_packages(&self) -> Result<HashSet<Package>> {
let cache = new_cache!()?;
let sort = PackageSort::default().installed().manually_installed();
let mut result = HashSet::new();
for pkg in cache.packages(&sort)? {
result.insert(Package::from(pkg.name().to_string()));
}
Ok(result)
}
fn make_dependency(&self, packages: &[Package]) -> 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: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = build_base_command_with_privileges(self.get_binary());
cmd.args(self.get_switches_install());
if noconfirm {
cmd.args(self.get_switches_noconfirm());
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Remove the specified packages.
fn remove_packages(&self, packages: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = build_base_command_with_privileges(self.get_binary());
cmd.args(self.get_switches_remove());
if noconfirm {
cmd.args(self.get_switches_noconfirm());
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
}
impl Debian {
pub(crate) fn new() -> Self {
Self {
packages: HashSet::new(),
}
}
}
+141
View File
@@ -0,0 +1,141 @@
use std::collections::HashSet;
use std::process::Command;
use anyhow::Result;
use crate::backend::backend_trait::{Backend, Switches, Text};
use crate::backend::macros::impl_backend_constants;
use crate::cmd::run_external_command;
use crate::{Group, Package};
#[derive(Debug, Clone)]
pub struct Fedora {
pub(crate) packages: HashSet<Package>,
}
const BINARY: Text = "dnf";
const SECTION: Text = "fedora";
const SWITCHES_INFO: Switches = &["info"];
const SWITCHES_INSTALL: Switches = &["install"];
const SWITCHES_MAKE_DEPENDENCY: Switches = &[];
const SWITCHES_NOCONFIRM: Switches = &["--assumeyes"];
const SWITCHES_REMOVE: Switches = &["remove"];
const SUPPORTS_AS_DEPENDENCY: bool = false;
/// 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}",
];
/// 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", "@"];
impl Backend for Fedora {
impl_backend_constants!();
fn get_all_installed_packages(&self) -> Result<HashSet<Package>> {
let mut cmd = Command::new(self.get_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<HashSet<Package>> {
let mut cmd = Command::new(self.get_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: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = Command::new("sudo");
cmd.arg(self.get_binary());
cmd.args(self.get_switches_install());
if noconfirm {
cmd.args(self.get_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: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = Command::new("sudo");
cmd.arg(self.get_binary());
cmd.args(self.get_switches_remove());
if noconfirm {
cmd.args(self.get_switches_noconfirm());
}
for p in packages {
cmd.arg(&p.name);
}
run_external_command(cmd)
}
fn show_package_info(&self, package: &Package) -> Result<()> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_info());
cmd.arg(&package.name);
run_external_command(cmd)
}
fn make_dependency(&self, _: &[Package]) -> Result<()> {
panic!("Not supported by the package manager!")
}
}
impl Fedora {
pub fn new() -> Self {
Self {
packages: HashSet::new(),
}
}
}
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()
}
}
+120
View File
@@ -0,0 +1,120 @@
use std::collections::HashSet;
use std::process::Command;
use anyhow::Result;
use crate::backend::backend_trait::{Backend, Switches, Text};
use crate::backend::macros::impl_backend_constants;
use crate::cmd::run_external_command;
use crate::{Group, Package};
#[derive(Debug, Clone)]
pub struct Flatpak {
pub(crate) packages: HashSet<Package>,
pub(crate) systemwide: bool,
}
const BINARY: Text = "flatpak";
const SECTION: Text = "flatpak";
const SWITCHES_INSTALL: Switches = &["install"];
const SWITCHES_INFO: Switches = &["info"];
const SWITCHES_MAKE_DEPENDENCY: Switches = &[];
const SWITCHES_NOCONFIRM: Switches = &["--assumeyes"];
const SWITCHES_REMOVE: Switches = &["uninstall"];
const SUPPORTS_AS_DEPENDENCY: bool = false;
impl Backend for Flatpak {
impl_backend_constants!();
fn get_all_installed_packages(&self) -> Result<HashSet<Package>> {
self.get_installed_packages(true)
}
fn get_explicitly_installed_packages(&self) -> Result<HashSet<Package>> {
self.get_installed_packages(false)
}
/// Install the specified packages.
fn install_packages(&self, packages: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_install());
cmd.args(self.get_switches_runtime());
if noconfirm {
cmd.args(self.get_switches_noconfirm());
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
fn make_dependency(&self, _: &[Package]) -> Result<()> {
panic!("not supported by {}", BINARY)
}
/// Remove the specified packages.
fn remove_packages(&self, packages: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_remove());
cmd.args(self.get_switches_runtime());
if noconfirm {
cmd.args(self.get_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 mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_info());
cmd.args(self.get_switches_runtime());
cmd.arg(format!("{package}"));
run_external_command(cmd)
}
}
impl Flatpak {
pub(crate) fn new() -> Self {
Self {
packages: HashSet::new(),
systemwide: true,
}
}
fn get_switches_runtime(&self) -> Switches {
if self.systemwide {
&[]
} else {
&["--user"]
}
}
fn get_installed_packages(&self, include_implicit: bool) -> Result<HashSet<Package>> {
let mut cmd = Command::new(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::<HashSet<Package>>())
}
}
+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;
+118
View File
@@ -0,0 +1,118 @@
use std::collections::HashSet;
use std::process::Command;
use anyhow::Context;
use anyhow::Result;
use serde_json::Value;
use crate::backend::backend_trait::{Backend, Switches, Text};
use crate::backend::macros::impl_backend_constants;
use crate::{Group, Package};
#[derive(Debug, Clone)]
pub struct Python {
pub(crate) binary: String,
pub(crate) packages: HashSet<Package>,
}
const BINARY: Text = "pip";
const SECTION: Text = "python";
const SWITCHES_INFO: Switches = &["show"];
const SWITCHES_INSTALL: Switches = &["install"];
const SWITCHES_MAKE_DEPENDENCY: Switches = &[]; // not needed
const SWITCHES_NOCONFIRM: Switches = &[]; // not needed
const SWITCHES_REMOVE: Switches = &["uninstall"];
const SUPPORTS_AS_DEPENDENCY: bool = false;
macro_rules! ERROR{
($bin:expr) => {
panic!("Cannot use {} for package management in python. Please use a valid package manager like pip or pipx.", $bin)
};
}
impl Backend for Python {
impl_backend_constants!();
fn get_binary(&self) -> Text {
let r#box = self.binary.clone().into_boxed_str();
Box::leak(r#box)
}
fn get_all_installed_packages(&self) -> Result<HashSet<Package>> {
let mut cmd = Command::new(self.get_binary());
let output = run_pip_command(&mut cmd, self.get_switches_runtime())?;
self.extract_packages(output)
}
fn get_explicitly_installed_packages(&self) -> Result<HashSet<Package>> {
let mut cmd = Command::new(self.get_binary());
let output = run_pip_command(&mut cmd, self.get_switches_explicit())?;
self.extract_packages(output)
}
fn make_dependency(&self, _packages: &[Package]) -> Result<()> {
panic!("not supported by {}", 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)
}
impl Python {
pub(crate) fn new() -> Self {
Self {
binary: BINARY.to_string(),
packages: HashSet::new(),
}
}
fn get_switches_runtime(&self) -> Switches {
match self.get_binary() {
"pip" => &["list", "--format", "json", "--not-required", "--user"],
"pipx" => &["list", "--json"],
_ => ERROR!(self.get_binary()),
}
}
fn get_switches_explicit(&self) -> Switches {
match self.get_binary() {
"pip" => &["list", "--format", "json", "--user"],
"pipx" => &["list", "--json"],
_ => ERROR!(self.get_binary()),
}
}
fn extract_packages(&self, output: Value) -> Result<HashSet<Package>> {
match self.get_binary() {
"pip" => extract_pacdef_packages(output),
"pipx" => extract_pacdef_packages_pipx(output),
_ => ERROR!(self.get_binary()),
}
}
}
fn extract_pacdef_packages(value: Value) -> Result<HashSet<Package>> {
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<HashSet<Package>> {
let result = value["venvs"]
.as_object()
.context("getting inner json object")?
.iter()
.map(|(name, _)| Package::from(name.as_str()))
.collect();
Ok(result)
}
+91
View File
@@ -0,0 +1,91 @@
use std::collections::HashSet;
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::backend::backend_trait::{Backend, Switches, Text};
use crate::backend::macros::impl_backend_constants;
use crate::{Group, Package};
#[derive(Debug, Clone)]
pub struct Rust {
pub(crate) packages: HashSet<Package>,
}
const BINARY: Text = "cargo";
const SECTION: Text = "rust";
const SWITCHES_INSTALL: Switches = &["install"];
const SWITCHES_INFO: Switches = &["search", "--limit", "1"];
const SWITCHES_MAKE_DEPENDENCY: Switches = &[];
const SWITCHES_NOCONFIRM: Switches = &[]; // not needed
const SWITCHES_REMOVE: Switches = &["uninstall"];
const SUPPORTS_AS_DEPENDENCY: bool = false;
impl Backend for Rust {
impl_backend_constants!();
fn get_all_installed_packages(&self) -> Result<HashSet<Package>> {
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 => {
eprintln!(
"WARNING: no crates file found for cargo. Assuming no crates installed yet."
);
return Ok(HashSet::new());
}
Err(err) => bail!(err),
};
let json: Value =
serde_json::from_str(&content).context("parsing JSON from crates file")?;
extract_packages(&json).context("extracing packages from crates file")
}
fn get_explicitly_installed_packages(&self) -> Result<HashSet<Package>> {
self.get_all_installed_packages()
.context("getting all installed packages")
}
fn make_dependency(&self, _: &[Package]) -> Result<()> {
panic!("not supported by {}", BINARY)
}
}
fn extract_packages(json: &Value) -> Result<HashSet<Package>> {
let result: HashSet<_> = 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)
}
impl Rust {
pub(crate) fn new() -> Self {
Self {
packages: HashSet::new(),
}
}
}
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)
}
@@ -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
}
@@ -0,0 +1,223 @@
mod helpers;
mod types;
use crate::backend::backend_trait::{Backend, Switches, Text};
use crate::backend::macros::impl_backend_constants;
use crate::cmd::run_external_command;
use crate::{Group, Package};
use anyhow::{bail, Context, Result};
use std::collections::HashSet;
use std::process::Command;
use self::helpers::{
group_components_by_toolchains, install_components, toolchain_of_component_was_already_removed,
};
pub use self::types::Rustup;
use self::types::{Repotype, RustupPackage};
const BINARY: Text = "rustup";
const SECTION: Text = "rustup";
const SWITCHES_INSTALL: Switches = &["component", "add"];
const SWITCHES_INFO: Switches = &["component", "list", "--installed"];
const SWITCHES_MAKE_DEPENDENCY: Switches = &[];
const SWITCHES_NOCONFIRM: Switches = &[];
const SWITCHES_REMOVE: Switches = &["component", "remove"];
const SUPPORTS_AS_DEPENDENCY: bool = false;
impl Backend for Rustup {
impl_backend_constants!();
fn get_all_installed_packages(&self) -> Result<HashSet<Package>> {
let toolchains_vec = self
.run_toolchain_command(Repotype::Toolchain.get_info_switches())
.context("Getting installed toolchains")?;
let toolchains: HashSet<Package> = toolchains_vec
.iter()
.map(|name| ["toolchain", name].join("/").into())
.collect();
let components: HashSet<Package> = 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 = HashSet::new();
packages.extend(toolchains);
packages.extend(components);
Ok(packages)
}
fn get_explicitly_installed_packages(&self) -> Result<HashSet<Package>> {
self.get_all_installed_packages()
.context("Getting all installed packages")
}
fn make_dependency(&self, _: &[Package]) -> Result<()> {
panic!("Not supported by {}", self.get_binary())
}
fn install_packages(&self, packages: &[Package], _: 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: &[Package], _: 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.get_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.get_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.get_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.get_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.get_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.get_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(())
}
}
@@ -0,0 +1,149 @@
use anyhow::{bail, Context, Result};
use std::collections::HashSet;
use crate::{backend::backend_trait::Switches, Package};
#[derive(Debug, Clone)]
pub struct Rustup {
pub(crate) packages: HashSet<Package>,
}
#[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 Rustup {
pub fn new() -> Self {
Self {
packages: HashSet::new(),
}
}
}
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: &[Package]) -> 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))
}
}
+134
View File
@@ -0,0 +1,134 @@
use std::collections::HashSet;
use std::process::Command;
use anyhow::Result;
use regex::Regex;
use crate::backend::backend_trait::{Backend, Switches, Text};
use crate::backend::macros::impl_backend_constants;
use crate::backend::root::build_base_command_with_privileges;
use crate::cmd::run_external_command;
use crate::{Group, Package};
#[derive(Debug, Clone)]
pub struct Void {
pub(crate) packages: HashSet<Package>,
}
const BINARY: Text = "xbps-install";
const INSTALL_BINARY: Text = "xbps-install";
const REMOVE_BINARY: Text = "xbps-remove";
const QUERY_BINARY: Text = "xbps-query";
const PKGDB_BINARY: Text = "xbps-pkgdb";
const SECTION: Text = "void";
const SWITCHES_INFO: Switches = &[];
const SWITCHES_INSTALL: Switches = &["-S"];
const SWITCHES_MAKE_DEPENDENCY: Switches = &["-m", "auto"];
const SWITCHES_NOCONFIRM: Switches = &["-y"];
const SWITCHES_REMOVE: Switches = &["-R"];
const SUPPORTS_AS_DEPENDENCY: bool = true;
impl Backend for Void {
impl_backend_constants!();
fn get_all_installed_packages(&self) -> Result<HashSet<Package>> {
// 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<HashSet<Package>> {
// 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: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = build_base_command_with_privileges(INSTALL_BINARY);
cmd.args(self.get_switches_install());
if noconfirm {
cmd.args(self.get_switches_noconfirm());
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
fn remove_packages(&self, packages: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = build_base_command_with_privileges(REMOVE_BINARY);
cmd.args(self.get_switches_remove());
if noconfirm {
cmd.args(self.get_switches_noconfirm());
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
fn make_dependency(&self, packages: &[Package]) -> Result<()> {
let mut cmd = build_base_command_with_privileges(PKGDB_BINARY);
cmd.args(self.get_switches_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 mut cmd = Command::new(QUERY_BINARY);
cmd.args(self.get_switches_info());
cmd.arg(format!("{package}"));
run_external_command(cmd)
}
}
impl Void {
pub(crate) fn new() -> Self {
Self {
packages: HashSet::new(),
}
}
}
+203
View File
@@ -0,0 +1,203 @@
use std::any::Any;
use std::cmp::{Eq, Ord};
use std::collections::{HashMap, HashSet};
use std::fmt::Debug;
use std::hash::Hash;
use std::process::Command;
use std::rc::Rc;
use anyhow::{Context, Result};
use crate::cmd::run_external_command;
use crate::{Group, Package};
pub(in crate::backend) type Switches = &'static [&'static str];
pub(in crate::backend) type Text = &'static str;
/// The trait of a struct that is used as a backend.
pub trait Backend: Debug {
/// Return the actual binary. Iff the backend supports different
/// binaries, you will need to overwrite this implementation to return
/// the binary that was loaded at runtime. See
/// [`Backend::get_binary_default()`].
fn get_binary(&self) -> Text {
self.get_binary_default()
}
/// Return the default binary as defined in the constant of the module.
/// See [`Backend::get_binary()`].
fn get_binary_default(&self) -> Text;
/// Get the name of the section in the group files.
fn get_section(&self) -> Text;
/// Get CLI switches for the package manager to show information for
/// packages.
fn get_switches_info(&self) -> Switches;
/// Get CLI switches for the package manager to install packages.
fn get_switches_install(&self) -> Switches;
/// Get CLI switches for the package manager to perform `sync` and `clean` without
/// confirmation.
fn get_switches_noconfirm(&self) -> Switches;
/// Get CLI switches for the package manager to remove packages.
fn get_switches_remove(&self) -> Switches;
/// Get CLI switches for the package manager to mark packages as
/// dependency. This is not supported by all package managers. See
/// [`Backend::supports_as_dependency`].
fn get_switches_make_dependency(&self) -> Switches;
/// Load all packages from a set of groups. The backend will visit all groups,
/// find its own section, and clone all packages into its own struct.
fn load(&mut self, groups: &HashSet<Group>);
/// Get all managed packages for this backend, i.e. all packages
/// under the corresponding section in all group files.
fn get_managed_packages(&self) -> &HashSet<Package>;
/// 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<HashSet<Package>>;
/// 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<HashSet<Package>>;
/// Assign each of the packages to an individual group by editing the
/// group files.
fn assign_group(&self, to_assign: Vec<(Package, Rc<Group>)>) -> Result<()> {
let group_package_map = to_hashmap(to_assign);
let section_header = format!("[{}]", self.get_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: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_install());
if noconfirm {
cmd.args(self.get_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 depedent packages.
fn make_dependency(&self, packages: &[Package]) -> Result<()> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_make_dependency());
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Remove the specified packages.
///
fn remove_packages(&self, packages: &[Package], noconfirm: bool) -> Result<()> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_remove());
if noconfirm {
cmd.args(self.get_switches_noconfirm());
}
for p in packages {
cmd.arg(format!("{p}"));
}
run_external_command(cmd)
}
/// Get missing packages, sorted alphabetically.
fn get_missing_packages_sorted(&self) -> Result<Vec<Package>> {
let installed = self
.get_all_installed_packages()
.context("could not get installed packages")?;
let managed = self.get_managed_packages();
let mut diff: Vec<_> = managed.difference(&installed).cloned().collect();
diff.sort_unstable();
Ok(diff)
}
/// Show information from package manager for package.
fn show_package_info(&self, package: &Package) -> Result<()> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_info());
cmd.arg(format!("{package}"));
run_external_command(cmd)
}
/// Get unmanaged packages, sorted alphabetically.
fn get_unmanaged_packages_sorted(&self) -> Result<Vec<Package>> {
let installed = self
.get_explicitly_installed_packages()
.context("could not get explicitly installed packages")?;
let required = self.get_managed_packages();
let mut diff: Vec<_> = installed.difference(required).cloned().collect();
diff.sort_unstable();
Ok(diff)
}
/// Return a mutable reference to self as `Any`. Required for downcasting.
fn as_any_mut(&mut self) -> &mut dyn Any;
/// Whether the underlying package manager supports dependency packages.
fn supports_as_dependency(&self) -> bool;
}
/// For a vector of tuples containing a `V` and `K`, where a `K` may occur more than
/// once and each `V` exactly once, create a `HashMap` that associates each `K` with
/// a `Vec<V>`.
fn to_hashmap<K, V>(to_assign: Vec<(V, K)>) -> HashMap<K, Vec<V>>
where
K: Hash + Eq,
V: Ord,
{
let mut map = HashMap::new();
for (value, key) in to_assign {
let inner: &mut Vec<V> = map.entry(key).or_default();
inner.push(value);
}
for vecs in map.values_mut() {
vecs.sort_unstable();
}
map
}
+21
View File
@@ -0,0 +1,21 @@
use super::{Backend, Backends};
#[derive(Debug)]
pub struct BackendIter {
pub(crate) next: Option<Backends>,
}
impl Iterator for BackendIter {
type Item = Box<dyn Backend>;
fn next(&mut self) -> Option<Self::Item> {
match &self.next {
None => None,
Some(b) => {
let result = b.get_backend();
self.next = b.next();
Some(result)
}
}
}
}
+60
View File
@@ -0,0 +1,60 @@
/// Used to implement parts of the `Backend` trait that should not change between the actual
/// backends (boilerplate).
macro_rules! impl_backend_constants {
() => {
fn get_binary_default(&self) -> Text {
BINARY
}
fn get_section(&self) -> Text {
SECTION
}
fn get_switches_info(&self) -> Switches {
SWITCHES_INFO
}
fn get_switches_install(&self) -> Switches {
SWITCHES_INSTALL
}
fn get_switches_noconfirm(&self) -> Switches {
SWITCHES_NOCONFIRM
}
fn get_switches_remove(&self) -> Switches {
SWITCHES_REMOVE
}
fn get_switches_make_dependency(&self) -> Switches {
SWITCHES_MAKE_DEPENDENCY
}
fn get_managed_packages(&self) -> &HashSet<Package> {
&self.packages
}
fn load(&mut self, groups: &HashSet<Group>) {
let own_section_name = self.get_section();
groups
.iter()
.flat_map(|g| &g.sections)
.filter(|section| section.name == own_section_name)
.flat_map(|section| &section.packages)
.for_each(|package| {
self.packages.insert(package.clone());
})
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn supports_as_dependency(&self) -> bool {
SUPPORTS_AS_DEPENDENCY
}
};
}
pub(crate) use impl_backend_constants;
+26
View File
@@ -0,0 +1,26 @@
mod actual;
mod backend_trait;
mod iter;
mod macros;
mod root;
mod todo_per_backend;
pub use backend_trait::Backend;
pub use iter::BackendIter;
pub use todo_per_backend::ToDoPerBackend;
use pacdef_macros::Register;
#[derive(Debug, Register)]
pub enum Backends {
#[cfg(feature = "arch")]
Arch,
#[cfg(feature = "debian")]
Debian,
Flatpak,
Fedora,
Python,
Rust,
Rustup,
Void,
}
+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
}
@@ -0,0 +1,93 @@
use std::fmt::Write;
use anyhow::{Context, Result};
use super::Backend;
use crate::Package;
/// A vector of tuples containing a `dyn Backend` 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<(Box<dyn Backend>, Vec<Package>)>);
impl ToDoPerBackend {
pub(crate) fn new() -> Self {
Self(vec![])
}
pub(crate) fn push(&mut self, item: (Box<dyn Backend>, Vec<Package>)) {
self.0.push(item);
}
pub(crate) fn into_iter(self) -> impl Iterator<Item = (Box<dyn Backend>, Vec<Package>)> {
self.0.into_iter()
}
pub(crate) fn iter(&self) -> impl Iterator<Item = &(Box<dyn Backend>, Vec<Package>)> {
self.0.iter()
}
pub(crate) fn nothing_to_do_for_all_backends(&self) -> bool {
self.0.iter().all(|(_, diff)| diff.is_empty())
}
pub(crate) fn install_missing_packages(&self, noconfirm: bool) -> Result<()> {
for (backend, packages) in &self.0 {
if packages.is_empty() {
continue;
}
Backend::install_packages(&**backend, packages, noconfirm)
.with_context(|| format!("installing packages for {}", backend.get_section()))?;
}
Ok(())
}
pub(crate) fn remove_unmanaged_packages(&self, noconfirm: bool) -> Result<()> {
for (backend, packages) in &self.0 {
if packages.is_empty() {
continue;
}
Backend::remove_packages(&**backend, packages, noconfirm)
.with_context(|| format!("removing packages for {}", backend.get_section()))?;
}
Ok(())
}
pub(crate) 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.get_section()))?;
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(())
}
}
+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(())
}
+99
View File
@@ -0,0 +1,99 @@
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_derive::{Deserialize, Serialize};
// Update the master README if fields change.
/// Config for the program, as listed in `$XDG_CONFIG_HOME/pacdef/pacdef.yaml`.
#[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!(crate::Error::ConfigFileNotFound)
}
bail!("unexpected error occured: {e:?}");
}
};
serde_yaml::from_str(&content).context("parsing yaml 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 = serde_yaml::to_string(&self).context("converting Config to yaml")?;
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(),
}
}
}
+636
View File
@@ -0,0 +1,636 @@
use std::collections::{HashMap, HashSet};
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 anyhow::{bail, ensure, Context, Result};
use const_format::formatcp;
use crate::args::{self, PackageAction};
use crate::backend::{Backend, Backends, ToDoPerBackend};
use crate::cmd::run_edit_command;
use crate::env::should_print_debug_info;
use crate::path::{binary_in_path, get_absolutized_file_paths, get_group_dir};
use crate::search;
use crate::ui::get_user_confirmation;
use crate::Config;
use crate::Group;
use crate::{review, Error};
/// Most data that is required during runtime of the program.
pub struct Pacdef {
/// The command line arguments. Is an `Option` so that we can take ownership later without cloning.
args: Option<args::Arguments>,
/// The config of the program.
config: Config,
/// The hashset of all groups.
groups: HashSet<Group>,
}
impl Pacdef {
/// Creates a new [`Pacdef`]. `config` should be passed from [`Config::load`], and `args` from
/// [`args::get`].
#[must_use]
pub const fn new(args: args::Arguments, config: Config, groups: HashSet<Group>) -> Self {
Self {
args: Some(args),
config,
groups,
}
}
/// 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.
///
/// # Panics
///
/// This function panics if the `args` field is `None`.
#[allow(clippy::unit_arg)]
pub fn run_action_from_arg(mut self) -> Result<()> {
match self
.args
.take()
.expect("if there were no args we would not get to here")
{
args::Arguments::Group(group_args) => self.run_group_subcommand(&group_args),
args::Arguments::Package(package_args) => self.run_package_subcommand(&package_args),
args::Arguments::Version => Ok(self.show_version()),
}
}
fn run_group_subcommand(self, args: &args::GroupAction) -> Result<()> {
use args::GroupAction::*;
match args {
Edit(args::Groups(groups)) => self.edit_groups(groups),
Export(args::Groups(groups), args::OutputDir(dir), args::Force(force)) => {
self.export_groups(groups, dir.as_ref(), *force)
}
Import(args::Groups(groups)) => self.import_groups(groups),
List => self.show_groups(),
New(args::Groups(groups), args::Edit(edit)) => self.new_groups(groups, *edit),
Remove(args::Groups(groups)) => self.remove_groups(groups),
Show(args::Groups(groups)) => self.show_group_content(groups),
}
}
fn run_package_subcommand(mut self, args: &PackageAction) -> Result<()> {
use args::PackageAction::*;
match args {
Clean(args::Noconfirm(noconfirm)) => self.clean_packages(*noconfirm),
Review => review::review(self.get_unmanaged_packages()?, self.groups),
Search(args::Regex(regex)) => {
self.warn_about_groups_that_arent_symlinks();
search::search_packages(regex, &self.groups)
}
Sync(args::Noconfirm(noconfirm)) => self.install_packages(*noconfirm),
Unmanaged => self.show_unmanaged_packages(),
}
}
fn get_missing_packages(&mut self) -> Result<ToDoPerBackend> {
let mut to_install = ToDoPerBackend::new();
if self.groups.is_empty() {
eprintln!("WARNING: no group files found");
}
for mut backend in Backends::iter() {
if self
.config
.disabled_backends
.contains(&backend.get_section().to_string())
{
continue;
}
if !binary_in_path(backend.get_binary())? {
continue;
}
self.overwrite_values_from_config(&mut *backend);
backend.load(&self.groups);
match backend.get_missing_packages_sorted() {
Ok(diff) => to_install.push((backend, diff)),
Err(error) => show_backend_query_error(&error, &*backend),
};
}
Ok(to_install)
}
fn overwrite_values_from_config(&mut self, backend: &mut dyn Backend) {
#[cfg(feature = "arch")]
{
if let Some(arch) = backend.as_any_mut().downcast_mut::<crate::backend::Arch>() {
arch.binary = self.config.aur_helper.clone();
arch.aur_rm_args = self.config.aur_rm_args.clone();
}
}
if let Some(flatpak) = backend
.as_any_mut()
.downcast_mut::<crate::backend::Flatpak>()
{
flatpak.systemwide = self.config.flatpak_systemwide;
}
if let Some(python) = backend
.as_any_mut()
.downcast_mut::<crate::backend::Python>()
{
python.binary = self.config.pip_binary.clone();
}
}
fn install_packages(&mut self, noconfirm: bool) -> Result<()> {
self.warn_about_groups_that_arent_symlinks();
let to_install = self.get_missing_packages()?;
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 noconfirm {
println!("proceeding without confirmation");
} else if !get_user_confirmation()? {
return Ok(());
}
to_install.install_missing_packages(noconfirm)
}
fn warn_about_groups_that_arent_symlinks(&self) {
for group in &self.groups {
if group.warn_symlink {
eprintln!(
"WARNING: group file {} is not a symlink",
group.path.to_string_lossy()
);
}
}
}
fn edit_groups(&self, groups: &[String]) -> Result<()> {
self.warn_about_groups_that_arent_symlinks();
if self.groups.is_empty() {
eprintln!("WARNING: no group files found");
}
let group_files: Vec<_> = find_groups_by_name(groups, &self.groups)
.context("getting group files for args")?
.into_iter()
.map(|g| g.path.as_path())
.collect();
run_edit_command(&group_files).context("running editor")?;
Ok(())
}
#[allow(clippy::unused_self)]
fn show_version(self) {
println!("{}", get_name_and_version());
}
fn show_unmanaged_packages(mut self) -> Result<()> {
let unmanaged_per_backend = &self.get_unmanaged_packages()?;
if unmanaged_per_backend.nothing_to_do_for_all_backends() {
return Ok(());
}
unmanaged_per_backend
.show()
.context("printing things to do")
}
/// 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(&mut self) -> Result<ToDoPerBackend> {
self.warn_about_groups_that_arent_symlinks();
if self.groups.is_empty() {
eprintln!("WARNING: no group files found");
}
let mut result = ToDoPerBackend::new();
for mut backend in Backends::iter() {
if self
.config
.disabled_backends
.contains(&backend.get_section().to_string())
{
continue;
}
if !binary_in_path(backend.get_binary())? {
continue;
}
self.overwrite_values_from_config(&mut *backend);
backend.load(&self.groups);
match backend.get_unmanaged_packages_sorted() {
Ok(unmanaged) => result.push((backend, unmanaged)),
Err(error) => show_backend_query_error(&error, &*backend),
};
}
Ok(result)
}
/// 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.
#[allow(clippy::unnecessary_wraps)]
fn show_groups(self) -> Result<()> {
self.warn_about_groups_that_arent_symlinks();
if self.groups.is_empty() {
eprintln!("WARNING: no group files found");
}
let mut vec: Vec<_> = self.groups.iter().collect();
vec.sort_unstable();
for g in vec {
println!("{}", g.name);
}
Ok(())
}
fn clean_packages(&mut self, noconfirm: bool) -> Result<()> {
let to_remove = self.get_unmanaged_packages()?;
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 noconfirm {
println!("proceeding without confirmation");
} else if !get_user_confirmation()? {
return Ok(());
}
to_remove.remove_unmanaged_packages(noconfirm)
}
fn show_group_content(&self, args: &[String]) -> Result<()> {
self.warn_about_groups_that_arent_symlinks();
if self.groups.is_empty() {
eprintln!("WARNING: no group files found");
}
let mut errors = vec![];
let mut groups = vec![];
// make sure all args exist before doing anything
for arg_group in args {
let possible_group = self.groups.iter().find(|g| g.name == **arg_group);
let Some(group) = possible_group else {
errors.push((*arg_group).clone());
continue;
};
groups.push(group);
}
// return an error if any arg was not found
ensure!(
errors.is_empty(),
crate::Error::MultipleGroupsNotFound(errors)
);
let show_more_than_one_group = args.len() > 1;
let mut iter = 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(())
}
#[allow(clippy::unused_self)]
fn import_groups(&self, file_names: &[String]) -> Result<()> {
let files = get_absolutized_file_paths(file_names)?;
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() {
eprintln!("file {target_name} does not exist, skipping");
continue;
}
let mut link = groups_dir.clone();
link.push(target_name);
if link.exists() {
eprintln!("group {target_name} already exists, skipping");
} else {
symlink(target, link)?;
}
}
Ok(())
}
fn remove_groups(&self, groups: &[String]) -> Result<()> {
if self.groups.is_empty() {
eprintln!("WARNING: no group files found");
}
let found = find_groups_by_name(groups, &self.groups)?;
for group in found {
remove_file(&group.path)?;
}
Ok(())
}
/// 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.
#[allow(clippy::unused_self)]
fn new_groups(&self, new_groups: &[String], edit: bool) -> Result<()> {
let group_path = get_group_dir()?;
// prevent group names that resolve to directories
for name in new_groups {
ensure!(
*name != "." && *name != "..",
crate::Error::InvalidGroupName(name.clone())
);
}
let paths: Vec<_> = new_groups
.iter()
.map(|name| {
let mut base = group_path.clone();
base.push(name);
base
})
.collect();
for file in &paths {
ensure!(
!file.exists(),
crate::Error::GroupAlreadyExists(file.clone())
);
}
for file in &paths {
File::create(file)?;
}
if edit {
run_edit_command(&paths).context("running editor")?;
}
Ok(())
}
/// 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 export_groups(
&self,
names: &[String],
output_dir: Option<&String>,
force: bool,
) -> Result<()> {
let groups = find_groups_by_name(names, &self.groups)?;
let output_dir = match output_dir.map(PathBuf::from) {
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!(
!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(())
}
}
/// 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 HashSet<Group>) -> 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`.
#[allow(clippy::option_if_let_else)]
fn show_backend_query_error(error: &anyhow::Error, backend: &dyn Backend) {
let section = backend.get_section();
if should_print_debug_info() {
eprintln!("WARNING: skipping backend '{section}':");
for err in error.chain() {
eprintln!(" {err}");
}
} else {
eprintln!("WARNING: skipping backend '{section}': {error}");
}
}
/// If the crate was compiled from git, return `pacdef, <version> (<hash>)`.
/// Otherwise return `pacdef, <version>`.
fn get_name_and_version() -> String {
let backends = get_included_backends();
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);
}
result
}
/// 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() -> Vec<&'static str> {
let mut result = vec![];
for backend in Backends::iter() {
result.push(backend.get_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,
}
}
+51
View File
@@ -0,0 +1,51 @@
use std::error::Error as ErrorTrait;
use std::fmt::{Display, Write};
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 => f.write_str("no packages matching query"),
Self::ConfigFileNotFound => f.write_str("config file not found"),
Self::GroupFileNotFound(name) => f.write_str(&format!("group file '{name}' not found")),
Self::GroupAlreadyExists(path) => f.write_str(&format!(
"group file '{}' already exists",
path.to_string_lossy()
)),
Self::InvalidGroupName(name) => {
f.write_str(&format!("group name '{name}' is not valid"))
}
Self::MultipleGroupsNotFound(vec) => {
f.write_str("could not find the following groups:\n")?;
let mut iter = vec.iter().peekable();
while let Some(group) = iter.next() {
f.write_str(&format!(" {group}"))?;
if iter.peek().is_some() {
f.write_char('\n')?;
}
}
Ok(())
}
}
}
}
impl ErrorTrait for Error {}
+328
View File
@@ -0,0 +1,328 @@
use std::collections::HashSet;
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 super::{Package, Section};
/// Representation of a group file.
#[derive(Debug)]
pub struct Group {
/// Name of the group (file name from which it was read, relative to the group
/// base dir).
pub(crate) name: String,
/// The sections in the file which in turn hold the packages.
pub(crate) sections: HashSet<Section>,
/// The absolute path of the original file.
pub(crate) path: PathBuf,
/// Whether the main program should warn this group being loaded from a symlink.
pub(crate) 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<HashSet<Self>> {
let mut result = HashSet::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 = HashSet::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();
eprintln!("WARNING: could not process a section under group '{name}': {err}");
}
}
}
if sections.is_empty() {
eprintln!("WARNING: 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(crate) fn save_packages(&self, section_header: &str, packages: &[Package]) -> 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: &[Package],
) -> 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: &[Package],
) {
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);
}
}
+17
View File
@@ -0,0 +1,17 @@
/*!
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.
*/
mod group;
mod package;
mod section;
pub use group::Group;
pub use package::Package;
pub use section::Section;
+127
View File
@@ -0,0 +1,127 @@
use std::fmt::{Display, Write};
use std::hash::Hash;
/// A struct to represent a single package, consiting of a `name`, and
/// optionally a `repo`.
#[derive(Debug, Eq, PartialOrd, Ord, Clone)]
pub struct Package {
pub(crate) name: String,
pub(crate) 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.
/// Returns `None` if there is nothing left after trimming.
pub(crate) 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 {
let self_repo = self.repo.as_ref();
let other_repo = other.repo.as_ref();
// iff both packages have repos, they must be identical, otherwise we don't care
let repos_are_identical =
self_repo.map_or(true, |sr| other_repo.map_or(true, |or| sr == or));
let names_are_identical = self.name == other.name;
names_are_identical && repos_are_identical
}
}
impl Hash for Package {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.name.hash(state);
}
}
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);
}
#[allow(clippy::unwrap_used)]
#[test]
fn from() {
let x = "myrepo/somepackage # ".to_string();
let p = Package::try_from(x).unwrap();
assert_eq!(p.name, "somepackage");
assert_eq!(p.repo, Some("myrepo".to_string()));
}
}
+118
View File
@@ -0,0 +1,118 @@
use std::collections::HashSet;
use std::fmt::{Display, Write};
use std::hash::Hash;
use std::iter::Peekable;
use anyhow::{ensure, Context, Result};
use super::Package;
#[derive(Debug)]
pub struct Section {
pub name: String,
pub packages: HashSet<Package>,
}
impl Section {
pub(crate) fn new(name: String, packages: HashSet<Package>) -> Self {
Self { name, packages }
}
pub(crate) 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 = HashSet::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 HashSet<Package>) {
let package_name = package.name.clone();
let newly_inserted = packages.insert(package);
if !newly_inserted {
eprintln!("warning: {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(())
}
}
+45
View File
@@ -0,0 +1,45 @@
/*!
This library contains all logic that happens in `pacdef` under the hood.
*/
#![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
)]
mod args;
mod backend;
mod cmd;
mod config;
mod core;
mod env;
mod errors;
mod grouping;
pub mod path;
mod review;
mod search;
mod ui;
pub use crate::args::get as get_args;
pub use crate::config::Config;
pub use crate::core::Pacdef;
pub use crate::errors::Error;
pub use crate::grouping::Group;
pub(crate) use crate::grouping::Package;
extern crate pacdef_macros;
+99
View File
@@ -0,0 +1,99 @@
/*!
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 pacdef::path::{get_config_path, get_config_path_old_version, get_group_dir};
use pacdef::{get_args, Config, Error as PacdefError, Group, Pacdef};
const MAJOR_UPDATE_MESSAGE: &str = "VERSION UPGRADE
You seem to have used version 0.x of pacdef before.
Version 1.x changes the syntax of the config files and the command line arguments.
Check out https://github.com/steven-omaha/pacdef for new syntax information.
This message will not appear again.
------";
fn main() -> ExitCode {
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>() {
eprintln!("{root_error}");
ExitCode::FAILURE
} else {
result.report()
}
}
}
}
fn main_inner() -> Result<()> {
let args = get_args();
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()))?;
let pacdef = Pacdef::new(args, config, groups);
pacdef.run_action_from_arg().context("running action")
}
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(())
}
+177
View File
@@ -0,0 +1,177 @@
/*!
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.yaml";
const CONFIG_FILE_NAME_OLD: &str = "pacdef.conf";
/// 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(crate) 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(crate) 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(crate) 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.yaml`.
///
/// # 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(crate) 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(crate) 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.
pub(crate) 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);
}
}
+108
View File
@@ -0,0 +1,108 @@
use std::rc::Rc;
use crate::backend::Backend;
use crate::{Group, Package};
use super::strategy::Strategy;
#[derive(Debug, PartialEq)]
pub(super) enum ReviewAction {
AsDependency(Package),
Delete(Package),
AssignGroup(Package, Rc<Group>),
}
#[derive(Debug)]
pub(super) enum ReviewIntention {
AsDependency,
AssignGroup,
Delete,
Info,
Invalid,
Skip,
Quit,
Apply,
}
#[derive(Debug)]
pub(super) struct ReviewsPerBackend {
items: Vec<(Box<dyn Backend>, Vec<ReviewAction>)>,
}
impl ReviewsPerBackend {
pub(super) fn new() -> Self {
Self { items: vec![] }
}
pub(super) fn nothing_to_do(&self) -> bool {
self.items.iter().all(|(_, vec)| vec.is_empty())
}
pub(super) fn push(&mut self, value: (Box<dyn Backend>, 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(super) fn into_strategies(self) -> Vec<Strategy> {
let mut result = vec![];
for (backend, actions) in self {
let mut to_delete = vec![];
let mut assign_group = vec![];
let mut as_dependency = vec![];
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 = (Box<dyn Backend>, Vec<ReviewAction>);
type IntoIter = std::vec::IntoIter<(Box<dyn Backend>, Vec<ReviewAction>)>;
fn into_iter(self) -> Self::IntoIter {
self.items.into_iter()
}
}
pub(super) enum ContinueWithReview {
Yes,
No,
NoAndApply,
}
fn extract_actions(
actions: Vec<ReviewAction>,
to_delete: &mut Vec<Package>,
assign_group: &mut Vec<(Package, Rc<Group>)>,
as_dependency: &mut Vec<Package>,
) {
for action in actions {
match action {
ReviewAction::Delete(package) => to_delete.push(package),
ReviewAction::AssignGroup(package, group) => assign_group.push((package, group)),
ReviewAction::AsDependency(package) => as_dependency.push(package),
}
}
}
+186
View File
@@ -0,0 +1,186 @@
mod datastructures;
mod strategy;
use std::io::{stdin, stdout, Write};
use std::rc::Rc;
use anyhow::Result;
use crate::backend::{Backend, ToDoPerBackend};
use crate::ui::{get_user_confirmation, read_single_char_from_terminal};
use crate::{Group, Package};
use self::datastructures::{ContinueWithReview, ReviewAction, ReviewIntention, ReviewsPerBackend};
use self::strategy::Strategy;
pub fn review(
todo_per_backend: ToDoPerBackend,
groups: impl IntoIterator<Item = Group>,
) -> Result<()> {
let mut reviews = ReviewsPerBackend::new();
let mut groups: Vec<Rc<Group>> = groups.into_iter().map(Rc::new).collect();
groups.sort_unstable();
if todo_per_backend.nothing_to_do_for_all_backends() {
println!("nothing to do");
return Ok(());
}
'outer: for (backend, packages) in todo_per_backend.into_iter() {
let mut actions = vec![];
for package in packages {
println!("{}: {package}", backend.get_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: &[Rc<Group>],
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));
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: &[Rc<Group>]) {
let number_digits = get_amount_of_digits_for_number(groups.len());
for (i, group) in groups.iter().enumerate() {
println!("{i:>number_digits$}: {}", group.name);
}
}
#[allow(clippy::as_conversions)] // this cannot introduce errors for any reasonably sized numbers.
fn get_amount_of_digits_for_number(number: usize) -> usize {
(number as f64).log10().trunc() as usize + 1
}
fn ask_group(groups: &[Rc<Group>]) -> Result<Option<Rc<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(Some(groups[idx].clone()))
} else {
Ok(None)
}
}
+79
View File
@@ -0,0 +1,79 @@
use std::rc::Rc;
use anyhow::Result;
use crate::backend::Backend;
use crate::{Group, Package};
#[derive(Debug)]
pub(super) struct Strategy {
backend: Box<dyn Backend>,
delete: Vec<Package>,
as_dependency: Vec<Package>,
assign_group: Vec<(Package, Rc<Group>)>,
}
impl Strategy {
pub(super) fn new(
backend: Box<dyn Backend>,
delete: Vec<Package>,
as_dependency: Vec<Package>,
assign_group: Vec<(Package, Rc<Group>)>,
) -> Self {
Self {
backend,
delete,
as_dependency,
assign_group,
}
}
pub(super) 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(super) fn show(&self) {
if self.nothing_to_do() {
return;
}
println!("[{}]", self.backend.get_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(super) fn nothing_to_do(&self) -> bool {
self.delete.is_empty() && self.as_dependency.is_empty() && self.assign_group.is_empty()
}
}
+100
View File
@@ -0,0 +1,100 @@
use std::collections::HashSet;
use std::iter::Peekable;
use std::vec::IntoIter;
use anyhow::{bail, Result};
use regex::Regex;
use crate::grouping::{Group, Package, Section};
/// 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: &HashSet<Group>) -> Result<()> {
if groups.is_empty() {
eprintln!("WARNING: no group files found");
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 = g.name.clone();
*s0 = s.name.clone();
}
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)
}