1use crate::core::Process;
6use thiserror::Error;
7
8#[derive(Error, Debug)]
10pub enum ProcError {
11 #[error("No process found matching '{0}'\n Try: proc list to list all processes")]
13 ProcessNotFound(String),
14
15 #[error("No process found matching '{name}'\n {}\n Try: proc list to list all processes", command_line_hint(.name, *.in_command_line))]
18 NameNotFound {
19 name: String,
21 in_command_line: usize,
23 },
24
25 #[error(
28 "No process found matching '{target}' ({reason})\n Try: proc list to list all processes"
29 )]
30 FilteredOut {
31 target: String,
33 reason: String,
35 },
36
37 #[error("No process listening on port {0}\n Try: proc ports")]
39 PortNotFound(u16),
40
41 #[error("Permission denied for PID {0}\n Try: sudo proc <command>")]
43 PermissionDenied(u32),
44
45 #[error("Invalid input: {0}")]
47 InvalidInput(String),
48
49 #[error("Invalid input: {message}")]
53 ConfirmationRequired {
54 message: String,
56 processes: Vec<Process>,
58 not_found: Option<Vec<String>>,
61 },
62
63 #[error("System error: {0}")]
65 SystemError(String),
66
67 #[error("Operation timed out: {0}")]
69 Timeout(String),
70
71 #[error("Failed to parse: {0}")]
73 ParseError(String),
74
75 #[error("Not supported on this platform: {0}")]
77 NotSupported(String),
78
79 #[error("Process {0} is no longer running")]
81 ProcessGone(u32),
82
83 #[error("Signal failed: {0}")]
85 SignalError(String),
86
87 #[error("{message}")]
91 Reported {
92 message: String,
94 exit_code: ExitCode,
96 },
97}
98
99pub const REASON_INVALID_INPUT: &str = "invalid_input";
102
103pub const REASON_NEEDS_CONFIRMATION: &str = "needs_confirmation";
106
107pub const REASON_NOT_SUPPORTED: &str = "not_supported";
109
110pub fn command_line_hint(name: &str, count: usize) -> String {
112 let (who, has, its) = if count == 1 {
113 ("process", "has", "its")
114 } else {
115 ("processes", "have", "their")
116 };
117 format!("{count} {who} {has} '{name}' in {its} command line (add --cmd to match those)")
118}
119
120impl ProcError {
121 pub fn command_line_hint(&self) -> Option<String> {
123 match self {
124 ProcError::NameNotFound {
125 name,
126 in_command_line,
127 } => Some(command_line_hint(name, *in_command_line)),
128 _ => None,
129 }
130 }
131
132 pub fn reason(&self) -> Option<&'static str> {
135 match self {
136 ProcError::InvalidInput(_) => Some(REASON_INVALID_INPUT),
137 ProcError::ConfirmationRequired { .. } => Some(REASON_NEEDS_CONFIRMATION),
138 ProcError::NotSupported(_) => Some(REASON_NOT_SUPPORTED),
139 _ => None,
140 }
141 }
142
143 pub fn reported(exit_code: ExitCode, message: impl Into<String>) -> Self {
145 ProcError::Reported {
146 message: message.into(),
147 exit_code,
148 }
149 }
150}
151
152impl From<std::io::Error> for ProcError {
153 fn from(err: std::io::Error) -> Self {
154 match err.kind() {
155 std::io::ErrorKind::PermissionDenied => ProcError::PermissionDenied(0),
156 std::io::ErrorKind::NotFound => ProcError::ProcessNotFound("unknown".to_string()),
157 _ => ProcError::SystemError(err.to_string()),
158 }
159 }
160}
161
162impl From<serde_json::Error> for ProcError {
163 fn from(err: serde_json::Error) -> Self {
164 ProcError::ParseError(err.to_string())
165 }
166}
167
168impl From<regex::Error> for ProcError {
169 fn from(err: regex::Error) -> Self {
170 ProcError::InvalidInput(format!("Invalid pattern: {}", err))
171 }
172}
173
174impl From<dialoguer::Error> for ProcError {
175 fn from(err: dialoguer::Error) -> Self {
176 ProcError::SystemError(format!("Dialog error: {}", err))
177 }
178}
179
180pub type Result<T> = std::result::Result<T, ProcError>;
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ExitCode {
186 Success = 0,
188 GeneralError = 1,
190 NotFound = 2,
192 PermissionDenied = 3,
194 InvalidInput = 4,
196}
197
198impl From<&ProcError> for ExitCode {
199 fn from(err: &ProcError) -> Self {
200 match err {
201 ProcError::ProcessNotFound(_)
202 | ProcError::NameNotFound { .. }
203 | ProcError::FilteredOut { .. }
204 | ProcError::PortNotFound(_) => ExitCode::NotFound,
205 ProcError::PermissionDenied(_) => ExitCode::PermissionDenied,
206 ProcError::InvalidInput(_)
207 | ProcError::NotSupported(_)
208 | ProcError::ConfirmationRequired { .. } => ExitCode::InvalidInput,
209 ProcError::Reported { exit_code, .. } => *exit_code,
210 _ => ExitCode::GeneralError,
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn not_supported_on_this_os_is_invalid_input() {
221 let err = ProcError::NotSupported("freeze".to_string());
224 assert_eq!(ExitCode::from(&err) as i32, 4);
225 }
226
227 fn exit_code(err: ProcError) -> i32 {
228 ExitCode::from(&err) as i32
229 }
230
231 #[test]
232 fn every_error_has_its_message_and_exit_code() {
233 let confirmation = ProcError::ConfirmationRequired {
234 message: "Pass --yes".to_string(),
235 processes: Vec::new(),
236 not_found: None,
237 };
238 let cases: Vec<(ProcError, &str, i32)> = vec![
239 (
240 ProcError::ProcessNotFound("node".to_string()),
241 "No process found matching 'node'\n Try: proc list to list all processes",
242 2,
243 ),
244 (
245 ProcError::NameNotFound {
246 name: "serve".to_string(),
247 in_command_line: 2,
248 },
249 "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",
250 2,
251 ),
252 (
253 ProcError::FilteredOut {
254 target: "node".to_string(),
255 reason: "no matches with specified filters".to_string(),
256 },
257 "No process found matching 'node' (no matches with specified filters)\n Try: proc list to list all processes",
258 2,
259 ),
260 (
261 ProcError::PortNotFound(3000),
262 "No process listening on port 3000\n Try: proc ports",
263 2,
264 ),
265 (
266 ProcError::PermissionDenied(42),
267 "Permission denied for PID 42\n Try: sudo proc <command>",
268 3,
269 ),
270 (
271 ProcError::InvalidInput("bad".to_string()),
272 "Invalid input: bad",
273 4,
274 ),
275 (confirmation, "Invalid input: Pass --yes", 4),
276 (
277 ProcError::SystemError("boom".to_string()),
278 "System error: boom",
279 1,
280 ),
281 (
282 ProcError::Timeout("10s".to_string()),
283 "Operation timed out: 10s",
284 1,
285 ),
286 (
287 ProcError::ParseError("x".to_string()),
288 "Failed to parse: x",
289 1,
290 ),
291 (
292 ProcError::NotSupported("freeze".to_string()),
293 "Not supported on this platform: freeze",
294 4,
295 ),
296 (
297 ProcError::ProcessGone(7),
298 "Process 7 is no longer running",
299 1,
300 ),
301 (
302 ProcError::SignalError("EPERM".to_string()),
303 "Signal failed: EPERM",
304 1,
305 ),
306 (
307 ProcError::reported(ExitCode::NotFound, "nothing matched"),
308 "nothing matched",
309 2,
310 ),
311 ];
312 assert_eq!(ExitCode::Success as i32, 0);
313 for (err, message, code) in cases {
314 assert_eq!(err.to_string(), message);
315 assert_eq!(err.reason().is_some(), code == 4, "{message}");
317 assert_eq!(exit_code(err), code, "{message}");
318 }
319 }
320
321 #[test]
322 fn each_exit_4_error_has_its_own_reason() {
323 let confirmation = ProcError::ConfirmationRequired {
324 message: "Pass --yes".to_string(),
325 processes: Vec::new(),
326 not_found: None,
327 };
328 assert_eq!(
329 ProcError::InvalidInput("bad".to_string()).reason(),
330 Some("invalid_input")
331 );
332 assert_eq!(confirmation.reason(), Some("needs_confirmation"));
333 assert_eq!(
334 ProcError::NotSupported("freeze".to_string()).reason(),
335 Some("not_supported")
336 );
337 assert_eq!(REASON_INVALID_INPUT, "invalid_input");
339 assert_eq!(REASON_NEEDS_CONFIRMATION, "needs_confirmation");
340 assert_eq!(REASON_NOT_SUPPORTED, "not_supported");
341 }
342
343 #[test]
344 fn only_a_name_that_is_in_command_lines_carries_the_cmd_hint() {
345 let one = ProcError::NameNotFound {
346 name: "serve".to_string(),
347 in_command_line: 1,
348 };
349 assert_eq!(
350 one.command_line_hint().unwrap(),
351 "1 process has 'serve' in its command line (add --cmd to match those)"
352 );
353 let many = ProcError::NameNotFound {
354 name: "serve".to_string(),
355 in_command_line: 3,
356 };
357 assert_eq!(
358 many.command_line_hint().unwrap(),
359 "3 processes have 'serve' in their command line (add --cmd to match those)"
360 );
361 assert_eq!(
362 ProcError::ProcessNotFound("serve".to_string()).command_line_hint(),
363 None
364 );
365 }
366
367 #[test]
368 fn a_reported_error_keeps_the_exit_code_it_was_given() {
369 for code in [
370 ExitCode::Success,
371 ExitCode::GeneralError,
372 ExitCode::NotFound,
373 ExitCode::PermissionDenied,
374 ExitCode::InvalidInput,
375 ] {
376 let err = ProcError::reported(code, "done");
377 assert_eq!(ExitCode::from(&err), code);
378 }
379 }
380
381 #[test]
382 fn an_io_error_becomes_the_matching_proc_error() {
383 use std::io::{Error, ErrorKind};
384 let denied = ProcError::from(Error::from(ErrorKind::PermissionDenied));
385 assert!(
386 matches!(denied, ProcError::PermissionDenied(0)),
387 "{denied:?}"
388 );
389 let missing = ProcError::from(Error::from(ErrorKind::NotFound));
390 assert!(
391 matches!(&missing, ProcError::ProcessNotFound(t) if t == "unknown"),
392 "{missing:?}"
393 );
394 let other = ProcError::from(Error::new(ErrorKind::BrokenPipe, "pipe closed"));
395 assert!(
396 matches!(&other, ProcError::SystemError(m) if m == "pipe closed"),
397 "{other:?}"
398 );
399 }
400
401 #[test]
402 fn a_json_error_becomes_a_parse_error() {
403 let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
404 let expected = source.to_string();
405 let err = ProcError::from(source);
406 assert!(
407 matches!(&err, ProcError::ParseError(m) if *m == expected),
408 "{err:?}"
409 );
410 }
411
412 #[test]
413 fn a_bad_regex_becomes_invalid_input() {
414 let pattern = String::from("(");
416 let source = regex::Regex::new(&pattern).unwrap_err();
417 let err = ProcError::from(source);
418 assert!(
419 matches!(&err, ProcError::InvalidInput(m) if m.starts_with("Invalid pattern: ")),
420 "{err:?}"
421 );
422 assert_eq!(exit_code(err), 4);
423 }
424
425 #[test]
426 fn a_dialog_error_becomes_a_system_error() {
427 let source = dialoguer::Error::IO(std::io::Error::other("no terminal"));
428 let err = ProcError::from(source);
429 assert!(
430 matches!(&err, ProcError::SystemError(m) if m == "Dialog error: IO error: no terminal"),
431 "{err:?}"
432 );
433 }
434}