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    #[error("Invalid input: {message}")]
53    ConfirmationRequired {
54        /// What to pass to proceed
55        message: String,
56        /// The processes the command would have changed
57        processes: Vec<Process>,
58        /// The targets that matched nothing, for a command that takes targets
59        /// (`None` for one that finds its own processes)
60        not_found: Option<Vec<String>>,
61    },
62
63    /// An underlying system call failed
64    #[error("System error: {0}")]
65    SystemError(String),
66
67    /// The operation exceeded the allowed time limit
68    #[error("Operation timed out: {0}")]
69    Timeout(String),
70
71    /// Failed to parse input or system output
72    #[error("Failed to parse: {0}")]
73    ParseError(String),
74
75    /// Feature is not available on the current platform
76    #[error("Not supported on this platform: {0}")]
77    NotSupported(String),
78
79    /// The target process terminated during the operation
80    #[error("Process {0} is no longer running")]
81    ProcessGone(u32),
82
83    /// Failed to send a signal to the process
84    #[error("Signal failed: {0}")]
85    SignalError(String),
86
87    /// The command already printed its result, but the operation did not fully
88    /// succeed (some processes failed, a port stayed busy, a wait timed out).
89    /// `main` exits with `exit_code` without printing anything further.
90    #[error("{message}")]
91    Reported {
92        /// Human-readable summary of what did not succeed
93        message: String,
94        /// Exit code to return
95        exit_code: ExitCode,
96    },
97}
98
99/// The `reason` of an exit-4 JSON error caused by bad flags, values or
100/// arguments, including clap's own usage errors
101pub const REASON_INVALID_INPUT: &str = "invalid_input";
102
103/// The `reason` of an exit-4 JSON error for a command that changes processes
104/// and was not given `--yes`
105pub const REASON_NEEDS_CONFIRMATION: &str = "needs_confirmation";
106
107/// The `reason` of an exit-4 JSON error for a command this OS does not support
108pub const REASON_NOT_SUPPORTED: &str = "not_supported";
109
110/// The hint for a name that matched nothing by name but is in some command lines
111pub 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    /// The `--cmd` hint, when this error carries one
122    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    /// Why this error is exit 4 (nothing was done), as the `reason` of the JSON
133    /// error. `None` for every error with another exit code.
134    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    /// Creates a [`ProcError::Reported`] for a result that has already been printed.
144    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
180/// Result type alias for proc operations
181pub type Result<T> = std::result::Result<T, ProcError>;
182
183/// Exit codes for CLI
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ExitCode {
186    /// Operation completed successfully
187    Success = 0,
188    /// A general error occurred
189    GeneralError = 1,
190    /// The requested process or port was not found
191    NotFound = 2,
192    /// Operation requires elevated privileges
193    PermissionDenied = 3,
194    /// Invalid arguments or input provided
195    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        // "Can't do this here" has one exit code, the same as an unusable
222        // flag value such as `stop --signal` on Windows
223        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            // exit 4 always says why, and no other exit code does
316            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        // the constants are what clap's usage errors use too
338        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        // Built at run time: clippy rejects a literal that is not a valid regex
415        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}