1#![forbid(unsafe_code)]
2
3use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22pub mod fuzzy;
23pub use fuzzy::{enrich_oracle_error, fuzzy_suggest, levenshtein};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
32#[non_exhaustive]
33pub enum ErrorClass {
34 ObjectNotFound,
37 InsufficientPrivilege,
40 SyntaxError,
42 ConnectionFailed,
44 RuntimeStateRequired,
48 ChallengeRequired,
51 LeaseRequired,
54 ForbiddenStatement,
57 OperatingLevelTooLow,
60 Busy,
62 InvalidArguments,
64 PolicyDenied,
67 Timeout,
69 Transient,
71 Internal,
73}
74
75impl ErrorClass {
76 #[must_use]
79 pub fn default_suggested_tool(self) -> Option<&'static str> {
80 match self {
81 ErrorClass::ObjectNotFound => Some("oracle_schema_inspect"),
82 ErrorClass::OperatingLevelTooLow | ErrorClass::ChallengeRequired => {
83 Some("oracle_session")
84 }
85 ErrorClass::RuntimeStateRequired | ErrorClass::ConnectionFailed => {
86 Some("oracle_connect")
87 }
88 _ => None,
89 }
90 }
91
92 #[must_use]
96 pub fn is_retryable(self) -> bool {
97 matches!(
98 self,
99 ErrorClass::Busy | ErrorClass::Transient | ErrorClass::Timeout
100 )
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ErrorEnvelope {
111 #[serde(rename = "isError")]
113 pub is_error: bool,
114 pub error_class: ErrorClass,
116 pub message: String,
118 #[serde(skip_serializing_if = "Option::is_none", default)]
120 pub ora_code: Option<i32>,
121 #[serde(skip_serializing_if = "Option::is_none", default)]
123 pub suggested_tool: Option<String>,
124 #[serde(skip_serializing_if = "Vec::is_empty", default)]
126 pub fuzzy_matches: Vec<String>,
127 #[serde(skip_serializing_if = "Vec::is_empty", default)]
129 pub next_steps: Vec<String>,
130 #[serde(skip_serializing_if = "Option::is_none", default)]
132 pub retry_after_ms: Option<u64>,
133}
134
135impl ErrorEnvelope {
136 #[must_use]
139 pub fn new(error_class: ErrorClass, message: impl Into<String>) -> Self {
140 ErrorEnvelope {
141 is_error: true,
142 error_class,
143 message: message.into(),
144 ora_code: None,
145 suggested_tool: error_class.default_suggested_tool().map(str::to_owned),
146 fuzzy_matches: Vec::new(),
147 next_steps: Vec::new(),
148 retry_after_ms: None,
149 }
150 }
151
152 #[must_use]
154 pub fn with_ora_code(mut self, code: i32) -> Self {
155 self.ora_code = Some(code);
156 self
157 }
158
159 #[must_use]
161 pub fn with_suggested_tool(mut self, tool: impl Into<String>) -> Self {
162 self.suggested_tool = Some(tool.into());
163 self
164 }
165
166 #[must_use]
168 pub fn with_fuzzy_matches(mut self, matches: Vec<String>) -> Self {
169 self.fuzzy_matches = matches;
170 self
171 }
172
173 #[must_use]
175 pub fn with_next_step(mut self, step: impl Into<String>) -> Self {
176 self.next_steps.push(step.into());
177 self
178 }
179
180 #[must_use]
182 pub fn with_retry_after_ms(mut self, ms: u64) -> Self {
183 self.retry_after_ms = Some(ms);
184 self
185 }
186
187 #[must_use]
194 pub fn to_json(&self) -> serde_json::Value {
195 serde_json::to_value(self).unwrap_or_else(|_| {
196 serde_json::json!({
197 "isError": true,
198 "error_class": "INTERNAL",
199 "message": "error envelope failed to serialize",
200 })
201 })
202 }
203}
204
205#[must_use]
209pub fn parse_ora_code(message: &str) -> Option<i32> {
210 let idx = message.find("ORA-")?;
211 let digits: String = message[idx + 4..]
212 .chars()
213 .take_while(char::is_ascii_digit)
214 .collect();
215 if digits.is_empty() {
216 None
217 } else {
218 digits.parse::<i32>().ok()
219 }
220}
221
222#[must_use]
228pub fn classify_ora_code(code: i32) -> ErrorClass {
229 match code {
230 942 | 4043 => ErrorClass::ObjectNotFound,
233 1031 | 1017 | 1045 | 28009 => ErrorClass::InsufficientPrivilege,
235 1456 | 16000 => ErrorClass::ForbiddenStatement,
237 3113 | 3114 | 12170 | 12541 | 12514 | 12537 | 12543 => ErrorClass::Transient,
239 12519 | 18 | 20 => ErrorClass::Busy,
241 900..=999 => ErrorClass::SyntaxError,
243 _ => ErrorClass::Internal,
245 }
246}
247
248#[must_use]
251pub fn envelope_from_oracle_message(message: &str) -> ErrorEnvelope {
252 match parse_ora_code(message) {
253 Some(code) => {
254 let class = classify_ora_code(code);
255 ErrorEnvelope::new(class, message.to_owned()).with_ora_code(code)
256 }
257 None => ErrorEnvelope::new(ErrorClass::Internal, message.to_owned()),
258 }
259}
260
261#[derive(Debug, Error)]
267#[non_exhaustive]
268pub enum OracleMcpError {
269 #[error("oracle error: {0}")]
271 Oracle(String),
272 #[error("object not found: {name}")]
274 ObjectNotFound {
275 name: String,
277 fuzzy_matches: Vec<String>,
279 },
280 #[error("insufficient privilege: {0}")]
282 InsufficientPrivilege(String),
283 #[error("statement refused by guard: {0}")]
285 ForbiddenStatement(String),
286 #[error("session lease required: {0}")]
288 LeaseRequired(String),
289 #[error("operating level too low: {0}")]
291 OperatingLevelTooLow(String),
292 #[error("challenge required: {0}")]
294 ChallengeRequired(String),
295 #[error("runtime state required: {0}")]
297 RuntimeStateRequired(String),
298 #[error("server busy")]
300 Busy {
301 retry_after_ms: u64,
303 },
304 #[error("invalid arguments: {0}")]
306 InvalidArguments(String),
307 #[error("policy denied: {0}")]
309 PolicyDenied(String),
310 #[error("internal error: {0}")]
312 Internal(String),
313}
314
315impl OracleMcpError {
316 #[must_use]
318 pub fn into_envelope(self) -> ErrorEnvelope {
319 match self {
320 OracleMcpError::Oracle(msg) => envelope_from_oracle_message(&msg),
321 OracleMcpError::ObjectNotFound {
322 name,
323 fuzzy_matches,
324 } => ErrorEnvelope::new(
325 ErrorClass::ObjectNotFound,
326 format!("object not found: {name}"),
327 )
328 .with_fuzzy_matches(fuzzy_matches),
329 OracleMcpError::InsufficientPrivilege(msg) => {
330 ErrorEnvelope::new(ErrorClass::InsufficientPrivilege, msg)
331 }
332 OracleMcpError::ForbiddenStatement(msg) => {
333 ErrorEnvelope::new(ErrorClass::ForbiddenStatement, msg)
334 }
335 OracleMcpError::LeaseRequired(msg) => {
336 ErrorEnvelope::new(ErrorClass::LeaseRequired, msg)
337 .with_next_step("call oracle_session(acquire_lease) and pass the lease_id")
338 }
339 OracleMcpError::OperatingLevelTooLow(msg) => {
340 ErrorEnvelope::new(ErrorClass::OperatingLevelTooLow, msg)
341 .with_next_step("call oracle_session(escalate, target=<level>)")
342 }
343 OracleMcpError::ChallengeRequired(msg) => {
344 ErrorEnvelope::new(ErrorClass::ChallengeRequired, msg)
345 }
346 OracleMcpError::RuntimeStateRequired(msg) => {
347 ErrorEnvelope::new(ErrorClass::RuntimeStateRequired, msg)
348 }
349 OracleMcpError::Busy { retry_after_ms } => {
350 ErrorEnvelope::new(ErrorClass::Busy, "server busy")
351 .with_retry_after_ms(retry_after_ms)
352 }
353 OracleMcpError::InvalidArguments(msg) => {
354 ErrorEnvelope::new(ErrorClass::InvalidArguments, msg)
355 }
356 OracleMcpError::PolicyDenied(msg) => ErrorEnvelope::new(ErrorClass::PolicyDenied, msg),
357 OracleMcpError::Internal(msg) => ErrorEnvelope::new(ErrorClass::Internal, msg),
358 }
359 }
360}
361
362pub type Result<T> = std::result::Result<T, OracleMcpError>;
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn parse_ora_code_extracts_leading_code() {
371 assert_eq!(
372 parse_ora_code("ORA-00942: table or view does not exist"),
373 Some(942)
374 );
375 assert_eq!(
376 parse_ora_code("foo ORA-1031: insufficient privileges"),
377 Some(1031)
378 );
379 assert_eq!(parse_ora_code("no oracle code here"), None);
380 assert_eq!(parse_ora_code("ORA-: malformed"), None);
381 }
382
383 #[test]
384 fn classify_known_codes() {
385 assert_eq!(classify_ora_code(942), ErrorClass::ObjectNotFound);
386 assert_eq!(classify_ora_code(4043), ErrorClass::ObjectNotFound);
387 assert_eq!(classify_ora_code(1031), ErrorClass::InsufficientPrivilege);
388 assert_eq!(classify_ora_code(1456), ErrorClass::ForbiddenStatement);
389 assert_eq!(classify_ora_code(3113), ErrorClass::Transient);
390 assert_eq!(classify_ora_code(12519), ErrorClass::Busy);
391 assert_eq!(classify_ora_code(923), ErrorClass::SyntaxError);
392 assert_eq!(classify_ora_code(7777), ErrorClass::Internal);
393 }
394
395 #[test]
396 fn object_not_found_envelope_golden() {
397 let env = ErrorEnvelope::new(ErrorClass::ObjectNotFound, "object not found: EMPLOYES")
398 .with_ora_code(942)
399 .with_fuzzy_matches(vec!["EMPLOYEES".to_owned(), "EMPLOYEE".to_owned()]);
400 let json = serde_json::to_value(&env).expect("serialize");
401 assert_eq!(json["isError"], serde_json::json!(true));
402 assert_eq!(json["error_class"], serde_json::json!("OBJECT_NOT_FOUND"));
403 assert_eq!(json["ora_code"], serde_json::json!(942));
404 assert_eq!(
405 json["suggested_tool"],
406 serde_json::json!("oracle_schema_inspect")
407 );
408 assert_eq!(
409 json["fuzzy_matches"],
410 serde_json::json!(["EMPLOYEES", "EMPLOYEE"])
411 );
412 assert!(json.get("next_steps").is_none());
414 assert!(json.get("retry_after_ms").is_none());
415 }
416
417 #[test]
418 fn busy_envelope_carries_retry_after() {
419 let env = OracleMcpError::Busy {
420 retry_after_ms: 250,
421 }
422 .into_envelope();
423 let json = serde_json::to_value(&env).expect("serialize");
424 assert_eq!(json["error_class"], serde_json::json!("BUSY"));
425 assert_eq!(json["retry_after_ms"], serde_json::json!(250));
426 }
427
428 #[test]
429 fn oracle_message_roundtrips_through_envelope() {
430 let env = OracleMcpError::Oracle("ORA-00942: table or view does not exist".to_owned())
431 .into_envelope();
432 assert_eq!(env.error_class, ErrorClass::ObjectNotFound);
433 assert_eq!(env.ora_code, Some(942));
434 assert_eq!(env.suggested_tool.as_deref(), Some("oracle_schema_inspect"));
435 }
436
437 #[test]
438 fn envelope_serde_roundtrip_is_stable() {
439 let env = ErrorEnvelope::new(ErrorClass::LeaseRequired, "needs a lease")
440 .with_next_step("call oracle_session(acquire_lease)");
441 let json = serde_json::to_string(&env).expect("serialize");
442 let back: ErrorEnvelope = serde_json::from_str(&json).expect("deserialize");
443 assert_eq!(env, back);
444 }
445
446 #[test]
447 fn retryability_matches_class() {
448 assert!(ErrorClass::Busy.is_retryable());
449 assert!(ErrorClass::Transient.is_retryable());
450 assert!(!ErrorClass::ObjectNotFound.is_retryable());
451 assert!(!ErrorClass::ForbiddenStatement.is_retryable());
452 }
453}