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