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 fn command_line_hint(name: &str, count: usize) -> String {
101 let (who, has, its) = if count == 1 {
102 ("process", "has", "its")
103 } else {
104 ("processes", "have", "their")
105 };
106 format!("{count} {who} {has} '{name}' in {its} command line (add --cmd to match those)")
107}
108
109impl ProcError {
110 pub fn command_line_hint(&self) -> Option<String> {
112 match self {
113 ProcError::NameNotFound {
114 name,
115 in_command_line,
116 } => Some(command_line_hint(name, *in_command_line)),
117 _ => None,
118 }
119 }
120
121 pub fn reported(exit_code: ExitCode, message: impl Into<String>) -> Self {
123 ProcError::Reported {
124 message: message.into(),
125 exit_code,
126 }
127 }
128}
129
130impl From<std::io::Error> for ProcError {
131 fn from(err: std::io::Error) -> Self {
132 match err.kind() {
133 std::io::ErrorKind::PermissionDenied => ProcError::PermissionDenied(0),
134 std::io::ErrorKind::NotFound => ProcError::ProcessNotFound("unknown".to_string()),
135 _ => ProcError::SystemError(err.to_string()),
136 }
137 }
138}
139
140impl From<serde_json::Error> for ProcError {
141 fn from(err: serde_json::Error) -> Self {
142 ProcError::ParseError(err.to_string())
143 }
144}
145
146impl From<regex::Error> for ProcError {
147 fn from(err: regex::Error) -> Self {
148 ProcError::InvalidInput(format!("Invalid pattern: {}", err))
149 }
150}
151
152impl From<dialoguer::Error> for ProcError {
153 fn from(err: dialoguer::Error) -> Self {
154 ProcError::SystemError(format!("Dialog error: {}", err))
155 }
156}
157
158pub type Result<T> = std::result::Result<T, ProcError>;
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum ExitCode {
164 Success = 0,
166 GeneralError = 1,
168 NotFound = 2,
170 PermissionDenied = 3,
172 InvalidInput = 4,
174}
175
176impl From<&ProcError> for ExitCode {
177 fn from(err: &ProcError) -> Self {
178 match err {
179 ProcError::ProcessNotFound(_)
180 | ProcError::NameNotFound { .. }
181 | ProcError::FilteredOut { .. }
182 | ProcError::PortNotFound(_) => ExitCode::NotFound,
183 ProcError::PermissionDenied(_) => ExitCode::PermissionDenied,
184 ProcError::InvalidInput(_)
185 | ProcError::NotSupported(_)
186 | ProcError::ConfirmationRequired { .. } => ExitCode::InvalidInput,
187 ProcError::Reported { exit_code, .. } => *exit_code,
188 _ => ExitCode::GeneralError,
189 }
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn not_supported_on_this_os_is_invalid_input() {
199 let err = ProcError::NotSupported("freeze".to_string());
202 assert_eq!(ExitCode::from(&err) as i32, 4);
203 }
204
205 fn exit_code(err: ProcError) -> i32 {
206 ExitCode::from(&err) as i32
207 }
208
209 #[test]
210 fn every_error_has_its_message_and_exit_code() {
211 let confirmation = ProcError::ConfirmationRequired {
212 message: "Pass --yes".to_string(),
213 processes: Vec::new(),
214 not_found: None,
215 };
216 let cases: Vec<(ProcError, &str, i32)> = vec![
217 (
218 ProcError::ProcessNotFound("node".to_string()),
219 "No process found matching 'node'\n Try: proc list to list all processes",
220 2,
221 ),
222 (
223 ProcError::NameNotFound {
224 name: "serve".to_string(),
225 in_command_line: 2,
226 },
227 "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",
228 2,
229 ),
230 (
231 ProcError::FilteredOut {
232 target: "node".to_string(),
233 reason: "no matches with specified filters".to_string(),
234 },
235 "No process found matching 'node' (no matches with specified filters)\n Try: proc list to list all processes",
236 2,
237 ),
238 (
239 ProcError::PortNotFound(3000),
240 "No process listening on port 3000\n Try: proc ports",
241 2,
242 ),
243 (
244 ProcError::PermissionDenied(42),
245 "Permission denied for PID 42\n Try: sudo proc <command>",
246 3,
247 ),
248 (
249 ProcError::InvalidInput("bad".to_string()),
250 "Invalid input: bad",
251 4,
252 ),
253 (confirmation, "Invalid input: Pass --yes", 4),
254 (
255 ProcError::SystemError("boom".to_string()),
256 "System error: boom",
257 1,
258 ),
259 (
260 ProcError::Timeout("10s".to_string()),
261 "Operation timed out: 10s",
262 1,
263 ),
264 (
265 ProcError::ParseError("x".to_string()),
266 "Failed to parse: x",
267 1,
268 ),
269 (
270 ProcError::NotSupported("freeze".to_string()),
271 "Not supported on this platform: freeze",
272 4,
273 ),
274 (
275 ProcError::ProcessGone(7),
276 "Process 7 is no longer running",
277 1,
278 ),
279 (
280 ProcError::SignalError("EPERM".to_string()),
281 "Signal failed: EPERM",
282 1,
283 ),
284 (
285 ProcError::reported(ExitCode::NotFound, "nothing matched"),
286 "nothing matched",
287 2,
288 ),
289 ];
290 assert_eq!(ExitCode::Success as i32, 0);
291 for (err, message, code) in cases {
292 assert_eq!(err.to_string(), message);
293 assert_eq!(exit_code(err), code, "{message}");
294 }
295 }
296
297 #[test]
298 fn only_a_name_that_is_in_command_lines_carries_the_cmd_hint() {
299 let one = ProcError::NameNotFound {
300 name: "serve".to_string(),
301 in_command_line: 1,
302 };
303 assert_eq!(
304 one.command_line_hint().unwrap(),
305 "1 process has 'serve' in its command line (add --cmd to match those)"
306 );
307 let many = ProcError::NameNotFound {
308 name: "serve".to_string(),
309 in_command_line: 3,
310 };
311 assert_eq!(
312 many.command_line_hint().unwrap(),
313 "3 processes have 'serve' in their command line (add --cmd to match those)"
314 );
315 assert_eq!(
316 ProcError::ProcessNotFound("serve".to_string()).command_line_hint(),
317 None
318 );
319 }
320
321 #[test]
322 fn a_reported_error_keeps_the_exit_code_it_was_given() {
323 for code in [
324 ExitCode::Success,
325 ExitCode::GeneralError,
326 ExitCode::NotFound,
327 ExitCode::PermissionDenied,
328 ExitCode::InvalidInput,
329 ] {
330 let err = ProcError::reported(code, "done");
331 assert_eq!(ExitCode::from(&err), code);
332 }
333 }
334
335 #[test]
336 fn an_io_error_becomes_the_matching_proc_error() {
337 use std::io::{Error, ErrorKind};
338 let denied = ProcError::from(Error::from(ErrorKind::PermissionDenied));
339 assert!(
340 matches!(denied, ProcError::PermissionDenied(0)),
341 "{denied:?}"
342 );
343 let missing = ProcError::from(Error::from(ErrorKind::NotFound));
344 assert!(
345 matches!(&missing, ProcError::ProcessNotFound(t) if t == "unknown"),
346 "{missing:?}"
347 );
348 let other = ProcError::from(Error::new(ErrorKind::BrokenPipe, "pipe closed"));
349 assert!(
350 matches!(&other, ProcError::SystemError(m) if m == "pipe closed"),
351 "{other:?}"
352 );
353 }
354
355 #[test]
356 fn a_json_error_becomes_a_parse_error() {
357 let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
358 let expected = source.to_string();
359 let err = ProcError::from(source);
360 assert!(
361 matches!(&err, ProcError::ParseError(m) if *m == expected),
362 "{err:?}"
363 );
364 }
365
366 #[test]
367 fn a_bad_regex_becomes_invalid_input() {
368 let pattern = String::from("(");
370 let source = regex::Regex::new(&pattern).unwrap_err();
371 let err = ProcError::from(source);
372 assert!(
373 matches!(&err, ProcError::InvalidInput(m) if m.starts_with("Invalid pattern: ")),
374 "{err:?}"
375 );
376 assert_eq!(exit_code(err), 4);
377 }
378
379 #[test]
380 fn a_dialog_error_becomes_a_system_error() {
381 let source = dialoguer::Error::IO(std::io::Error::other("no terminal"));
382 let err = ProcError::from(source);
383 assert!(
384 matches!(&err, ProcError::SystemError(m) if m == "Dialog error: IO error: no terminal"),
385 "{err:?}"
386 );
387 }
388}