Skip to main content

rec/
error.rs

1use thiserror::Error;
2
3/// Exit code for successful execution
4pub const EXIT_SUCCESS: u8 = 0;
5/// Exit code for user errors (bad input, not found, invalid state)
6pub const EXIT_USER_ERROR: u8 = 1;
7/// Exit code for system errors (I/O, permissions, corrupt data)
8pub const EXIT_SYSTEM_ERROR: u8 = 2;
9/// Exit code for interrupted execution (Ctrl+C)
10pub const EXIT_INTERRUPTED: u8 = 130;
11
12/// Error types for the rec CLI.
13///
14/// Covers all anticipated failure modes including:
15/// - I/O errors (file operations)
16/// - Serialization errors (JSON, TOML)
17/// - Domain errors (session management, recording state)
18#[derive(Error, Debug)]
19pub enum RecError {
20    /// I/O error during file operations
21    #[error("IO error: {0}")]
22    Io(#[from] std::io::Error),
23
24    /// JSON serialization/deserialization error
25    #[error("JSON serialization error: {0}")]
26    Json(#[from] serde_json::Error),
27
28    /// TOML parsing error
29    #[error("TOML parsing error: {0}")]
30    Toml(#[from] toml::de::Error),
31
32    /// Session not found by name or ID
33    #[error("Session not found: {0}")]
34    SessionNotFound(String),
35
36    /// Session already exists with this name
37    #[error("Session already exists: {0}")]
38    SessionExists(String),
39
40    /// Invalid session file format
41    #[error("Invalid session format: {0}")]
42    InvalidSession(String),
43
44    /// Configuration error
45    #[error("Config error: {0}")]
46    Config(String),
47
48    /// Attempted to start recording while already recording
49    #[error("Recording already in progress")]
50    RecordingInProgress,
51
52    /// Attempted to stop recording when not recording
53    #[error("No active recording")]
54    NoActiveRecording,
55
56    /// Stale lock file from a crashed process (includes PID)
57    #[error("Stale lock from process {0}")]
58    StaleLock(String),
59
60    /// Session name contains invalid characters
61    #[error("Invalid session name '{0}': only alphanumeric, dash, and underscore allowed")]
62    InvalidSessionName(String),
63
64    /// Alias name contains invalid characters
65    #[error("Invalid alias name '{0}': only alphanumeric, dash, and underscore allowed")]
66    InvalidAliasName(String),
67
68    /// Tag name contains invalid characters after normalization
69    #[error("Invalid tag name '{0}': only alphanumeric, dash, and underscore allowed")]
70    InvalidTagName(String),
71}
72
73impl RecError {
74    /// Return the appropriate exit code for this error.
75    ///
76    /// Exit code semantics:
77    /// - `EXIT_USER_ERROR` (1) = user error (bad input, not found, invalid state)
78    /// - `EXIT_SYSTEM_ERROR` (2) = system error (I/O failure, permissions, corrupt data)
79    #[must_use]
80    pub fn exit_code(&self) -> u8 {
81        match self {
82            RecError::Io(_) | RecError::Json(_) | RecError::Toml(_) | RecError::StaleLock(_) => {
83                EXIT_SYSTEM_ERROR
84            }
85            RecError::SessionNotFound(_)
86            | RecError::SessionExists(_)
87            | RecError::InvalidSession(_)
88            | RecError::Config(_)
89            | RecError::RecordingInProgress
90            | RecError::NoActiveRecording
91            | RecError::InvalidSessionName(_)
92            | RecError::InvalidAliasName(_)
93            | RecError::InvalidTagName(_) => EXIT_USER_ERROR,
94        }
95    }
96}
97
98/// Result type alias using `RecError`
99pub type Result<T> = std::result::Result<T, RecError>;
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn test_exit_code_user_errors() {
107        assert_eq!(
108            RecError::SessionNotFound("x".into()).exit_code(),
109            EXIT_USER_ERROR
110        );
111        assert_eq!(
112            RecError::SessionExists("x".into()).exit_code(),
113            EXIT_USER_ERROR
114        );
115        assert_eq!(
116            RecError::InvalidSession("x".into()).exit_code(),
117            EXIT_USER_ERROR
118        );
119        assert_eq!(RecError::Config("x".into()).exit_code(), EXIT_USER_ERROR);
120        assert_eq!(RecError::RecordingInProgress.exit_code(), EXIT_USER_ERROR);
121        assert_eq!(RecError::NoActiveRecording.exit_code(), EXIT_USER_ERROR);
122        assert_eq!(
123            RecError::InvalidSessionName("x".into()).exit_code(),
124            EXIT_USER_ERROR
125        );
126        assert_eq!(
127            RecError::InvalidAliasName("x".into()).exit_code(),
128            EXIT_USER_ERROR
129        );
130        assert_eq!(
131            RecError::InvalidTagName("x".into()).exit_code(),
132            EXIT_USER_ERROR
133        );
134    }
135
136    #[test]
137    fn test_exit_code_system_errors() {
138        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
139        assert_eq!(RecError::Io(io_err).exit_code(), EXIT_SYSTEM_ERROR);
140
141        let json_err: serde_json::Error =
142            serde_json::from_str::<serde_json::Value>("bad").unwrap_err();
143        assert_eq!(RecError::Json(json_err).exit_code(), EXIT_SYSTEM_ERROR);
144
145        assert_eq!(
146            RecError::StaleLock("1234".into()).exit_code(),
147            EXIT_SYSTEM_ERROR
148        );
149    }
150
151    #[test]
152    fn test_error_display() {
153        let err = RecError::SessionNotFound("test-session".to_string());
154        assert_eq!(err.to_string(), "Session not found: test-session");
155
156        let err = RecError::RecordingInProgress;
157        assert_eq!(err.to_string(), "Recording already in progress");
158
159        let err = RecError::Config("invalid key".to_string());
160        assert_eq!(err.to_string(), "Config error: invalid key");
161
162        let err = RecError::InvalidAliasName("bad/alias".to_string());
163        assert_eq!(
164            err.to_string(),
165            "Invalid alias name 'bad/alias': only alphanumeric, dash, and underscore allowed"
166        );
167
168        let err = RecError::InvalidTagName("bad@tag".to_string());
169        assert_eq!(
170            err.to_string(),
171            "Invalid tag name 'bad@tag': only alphanumeric, dash, and underscore allowed"
172        );
173    }
174
175    #[test]
176    fn test_io_error_conversion() {
177        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
178        let rec_err: RecError = io_err.into();
179
180        match rec_err {
181            RecError::Io(_) => {}
182            _ => panic!("Expected RecError::Io"),
183        }
184    }
185
186    #[test]
187    fn test_json_error_conversion() {
188        let json_str = "{ invalid json }";
189        let json_result: std::result::Result<serde_json::Value, _> = serde_json::from_str(json_str);
190        let json_err = json_result.unwrap_err();
191        let rec_err: RecError = json_err.into();
192
193        match rec_err {
194            RecError::Json(_) => {}
195            _ => panic!("Expected RecError::Json"),
196        }
197    }
198
199    #[test]
200    fn test_result_type() {
201        fn returns_error() -> Result<()> {
202            Err(RecError::NoActiveRecording)
203        }
204
205        assert!(returns_error().is_err());
206    }
207}