tmux_lib/
error.rs

1use std::{io, process::Output};
2
3/// Describes all errors variants from this crate.
4#[derive(thiserror::Error, Debug)]
5pub enum Error {
6    /// A tmux invocation returned some output where none was expected (actions such as
7    /// some `tmux display-message` invocations).
8    #[error(
9        "unexpected process output: intent: `{intent}`, stdout: `{stdout}`, stderr: `{stderr}`"
10    )]
11    UnexpectedTmuxOutput {
12        intent: &'static str,
13        stdout: String,
14        stderr: String,
15    },
16
17    /// Indicates Tmux has a weird config, like missing the `"default-shell"`.
18    #[error("unexpected tmux config: `{0}`")]
19    TmuxConfig(&'static str),
20
21    /// Some parsing error.
22    #[error("failed parsing: `{intent}`")]
23    ParseError {
24        desc: &'static str,
25        intent: &'static str,
26        err: nom::Err<nom::error::Error<String>>,
27    },
28
29    /// Failed parsing the output of a process invocation as utf-8.
30    #[error("failed parsing utf-8 string: `{source}`")]
31    Utf8 {
32        #[from]
33        /// Source error.
34        source: std::string::FromUtf8Error,
35    },
36
37    /// Some IO error.
38    #[error("failed with io: `{source}`")]
39    Io {
40        #[from]
41        /// Source error.
42        source: io::Error,
43    },
44}
45
46/// Convert a nom error into an owned error and add the parsing intent.
47///
48/// # Errors
49///
50/// This maps to a `Error::ParseError`.
51#[must_use]
52pub fn map_add_intent(
53    desc: &'static str,
54    intent: &'static str,
55    nom_err: nom::Err<nom::error::Error<&str>>,
56) -> Error {
57    Error::ParseError {
58        desc,
59        intent,
60        err: nom_err.to_owned(),
61    }
62}
63
64/// Ensure that the output's stdout and stderr are empty, indicating
65/// the command had succeeded.
66///
67/// # Errors
68///
69/// Returns a `Error::UnexpectedTmuxOutput` in case .
70pub fn check_empty_process_output(
71    output: &Output,
72    intent: &'static str,
73) -> std::result::Result<(), Error> {
74    if !output.stdout.is_empty() || !output.stderr.is_empty() {
75        let stdout = String::from_utf8_lossy(&output.stdout[..]).to_string();
76        let stderr = String::from_utf8_lossy(&output.stderr[..]).to_string();
77        return Err(Error::UnexpectedTmuxOutput {
78            intent,
79            stdout,
80            stderr,
81        });
82    }
83    Ok(())
84}
85
86/// Ensure that the tmux command succeeded (exit status 0) before parsing its output.
87///
88/// This prevents confusing parse errors when tmux fails and returns empty or
89/// garbage stdout. Instead, we get a clear error with the actual stderr message.
90///
91/// # Errors
92///
93/// Returns `Error::UnexpectedTmuxOutput` if the command exited with non-zero status.
94pub fn check_process_success(
95    output: &Output,
96    intent: &'static str,
97) -> std::result::Result<(), Error> {
98    if !output.status.success() {
99        let stdout = String::from_utf8_lossy(&output.stdout[..]).to_string();
100        let stderr = String::from_utf8_lossy(&output.stderr[..]).to_string();
101        return Err(Error::UnexpectedTmuxOutput {
102            intent,
103            stdout,
104            stderr,
105        });
106    }
107    Ok(())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::os::unix::process::ExitStatusExt;
114    use std::process::ExitStatus;
115
116    fn make_output(status_code: i32, stdout: &[u8], stderr: &[u8]) -> Output {
117        Output {
118            status: ExitStatus::from_raw(status_code << 8), // Unix exit codes are shifted
119            stdout: stdout.to_vec(),
120            stderr: stderr.to_vec(),
121        }
122    }
123
124    #[test]
125    fn check_empty_process_output_succeeds_when_empty() {
126        let output = make_output(0, b"", b"");
127        let result = check_empty_process_output(&output, "test-intent");
128        assert!(result.is_ok());
129    }
130
131    #[test]
132    fn check_empty_process_output_fails_when_stdout_not_empty() {
133        let output = make_output(0, b"some output", b"");
134        let result = check_empty_process_output(&output, "test-intent");
135        assert!(result.is_err());
136
137        match result.unwrap_err() {
138            Error::UnexpectedTmuxOutput {
139                intent,
140                stdout,
141                stderr,
142            } => {
143                assert_eq!(intent, "test-intent");
144                assert_eq!(stdout, "some output");
145                assert_eq!(stderr, "");
146            }
147            _ => panic!("Expected UnexpectedTmuxOutput error"),
148        }
149    }
150
151    #[test]
152    fn check_empty_process_output_fails_when_stderr_not_empty() {
153        let output = make_output(0, b"", b"error message");
154        let result = check_empty_process_output(&output, "test-intent");
155        assert!(result.is_err());
156
157        match result.unwrap_err() {
158            Error::UnexpectedTmuxOutput {
159                intent,
160                stdout,
161                stderr,
162            } => {
163                assert_eq!(intent, "test-intent");
164                assert_eq!(stdout, "");
165                assert_eq!(stderr, "error message");
166            }
167            _ => panic!("Expected UnexpectedTmuxOutput error"),
168        }
169    }
170
171    #[test]
172    fn check_empty_process_output_fails_when_both_not_empty() {
173        let output = make_output(0, b"stdout", b"stderr");
174        let result = check_empty_process_output(&output, "test-intent");
175        assert!(result.is_err());
176
177        match result.unwrap_err() {
178            Error::UnexpectedTmuxOutput { stdout, stderr, .. } => {
179                assert_eq!(stdout, "stdout");
180                assert_eq!(stderr, "stderr");
181            }
182            _ => panic!("Expected UnexpectedTmuxOutput error"),
183        }
184    }
185
186    #[test]
187    fn check_process_success_succeeds_on_zero_exit() {
188        let output = make_output(0, b"output", b"");
189        let result = check_process_success(&output, "test-intent");
190        assert!(result.is_ok());
191    }
192
193    #[test]
194    fn check_process_success_fails_on_nonzero_exit() {
195        let output = make_output(1, b"", b"command failed");
196        let result = check_process_success(&output, "test-intent");
197        assert!(result.is_err());
198
199        match result.unwrap_err() {
200            Error::UnexpectedTmuxOutput {
201                intent,
202                stdout,
203                stderr,
204            } => {
205                assert_eq!(intent, "test-intent");
206                assert_eq!(stdout, "");
207                assert_eq!(stderr, "command failed");
208            }
209            _ => panic!("Expected UnexpectedTmuxOutput error"),
210        }
211    }
212
213    #[test]
214    fn map_add_intent_creates_parse_error() {
215        use nom::error::{Error as NomError, ErrorKind};
216
217        let nom_err: nom::Err<NomError<&str>> =
218            nom::Err::Error(NomError::new("remaining input", ErrorKind::Tag));
219
220        let error = map_add_intent("description", "expected format", nom_err);
221
222        match error {
223            Error::ParseError { desc, intent, .. } => {
224                assert_eq!(desc, "description");
225                assert_eq!(intent, "expected format");
226            }
227            _ => panic!("Expected ParseError"),
228        }
229    }
230
231    #[test]
232    fn error_display_messages() {
233        // Test UnexpectedTmuxOutput display
234        let err = Error::UnexpectedTmuxOutput {
235            intent: "test",
236            stdout: "out".to_string(),
237            stderr: "err".to_string(),
238        };
239        let msg = format!("{}", err);
240        assert!(msg.contains("unexpected process output"));
241        assert!(msg.contains("test"));
242
243        // Test TmuxConfig display
244        let err = Error::TmuxConfig("missing default-shell");
245        let msg = format!("{}", err);
246        assert!(msg.contains("unexpected tmux config"));
247        assert!(msg.contains("missing default-shell"));
248    }
249}