Skip to main content

rs_guard/
error.rs

1//! Error types for the rs-guard application.
2//!
3//! Provides a unified [`RsGuardError`] enum covering all failure modes
4//! encountered during diff fetching, LLM interaction, verdict parsing,
5//! GitHub API communication, and general I/O.
6
7use thiserror::Error;
8
9/// Unified error type for all rs-guard operations.
10#[derive(Error, Debug)]
11pub enum RsGuardError {
12    /// GitHub REST API returned an error response.
13    #[error("GitHub API error: {status} - {message}")]
14    GitHubApi {
15        /// HTTP status code returned by GitHub (0 for connection/timeout failures).
16        status: u16,
17        /// Response body or description of the failure.
18        message: String,
19    },
20
21    /// LLM provider API returned an error response.
22    #[error("LLM API error ({provider}): {status} - {message}")]
23    LlmApi {
24        /// Name of the LLM provider (e.g. "deepseek").
25        provider: String,
26        /// HTTP status code returned by the provider (0 for connection/timeout failures).
27        status: u16,
28        /// Response body or description of the failure.
29        message: String,
30    },
31
32    /// Failed to parse the verdict metadata block from an LLM response.
33    #[error("Failed to parse verdict: {0}")]
34    VerdictParse(
35        /// Description of the parsing failure.
36        String,
37    ),
38
39    /// Configuration is invalid or a required value is missing.
40    #[error("Configuration error: {0}")]
41    Config(
42        /// Description of the configuration problem.
43        String,
44    ),
45
46    /// An I/O operation failed.
47    #[error("I/O error: {0}")]
48    Io(#[from] std::io::Error),
49
50    /// The PR diff exceeds the maximum allowed size.
51    #[error(
52        "Diff too large: {size_bytes} bytes ({line_count} lines). Maximum is 100KB or 1500 lines."
53    )]
54    DiffTooLarge {
55        /// Actual diff size in bytes.
56        size_bytes: usize,
57        /// Actual diff line count.
58        line_count: usize,
59    },
60
61    /// The diff contained no content.
62    #[error("No diff content found")]
63    EmptyDiff,
64
65    /// The diff response did not contain valid diff content (e.g. received JSON error body).
66    #[error("Invalid diff content: response does not appear to be a diff")]
67    InvalidDiffContent,
68
69    /// The GitHub token lacks permission to perform the requested review action.
70    #[error("Permission denied for review state {state}: {message}")]
71    PermissionDenied {
72        /// The review state that was attempted (e.g. "APPROVE").
73        state: String,
74        /// Description of the permission failure.
75        message: String,
76    },
77}
78
79impl RsGuardError {
80    /// Returns `true` if this error is transient and the operation should be retried.
81    ///
82    /// Retryable conditions:
83    /// - HTTP 429 (rate limited), 502, 503, or 504
84    /// - Status 0 (connection error, timeout, DNS failure)
85    pub fn is_retryable(&self) -> bool {
86        matches!(
87            self,
88            RsGuardError::GitHubApi {
89                status: 0 | 429 | 502 | 503 | 504,
90                ..
91            } | RsGuardError::LlmApi {
92                status: 0 | 429 | 502 | 503 | 504,
93                ..
94            }
95        )
96    }
97
98    /// Returns `true` if this error indicates insufficient GitHub permissions.
99    pub fn is_permission_denied(&self) -> bool {
100        match self {
101            RsGuardError::GitHubApi { status: 403, .. } => true,
102            RsGuardError::GitHubApi {
103                status: 422,
104                message,
105            } => message.to_lowercase().contains("not permitted"),
106            RsGuardError::PermissionDenied { .. } => true,
107            _ => false,
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_is_retryable_github_429() {
118        let err = RsGuardError::GitHubApi {
119            status: 429,
120            message: "rate limited".to_string(),
121        };
122        assert!(err.is_retryable());
123    }
124
125    #[test]
126    fn test_is_retryable_github_502() {
127        let err = RsGuardError::GitHubApi {
128            status: 502,
129            message: "bad gateway".to_string(),
130        };
131        assert!(err.is_retryable());
132    }
133
134    #[test]
135    fn test_is_retryable_github_503() {
136        let err = RsGuardError::GitHubApi {
137            status: 503,
138            message: "service unavailable".to_string(),
139        };
140        assert!(err.is_retryable());
141    }
142
143    #[test]
144    fn test_is_retryable_github_504() {
145        let err = RsGuardError::GitHubApi {
146            status: 504,
147            message: "gateway timeout".to_string(),
148        };
149        assert!(err.is_retryable());
150    }
151
152    #[test]
153    fn test_is_retryable_github_0() {
154        let err = RsGuardError::GitHubApi {
155            status: 0,
156            message: "connection error".to_string(),
157        };
158        assert!(err.is_retryable());
159    }
160
161    #[test]
162    fn test_is_retryable_github_404_not_retryable() {
163        let err = RsGuardError::GitHubApi {
164            status: 404,
165            message: "not found".to_string(),
166        };
167        assert!(!err.is_retryable());
168    }
169
170    #[test]
171    fn test_is_retryable_github_403_not_retryable() {
172        let err = RsGuardError::GitHubApi {
173            status: 403,
174            message: "forbidden".to_string(),
175        };
176        assert!(!err.is_retryable());
177    }
178
179    #[test]
180    fn test_is_retryable_llm_429() {
181        let err = RsGuardError::LlmApi {
182            provider: "deepseek".to_string(),
183            status: 429,
184            message: "rate limited".to_string(),
185        };
186        assert!(err.is_retryable());
187    }
188
189    #[test]
190    fn test_is_retryable_llm_0() {
191        let err = RsGuardError::LlmApi {
192            provider: "deepseek".to_string(),
193            status: 0,
194            message: "connection error".to_string(),
195        };
196        assert!(err.is_retryable());
197    }
198
199    #[test]
200    fn test_is_retryable_config_not_retryable() {
201        let err = RsGuardError::Config("bad config".to_string());
202        assert!(!err.is_retryable());
203    }
204
205    #[test]
206    fn test_is_permission_denied_403() {
207        let err = RsGuardError::GitHubApi {
208            status: 403,
209            message: "forbidden".to_string(),
210        };
211        assert!(err.is_permission_denied());
212    }
213
214    #[test]
215    fn test_is_permission_denied_422_not_permitted() {
216        let err = RsGuardError::GitHubApi {
217            status: 422,
218            message: "Review not permitted for this user".to_string(),
219        };
220        assert!(err.is_permission_denied());
221    }
222
223    #[test]
224    fn test_is_permission_denied_422_case_insensitive() {
225        let err = RsGuardError::GitHubApi {
226            status: 422,
227            message: "NOT PERMITTED".to_string(),
228        };
229        assert!(err.is_permission_denied());
230    }
231
232    #[test]
233    fn test_is_permission_denied_422_other_message() {
234        let err = RsGuardError::GitHubApi {
235            status: 422,
236            message: "Validation failed".to_string(),
237        };
238        assert!(!err.is_permission_denied());
239    }
240
241    #[test]
242    fn test_is_permission_denied_explicit_variant() {
243        let err = RsGuardError::PermissionDenied {
244            state: "APPROVE".to_string(),
245            message: "not allowed".to_string(),
246        };
247        assert!(err.is_permission_denied());
248    }
249
250    #[test]
251    fn test_is_permission_denied_404_not_denied() {
252        let err = RsGuardError::GitHubApi {
253            status: 404,
254            message: "not found".to_string(),
255        };
256        assert!(!err.is_permission_denied());
257    }
258}