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(
29 "No process found matching '{target}' (ruled out by {})\n Try: proc list to list all processes",
30 guard_list(.ruled_out_by)
31 )]
32 FilteredOut {
33 target: String,
35 ruled_out_by: Vec<(String, String)>,
38 },
39
40 #[error("No process listening on port {0}\n Try: proc ports")]
42 PortNotFound(u16),
43
44 #[error("Permission denied for PID {0}\n Try: sudo proc <command>")]
46 PermissionDenied(u32),
47
48 #[error("Invalid input: {0}")]
50 InvalidInput(String),
51
52 #[error("{message}")]
59 ConfirmationRequired {
60 message: String,
62 processes: Vec<Process>,
64 not_found: Option<Vec<String>>,
67 },
68
69 #[error("System error: {0}")]
71 SystemError(String),
72
73 #[error("Operation timed out: {0}")]
75 Timeout(String),
76
77 #[error("Failed to parse: {0}")]
79 ParseError(String),
80
81 #[error("Not supported on this platform: {0}")]
83 NotSupported(String),
84
85 #[error("Process {0} is no longer running")]
87 ProcessGone(u32),
88
89 #[error("Signal failed: {0}")]
91 SignalError(String),
92
93 #[error("{message}")]
97 Reported {
98 message: String,
100 exit_code: ExitCode,
102 },
103}
104
105pub const REASON_SYSTEM_ERROR: &str = "system_error";
108
109pub const REASON_NOT_FOUND: &str = "not_found";
112
113pub const REASON_COMMAND_LINE_ONLY: &str = "command_line_only";
116
117pub const REASON_RULED_OUT: &str = "ruled_out";
121
122pub const REASON_PERMISSION_DENIED: &str = "permission_denied";
124
125pub const REASON_INVALID_INPUT: &str = "invalid_input";
128
129pub const REASON_NEEDS_CONFIRMATION: &str = "needs_confirmation";
132
133pub const REASON_NOT_SUPPORTED: &str = "not_supported";
135
136fn guard_list(guards: &[(String, String)]) -> String {
138 let named: Vec<String> = guards
139 .iter()
140 .map(|(flag, value)| format!("{flag} {value}"))
141 .collect();
142 named.join(" and ")
143}
144
145pub fn command_line_hint(name: &str, count: usize) -> String {
147 let (who, has, its) = if count == 1 {
148 ("process", "has", "its")
149 } else {
150 ("processes", "have", "their")
151 };
152 format!("{count} {who} {has} '{name}' in {its} command line (add --cmd to match those)")
153}
154
155impl ProcError {
156 pub fn command_line_hint(&self) -> Option<String> {
158 match self {
159 ProcError::NameNotFound {
160 name,
161 in_command_line,
162 } => Some(command_line_hint(name, *in_command_line)),
163 _ => None,
164 }
165 }
166
167 pub fn reason(&self) -> Option<&'static str> {
172 match self {
173 ProcError::ProcessNotFound(_)
174 | ProcError::PortNotFound(_)
175 | ProcError::ProcessGone(_) => Some(REASON_NOT_FOUND),
176 ProcError::NameNotFound { .. } => Some(REASON_COMMAND_LINE_ONLY),
177 ProcError::FilteredOut { .. } => Some(REASON_RULED_OUT),
178 ProcError::PermissionDenied(_) => Some(REASON_PERMISSION_DENIED),
179 ProcError::InvalidInput(_) => Some(REASON_INVALID_INPUT),
180 ProcError::ConfirmationRequired { .. } => Some(REASON_NEEDS_CONFIRMATION),
181 ProcError::NotSupported(_) => Some(REASON_NOT_SUPPORTED),
182 ProcError::SystemError(_)
183 | ProcError::Timeout(_)
184 | ProcError::ParseError(_)
185 | ProcError::SignalError(_) => Some(REASON_SYSTEM_ERROR),
186 ProcError::Reported { .. } => None,
187 }
188 }
189
190 pub fn ruled_out_by(&self) -> Option<Vec<&str>> {
193 match self {
194 ProcError::FilteredOut { ruled_out_by, .. } => {
195 Some(ruled_out_by.iter().map(|(flag, _)| flag.as_str()).collect())
196 }
197 _ => None,
198 }
199 }
200
201 pub fn name_not_found(name: &str, in_command_line: usize) -> Self {
205 if in_command_line > 0 {
206 ProcError::NameNotFound {
207 name: name.to_string(),
208 in_command_line,
209 }
210 } else {
211 ProcError::ProcessNotFound(name.to_string())
212 }
213 }
214
215 pub fn reported(exit_code: ExitCode, message: impl Into<String>) -> Self {
217 ProcError::Reported {
218 message: message.into(),
219 exit_code,
220 }
221 }
222}
223
224impl From<std::io::Error> for ProcError {
225 fn from(err: std::io::Error) -> Self {
226 match err.kind() {
227 std::io::ErrorKind::PermissionDenied => ProcError::PermissionDenied(0),
228 std::io::ErrorKind::NotFound => ProcError::ProcessNotFound("unknown".to_string()),
229 _ => ProcError::SystemError(err.to_string()),
230 }
231 }
232}
233
234impl From<serde_json::Error> for ProcError {
235 fn from(err: serde_json::Error) -> Self {
236 ProcError::ParseError(err.to_string())
237 }
238}
239
240impl From<regex::Error> for ProcError {
241 fn from(err: regex::Error) -> Self {
242 ProcError::InvalidInput(format!("Invalid pattern: {}", err))
243 }
244}
245
246impl From<dialoguer::Error> for ProcError {
247 fn from(err: dialoguer::Error) -> Self {
248 ProcError::SystemError(format!("Dialog error: {}", err))
249 }
250}
251
252pub type Result<T> = std::result::Result<T, ProcError>;
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum ExitCode {
258 Success = 0,
260 GeneralError = 1,
262 NotFound = 2,
264 PermissionDenied = 3,
266 InvalidInput = 4,
268}
269
270impl From<&ProcError> for ExitCode {
271 fn from(err: &ProcError) -> Self {
272 match err {
273 ProcError::ProcessNotFound(_)
274 | ProcError::NameNotFound { .. }
275 | ProcError::FilteredOut { .. }
276 | ProcError::PortNotFound(_)
277 | ProcError::ProcessGone(_) => ExitCode::NotFound,
278 ProcError::PermissionDenied(_) => ExitCode::PermissionDenied,
279 ProcError::InvalidInput(_)
280 | ProcError::NotSupported(_)
281 | ProcError::ConfirmationRequired { .. } => ExitCode::InvalidInput,
282 ProcError::Reported { exit_code, .. } => *exit_code,
283 _ => ExitCode::GeneralError,
284 }
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn not_supported_on_this_os_is_invalid_input() {
294 let err = ProcError::NotSupported("freeze".to_string());
297 assert_eq!(ExitCode::from(&err) as i32, 4);
298 }
299
300 fn exit_code(err: ProcError) -> i32 {
301 ExitCode::from(&err) as i32
302 }
303
304 #[test]
305 fn every_error_has_its_message_and_exit_code() {
306 let confirmation = ProcError::ConfirmationRequired {
307 message: "Pass --yes".to_string(),
308 processes: Vec::new(),
309 not_found: None,
310 };
311 let filtered = ProcError::FilteredOut {
312 target: "1234".to_string(),
313 ruled_out_by: vec![
314 ("--by".to_string(), "node".to_string()),
315 ("--start-time".to_string(), "1700000000".to_string()),
316 ],
317 };
318 let cases: Vec<(ProcError, &str, i32, Option<&str>)> = vec![
319 (
320 ProcError::ProcessNotFound("node".to_string()),
321 "No process found matching 'node'\n Try: proc list to list all processes",
322 2,
323 Some("not_found"),
324 ),
325 (
326 ProcError::NameNotFound {
327 name: "serve".to_string(),
328 in_command_line: 2,
329 },
330 "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",
331 2,
332 Some("command_line_only"),
333 ),
334 (
335 filtered,
336 "No process found matching '1234' (ruled out by --by node and --start-time 1700000000)\n Try: proc list to list all processes",
337 2,
338 Some("ruled_out"),
339 ),
340 (
341 ProcError::PortNotFound(3000),
342 "No process listening on port 3000\n Try: proc ports",
343 2,
344 Some("not_found"),
345 ),
346 (
347 ProcError::PermissionDenied(42),
348 "Permission denied for PID 42\n Try: sudo proc <command>",
349 3,
350 Some("permission_denied"),
351 ),
352 (
353 ProcError::InvalidInput("bad".to_string()),
354 "Invalid input: bad",
355 4,
356 Some("invalid_input"),
357 ),
358 (confirmation, "Pass --yes", 4, Some("needs_confirmation")),
359 (
360 ProcError::SystemError("boom".to_string()),
361 "System error: boom",
362 1,
363 Some("system_error"),
364 ),
365 (
366 ProcError::Timeout("10s".to_string()),
367 "Operation timed out: 10s",
368 1,
369 Some("system_error"),
370 ),
371 (
372 ProcError::ParseError("x".to_string()),
373 "Failed to parse: x",
374 1,
375 Some("system_error"),
376 ),
377 (
378 ProcError::NotSupported("freeze".to_string()),
379 "Not supported on this platform: freeze",
380 4,
381 Some("not_supported"),
382 ),
383 (
385 ProcError::ProcessGone(7),
386 "Process 7 is no longer running",
387 2,
388 Some("not_found"),
389 ),
390 (
391 ProcError::SignalError("EPERM".to_string()),
392 "Signal failed: EPERM",
393 1,
394 Some("system_error"),
395 ),
396 (
398 ProcError::reported(ExitCode::NotFound, "nothing matched"),
399 "nothing matched",
400 2,
401 None,
402 ),
403 ];
404 assert_eq!(ExitCode::Success as i32, 0);
405 for (err, message, code, reason) in cases {
406 assert_eq!(err.to_string(), message);
407 assert_eq!(err.reason(), reason, "{message}");
408 assert_eq!(exit_code(err), code, "{message}");
409 }
410 }
411
412 #[test]
413 fn only_a_guard_error_names_the_guards_in_json() {
414 let filtered = ProcError::FilteredOut {
415 target: "1234".to_string(),
416 ruled_out_by: vec![("--in".to_string(), "/app".to_string())],
417 };
418 assert_eq!(filtered.ruled_out_by(), Some(vec!["--in"]));
419 assert_eq!(
420 ProcError::ProcessNotFound("1".to_string()).ruled_out_by(),
421 None
422 );
423 }
424
425 #[test]
426 fn each_exit_4_error_has_its_own_reason() {
427 let confirmation = ProcError::ConfirmationRequired {
428 message: "Pass --yes".to_string(),
429 processes: Vec::new(),
430 not_found: None,
431 };
432 assert_eq!(
433 ProcError::InvalidInput("bad".to_string()).reason(),
434 Some("invalid_input")
435 );
436 assert_eq!(confirmation.reason(), Some("needs_confirmation"));
437 assert_eq!(
438 ProcError::NotSupported("freeze".to_string()).reason(),
439 Some("not_supported")
440 );
441 assert_eq!(REASON_INVALID_INPUT, "invalid_input");
443 assert_eq!(REASON_NEEDS_CONFIRMATION, "needs_confirmation");
444 assert_eq!(REASON_NOT_SUPPORTED, "not_supported");
445 assert_eq!(REASON_SYSTEM_ERROR, "system_error");
446 assert_eq!(REASON_NOT_FOUND, "not_found");
447 assert_eq!(REASON_COMMAND_LINE_ONLY, "command_line_only");
448 assert_eq!(REASON_RULED_OUT, "ruled_out");
449 assert_eq!(REASON_PERMISSION_DENIED, "permission_denied");
450 }
451
452 #[test]
453 fn only_a_name_that_is_in_command_lines_carries_the_cmd_hint() {
454 let one = ProcError::NameNotFound {
455 name: "serve".to_string(),
456 in_command_line: 1,
457 };
458 assert_eq!(
459 one.command_line_hint().unwrap(),
460 "1 process has 'serve' in its command line (add --cmd to match those)"
461 );
462 let many = ProcError::NameNotFound {
463 name: "serve".to_string(),
464 in_command_line: 3,
465 };
466 assert_eq!(
467 many.command_line_hint().unwrap(),
468 "3 processes have 'serve' in their command line (add --cmd to match those)"
469 );
470 assert_eq!(
471 ProcError::ProcessNotFound("serve".to_string()).command_line_hint(),
472 None
473 );
474 }
475
476 #[test]
477 fn a_reported_error_keeps_the_exit_code_it_was_given() {
478 for code in [
479 ExitCode::Success,
480 ExitCode::GeneralError,
481 ExitCode::NotFound,
482 ExitCode::PermissionDenied,
483 ExitCode::InvalidInput,
484 ] {
485 let err = ProcError::reported(code, "done");
486 assert_eq!(ExitCode::from(&err), code);
487 }
488 }
489
490 #[test]
491 fn an_io_error_becomes_the_matching_proc_error() {
492 use std::io::{Error, ErrorKind};
493 let denied = ProcError::from(Error::from(ErrorKind::PermissionDenied));
494 assert!(
495 matches!(denied, ProcError::PermissionDenied(0)),
496 "{denied:?}"
497 );
498 let missing = ProcError::from(Error::from(ErrorKind::NotFound));
499 assert!(
500 matches!(&missing, ProcError::ProcessNotFound(t) if t == "unknown"),
501 "{missing:?}"
502 );
503 let other = ProcError::from(Error::new(ErrorKind::BrokenPipe, "pipe closed"));
504 assert!(
505 matches!(&other, ProcError::SystemError(m) if m == "pipe closed"),
506 "{other:?}"
507 );
508 }
509
510 #[test]
511 fn a_json_error_becomes_a_parse_error() {
512 let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
513 let expected = source.to_string();
514 let err = ProcError::from(source);
515 assert!(
516 matches!(&err, ProcError::ParseError(m) if *m == expected),
517 "{err:?}"
518 );
519 }
520
521 #[test]
522 fn a_bad_regex_becomes_invalid_input() {
523 let pattern = String::from("(");
525 let source = regex::Regex::new(&pattern).unwrap_err();
526 let err = ProcError::from(source);
527 assert!(
528 matches!(&err, ProcError::InvalidInput(m) if m.starts_with("Invalid pattern: ")),
529 "{err:?}"
530 );
531 assert_eq!(exit_code(err), 4);
532 }
533
534 #[test]
535 fn a_dialog_error_becomes_a_system_error() {
536 let source = dialoguer::Error::IO(std::io::Error::other("no terminal"));
537 let err = ProcError::from(source);
538 assert!(
539 matches!(&err, ProcError::SystemError(m) if m == "Dialog error: IO error: no terminal"),
540 "{err:?}"
541 );
542 }
543}