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 its guards (`--by`, `--in`, `--start-time`) ruled
26    /// out everything it matched. The target sits in the quotes and the guards
27    /// that ruled it out follow them.
28    #[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        /// The target as typed (`node`, `:3000`, `1234`)
34        target: String,
35        /// Each guard that ruled it out: its flag and the value typed
36        /// (`("--by", "node")`)
37        ruled_out_by: Vec<(String, String)>,
38    },
39
40    /// No process is listening on the specified port
41    #[error("No process listening on port {0}\n  Try: proc ports")]
42    PortNotFound(u16),
43
44    /// Insufficient permissions to operate on the process
45    #[error("Permission denied for PID {0}\n  Try: sudo proc <command>")]
46    PermissionDenied(u32),
47
48    /// User provided invalid input or arguments
49    #[error("Invalid input: {0}")]
50    InvalidInput(String),
51
52    /// A command that changes processes was run without `--yes` where nobody
53    /// can be asked (`--json`, or no terminal). Carries the processes it would
54    /// have changed so `--json` callers can see them in the error.
55    ///
56    /// Not shown as invalid input: the call is valid, it only needs a yes, and
57    /// the JSON `reason` (`needs_confirmation`) says so.
58    #[error("{message}")]
59    ConfirmationRequired {
60        /// What to pass to proceed
61        message: String,
62        /// The processes the command would have changed
63        processes: Vec<Process>,
64        /// The targets that matched nothing, for a command that takes targets
65        /// (`None` for one that finds its own processes)
66        not_found: Option<Vec<String>>,
67    },
68
69    /// An underlying system call failed
70    #[error("System error: {0}")]
71    SystemError(String),
72
73    /// The operation exceeded the allowed time limit
74    #[error("Operation timed out: {0}")]
75    Timeout(String),
76
77    /// Failed to parse input or system output
78    #[error("Failed to parse: {0}")]
79    ParseError(String),
80
81    /// Feature is not available on the current platform
82    #[error("Not supported on this platform: {0}")]
83    NotSupported(String),
84
85    /// The target process terminated during the operation
86    #[error("Process {0} is no longer running")]
87    ProcessGone(u32),
88
89    /// Failed to send a signal to the process
90    #[error("Signal failed: {0}")]
91    SignalError(String),
92
93    /// The command already printed its result, but the operation did not fully
94    /// succeed (some processes failed, a port stayed busy, a wait timed out).
95    /// `main` exits with `exit_code` without printing anything further.
96    #[error("{message}")]
97    Reported {
98        /// Human-readable summary of what did not succeed
99        message: String,
100        /// Exit code to return
101        exit_code: ExitCode,
102    },
103}
104
105/// The `reason` of an exit-1 JSON error: proc itself could not do its part,
106/// such as reading the process or port table
107pub const REASON_SYSTEM_ERROR: &str = "system_error";
108
109/// The `reason` of an exit-2 JSON error: no process holds that PID, name or
110/// port (any more)
111pub const REASON_NOT_FOUND: &str = "not_found";
112
113/// The `reason` of an exit-2 JSON error for a name that no process has, though
114/// some have it in their command line (`--cmd` would match those)
115pub const REASON_COMMAND_LINE_ONLY: &str = "command_line_only";
116
117/// The `reason` of an exit-2 JSON error for a target whose guards (`--by`,
118/// `--in`, `--start-time`) ruled out every process it found; `ruled_out_by`
119/// names them
120pub const REASON_RULED_OUT: &str = "ruled_out";
121
122/// The `reason` of an exit-3 JSON error
123pub const REASON_PERMISSION_DENIED: &str = "permission_denied";
124
125/// The `reason` of an exit-4 JSON error caused by bad flags, values or
126/// arguments, including clap's own usage errors
127pub const REASON_INVALID_INPUT: &str = "invalid_input";
128
129/// The `reason` of an exit-4 JSON error for a command that changes processes
130/// and was not given `--yes`
131pub const REASON_NEEDS_CONFIRMATION: &str = "needs_confirmation";
132
133/// The `reason` of an exit-4 JSON error for a command this OS does not support
134pub const REASON_NOT_SUPPORTED: &str = "not_supported";
135
136/// Guards as the message names them: `--by node and --start-time 1700000000`
137fn 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
145/// The hint for a name that matched nothing by name but is in some command lines
146pub 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    /// The `--cmd` hint, when this error carries one
157    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    /// Why this error happened, as the `reason` of the JSON error: a fixed word
168    /// a caller branches on instead of reading the message. Every error has
169    /// one except [`ProcError::Reported`], whose command already printed its
170    /// result and prints no error.
171    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    /// The flags of the guards that ruled the target out, for the JSON's
191    /// `ruled_out_by`; `None` for every other error
192    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    /// The error for a name that matched no process: [`ProcError::NameNotFound`]
202    /// when `in_command_line` processes have it in their command line (which
203    /// `--cmd` would match), [`ProcError::ProcessNotFound`] when none do.
204    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    /// Creates a [`ProcError::Reported`] for a result that has already been printed.
216    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
252/// Result type alias for proc operations
253pub type Result<T> = std::result::Result<T, ProcError>;
254
255/// Exit codes for CLI
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum ExitCode {
258    /// Operation completed successfully
259    Success = 0,
260    /// A general error occurred
261    GeneralError = 1,
262    /// The requested process or port was not found
263    NotFound = 2,
264    /// Operation requires elevated privileges
265    PermissionDenied = 3,
266    /// Invalid arguments or input provided
267    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        // "Can't do this here" has one exit code, the same as an unusable
295        // flag value such as `stop --signal` on Windows
296        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            // every holder of a port ended between listing it and looking it up
384            (
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            // printed its own result; no error is printed, so no reason
397            (
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        // the constants are what clap's usage errors use too
442        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        // Built at run time: clippy rejects a literal that is not a valid regex
524        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}