introduce workspace

This commit is contained in:
steven-omaha
2023-02-13 19:46:35 +01:00
parent c13233a2f3
commit d7ea6ce708
37 changed files with 90 additions and 68 deletions
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "core"
version = "1.0.0-beta2"
edition = "2021"
description = "declarative package manager for Arch Linux"
license = "GPLv3"
repository = "https://github.com/steven-omaha/pacdef"
[dependencies]
alpm = "*"
anyhow = "*"
clap = "*"
path-absolutize = "*"
regex = "*"
serde_json = "*"
serde_yaml = "*"
serde_derive = "*"
serde = "*"
termios = "*"
macros = {path = "../macros", version = "*"}
+10
View File
@@ -0,0 +1,10 @@
use std::process::Command;
fn main() {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.unwrap();
let git_hash = String::from_utf8(output.stdout).unwrap();
println!("cargo:rustc-env=GIT_HASH={git_hash}");
}
+17
View File
@@ -0,0 +1,17 @@
use macros::Action;
#[derive(Debug, Action)]
pub(crate) enum Actions {
Clean,
Edit,
Groups,
Import,
New,
Remove,
Review,
Search,
Show,
Sync,
Unmanaged,
Version,
}
+82
View File
@@ -0,0 +1,82 @@
use std::path::PathBuf;
use clap::{Arg, ArgMatches, Command};
use path_absolutize::Absolutize;
use crate::action::*;
use crate::core::get_version_string;
fn get_arg_parser() -> Command {
Command::new("pacdef")
.about("declarative package manager for Arch Linux")
.version(get_version_string())
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(Command::new(CLEAN).about("remove unmanaged packages"))
.subcommand(
Command::new(EDIT)
.about("edit one or more existing group files")
.arg_required_else_help(true)
.arg(Arg::new("group").num_args(1..)),
)
.subcommand(Command::new(GROUPS).about("show names of imported groups"))
.subcommand(
Command::new(IMPORT)
.about("import one or more group files")
.arg_required_else_help(true)
.arg(Arg::new("files").num_args(1..)),
)
.subcommand(
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..)),
)
.subcommand(
Command::new(REMOVE)
.about("remove one or more previously imported groups")
.arg_required_else_help(true)
.arg(Arg::new("groups").num_args(1..)),
)
.subcommand(Command::new(REVIEW).about("review unmanaged packages"))
.subcommand(
Command::new(SEARCH)
.about("search for packages which match a provided string literal or regex")
.arg_required_else_help(true)
.arg(Arg::new("string")),
)
.subcommand(
Command::new(SHOW)
.about("show packages under an imported group")
.arg_required_else_help(true)
.arg(Arg::new("group").num_args(1..)),
)
.subcommand(Command::new(SYNC).about("install packages from all imported groups"))
.subcommand(
Command::new(UNMANAGED)
.about("show explicitly installed packages not managed by pacdef"),
)
.subcommand(Command::new(VERSION).about("show version info"))
}
#[must_use]
pub fn get() -> clap::ArgMatches {
get_arg_parser().get_matches()
}
pub(crate) fn get_absolutized_file_paths(arg_match: &ArgMatches) -> Vec<PathBuf> {
arg_match
.get_many::<String>("files")
.unwrap()
.cloned()
.map(PathBuf::from)
.map(|path| path.absolutize().unwrap().into_owned())
.collect()
}
+2
View File
@@ -0,0 +1,2 @@
pub mod pacman;
pub mod rust;
+115
View File
@@ -0,0 +1,115 @@
use std::collections::HashSet;
use std::process::{Command, ExitStatus};
use alpm::Alpm;
use alpm::PackageReason::Explicit;
use anyhow::{Context, Result};
use crate::backend::backend_trait::*;
use crate::{impl_backend_constants, Group, Package};
#[derive(Debug)]
pub(crate) struct Pacman {
pub(crate) binary: String,
pub(crate) aur_rm_args: Option<Vec<String>>,
pub(crate) packages: HashSet<Package>,
}
const BINARY: Text = "paru";
const SECTION: Text = "pacman";
const SWITCHES_INFO: Switches = &["--query", "--info"];
const SWITCHES_INSTALL: Switches = &["--sync"];
const SWITCHES_MAKE_DEPENDENCY: Switches = &["--database", "--asdeps"];
const SWITCHES_REMOVE: Switches = &["--remove", "--recursive"];
impl Backend for Pacman {
impl_backend_constants!();
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]) -> Result<ExitStatus> {
let mut cmd = Command::new(&self.binary);
cmd.args(self.get_switches_install());
for p in packages {
cmd.arg(format!("{p}"));
}
cmd.status()
.with_context(|| format!("running command {cmd:?}"))
}
/// Remove the specified packages.
fn remove_packages(&self, packages: &[Package]) -> Result<ExitStatus> {
let mut cmd = Command::new(&self.binary);
cmd.args(self.get_switches_remove());
if let Some(rm_args) = &self.aur_rm_args {
cmd.args(rm_args);
}
for p in packages {
cmd.arg(format!("{p}"));
}
cmd.status()
.with_context(|| format!("running 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 Pacman {
pub(crate) fn new() -> Self {
Self {
binary: BINARY.to_string(),
aur_rm_args: None,
packages: HashSet::new(),
}
}
}
+76
View File
@@ -0,0 +1,76 @@
use std::collections::HashSet;
use std::fs::read_to_string;
use std::path::PathBuf;
use std::process::ExitStatus;
use anyhow::{Context, Result};
use serde_json::Value;
use crate::backend::backend_trait::*;
use crate::{impl_backend_constants, Group, Package};
#[derive(Debug)]
pub(crate) 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_REMOVE: Switches = &["uninstall"];
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 = read_to_string(file).context("reading crates file")?;
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<ExitStatus> {
unreachable!("cargo does not have unmanaged packages")
}
}
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_home_dir().context("getting home dir")?;
result.push(".cargo");
result.push(".crates2.json");
Ok(result)
}
+136
View File
@@ -0,0 +1,136 @@
use std::collections::HashMap;
use std::fmt::Debug;
use std::process::Command;
use std::rc::Rc;
use std::{collections::HashSet, process::ExitStatus};
use anyhow::{Context, Result};
use crate::{Group, Package};
pub(in crate::backend) type Switches = &'static [&'static str];
pub(in crate::backend) type Text = &'static str;
pub(crate) trait Backend: Debug {
fn get_binary(&self) -> Text;
fn get_section(&self) -> Text;
fn get_switches_info(&self) -> Switches;
fn get_switches_install(&self) -> Switches;
fn get_switches_remove(&self) -> Switches;
fn get_switches_make_dependency(&self) -> Switches;
fn load(&mut self, groups: &HashSet<Group>);
fn get_managed_packages(&self) -> &HashSet<Package>;
/// Get all packages that are installed in the system.
fn get_all_installed_packages(&self) -> Result<HashSet<Package>>;
/// Get all packages that were installed in the system explicitly.
fn get_explicitly_installed_packages(&self) -> Result<HashSet<Package>>;
fn assign_group(&self, to_assign: Vec<(Package, Rc<Group>)>) {
let group_package_map = get_group_packages_map(to_assign);
let section_header = format!("[{}]", self.get_section());
for (group, packages) in group_package_map {
group.save_packages(&section_header, &packages);
}
}
/// Install the specified packages.
fn install_packages(&self, packages: &[Package]) -> Result<ExitStatus> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_install());
for p in packages {
cmd.arg(format!("{p}"));
}
cmd.status()
.with_context(|| format!("running command {cmd:?}"))
}
fn make_dependency(&self, packages: &[Package]) -> Result<ExitStatus> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_make_dependency());
for p in packages {
cmd.arg(format!("{p}"));
}
cmd.status()
.with_context(|| format!("running command [{cmd:?}]"))
}
/// Remove the specified packages.
fn remove_packages(&self, packages: &[Package]) -> Result<ExitStatus> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_remove());
for p in packages {
cmd.arg(format!("{p}"));
}
cmd.status()
.with_context(|| format!("running command [{cmd:?}]"))
}
/// extract packages from its own section as read from group files
fn extract_packages_from_group_file_content(&self, content: &str) -> HashSet<Package> {
content
.lines()
.skip_while(|line| !line.starts_with(&format!("[{}]", self.get_section())))
.skip(1)
.filter(|line| !line.starts_with('['))
.fuse()
.filter_map(Package::try_from)
.collect()
}
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)
}
fn add_packages(&mut self, packages: HashSet<Package>);
/// Show information from package manager for package.
fn show_package_info(&self, package: &Package) -> Result<ExitStatus> {
let mut cmd = Command::new(self.get_binary());
cmd.args(self.get_switches_info());
cmd.arg(format!("{package}"));
cmd.status()
.with_context(|| format!("running command {cmd:?}"))
}
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)
}
}
fn get_group_packages_map(
to_assign: Vec<(Package, Rc<Group>)>,
) -> HashMap<Rc<Group>, Vec<Package>> {
let mut group_package_map = HashMap::new();
for (p, group) in to_assign {
if !group_package_map.contains_key(&group) {
group_package_map.insert(group.clone(), vec![]);
}
let inner = group_package_map.get_mut(&group).unwrap();
inner.push(p);
}
for vecs in group_package_map.values_mut() {
vecs.sort();
}
group_package_map
}
+21
View File
@@ -0,0 +1,21 @@
use super::{Backend, Backends};
#[derive(Debug)]
pub(crate) 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)
}
}
}
}
+51
View File
@@ -0,0 +1,51 @@
#[macro_export]
macro_rules! impl_backend_constants {
() => {
fn get_binary(&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_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 add_packages(&mut self, packages: HashSet<Package>) {
for p in packages {
self.packages.insert(p);
}
}
};
}
+17
View File
@@ -0,0 +1,17 @@
mod actual;
mod backend_trait;
mod iter;
mod macros;
mod todo_per_backend;
pub(crate) use backend_trait::Backend;
pub(crate) use iter::BackendIter;
pub(crate) use todo_per_backend::ToDoPerBackend;
use ::macros::Register;
#[derive(Debug, Register)]
pub(crate) enum Backends {
Pacman,
Rust,
}
@@ -0,0 +1,86 @@
use std::process::ExitStatus;
use anyhow::{bail, ensure, Context, Result};
use super::Backend;
use crate::Package;
#[derive(Debug)]
pub(crate) 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) -> Result<()> {
self.handle_backend_command(Backend::install_packages, "install", "installing")
.context("installing packages")
}
pub(crate) fn remove_unmanaged_packages(&self) -> Result<()> {
self.handle_backend_command(Backend::remove_packages, "remove", "removing")
.context("removing packages")
}
fn handle_backend_command<'a, F>(
&'a self,
func: F,
verb: &'_ str,
verb_continuous: &'_ str,
) -> Result<()>
where
F: Fn(&'a dyn Backend, &'a [Package]) -> Result<ExitStatus>,
{
for (backend, packages) in &self.0 {
if packages.is_empty() {
continue;
}
let exit_status = func(&**backend, packages).with_context(|| {
format!("{verb_continuous} packages for {}", backend.get_binary())
})?;
match exit_status.code() {
Some(val) => ensure!(val == 0, "command returned with exit code {val}"),
None => bail!("could not {verb} packages for {}", backend.get_binary()),
}
}
Ok(())
}
pub(crate) fn show(&self, indentend: bool) {
for (backend, packages) in self.iter() {
if packages.is_empty() {
continue;
}
if indentend {
print!(" ");
}
println!("[{}]", backend.get_section());
for package in packages {
if indentend {
print!(" ");
}
println!(" {package}");
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
use std::path::PathBuf;
use std::process::{Command, ExitStatus};
use anyhow::{anyhow, Context, Result};
use crate::env::get_editor;
pub fn run_edit_command(files: &[PathBuf]) -> Result<ExitStatus> {
let mut cmd = Command::new(get_editor().context("getting suitable editor")?);
cmd.current_dir(files[0].parent().unwrap());
for f in files {
cmd.arg(f.to_string_lossy().to_string());
}
cmd.status().map_err(|e| anyhow!(e))
}
+63
View File
@@ -0,0 +1,63 @@
use std::fs::{read_to_string, File};
use std::io::{ErrorKind, Write};
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use serde_derive::{Deserialize, Serialize};
use crate::path::get_pacdef_base_dir;
const CONFIG_FILE_NAME: &str = "pacdef.yaml";
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub aur_helper: String,
pub aur_rm_args: Option<Vec<String>>,
pub warn_not_symlinks: bool,
}
impl Config {
pub fn load() -> Result<Self> {
let file = get_config_file()?;
let from_file = read_to_string(&file);
if let Err(e) = from_file {
if e.kind() == ErrorKind::NotFound {
println!("creating default config under {file:?}");
return Self::use_default_and_save_to(file);
} else {
bail!("unexpected error occured: {e:?}");
};
}
let content = from_file.unwrap();
serde_yaml::from_str(&content).context("parsing yaml config")
}
fn use_default_and_save_to(file: PathBuf) -> Result<Self> {
let result = Self::default();
let content = serde_yaml::to_string(&result).context("converting Config to yaml")?;
let mut output = File::create(file).context("creating default config file")?;
write!(output, "{content}").context("writing default config")?;
Ok(result)
}
}
fn get_config_file() -> Result<PathBuf> {
let mut file = get_pacdef_base_dir().context("getting pacdef base dir for config file")?;
file.push(CONFIG_FILE_NAME);
Ok(file)
}
impl Default for Config {
fn default() -> Self {
Self {
aur_helper: "paru".into(),
aur_rm_args: None,
warn_not_symlinks: true,
}
}
}
+318
View File
@@ -0,0 +1,318 @@
use std::collections::HashSet;
use std::fs::{remove_file, File};
use std::os::unix::fs::symlink;
use std::path::PathBuf;
use anyhow::{anyhow, ensure, Context, Result};
use clap::ArgMatches;
use crate::action::*;
use crate::args;
use crate::backend::{Backend, Backends, ToDoPerBackend};
use crate::cmd::run_edit_command;
use crate::env::get_single_var;
use crate::path::get_pacdef_group_dir;
use crate::review;
use crate::search;
use crate::ui::get_user_confirmation;
use crate::Config;
use crate::Group;
pub struct Pacdef {
args: ArgMatches,
config: Config,
groups: HashSet<Group>,
}
impl Pacdef {
#[must_use]
pub fn new(args: ArgMatches, config: Config, groups: HashSet<Group>) -> Self {
Self {
args,
config,
groups,
}
}
#[allow(clippy::unit_arg)]
pub fn run_action_from_arg(mut self) -> Result<()> {
match self.args.subcommand() {
Some((CLEAN, _)) => self.clean_packages(),
Some((EDIT, args)) => self.edit_group_files(args).context("editing group files"),
Some((GROUPS, _)) => Ok(self.show_groups()),
Some((IMPORT, args)) => self.import_groups(args).context("importing groups"),
Some((NEW, args)) => self.new_groups(args).context("creating new group files"),
Some((REMOVE, args)) => self.remove_groups(args).context("removing groups"),
Some((REVIEW, _)) => review::review(self.get_unmanaged_packages(), self.groups)
.context("removing groups"),
Some((SHOW, args)) => self.show_group_content(args).context("showing groups"),
Some((SEARCH, args)) => {
search::search_packages(args, &self.groups).context("searching packages")
}
Some((SYNC, _)) => self.install_packages(),
Some((UNMANAGED, _)) => Ok(self.show_unmanaged_packages()),
Some((VERSION, _)) => Ok(self.show_version()),
Some((_, _)) => panic!(),
None => {
unreachable!("argument parser requires some subcommand to return an `ArgMatches`")
}
}
}
fn get_missing_packages(&mut self) -> ToDoPerBackend {
let mut to_install = ToDoPerBackend::new();
for backend in Backends::iter() {
let mut backend = self.overwrite_values_from_config(backend);
backend.load(&self.groups);
match backend.get_missing_packages_sorted() {
Ok(diff) => to_install.push((backend, diff)),
Err(error) => show_error(&error, &*backend),
};
}
to_install
}
fn overwrite_values_from_config(&mut self, backend: Box<dyn Backend>) -> Box<dyn Backend> {
if backend.get_section() == "pacman" {
Box::new(crate::backend::Pacman {
binary: self.config.aur_helper.clone(),
aur_rm_args: self.config.aur_rm_args.take(),
packages: HashSet::new(),
})
} else {
backend
}
}
fn install_packages(&mut self) -> Result<()> {
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:");
to_install.show(true);
if !get_user_confirmation()? {
return Ok(());
};
to_install.install_missing_packages()
}
fn edit_group_files(&self, groups: &ArgMatches) -> Result<()> {
let group_dir = crate::path::get_pacdef_group_dir()?;
let files: Vec<_> = groups
.get_many::<String>("group")
.context("getting group from args")?
.map(|file| {
let mut buf = group_dir.clone();
buf.push(file);
buf
})
.collect();
for file in &files {
ensure!(
file.exists(),
"group file {} not found",
file.to_string_lossy()
);
}
let success = run_edit_command(&files)
.context("running editor")?
.success();
ensure!(success, "editor exited with error");
Ok(())
}
fn show_version(self) {
println!("{}", get_version_string());
}
fn show_unmanaged_packages(mut self) {
let unmanaged_per_backend = &self.get_unmanaged_packages();
unmanaged_per_backend.show(false);
}
fn get_unmanaged_packages(&mut self) -> ToDoPerBackend {
let mut result = ToDoPerBackend::new();
for backend in Backends::iter() {
let mut backend = self.overwrite_values_from_config(backend);
backend.load(&self.groups);
match backend.get_unmanaged_packages_sorted() {
Ok(unmanaged) => result.push((backend, unmanaged)),
Err(error) => show_error(&error, &*backend),
};
}
result
}
fn show_groups(self) {
let mut vec: Vec<_> = self.groups.iter().collect();
vec.sort_unstable();
for g in vec {
println!("{}", g.name);
}
}
fn clean_packages(mut self) -> 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");
to_remove.show(true);
if !get_user_confirmation()? {
return Ok(());
};
to_remove.remove_unmanaged_packages()
}
fn show_group_content(&self, groups: &ArgMatches) -> Result<()> {
let mut iter = groups.get_many::<String>("group").unwrap().peekable();
let show_more_than_one_group = iter.size_hint().0 > 1;
while let Some(arg_group) = iter.next() {
let group = self
.groups
.iter()
.find(|g| g.name == *arg_group)
.ok_or_else(|| anyhow!("group {} not found", *arg_group))?;
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(())
}
fn import_groups(&self, args: &ArgMatches) -> Result<()> {
let files = args::get_absolutized_file_paths(args);
let groups_dir = get_pacdef_group_dir()?;
for target in files {
let target_name = target.file_name().unwrap().to_str().unwrap();
if !target.exists() {
println!("file {target_name} does not exist, skipping");
continue;
}
let mut link = groups_dir.clone();
link.push(target_name);
if link.exists() {
println!("group {target_name} already exists, skipping");
} else {
symlink(target, link)?;
}
}
Ok(())
}
fn remove_groups(&self, arg_match: &ArgMatches) -> Result<()> {
let paths = get_assumed_group_file_names(arg_match)?;
for file in &paths {
ensure!(file.exists(), "did not find the group under {file:?}");
}
for file in paths {
remove_file(file)?;
}
Ok(())
}
fn new_groups(&self, arg: &ArgMatches) -> Result<()> {
let paths = get_assumed_group_file_names(arg)?;
for file in &paths {
ensure!(!file.exists(), "group already exists under {file:?}");
}
for file in &paths {
File::create(file)?;
}
if arg.get_flag("edit") {
let success = run_edit_command(&paths)
.context("running editor")?
.success();
ensure!(success, "editor exited with error");
}
Ok(())
}
}
fn get_assumed_group_file_names(arg_match: &ArgMatches) -> Result<Vec<PathBuf>> {
let groups_dir = get_pacdef_group_dir()?;
let paths: Vec<_> = arg_match
.get_many::<String>("groups")
.unwrap()
.map(|s| {
let mut possible_group_file = groups_dir.clone();
possible_group_file.push(s);
possible_group_file
})
.collect();
Ok(paths)
}
fn show_error(error: &anyhow::Error, backend: &dyn Backend) {
let section = backend.get_section();
match get_single_var("RUST_BACKTRACE") {
Some(s) => {
if s == "1" || s == "full" {
println!("WARNING: skipping backend '{section}': {error:?}\n");
}
}
None => println!("WARNING: skipping backend '{section}': {error}"),
}
}
pub(crate) const fn get_version_string() -> &'static str {
concat!(
"pacdef, version: ",
env!("CARGO_PKG_VERSION"),
" (",
env!("GIT_HASH"),
")",
)
}
+15
View File
@@ -0,0 +1,15 @@
use std::env::var;
use anyhow::{anyhow, Result};
pub(crate) 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())
}
pub(crate) fn get_single_var(variable: &str) -> Option<String> {
var(variable).ok()
}
+180
View File
@@ -0,0 +1,180 @@
use std::fs::{read_to_string, File};
use std::hash::Hash;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::{collections::HashSet, fmt::Display};
use anyhow::{Context, Result};
use super::{Package, Section};
use crate::Config;
#[derive(Debug)]
pub struct Group {
pub(crate) name: String,
pub(crate) sections: HashSet<Section>,
pub(crate) path: PathBuf,
}
impl Group {
pub fn load(config: &Config) -> Result<HashSet<Self>> {
let mut result = HashSet::new();
let path = crate::path::get_pacdef_group_dir().context("getting pacdef group dir")?;
for entry in path.read_dir().context("reading group dir")? {
let file = entry.context("getting group file")?;
let path = file.path();
if config.warn_not_symlinks && !path.is_symlink() {
println!("WARNING: group file {path:?} is not a symlink");
}
let group =
Group::try_from(&path).with_context(|| format!("reading group file {path:?}"))?;
result.insert(group);
}
Ok(result)
}
}
impl PartialOrd for Group {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
match self.name.partial_cmp(&other.name) {
Some(core::cmp::Ordering::Equal) => None,
ord => ord,
}
}
}
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 {
fn try_from<P>(p: P) -> Result<Self>
where
P: AsRef<Path>,
{
let path = p.as_ref();
let content = read_to_string(path).context("reading file content")?;
let name = path
.file_name()
.context("getting file name")?
.to_string_lossy()
.to_string();
let mut lines = content.lines().peekable();
let mut sections = HashSet::new();
while lines.peek().is_some() {
match Section::try_from_lines(&mut lines).context("reading section") {
Ok(section) => {
sections.insert(section);
}
Err(e) => {
println!("WARNING: could not process a section under group '{name}': {e:?}\n");
}
}
}
if sections.is_empty() {
println!("WARNING: no sections found in group '{name}'");
}
let path = path.into();
Ok(Self {
name,
sections,
path,
})
}
pub(crate) fn save_packages(&self, section_header: &str, packages: &[Package]) {
let mut content = read_to_string(&self.path).unwrap();
if content.contains(section_header) {
write_packages_to_existing_section(&mut content, section_header, packages);
} else {
add_new_section_with_packages(&mut content, section_header, packages);
}
let mut file = File::create(&self.path).unwrap();
write!(file, "{content}").unwrap();
}
}
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(())
}
}
fn write_packages_to_existing_section(
group_file_content: &mut String,
section_header: &str,
packages: &[Package],
) {
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);
}
fn find_first_package_line_in_section(group_file_content: &str, section_header: &str) -> usize {
let section_start = group_file_content.find(section_header).unwrap();
let distance_to_next_newline = group_file_content[section_start..].find('\n').unwrap();
section_start + distance_to_next_newline + 1 // + 1 to be after the newline
}
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"));
}
}
+7
View File
@@ -0,0 +1,7 @@
mod group;
mod package;
mod section;
pub use group::Group;
pub(super) use package::Package;
pub(super) use section::Section;
+105
View File
@@ -0,0 +1,105 @@
use std::fmt::{Display, Write};
use std::hash::Hash;
#[derive(Debug, Eq, PartialOrd, Ord, Clone)]
pub struct Package {
pub(crate) name: String,
repo: Option<String>,
}
fn remove_all_but_package_name(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_all_but_package_name(&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 Package {
fn split_into_name_and_repo(s: &str) -> (String, Option<String>) {
let mut iter = s.split('/').rev();
let name = iter.next().unwrap().to_string();
let repo = iter.next().map(|s| s.to_string());
(name, repo)
}
pub(crate) fn try_from<S>(s: S) -> Option<Self>
where
S: AsRef<str>,
{
let trimmed = remove_all_but_package_name(s.as_ref());
if trimmed.is_empty() {
return None;
}
let (name, repo) = Self::split_into_name_and_repo(trimmed);
Some(Self { name, repo })
}
}
impl PartialEq for Package {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& match &self.repo {
None => true,
Some(r) => match &other.repo {
None => true,
Some(r2) => r == r2,
},
}
}
}
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);
}
#[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()));
}
}
+94
View File
@@ -0,0 +1,94 @@
use std::{
collections::HashSet,
fmt::{Display, Write},
hash::Hash,
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> + std::fmt::Debug)>,
) -> Result<Self> {
let name = iter
.find(|line| line.starts_with('['))
.context("finding beginning of next section")?
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.to_string();
let mut packages = HashSet::new();
// `while let` is unstable, unfortunately
while iter.peek().is_some() && !iter.peek().unwrap().starts_with('[') {
if let Some(package) = Package::try_from(iter.next().unwrap()) {
packages.insert(package);
}
}
ensure!(!packages.is_empty());
Ok(Self::new(name, packages))
}
}
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> {
self.name.partial_cmp(&other.name)
}
}
impl Ord for Section {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.partial_cmp(other).unwrap()
}
}
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(())
}
}
+20
View File
@@ -0,0 +1,20 @@
mod action;
pub mod args;
mod backend;
mod cmd;
mod config;
mod core;
mod env;
mod grouping;
mod path;
mod review;
mod search;
mod ui;
pub use crate::config::Config;
pub use crate::core::Pacdef;
pub use crate::grouping::Group;
pub(crate) use crate::grouping::Package;
pub use crate::search::NO_PACKAGES_FOUND;
extern crate macros;
+29
View File
@@ -0,0 +1,29 @@
use std::{env, path::PathBuf};
use anyhow::{Context, Result};
pub(crate) fn get_pacdef_group_dir() -> Result<PathBuf> {
let mut result = get_pacdef_base_dir().context("getting pacdef base dir")?;
result.push("groups");
Ok(result)
}
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)
}
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)
}
}
pub(crate) fn get_home_dir() -> Result<PathBuf> {
Ok(env::var("HOME").context("getting $HOME variable")?.into())
}
+56
View File
@@ -0,0 +1,56 @@
use std::rc::Rc;
use crate::backend::Backend;
use crate::{Group, Package};
#[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,
}
#[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);
}
}
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,
}
+134
View File
@@ -0,0 +1,134 @@
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(crate) 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(());
}
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(()),
}
}
reviews.push((backend, actions));
}
if reviews.nothing_to_do() {
println!("nothing to do");
return Ok(());
}
let strategies: Vec<Strategy> = reviews.into();
for strat in &strategies {
strat.show();
}
if !get_user_confirmation()? {
return Ok(());
}
for strat in strategies {
strat.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()? {
ReviewIntention::AsDependency => {
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),
}
}
Ok(ContinueWithReview::Yes)
}
fn ask_user_action_for_package() -> Result<ReviewIntention> {
print!("assign to (g)roup, (d)elete, (s)kip, (i)nfo, (a)s dependency, (q)uit? ");
stdout().lock().flush()?;
match read_single_char_from_terminal()? {
'a' => Ok(ReviewIntention::AsDependency),
'd' => Ok(ReviewIntention::Delete),
'g' => Ok(ReviewIntention::AssignGroup),
'i' => Ok(ReviewIntention::Info),
'q' => Ok(ReviewIntention::Quit),
's' => Ok(ReviewIntention::Skip),
_ => Ok(ReviewIntention::Invalid),
}
}
fn print_enumerated_groups(groups: &[Rc<Group>]) {
for (i, group) in groups.iter().enumerate() {
println!("{i}: {}", group.name);
}
}
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)
}
}
+124
View File
@@ -0,0 +1,124 @@
use std::rc::Rc;
use anyhow::Result;
use crate::backend::Backend;
use crate::{Group, Package};
use super::datastructures::{ReviewAction, ReviewsPerBackend};
#[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)?;
}
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 depdendency:");
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()
}
}
impl From<ReviewsPerBackend> for Vec<Strategy> {
fn from(reviews: ReviewsPerBackend) -> Self {
let mut result = vec![];
for (backend, actions) in reviews {
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
}
}
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),
}
}
}
+89
View File
@@ -0,0 +1,89 @@
use std::collections::HashSet;
use std::iter::Peekable;
use std::vec::IntoIter;
use anyhow::{bail, Context, Result};
use clap::ArgMatches;
use regex::Regex;
use crate::grouping::{Group, Package, Section};
pub const NO_PACKAGES_FOUND: &str = "no packages matching query";
pub(crate) fn search_packages(args: &ArgMatches, groups: &HashSet<Group>) -> Result<()> {
let search_string = args
.get_one::<String>("string")
.context("getting search string from arg")?;
let re = Regex::new(search_string)?;
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!(NO_PACKAGES_FOUND)
}
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(s: &Section, s0: &String) {
if s.name != *s0 {
println!("[{}]", s.name);
}
}
fn print_group_if_changed(g: &Group, g0: &String, s0: &mut String) {
if g.name != *g0 {
println!("{}", g.name);
for _ in 0..g.name.len() {
print!("-");
}
println!();
s0.clear();
}
}
+40
View File
@@ -0,0 +1,40 @@
use std::io::{self, Read, Write};
use anyhow::{Context, Result};
use termios::*;
pub(crate) fn get_user_confirmation() -> Result<bool> {
print!("Continue? [Y/n] ");
std::io::stdout().flush().unwrap();
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'))
}
pub(crate) 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 = [0u8; 1];
io::stdin()
.read_exact(&mut input[..])
.context("reading one byte from stdin")?;
let result = input[0] as char;
// stdin is not echoed automatically in this terminal mode
println!("{result}");
// restore previous settings
tcsetattr(fd, TCSANOW, &termios).context("restoring terminal mode")?;
Ok(result)
}