1pub mod mcp_mapper;
8
9use thiserror::Error;
10
11#[derive(Error, Debug)]
13pub enum AppError {
14 #[error("Configuration error: {0}")]
15 Config(#[from] ConfigError),
16
17 #[error("GitLab API error: {0}")]
18 GitLab(#[from] GitLabError),
19
20 #[error("Access denied: {0}")]
21 AccessDenied(#[from] AccessDeniedError),
22
23 #[error("Tool execution error: {0}")]
24 Tool(#[from] ToolError),
25
26 #[error("Transport error: {0}")]
27 Transport(#[from] TransportError),
28
29 #[error("Authentication error: {0}")]
30 Auth(#[from] AuthError),
31}
32
33#[derive(Error, Debug)]
35pub enum ConfigError {
36 #[error("Failed to load configuration: {0}")]
37 Load(String),
38
39 #[error("Invalid configuration: {message}")]
40 Invalid { message: String },
41
42 #[error("Missing required configuration: {field}")]
43 Missing { field: String },
44
45 #[error("Invalid regex pattern '{pattern}': {reason}")]
46 InvalidPattern { pattern: String, reason: String },
47
48 #[error("IO error: {0}")]
49 Io(#[from] std::io::Error),
50}
51
52#[derive(Error, Debug)]
54pub enum GitLabError {
55 #[error("HTTP request failed: {0}")]
56 Request(#[from] reqwest::Error),
57
58 #[error("GitLab API error (HTTP {status}): {message}")]
59 Api { status: u16, message: String },
60
61 #[error("Rate limited, retry after {retry_after} seconds")]
62 RateLimited { retry_after: u64 },
63
64 #[error("Resource not found: {resource}")]
65 NotFound { resource: String },
66
67 #[error("Unauthorized: invalid or expired token")]
68 Unauthorized,
69
70 #[error("Forbidden: insufficient permissions for {action}")]
71 Forbidden { action: String },
72
73 #[error("Invalid response from GitLab: {0}")]
74 InvalidResponse(String),
75
76 #[error("Request timeout after {timeout_secs} seconds")]
77 Timeout { timeout_secs: u64 },
78}
79
80impl GitLabError {
81 pub fn from_response(status: u16, body: &str) -> Self {
83 match status {
84 401 => GitLabError::Unauthorized,
85 403 => GitLabError::Forbidden {
86 action: "this operation".into(),
87 },
88 404 => GitLabError::NotFound {
89 resource: "requested resource".into(),
90 },
91 429 => {
92 GitLabError::RateLimited { retry_after: 60 }
94 }
95 _ => GitLabError::Api {
96 status,
97 message: if body.is_empty() {
98 format!("HTTP {}", status)
99 } else {
100 body.to_string()
101 },
102 },
103 }
104 }
105}
106
107#[derive(Error, Debug)]
109#[error("Access denied for tool '{tool}': {reason}")]
110pub struct AccessDeniedError {
111 pub tool: String,
112 pub reason: String,
113}
114
115impl AccessDeniedError {
116 pub fn new(tool: impl Into<String>, reason: impl Into<String>) -> Self {
117 Self {
118 tool: tool.into(),
119 reason: reason.into(),
120 }
121 }
122
123 pub fn read_only(tool: impl Into<String>) -> Self {
124 Self {
125 tool: tool.into(),
126 reason: "write operations are not permitted in read-only mode".into(),
127 }
128 }
129
130 pub fn denied_by_pattern(tool: impl Into<String>, pattern: impl Into<String>) -> Self {
131 Self {
132 tool: tool.into(),
133 reason: format!("denied by pattern '{}'", pattern.into()),
134 }
135 }
136
137 pub fn category_disabled(tool: impl Into<String>, category: impl Into<String>) -> Self {
138 Self {
139 tool: tool.into(),
140 reason: format!("category '{}' is disabled", category.into()),
141 }
142 }
143
144 pub fn project_restricted(tool: impl Into<String>, project: impl Into<String>) -> Self {
145 Self {
146 tool: tool.into(),
147 reason: format!("not permitted for project '{}'", project.into()),
148 }
149 }
150
151 pub fn project_restricted_with_hint(
153 tool: impl Into<String>,
154 project: impl Into<String>,
155 ) -> Self {
156 Self {
157 tool: tool.into(),
158 reason: format!(
159 "not allowed for project '{}', but may be available for other projects",
160 project.into()
161 ),
162 }
163 }
164
165 pub fn globally_unavailable(tool: impl Into<String>) -> Self {
167 Self {
168 tool: tool.into(),
169 reason: "this tool is not available".into(),
170 }
171 }
172}
173
174#[derive(Error, Debug)]
176pub enum ToolError {
177 #[error("Invalid arguments: {0}")]
178 InvalidArguments(String),
179
180 #[error("Missing required argument: {0}")]
181 MissingArgument(String),
182
183 #[error("Tool execution failed: {0}")]
184 ExecutionFailed(String),
185
186 #[error("GitLab API error: {0}")]
187 GitLab(#[from] GitLabError),
188
189 #[error("Serialization error: {0}")]
190 Serialization(#[from] serde_json::Error),
191
192 #[error("Tool not found: {0}")]
193 NotFound(String),
194
195 #[error("Access denied: {0}")]
196 AccessDenied(#[from] AccessDeniedError),
197}
198
199#[derive(Error, Debug)]
201pub enum TransportError {
202 #[error("IO error: {0}")]
203 Io(#[from] std::io::Error),
204
205 #[error("JSON serialization error: {0}")]
206 Json(#[from] serde_json::Error),
207
208 #[error("Connection closed")]
209 ConnectionClosed,
210
211 #[error("Invalid message format: {0}")]
212 InvalidMessage(String),
213
214 #[error("HTTP server error: {0}")]
215 Http(String),
216}
217
218#[derive(Error, Debug)]
220pub enum AuthError {
221 #[error("No authentication configured")]
222 NotConfigured,
223
224 #[error("Invalid token format")]
225 InvalidToken,
226
227 #[error("Token expired")]
228 TokenExpired,
229
230 #[error("Authentication failed: {0}")]
231 Failed(String),
232}
233
234pub type Result<T> = std::result::Result<T, AppError>;
236
237pub type ToolResult<T> = std::result::Result<T, ToolError>;
239
240pub type GitLabResult<T> = std::result::Result<T, GitLabError>;
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn test_gitlab_error_from_response() {
249 assert!(matches!(
250 GitLabError::from_response(401, ""),
251 GitLabError::Unauthorized
252 ));
253
254 assert!(matches!(
255 GitLabError::from_response(403, ""),
256 GitLabError::Forbidden { .. }
257 ));
258
259 assert!(matches!(
260 GitLabError::from_response(404, ""),
261 GitLabError::NotFound { .. }
262 ));
263
264 assert!(matches!(
265 GitLabError::from_response(429, ""),
266 GitLabError::RateLimited { .. }
267 ));
268
269 let api_err = GitLabError::from_response(500, "Internal server error");
270 assert!(matches!(api_err, GitLabError::Api { status: 500, .. }));
271 }
272
273 #[test]
274 fn test_access_denied_constructors() {
275 let err = AccessDeniedError::read_only("create_issue");
276 assert!(err.reason.contains("read-only"));
277
278 let err = AccessDeniedError::denied_by_pattern("delete_issue", "delete_.*");
279 assert!(err.reason.contains("delete_.*"));
280
281 let err = AccessDeniedError::category_disabled("list_wiki", "wiki");
282 assert!(err.reason.contains("wiki"));
283
284 let err = AccessDeniedError::project_restricted("merge_mr", "prod/app");
285 assert!(err.reason.contains("prod/app"));
286 }
287}