use crate::core::Process;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ProcError {
#[error("No process found matching '{0}'\n Try: proc list to list all processes")]
ProcessNotFound(String),
#[error("No process found matching '{name}'\n {}\n Try: proc list to list all processes", command_line_hint(.name, *.in_command_line))]
NameNotFound {
name: String,
in_command_line: usize,
},
#[error(
"No process found matching '{target}' ({reason})\n Try: proc list to list all processes"
)]
FilteredOut {
target: String,
reason: String,
},
#[error("No process listening on port {0}\n Try: proc ports")]
PortNotFound(u16),
#[error("Permission denied for PID {0}\n Try: sudo proc <command>")]
PermissionDenied(u32),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Invalid input: {message}")]
ConfirmationRequired {
message: String,
processes: Vec<Process>,
not_found: Option<Vec<String>>,
},
#[error("System error: {0}")]
SystemError(String),
#[error("Operation timed out: {0}")]
Timeout(String),
#[error("Failed to parse: {0}")]
ParseError(String),
#[error("Not supported on this platform: {0}")]
NotSupported(String),
#[error("Process {0} is no longer running")]
ProcessGone(u32),
#[error("Signal failed: {0}")]
SignalError(String),
#[error("{message}")]
Reported {
message: String,
exit_code: ExitCode,
},
}
pub const REASON_INVALID_INPUT: &str = "invalid_input";
pub const REASON_NEEDS_CONFIRMATION: &str = "needs_confirmation";
pub const REASON_NOT_SUPPORTED: &str = "not_supported";
pub fn command_line_hint(name: &str, count: usize) -> String {
let (who, has, its) = if count == 1 {
("process", "has", "its")
} else {
("processes", "have", "their")
};
format!("{count} {who} {has} '{name}' in {its} command line (add --cmd to match those)")
}
impl ProcError {
pub fn command_line_hint(&self) -> Option<String> {
match self {
ProcError::NameNotFound {
name,
in_command_line,
} => Some(command_line_hint(name, *in_command_line)),
_ => None,
}
}
pub fn reason(&self) -> Option<&'static str> {
match self {
ProcError::InvalidInput(_) => Some(REASON_INVALID_INPUT),
ProcError::ConfirmationRequired { .. } => Some(REASON_NEEDS_CONFIRMATION),
ProcError::NotSupported(_) => Some(REASON_NOT_SUPPORTED),
_ => None,
}
}
pub fn reported(exit_code: ExitCode, message: impl Into<String>) -> Self {
ProcError::Reported {
message: message.into(),
exit_code,
}
}
}
impl From<std::io::Error> for ProcError {
fn from(err: std::io::Error) -> Self {
match err.kind() {
std::io::ErrorKind::PermissionDenied => ProcError::PermissionDenied(0),
std::io::ErrorKind::NotFound => ProcError::ProcessNotFound("unknown".to_string()),
_ => ProcError::SystemError(err.to_string()),
}
}
}
impl From<serde_json::Error> for ProcError {
fn from(err: serde_json::Error) -> Self {
ProcError::ParseError(err.to_string())
}
}
impl From<regex::Error> for ProcError {
fn from(err: regex::Error) -> Self {
ProcError::InvalidInput(format!("Invalid pattern: {}", err))
}
}
impl From<dialoguer::Error> for ProcError {
fn from(err: dialoguer::Error) -> Self {
ProcError::SystemError(format!("Dialog error: {}", err))
}
}
pub type Result<T> = std::result::Result<T, ProcError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
Success = 0,
GeneralError = 1,
NotFound = 2,
PermissionDenied = 3,
InvalidInput = 4,
}
impl From<&ProcError> for ExitCode {
fn from(err: &ProcError) -> Self {
match err {
ProcError::ProcessNotFound(_)
| ProcError::NameNotFound { .. }
| ProcError::FilteredOut { .. }
| ProcError::PortNotFound(_) => ExitCode::NotFound,
ProcError::PermissionDenied(_) => ExitCode::PermissionDenied,
ProcError::InvalidInput(_)
| ProcError::NotSupported(_)
| ProcError::ConfirmationRequired { .. } => ExitCode::InvalidInput,
ProcError::Reported { exit_code, .. } => *exit_code,
_ => ExitCode::GeneralError,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_supported_on_this_os_is_invalid_input() {
let err = ProcError::NotSupported("freeze".to_string());
assert_eq!(ExitCode::from(&err) as i32, 4);
}
fn exit_code(err: ProcError) -> i32 {
ExitCode::from(&err) as i32
}
#[test]
fn every_error_has_its_message_and_exit_code() {
let confirmation = ProcError::ConfirmationRequired {
message: "Pass --yes".to_string(),
processes: Vec::new(),
not_found: None,
};
let cases: Vec<(ProcError, &str, i32)> = vec![
(
ProcError::ProcessNotFound("node".to_string()),
"No process found matching 'node'\n Try: proc list to list all processes",
2,
),
(
ProcError::NameNotFound {
name: "serve".to_string(),
in_command_line: 2,
},
"No process found matching 'serve'\n 2 processes have 'serve' in their command line (add --cmd to match those)\n Try: proc list to list all processes",
2,
),
(
ProcError::FilteredOut {
target: "node".to_string(),
reason: "no matches with specified filters".to_string(),
},
"No process found matching 'node' (no matches with specified filters)\n Try: proc list to list all processes",
2,
),
(
ProcError::PortNotFound(3000),
"No process listening on port 3000\n Try: proc ports",
2,
),
(
ProcError::PermissionDenied(42),
"Permission denied for PID 42\n Try: sudo proc <command>",
3,
),
(
ProcError::InvalidInput("bad".to_string()),
"Invalid input: bad",
4,
),
(confirmation, "Invalid input: Pass --yes", 4),
(
ProcError::SystemError("boom".to_string()),
"System error: boom",
1,
),
(
ProcError::Timeout("10s".to_string()),
"Operation timed out: 10s",
1,
),
(
ProcError::ParseError("x".to_string()),
"Failed to parse: x",
1,
),
(
ProcError::NotSupported("freeze".to_string()),
"Not supported on this platform: freeze",
4,
),
(
ProcError::ProcessGone(7),
"Process 7 is no longer running",
1,
),
(
ProcError::SignalError("EPERM".to_string()),
"Signal failed: EPERM",
1,
),
(
ProcError::reported(ExitCode::NotFound, "nothing matched"),
"nothing matched",
2,
),
];
assert_eq!(ExitCode::Success as i32, 0);
for (err, message, code) in cases {
assert_eq!(err.to_string(), message);
assert_eq!(err.reason().is_some(), code == 4, "{message}");
assert_eq!(exit_code(err), code, "{message}");
}
}
#[test]
fn each_exit_4_error_has_its_own_reason() {
let confirmation = ProcError::ConfirmationRequired {
message: "Pass --yes".to_string(),
processes: Vec::new(),
not_found: None,
};
assert_eq!(
ProcError::InvalidInput("bad".to_string()).reason(),
Some("invalid_input")
);
assert_eq!(confirmation.reason(), Some("needs_confirmation"));
assert_eq!(
ProcError::NotSupported("freeze".to_string()).reason(),
Some("not_supported")
);
assert_eq!(REASON_INVALID_INPUT, "invalid_input");
assert_eq!(REASON_NEEDS_CONFIRMATION, "needs_confirmation");
assert_eq!(REASON_NOT_SUPPORTED, "not_supported");
}
#[test]
fn only_a_name_that_is_in_command_lines_carries_the_cmd_hint() {
let one = ProcError::NameNotFound {
name: "serve".to_string(),
in_command_line: 1,
};
assert_eq!(
one.command_line_hint().unwrap(),
"1 process has 'serve' in its command line (add --cmd to match those)"
);
let many = ProcError::NameNotFound {
name: "serve".to_string(),
in_command_line: 3,
};
assert_eq!(
many.command_line_hint().unwrap(),
"3 processes have 'serve' in their command line (add --cmd to match those)"
);
assert_eq!(
ProcError::ProcessNotFound("serve".to_string()).command_line_hint(),
None
);
}
#[test]
fn a_reported_error_keeps_the_exit_code_it_was_given() {
for code in [
ExitCode::Success,
ExitCode::GeneralError,
ExitCode::NotFound,
ExitCode::PermissionDenied,
ExitCode::InvalidInput,
] {
let err = ProcError::reported(code, "done");
assert_eq!(ExitCode::from(&err), code);
}
}
#[test]
fn an_io_error_becomes_the_matching_proc_error() {
use std::io::{Error, ErrorKind};
let denied = ProcError::from(Error::from(ErrorKind::PermissionDenied));
assert!(
matches!(denied, ProcError::PermissionDenied(0)),
"{denied:?}"
);
let missing = ProcError::from(Error::from(ErrorKind::NotFound));
assert!(
matches!(&missing, ProcError::ProcessNotFound(t) if t == "unknown"),
"{missing:?}"
);
let other = ProcError::from(Error::new(ErrorKind::BrokenPipe, "pipe closed"));
assert!(
matches!(&other, ProcError::SystemError(m) if m == "pipe closed"),
"{other:?}"
);
}
#[test]
fn a_json_error_becomes_a_parse_error() {
let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let expected = source.to_string();
let err = ProcError::from(source);
assert!(
matches!(&err, ProcError::ParseError(m) if *m == expected),
"{err:?}"
);
}
#[test]
fn a_bad_regex_becomes_invalid_input() {
let pattern = String::from("(");
let source = regex::Regex::new(&pattern).unwrap_err();
let err = ProcError::from(source);
assert!(
matches!(&err, ProcError::InvalidInput(m) if m.starts_with("Invalid pattern: ")),
"{err:?}"
);
assert_eq!(exit_code(err), 4);
}
#[test]
fn a_dialog_error_becomes_a_system_error() {
let source = dialoguer::Error::IO(std::io::Error::other("no terminal"));
let err = ProcError::from(source);
assert!(
matches!(&err, ProcError::SystemError(m) if m == "Dialog error: IO error: no terminal"),
"{err:?}"
);
}
}