Skip to main content

proc_cli/
error.rs

1//! Error types for proc CLI
2//!
3//! Provides structured error handling with helpful suggestions for users.
4
5use crate::core::Process;
6use thiserror::Error;
7
8/// Main error type for proc operations
9#[derive(Error, Debug)]
10pub enum ProcError {
11    /// No process found matching the given target
12    #[error("No process found matching '{0}'\n  Try: proc list to list all processes")]
13    ProcessNotFound(String),
14
15    /// No process has this name, but some have it in their command line, which
16    /// `--cmd` would match
17    #[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        /// The name that was looked for
20        name: String,
21        /// How many processes have it in their command line (at least one)
22        in_command_line: usize,
23    },
24
25    /// A target exists but `--in` or `--by` ruled out everything it matched.
26    /// The target sits in the quotes and the reason follows them.
27    #[error(
28        "No process found matching '{target}' ({reason})\n  Try: proc list to list all processes"
29    )]
30    FilteredOut {
31        /// The target as typed (`node`, `:3000`, `1234`)
32        target: String,
33        /// Which filter ruled it out
34        reason: String,
35    },
36
37    /// No process is listening on the specified port
38    #[error("No process listening on port {0}\n  Try: proc ports")]
39    PortNotFound(u16),
40
41    /// Insufficient permissions to operate on the process
42    #[error("Permission denied for PID {0}\n  Try: sudo proc <command>")]
43    PermissionDenied(u32),
44
45    /// User provided invalid input or arguments
46    #[error("Invalid input: {0}")]
47    InvalidInput(String),
48
49    /// A command that changes processes was run without `--yes` where nobody
50    /// can be asked (`--json`, or no terminal). Carries the processes it would
51    /// have changed so `--json` callers can see them in the error.
52    ///
53    /// Not shown as invalid input: the call is valid, it only needs a yes, and
54    /// the JSON `reason` (`needs_confirmation`) says so.
55    #[error("{message}")]
56    ConfirmationRequired {
57        /// What to pass to proceed
58        message: String,
59        /// The processes the command would have changed
60        processes: Vec<Process>,
61        /// The targets that matched nothing, for a command that takes targets
62        /// (`None` for one that finds its own processes)
63        not_found: Option<Vec<String>>,
64    },
65
66    /// An underlying system call failed
67    #[error("System error: {0}")]
68    SystemError(String),
69
70    /// The operation exceeded the allowed time limit
71    #[error("Operation timed out: {0}")]
72    Timeout(String),
73
74    /// Failed to parse input or system output
75    #[error("Failed to parse: {0}")]
76    ParseError(String),
77
78    /// Feature is not available on the current platform
79    #[error("Not supported on this platform: {0}")]
80    NotSupported(String),
81
82    /// The target process terminated during the operation
83    #[error("Process {0} is no longer running")]
84    ProcessGone(u32),
85
86    /// Failed to send a signal to the process
87    #[error("Signal failed: {0}")]
88    SignalError(String),
89
90    /// The command already printed its result, but the operation did not fully
91    /// succeed (some processes failed, a port stayed busy, a wait timed out).
92    /// `main` exits with `exit_code` without printing anything further.
93    #[error("{message}")]
94    Reported {
95        /// Human-readable summary of what did not succeed
96        message: String,
97        /// Exit code to return
98        exit_code: ExitCode,
99    },
100}
101
102/// The `reason` of an exit-4 JSON error caused by bad flags, values or
103/// arguments, including clap's own usage errors
104pub const REASON_INVALID_INPUT: &str = "invalid_input";
105
106/// The `reason` of an exit-4 JSON error for a command that changes processes
107/// and was not given `--yes`
108pub const REASON_NEEDS_CONFIRMATION: &str = "needs_confirmation";
109
110/// The `reason` of an exit-4 JSON error for a command this OS does not support
111pub const REASON_NOT_SUPPORTED: &str = "not_supported";
112
113/// The hint for a name that matched nothing by name but is in some command lines
114pub 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    /// The `--cmd` hint, when this error carries one
125    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    /// Why this error is exit 4 (nothing was done), as the `reason` of the JSON
136    /// error. `None` for every error with another exit code.
137    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    /// The error for a name that matched no process: [`ProcError::NameNotFound`]
147    /// when `in_command_line` processes have it in their command line (which
148    /// `--cmd` would match), [`ProcError::ProcessNotFound`] when none do.
149    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    /// Creates a [`ProcError::Reported`] for a result that has already been printed.
161    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
197/// Result type alias for proc operations
198pub type Result<T> = std::result::Result<T, ProcError>;
199
200/// Exit codes for CLI
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum ExitCode {
203    /// Operation completed successfully
204    Success = 0,
205    /// A general error occurred
206    GeneralError = 1,
207    /// The requested process or port was not found
208    NotFound = 2,
209    /// Operation requires elevated privileges
210    PermissionDenied = 3,
211    /// Invalid arguments or input provided
212    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        // "Can't do this here" has one exit code, the same as an unusable
239        // flag value such as `stop --signal` on Windows
240        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            // exit 4 always says why, and no other exit code does
333            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        // the constants are what clap's usage errors use too
355        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        // Built at run time: clippy rejects a literal that is not a valid regex
432        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}