feature: User info in proc widget for Unix-based systems (#425)

Adds users into the process widget (for Unix-based systems).  This shows only in non-grouped modes, similar to state.  Search is also supported.

In addition, a quick fix to prevent users from being in grouped mode when they tried to enter tree mode while grouped.
This commit is contained in:
Clement Tsang 2021-02-28 17:40:55 -05:00 committed by GitHub
parent c406d95699
commit 53d8bdae32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 265 additions and 48 deletions

View File

@ -67,11 +67,13 @@
"cvars",
"czvf",
"denylist",
"doctest",
"dont",
"eselect",
"fedoracentos",
"fpath",
"fract",
"getpwuid",
"gnueabihf",
"gotop",
"gotop's",

View File

@ -9,19 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Features
- [#263](https://github.com/ClementTsang/bottom/pull/263): Adds the option for fine-grained kill signals on Unix-like systems.
- [#263](https://github.com/ClementTsang/bottom/pull/263): Added the option for fine-grained kill signals on Unix-like systems.
- [#333](https://github.com/ClementTsang/bottom/pull/333): Adds an "out of" indicator that can be enabled using `--show_table_scroll_position` (and its corresponding config option) to help keep track of scrolled position.
- [#333](https://github.com/ClementTsang/bottom/pull/333): Added an "out of" indicator that can be enabled using `--show_table_scroll_position` (and its corresponding config option) to help keep track of scrolled position.
- [#379](https://github.com/ClementTsang/bottom/pull/379): Adds `--process_command` flag and corresponding config option to default to showing a process' command.
- [#379](https://github.com/ClementTsang/bottom/pull/379): Added `--process_command` flag and corresponding config option to default to showing a process' command.
- [#381](https://github.com/ClementTsang/bottom/pull/381): Adds a filter in the config file for network interfaces.
- [#381](https://github.com/ClementTsang/bottom/pull/381): Added a filter in the config file for network interfaces.
- [#406](https://github.com/ClementTsang/bottom/pull/406): Adds the Nord colour scheme, as well as a light variant.
- [#406](https://github.com/ClementTsang/bottom/pull/406): Added the Nord colour scheme, as well as a light variant.
- [#409](https://github.com/ClementTsang/bottom/pull/409): Adds `Ctrl-w` and `Ctrl-h` shortcuts in search, to delete a word and delete a character respectively.
- [#409](https://github.com/ClementTsang/bottom/pull/409): Added `Ctrl-w` and `Ctrl-h` shortcuts in search, to delete a word and delete a character respectively.
- [#413](https://github.com/ClementTsang/bottom/pull/413): Adds mouse support for sorting process columns.
- [#413](https://github.com/ClementTsang/bottom/pull/413): Added mouse support for sorting process columns.
- [#425](https://github.com/ClementTsang/bottom/pull/425): Added user into the process widget for Unix-based systems.
## Changes
@ -43,6 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#423](https://github.com/ClementTsang/bottom/pull/423): Fixes disk encryption causing the disk widget to fail or not properly map I/O statistics.
- [#425](https://github.com/ClementTsang/bottom/pull/425): Fixed a bug allowing grouped mode in tree mode if already in grouped mode.
## [0.5.7] - 2021-01-30
## Bug Fixes

View File

@ -17,6 +17,11 @@ name = "btm"
path = "src/bin/main.rs"
doc = false
[lib]
test = false
doctest = false
doc = false
[profile.release]
debug = 0
lto = true

View File

@ -387,6 +387,7 @@ Use `btm --help` for more information.
| `write`, `w/s` | `write >= 1 kb` | Matches the write/s column in terms of bytes; supports comparison operators |
| `tread`, `t.read` | `tread <= 1024 gb` | Matches he total read column in terms of bytes; supports comparison operators |
| `twrite`, `t.write` | `twrite > 1024 tb` | Matches the total write column in terms of bytes; supports comparison operators |
| `user` | `user=root` | Matches by user; supports regex |
| `state` | `state=running` | Matches by state; supports regex |
#### Supported comparison operators
@ -464,7 +465,7 @@ As yet _another_ process/system visualization and management application, bottom
- Display temperatures from sensors
- Display information regarding processes, like CPU, memory, I/O usage, and process state
- Display information regarding processes, like CPU, memory, I/O usage, user, and process state
- Process management (well, if process killing is all you need)

View File

@ -121,6 +121,10 @@ pub struct App {
#[builder(default = false, setter(skip))]
pub did_config_fail_to_save: bool,
#[cfg(target_family = "unix")]
#[builder(default, setter(skip))]
pub user_table: processes::UserTable,
pub cpu_state: CpuState,
pub mem_state: MemState,
pub net_state: NetState,
@ -310,23 +314,35 @@ impl App {
// Forcefully switch off column if we were on it...
if (proc_widget_state.is_grouped
&& proc_widget_state.process_sorting_type
== data_harvester::processes::ProcessSorting::Pid)
&& (proc_widget_state.process_sorting_type
== processes::ProcessSorting::Pid
|| proc_widget_state.process_sorting_type
== processes::ProcessSorting::User
|| proc_widget_state.process_sorting_type
== processes::ProcessSorting::State))
|| (!proc_widget_state.is_grouped
&& proc_widget_state.process_sorting_type
== data_harvester::processes::ProcessSorting::Count)
== processes::ProcessSorting::Count)
{
proc_widget_state.process_sorting_type =
data_harvester::processes::ProcessSorting::CpuPercent; // Go back to default, negate PID for group
processes::ProcessSorting::CpuPercent; // Go back to default, negate PID for group
proc_widget_state.is_process_sort_descending = true;
}
proc_widget_state
.columns
.column_mapping
.get_mut(&processes::ProcessSorting::State)
.unwrap()
.enabled = !(proc_widget_state.is_grouped);
proc_widget_state.columns.set_to_sorted_index_from_type(
&proc_widget_state.process_sorting_type,
);
proc_widget_state.columns.try_set(
&processes::ProcessSorting::State,
!(proc_widget_state.is_grouped),
);
#[cfg(target_family = "unix")]
proc_widget_state.columns.try_set(
&processes::ProcessSorting::User,
!(proc_widget_state.is_grouped),
);
proc_widget_state
.columns
@ -657,6 +673,26 @@ impl App {
proc_widget_state.is_tree_mode = !proc_widget_state.is_tree_mode;
if proc_widget_state.is_tree_mode {
// Disable grouping if so!
proc_widget_state.is_grouped = false;
proc_widget_state
.columns
.try_enable(&processes::ProcessSorting::State);
#[cfg(target_family = "unix")]
proc_widget_state
.columns
.try_enable(&processes::ProcessSorting::User);
proc_widget_state
.columns
.try_disable(&processes::ProcessSorting::Count);
proc_widget_state
.columns
.try_enable(&processes::ProcessSorting::Pid);
// We enabled... set PID sort type to ascending.
proc_widget_state.process_sorting_type = processes::ProcessSorting::Pid;
proc_widget_state.is_process_sort_descending = false;

View File

@ -2,8 +2,11 @@ use crate::Pid;
use std::path::PathBuf;
use sysinfo::ProcessStatus;
#[cfg(target_family = "unix")]
use crate::utils::error;
#[cfg(target_os = "linux")]
use crate::utils::error::{self, BottomError};
use crate::utils::error::BottomError;
#[cfg(target_os = "linux")]
use fnv::{FnvHashMap, FnvHashSet};
@ -29,28 +32,29 @@ pub enum ProcessSorting {
TotalRead,
TotalWrite,
State,
User,
Count,
}
impl std::fmt::Display for ProcessSorting {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use ProcessSorting::*;
write!(
f,
"{}",
match &self {
CpuPercent => "CPU%",
MemPercent => "Mem%",
Mem => "Mem",
ReadPerSecond => "R/s",
WritePerSecond => "W/s",
TotalRead => "T.Read",
TotalWrite => "T.Write",
State => "State",
ProcessName => "Name",
Command => "Command",
Pid => "PID",
Count => "Count",
ProcessSorting::CpuPercent => "CPU%",
ProcessSorting::MemPercent => "Mem%",
ProcessSorting::Mem => "Mem",
ProcessSorting::ReadPerSecond => "R/s",
ProcessSorting::WritePerSecond => "W/s",
ProcessSorting::TotalRead => "T.Read",
ProcessSorting::TotalWrite => "T.Write",
ProcessSorting::State => "State",
ProcessSorting::ProcessName => "Name",
ProcessSorting::Command => "Command",
ProcessSorting::Pid => "PID",
ProcessSorting::Count => "Count",
ProcessSorting::User => "User",
}
)
}
@ -81,9 +85,13 @@ pub struct ProcessHarvest {
pub process_state_char: char,
/// This is the *effective* user ID.
pub uid: Option<u32>,
// pub real_uid: Option<u32>, // TODO: Add real user ID
pub gid: Option<u32>,
#[cfg(target_family = "unix")]
pub uid: Option<libc::uid_t>,
// TODO: Add real user ID
// pub real_uid: Option<u32>,
#[cfg(target_family = "unix")]
pub gid: Option<libc::gid_t>,
}
#[derive(Debug, Default, Clone)]
@ -114,6 +122,29 @@ impl PrevProcDetails {
}
}
#[cfg(target_family = "unix")]
#[derive(Debug, Default)]
pub struct UserTable {
pub uid_user_mapping: std::collections::HashMap<libc::uid_t, String>,
}
#[cfg(target_family = "unix")]
impl UserTable {
pub fn get_uid_to_username_mapping(&mut self, uid: libc::uid_t) -> error::Result<String> {
if let Some(user) = self.uid_user_mapping.get(&uid) {
Ok(user.clone())
} else {
let passwd = unsafe { libc::getpwuid(uid) };
let username = unsafe { std::ffi::CStr::from_ptr((*passwd).pw_name) }
.to_str()?
.to_string();
self.uid_user_mapping.insert(uid, username.clone());
Ok(username)
}
}
}
#[cfg(target_os = "linux")]
fn cpu_usage_calculation(
prev_idle: &mut f64, prev_non_idle: &mut f64,
@ -591,8 +622,6 @@ pub fn get_process_data(
total_write_bytes: disk_usage.total_written_bytes,
process_state: process_val.status().to_string().to_string(),
process_state_char: convert_process_status_to_char(process_val.status()),
uid: None,
gid: None,
});
}
}

View File

@ -26,7 +26,8 @@ pub trait ProcessQuery {
/// - PIDs: Use prefix `pid`, can use regex or match word (case is irrelevant).
/// - CPU: Use prefix `cpu`, cannot use r/m/c (regex, match word, case). Can compare.
/// - MEM: Use prefix `mem`, cannot use r/m/c. Can compare.
/// - STATE: Use prefix `state`, TODO when we update how state looks in 0.5 probably.
/// - STATE: Use prefix `state`, can use regex, match word, or case.
/// - USER: Use prefix `user`, can use regex, match word, or case.
/// - Read/s: Use prefix `r`. Can compare.
/// - Write/s: Use prefix `w`. Can compare.
/// - Total read: Use prefix `read`. Can compare.
@ -128,8 +129,6 @@ impl ProcessQuery for ProcWidgetState {
fn process_prefix(query: &mut VecDeque<String>, inside_quotation: bool) -> Result<Prefix> {
if let Some(queue_top) = query.pop_front() {
// debug!("Prefix QT: {:?}", queue_top);
if inside_quotation {
if queue_top == "\"" {
// This means we hit something like "". Return an empty prefix, and to deal with
@ -264,11 +263,20 @@ impl ProcessQuery for ProcWidgetState {
compare_prefix: None,
})
}
PrefixType::Pid | PrefixType::State => {
PrefixType::Pid | PrefixType::State | PrefixType::User => {
// We have to check if someone put an "="...
if content == "=" {
// Check next string if possible
if let Some(queue_next) = query.pop_front() {
// TODO: Need to consider the following cases:
// - (test)
// - (test
// - test)
// These are split into 2 to 3 different strings due to parentheses being
// delimiters in our query system.
//
// Do we want these to be valid? They should, as a string, right?
return Ok(Prefix {
or: None,
regex_prefix: Some((
@ -580,6 +588,7 @@ pub enum PrefixType {
TWrite,
Name,
State,
User,
__Nonexhaustive,
}
@ -602,6 +611,7 @@ impl std::str::FromStr for PrefixType {
"twrite" | "t.write" => Ok(TWrite),
"pid" => Ok(Pid),
"state" => Ok(State),
"user" => Ok(User),
_ => Ok(Name),
}
}
@ -628,7 +638,7 @@ impl Prefix {
} else if let Some((prefix_type, StringQuery::Value(regex_string))) = &mut self.regex_prefix
{
match prefix_type {
PrefixType::Pid | PrefixType::Name | PrefixType::State => {
PrefixType::Pid | PrefixType::Name | PrefixType::State | PrefixType::User => {
let escaped_regex: String;
let final_regex_string = &format!(
"{}{}{}{}",
@ -681,6 +691,13 @@ impl Prefix {
}),
PrefixType::Pid => r.is_match(process.pid.to_string().as_str()),
PrefixType::State => r.is_match(process.process_state.as_str()),
PrefixType::User => {
if let Some(user) = &process.user {
r.is_match(user.as_str())
} else {
false
}
}
_ => true,
}
} else {

View File

@ -174,6 +174,9 @@ impl ProcessSearchState {
pub struct ColumnInfo {
pub enabled: bool,
pub shortcut: Option<&'static str>,
// FIXME: Move column width logic here!
// pub hard_width: Option<u16>,
// pub max_soft_width: Option<f64>,
}
pub struct ProcColumn {
@ -205,6 +208,7 @@ impl Default for ProcColumn {
WritePerSecond,
TotalRead,
TotalWrite,
User,
State,
];
@ -219,6 +223,8 @@ impl Default for ProcColumn {
ColumnInfo {
enabled: true,
shortcut: Some("c"),
// hard_width: None,
// max_soft_width: None,
},
);
}
@ -228,6 +234,8 @@ impl Default for ProcColumn {
ColumnInfo {
enabled: true,
shortcut: Some("m"),
// hard_width: None,
// max_soft_width: None,
},
);
}
@ -237,6 +245,8 @@ impl Default for ProcColumn {
ColumnInfo {
enabled: false,
shortcut: Some("m"),
// hard_width: None,
// max_soft_width: None,
},
);
}
@ -246,6 +256,8 @@ impl Default for ProcColumn {
ColumnInfo {
enabled: true,
shortcut: Some("n"),
// hard_width: None,
// max_soft_width: None,
},
);
}
@ -255,6 +267,8 @@ impl Default for ProcColumn {
ColumnInfo {
enabled: false,
shortcut: Some("n"),
// hard_width: None,
// max_soft_width: None,
},
);
}
@ -264,6 +278,8 @@ impl Default for ProcColumn {
ColumnInfo {
enabled: true,
shortcut: Some("p"),
// hard_width: None,
// max_soft_width: None,
},
);
}
@ -273,6 +289,17 @@ impl Default for ProcColumn {
ColumnInfo {
enabled: false,
shortcut: None,
// hard_width: None,
// max_soft_width: None,
},
);
}
User => {
column_mapping.insert(
column,
ColumnInfo {
enabled: cfg!(target_family = "unix"),
shortcut: None,
},
);
}
@ -282,6 +309,8 @@ impl Default for ProcColumn {
ColumnInfo {
enabled: true,
shortcut: None,
// hard_width: None,
// max_soft_width: None,
},
);
}
@ -316,6 +345,33 @@ impl ProcColumn {
}
}
pub fn try_set(&mut self, column: &ProcessSorting, setting: bool) -> Option<bool> {
if let Some(mapping) = self.column_mapping.get_mut(column) {
mapping.enabled = setting;
Some(mapping.enabled)
} else {
None
}
}
pub fn try_enable(&mut self, column: &ProcessSorting) -> Option<bool> {
if let Some(mapping) = self.column_mapping.get_mut(column) {
mapping.enabled = true;
Some(mapping.enabled)
} else {
None
}
}
pub fn try_disable(&mut self, column: &ProcessSorting) -> Option<bool> {
if let Some(mapping) = self.column_mapping.get_mut(column) {
mapping.enabled = false;
Some(mapping.enabled)
} else {
None
}
}
pub fn is_enabled(&self, column: &ProcessSorting) -> bool {
if let Some(mapping) = self.column_mapping.get(column) {
mapping.enabled

View File

@ -30,6 +30,8 @@ static PROCESS_HEADERS_HARD_WIDTH_NO_GROUP: Lazy<Vec<Option<u16>>> = Lazy::new(|
Some(8),
Some(7),
Some(8),
#[cfg(target_family = "unix")]
None,
None,
]
});
@ -48,8 +50,6 @@ static PROCESS_HEADERS_HARD_WIDTH_GROUPED: Lazy<Vec<Option<u16>>> = Lazy::new(||
static PROCESS_HEADERS_SOFT_WIDTH_MAX_GROUPED_COMMAND: Lazy<Vec<Option<f64>>> =
Lazy::new(|| vec![None, Some(0.7), None, None, None, None, None, None]);
static PROCESS_HEADERS_SOFT_WIDTH_MAX_GROUPED_TREE: Lazy<Vec<Option<f64>>> =
Lazy::new(|| vec![None, Some(0.5), None, None, None, None, None, None]);
static PROCESS_HEADERS_SOFT_WIDTH_MAX_GROUPED_ELSE: Lazy<Vec<Option<f64>>> =
Lazy::new(|| vec![None, Some(0.3), None, None, None, None, None, None]);
@ -63,6 +63,8 @@ static PROCESS_HEADERS_SOFT_WIDTH_MAX_NO_GROUP_COMMAND: Lazy<Vec<Option<f64>>> =
None,
None,
None,
#[cfg(target_family = "unix")]
Some(0.05),
Some(0.2),
]
});
@ -76,6 +78,8 @@ static PROCESS_HEADERS_SOFT_WIDTH_MAX_NO_GROUP_TREE: Lazy<Vec<Option<f64>>> = La
None,
None,
None,
#[cfg(target_family = "unix")]
Some(0.05),
Some(0.2),
]
});
@ -89,6 +93,8 @@ static PROCESS_HEADERS_SOFT_WIDTH_MAX_NO_GROUP_ELSE: Lazy<Vec<Option<f64>>> = La
None,
None,
None,
#[cfg(target_family = "unix")]
Some(0.05),
Some(0.2),
]
});
@ -344,6 +350,7 @@ impl ProcessTableWidget for Painter {
);
// Calculate widths
// FIXME: See if we can move this into the recalculate block? I want to move column widths into the column widths
let hard_widths = if proc_widget_state.is_grouped {
&*PROCESS_HEADERS_HARD_WIDTH_GROUPED
} else {
@ -394,10 +401,10 @@ impl ProcessTableWidget for Painter {
.collect::<Vec<_>>();
let soft_widths_max = if proc_widget_state.is_grouped {
// Note grouped trees are not a thing.
if proc_widget_state.is_using_command {
&*PROCESS_HEADERS_SOFT_WIDTH_MAX_GROUPED_COMMAND
} else if proc_widget_state.is_tree_mode {
&*PROCESS_HEADERS_SOFT_WIDTH_MAX_GROUPED_TREE
} else {
&*PROCESS_HEADERS_SOFT_WIDTH_MAX_GROUPED_ELSE
}
@ -601,6 +608,7 @@ impl ProcessTableWidget for Painter {
}
}
// TODO: Make the cursor scroll back if there's space!
if let Some(proc_widget_state) =
app_state.proc_state.widget_states.get_mut(&(widget_id - 1))
{

View File

@ -272,7 +272,7 @@ pub const PROCESS_HELP_TEXT: [&str; 15] = [
"click on header Sorts the entries by that column, click again to invert the sort",
];
pub const SEARCH_HELP_TEXT: [&str; 48] = [
pub const SEARCH_HELP_TEXT: [&str; 49] = [
"4 - Process search widget",
"Tab Toggle between searching for PID and name",
"Esc Close the search widget (retains the filter)",
@ -299,6 +299,7 @@ pub const SEARCH_HELP_TEXT: [&str; 48] = [
"write, w/s ex: write <= 1 tb",
"tread, t.read ex: tread = 1",
"twrite, t.write ex: twrite = 1",
"user ex: user = root",
"state ex: state = running",
"",
"Comparison operators:",

View File

@ -62,6 +62,7 @@ pub struct ConvertedProcessData {
pub tw_f64: f64,
pub process_state: String,
pub process_char: char,
pub user: Option<String>,
/// Prefix printed before the process when displayed.
pub process_description_prefix: Option<String>,
@ -482,6 +483,7 @@ pub enum ProcessNamingType {
pub fn convert_process_data(
current_data: &data_farmer::DataCollection,
existing_converted_process_data: &mut HashMap<Pid, ConvertedProcessData>,
#[cfg(target_family = "unix")] user_table: &mut data_harvester::processes::UserTable,
) {
// TODO [THREAD]: Thread highlighting and hiding support
// For macOS see https://github.com/hishamhm/htop/pull/848/files
@ -503,6 +505,21 @@ pub fn convert_process_data(
0, converted_total_write.0, converted_total_write.1
);
let user = {
#[cfg(target_family = "unix")]
{
if let Some(uid) = process.uid {
user_table.get_uid_to_username_mapping(uid).ok()
} else {
None
}
}
#[cfg(not(target_family = "unix"))]
{
None
}
};
if let Some(process_entry) = existing_converted_process_data.get_mut(&process.pid) {
complete_pid_set.remove(&process.pid);
@ -527,6 +544,7 @@ pub fn convert_process_data(
process_entry.process_char = process.process_state_char;
process_entry.process_description_prefix = None;
process_entry.is_disabled_entry = false;
process_entry.user = user;
} else {
// ...I hate that I can't combine if let and an if statement in one line...
*process_entry = ConvertedProcessData {
@ -553,6 +571,7 @@ pub fn convert_process_data(
process_description_prefix: None,
is_disabled_entry: false,
is_collapsed_entry: false,
user,
};
}
} else {
@ -582,6 +601,7 @@ pub fn convert_process_data(
process_description_prefix: None,
is_disabled_entry: false,
is_collapsed_entry: false,
user,
},
);
}
@ -827,8 +847,18 @@ pub fn tree_process_data(
is_sort_descending,
)
}),
ProcessSorting::User => to_sort_vec.sort_by(|a, b| match (&a.1.user, &b.1.user) {
(Some(user_a), Some(user_b)) => utils::gen_util::get_ordering(
user_a.to_lowercase(),
user_b.to_lowercase(),
is_sort_descending,
),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Less,
}),
ProcessSorting::Count => {
// Should never occur in this case.
// Should never occur in this case, tree mode explicitly disables grouping.
}
}
}
@ -990,6 +1020,15 @@ pub fn stringify_process_data(
(process.write_per_sec.clone(), None),
(process.total_read.clone(), None),
(process.total_write.clone(), None),
#[cfg(target_family = "unix")]
(
if let Some(user) = &process.user {
user.clone()
} else {
"N/A".to_string()
},
None,
),
(
process.process_state.clone(),
Some(process.process_char.to_string()),
@ -1083,6 +1122,7 @@ pub fn group_process_data(
process_char: char::default(),
is_disabled_entry: false,
is_collapsed_entry: false,
user: None,
}
})
.collect::<Vec<_>>()

View File

@ -369,6 +369,8 @@ fn update_final_process_list(app: &mut App, widget_id: u64) {
convert_process_data(
&app.data_collection,
&mut app.canvas_data.single_process_data,
#[cfg(target_family = "unix")]
&mut app.user_table,
);
}
let process_filter = app.get_process_filter(widget_id);
@ -550,6 +552,16 @@ fn sort_process_data(
to_sort_vec.reverse();
}
}
ProcessSorting::User => to_sort_vec.sort_by(|a, b| match (&a.user, &b.user) {
(Some(user_a), Some(user_b)) => utils::gen_util::get_ordering(
user_a.to_lowercase(),
user_b.to_lowercase(),
proc_widget_state.is_process_sort_descending,
),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Less,
}),
ProcessSorting::Count => {
if proc_widget_state.is_grouped {
to_sort_vec.sort_by(|a, b| {

View File

@ -86,6 +86,12 @@ impl From<std::str::Utf8Error> for BottomError {
}
}
impl From<std::string::FromUtf8Error> for BottomError {
fn from(err: std::string::FromUtf8Error) -> Self {
BottomError::ConversionError(err.to_string())
}
}
impl From<regex::Error> for BottomError {
fn from(err: regex::Error) -> Self {
// We only really want the last part of it... so we'll do it the ugly way: