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 hint for a name that matched nothing by name but is in some command lines
100pub 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    /// The `--cmd` hint, when this error carries one
111    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    /// Creates a [`ProcError::Reported`] for a result that has already been printed.
122    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
158/// Result type alias for proc operations
159pub type Result<T> = std::result::Result<T, ProcError>;
160
161/// Exit codes for CLI
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum ExitCode {
164    /// Operation completed successfully
165    Success = 0,
166    /// A general error occurred
167    GeneralError = 1,
168    /// The requested process or port was not found
169    NotFound = 2,
170    /// Operation requires elevated privileges
171    PermissionDenied = 3,
172    /// Invalid arguments or input provided
173    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        // "Can't do this here" has one exit code, the same as an unusable
200        // flag value such as `stop --signal` on Windows
201        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        // Built at run time: clippy rejects a literal that is not a valid regex
369        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}