1use thiserror::Error;
5
6use crate::extractor::ExtractorError;
7use crate::fetcher::FetcherError;
8use crate::mcp::envelope::RoverError;
9use crate::storage::StorageError;
10use crate::tokenizer::TokenizerError;
11
12#[derive(Debug, Error)]
13pub enum McpError {
14 #[error("tokenizer error: {0}")]
15 Tokenizer(#[from] TokenizerError),
16
17 #[error("fetcher error: {0}")]
18 Fetcher(#[from] FetcherError),
19
20 #[error("extractor error: {0}")]
21 Extractor(#[from] ExtractorError),
22
23 #[error("storage error: {0}")]
24 Storage(#[from] StorageError),
25
26 #[error("invalid arguments: {0}")]
27 InvalidArgs(String),
28
29 #[error("invalid URL: {0}")]
30 InvalidUrl(String),
31
32 #[error("max_tokens exceeded: {actual} > {max} (was_auto: {was_auto})")]
33 MaxTokensExceeded {
34 actual: usize,
35 max: usize,
36 was_auto: bool,
37 },
38
39 #[error("too many URLs ({count}, max {max})")]
40 TooManyUrls { count: usize, max: usize },
41
42 #[error("empty URL list")]
43 EmptyUrlList,
44
45 #[error("summarizer error: {0}")]
46 Summarizer(#[from] crate::summarizer::SummarizerError),
47}
48
49impl McpError {
50 pub fn into_rover_error(self) -> RoverError {
52 match &self {
53 Self::MaxTokensExceeded {
54 actual,
55 max,
56 was_auto,
57 } => {
58 let msg = if *was_auto {
59 format!(
60 "content is {actual} tokens; max_tokens={max}. \
61 Auto-summarization was attempted and the result still exceeded \
62 the budget. Reduce max_tokens, or request a summarize call with \
63 stricter target_tokens."
64 )
65 } else {
66 format!(
67 "content is {actual} tokens; max_tokens={max}. \
68 You provided an explicit `summarize` arg and the summary still \
69 exceeded the budget. Increase max_tokens or request stricter \
70 target_tokens in the summarize call."
71 )
72 };
73 RoverError::new(RoverError::MAX_TOKENS_EXCEEDED, msg)
74 }
75 Self::InvalidArgs(m) => RoverError::new(RoverError::INVALID_ARGS, m.clone()),
76 Self::InvalidUrl(m) => RoverError::new(RoverError::INVALID_URL, m.clone()),
77 Self::TooManyUrls { .. } => {
78 RoverError::new(RoverError::TOO_MANY_URLS, self.to_string())
79 }
80 Self::EmptyUrlList => RoverError::new(RoverError::EMPTY_URL_LIST, self.to_string()),
81 Self::Tokenizer(e) => match e {
82 TokenizerError::UnknownFamily(name) => RoverError::new(
83 RoverError::INVALID_ARGS,
84 format!("unknown tokenizer family: {name}"),
85 ),
86 TokenizerError::Download { family, .. } => RoverError::new(
87 RoverError::TOKENIZER_UNAVAILABLE,
88 format!("could not fetch tokenizer for {family}: {e}"),
89 ),
90 TokenizerError::Parse { family, .. } => RoverError::new(
91 RoverError::TOKENIZER_UNAVAILABLE,
92 format!("tokenizer file for {family} is corrupt: {e}"),
93 ),
94 TokenizerError::Io { .. } | TokenizerError::NotLoaded(_) => {
95 RoverError::new(RoverError::TOKENIZER_UNAVAILABLE, e.to_string())
96 }
97 },
98 Self::Fetcher(e) => {
99 use crate::fetcher::FetcherError as F;
100 match e {
101 F::Ssrf(_) => RoverError::new(RoverError::SSRF_DENIED, e.to_string()),
102 F::Url(_) => RoverError::new(RoverError::INVALID_URL, e.to_string()),
103 F::Storage(_) => RoverError::new(RoverError::STORAGE_ERROR, e.to_string()),
104 F::Extract(_) => RoverError::new(RoverError::EXTRACT_FAILED, e.to_string()),
105 F::RobotsDisallowed { .. } => {
106 RoverError::new(RoverError::ROBOTS_DISALLOWED, e.to_string())
107 }
108 F::RobotsFetchFailed { .. } => {
109 RoverError::new(RoverError::ROBOTS_FETCH_FAILED, e.to_string())
110 }
111 F::RetryExhausted { .. } => {
112 RoverError::new(RoverError::RETRY_EXHAUSTED, e.to_string())
113 }
114 F::RateLimited { .. } => {
115 RoverError::new(RoverError::RATE_LIMITED, e.to_string())
116 }
117 F::Deferred { task_id } => {
118 RoverError::new(RoverError::DEFERRED, format!("deferred to task {task_id}"))
119 }
120 F::Http(_) | F::Dns { .. } | F::Decode | F::Status { .. } => {
121 RoverError::new(RoverError::FETCH_FAILED, e.to_string())
122 }
123 F::BotChallenge { .. } => {
124 RoverError::new(RoverError::BOT_CHALLENGE, e.to_string())
125 }
126 F::HeadlessFeatureNotCompiled => {
127 RoverError::new(RoverError::HEADLESS_FEATURE_NOT_COMPILED, e.to_string())
128 }
129 F::HeadlessRendererUnavailable => {
130 RoverError::new(RoverError::HEADLESS_RENDERER_UNAVAILABLE, e.to_string())
131 }
132 #[cfg(feature = "headless")]
133 F::Headless(he) => match he {
134 crate::fetcher::headless::HeadlessError::LaunchFailed(_) => {
135 RoverError::new(RoverError::HEADLESS_LAUNCH_FAILED, e.to_string())
136 }
137 crate::fetcher::headless::HeadlessError::Timeout { .. } => {
138 RoverError::new(RoverError::HEADLESS_RENDER_TIMEOUT, e.to_string())
139 }
140 crate::fetcher::headless::HeadlessError::PageClosed(_) => {
141 RoverError::new(RoverError::HEADLESS_PAGE_CLOSED, e.to_string())
142 }
143 _ => RoverError::new(RoverError::HEADLESS_INTERNAL_ERROR, e.to_string()),
144 },
145 }
146 }
147 Self::Extractor(e) => {
148 use crate::extractor::ExtractorError as X;
149 match e {
150 X::CaptionerCall { source, .. } => vlm_error_to_rover_error(source.as_ref()),
151 _ => RoverError::new(RoverError::EXTRACT_FAILED, e.to_string()),
152 }
153 }
154 Self::Storage(e) => RoverError::new(RoverError::STORAGE_ERROR, e.to_string()),
155 Self::Summarizer(e) => {
156 use crate::summarizer::SummarizerError as S;
157 match e {
158 S::NoSuchBackend { name } => RoverError::new(
159 RoverError::SUMMARIZER_NO_SUCH_BACKEND,
160 format!("no such summarizer backend: {name}"),
161 ),
162 S::NoExtractiveBackendForFallback => RoverError::new(
163 RoverError::SUMMARIZER_NO_EXTRACTIVE_FOR_FALLBACK,
164 "no extractive backend configured for fallback",
165 ),
166 S::BackendUnavailable { name, reason } => RoverError::new(
167 RoverError::SUMMARIZER_BACKEND_UNAVAILABLE,
168 format!("backend {name} unavailable: {reason}"),
169 ),
170 S::RateLimited { name } => RoverError::new(
171 RoverError::SUMMARIZER_RATE_LIMITED,
172 format!("backend {name} rate limited"),
173 ),
174 S::AuthFailed { name, reason } => RoverError::new(
175 RoverError::SUMMARIZER_AUTH_FAILED,
176 format!("backend {name} auth failed: {reason}"),
177 ),
178 S::ModelError { name, reason } => RoverError::new(
179 RoverError::SUMMARIZER_MODEL_ERROR,
180 format!("backend {name} model error: {reason}"),
181 ),
182 S::InvalidRequest { name, reason } => RoverError::new(
183 RoverError::SUMMARIZER_INVALID_REQUEST,
184 format!("invalid request to backend {name}: {reason}"),
185 ),
186 S::Storage(inner) => {
189 RoverError::new(RoverError::STORAGE_ERROR, inner.to_string())
190 }
191 S::Tokenizer(inner) => match inner {
192 TokenizerError::UnknownFamily(name) => RoverError::new(
193 RoverError::INVALID_ARGS,
194 format!("unknown tokenizer family: {name}"),
195 ),
196 TokenizerError::Download { family, .. } => RoverError::new(
197 RoverError::TOKENIZER_UNAVAILABLE,
198 format!("could not fetch tokenizer for {family}: {inner}"),
199 ),
200 TokenizerError::Parse { family, .. } => RoverError::new(
201 RoverError::TOKENIZER_UNAVAILABLE,
202 format!("tokenizer file for {family} is corrupt: {inner}"),
203 ),
204 TokenizerError::Io { .. } | TokenizerError::NotLoaded(_) => {
205 RoverError::new(RoverError::TOKENIZER_UNAVAILABLE, inner.to_string())
206 }
207 },
208 S::LocalFeatureNotCompiled => RoverError::new(
209 RoverError::SUMMARIZER_LOCAL_FEATURE_NOT_COMPILED,
210 e.to_string(),
211 ),
212 }
213 }
214 }
215 }
216}
217
218fn vlm_error_to_rover_error(e: &crate::vlm::VlmError) -> RoverError {
220 use crate::vlm::VlmError as V;
221 match e {
222 V::NoSuchCaptioner { name } => RoverError::new(
223 RoverError::CAPTIONER_NO_SUCH,
224 format!("no such captioner: {name}"),
225 ),
226 V::NoCaptionersConfigured => {
227 RoverError::new(RoverError::CAPTIONER_NOT_CONFIGURED, e.to_string())
228 }
229 V::LocalFeatureNotCompiled => RoverError::new(
230 RoverError::CAPTIONER_LOCAL_FEATURE_NOT_COMPILED,
231 e.to_string(),
232 ),
233 V::RateLimited { name } => RoverError::new(
234 RoverError::CAPTIONER_RATE_LIMITED,
235 format!("captioner {name} rate limited"),
236 ),
237 V::AuthFailed { name } => RoverError::new(
238 RoverError::CAPTIONER_AUTH_FAILED,
239 format!("captioner {name} auth failed"),
240 ),
241 V::Unavailable { name, reason } => RoverError::new(
242 RoverError::CAPTIONER_BACKEND_UNAVAILABLE,
243 format!("captioner {name} unavailable: {reason}"),
244 ),
245 V::SemaphoreClosed => {
246 RoverError::new(RoverError::CAPTIONER_BACKEND_UNAVAILABLE, e.to_string())
247 }
248 V::ModelError { name, reason } => RoverError::new(
249 RoverError::CAPTIONER_MODEL_ERROR,
250 format!("captioner {name} model error: {reason}"),
251 ),
252 V::ModelIntegrityFailure {
253 name,
254 file,
255 expected,
256 actual,
257 } => RoverError::new(
258 RoverError::CAPTIONER_MODEL_ERROR,
259 format!(
260 "captioner {name}: model file {file} has been modified \
261 (expected {expected}, got {actual})"
262 ),
263 ),
264 V::Storage(inner) => RoverError::new(RoverError::STORAGE_ERROR, inner.to_string()),
265 }
266}
267
268pub(crate) fn log_and_translate(err: McpError) -> RoverError {
270 tracing::warn!(target: "rover::mcp", error = ?err, "tool error");
271 err.into_rover_error()
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn max_tokens_translation_uses_stable_code() {
280 let e = McpError::MaxTokensExceeded {
281 actual: 5000,
282 max: 1000,
283 was_auto: true,
284 };
285 let r = e.into_rover_error();
286 assert_eq!(r.code, RoverError::MAX_TOKENS_EXCEEDED);
287 assert!(r.message.contains("5000"));
288 assert!(r.message.contains("1000"));
289 assert!(r.message.contains("summarize"));
290 assert!(r.message.contains("Auto-summarization"));
291 }
292
293 #[test]
294 fn max_tokens_translation_explicit_summarize_message_differs() {
295 let auto = McpError::MaxTokensExceeded {
296 actual: 5000,
297 max: 1000,
298 was_auto: true,
299 }
300 .into_rover_error();
301 let explicit = McpError::MaxTokensExceeded {
302 actual: 5000,
303 max: 1000,
304 was_auto: false,
305 }
306 .into_rover_error();
307 assert_eq!(explicit.code, RoverError::MAX_TOKENS_EXCEEDED);
308 assert!(explicit.message.contains("5000"));
309 assert!(explicit.message.contains("1000"));
310 assert!(
311 explicit.message.contains("explicit `summarize` arg"),
312 "expected explicit-summarize message, got: {}",
313 explicit.message,
314 );
315 assert_ne!(
316 auto.message, explicit.message,
317 "auto vs explicit messages should differ",
318 );
319 }
320
321 #[test]
322 fn invalid_args_translation() {
323 let e = McpError::InvalidArgs("bad".into());
324 let r = e.into_rover_error();
325 assert_eq!(r.code, RoverError::INVALID_ARGS);
326 assert_eq!(r.message, "bad");
327 }
328
329 #[test]
330 fn fetcher_url_routes_to_invalid_url() {
331 use crate::fetcher::FetcherError;
332 let parse_err = url::Url::parse("not a url").unwrap_err();
334 let e = McpError::Fetcher(FetcherError::Url(parse_err));
335 let r = e.into_rover_error();
336 assert_eq!(r.code, RoverError::INVALID_URL);
337 }
338
339 #[test]
340 fn fetcher_storage_routes_to_storage_error() {
341 use crate::fetcher::FetcherError;
342 use crate::storage::StorageError;
343 let rusqlite_err = rusqlite::Error::InvalidQuery;
345 let storage_err: StorageError = rusqlite_err.into();
346 let e = McpError::Fetcher(FetcherError::Storage(storage_err));
347 let r = e.into_rover_error();
348 assert_eq!(r.code, RoverError::STORAGE_ERROR);
349 }
350
351 #[test]
352 fn extractor_output_error_routes_to_extract_failed() {
353 use crate::extractor::ExtractorError;
354 let e = McpError::Extractor(ExtractorError::Output {
355 path: "/no/such".into(),
356 source: std::io::Error::new(std::io::ErrorKind::NotFound, "nope"),
357 });
358 let r = e.into_rover_error();
359 assert_eq!(r.code, RoverError::EXTRACT_FAILED);
360 assert!(r.message.contains("/no/such"));
361 }
362
363 #[test]
364 fn fetcher_extract_routes_to_extract_failed() {
365 use crate::extractor::ExtractorError;
366 use crate::fetcher::FetcherError;
367 let inner = ExtractorError::Output {
368 path: "/tmp/x".into(),
369 source: std::io::Error::new(std::io::ErrorKind::NotFound, "nope"),
370 };
371 let e = McpError::Fetcher(FetcherError::Extract(inner));
372 let r = e.into_rover_error();
373 assert_eq!(r.code, RoverError::EXTRACT_FAILED);
374 assert!(r.message.contains("/tmp/x"));
375 }
376
377 #[test]
378 fn fetcher_robots_disallowed_routes_to_robots_disallowed() {
379 let e = McpError::Fetcher(crate::fetcher::FetcherError::RobotsDisallowed {
380 url: "https://example.com/admin".into(),
381 ua: "Rover/0.1".into(),
382 });
383 let r = e.into_rover_error();
384 assert_eq!(r.code, RoverError::ROBOTS_DISALLOWED);
385 assert!(r.message.contains("example.com/admin"));
386 assert!(r.message.contains("Rover/0.1"));
387 }
388
389 #[test]
390 fn fetcher_robots_fetch_failed_routes_to_robots_fetch_failed() {
391 let inner = crate::fetcher::FetcherError::Decode;
392 let e = McpError::Fetcher(crate::fetcher::FetcherError::RobotsFetchFailed {
393 host: "example.com".into(),
394 source: Box::new(inner),
395 });
396 let r = e.into_rover_error();
397 assert_eq!(r.code, RoverError::ROBOTS_FETCH_FAILED);
398 assert!(r.message.contains("example.com"));
399 }
400
401 #[test]
402 fn robots_fetch_failed_translation_carries_source_message() {
403 use crate::fetcher::FetcherError;
404 let e = McpError::Fetcher(FetcherError::RobotsFetchFailed {
405 host: "example.com".to_string(),
406 source: Box::new(FetcherError::Decode),
407 });
408 let r = e.into_rover_error();
409 assert_eq!(r.code, RoverError::ROBOTS_FETCH_FAILED);
410 assert!(
411 r.message.contains("response decoding failed"),
412 "expected inner cause in {}",
413 r.message,
414 );
415 }
416
417 #[test]
418 fn fetcher_retry_exhausted_routes_to_retry_exhausted() {
419 let last = Box::new(crate::fetcher::FetcherError::Status {
420 status: 503,
421 url: "https://example.com/".into(),
422 });
423 let e =
424 McpError::Fetcher(crate::fetcher::FetcherError::RetryExhausted { attempts: 4, last });
425 let r = e.into_rover_error();
426 assert_eq!(r.code, RoverError::RETRY_EXHAUSTED);
427 assert!(r.message.contains("4 attempts"));
428 }
429
430 #[test]
431 fn deferred_translation_uses_stable_code() {
432 let e = McpError::Fetcher(crate::fetcher::FetcherError::Deferred {
433 task_id: "abc".into(),
434 });
435 let r = e.into_rover_error();
436 assert_eq!(r.code, RoverError::DEFERRED);
437 assert!(r.message.contains("abc"));
438 }
439
440 #[test]
441 fn summarizer_no_such_backend_translates() {
442 let e = McpError::Summarizer(crate::summarizer::SummarizerError::NoSuchBackend {
443 name: "missing".into(),
444 });
445 let r = e.into_rover_error();
446 assert_eq!(r.code, RoverError::SUMMARIZER_NO_SUCH_BACKEND);
447 assert!(r.message.contains("missing"));
448 }
449
450 #[test]
451 fn summarizer_rate_limited_translates() {
452 let e = McpError::Summarizer(crate::summarizer::SummarizerError::RateLimited {
453 name: "fast".into(),
454 });
455 let r = e.into_rover_error();
456 assert_eq!(r.code, RoverError::SUMMARIZER_RATE_LIMITED);
457 assert!(r.message.contains("fast"));
458 }
459
460 #[test]
461 fn summarizer_auth_failed_translates() {
462 let e = McpError::Summarizer(crate::summarizer::SummarizerError::AuthFailed {
463 name: "fast".into(),
464 reason: "401".into(),
465 });
466 let r = e.into_rover_error();
467 assert_eq!(r.code, RoverError::SUMMARIZER_AUTH_FAILED);
468 assert!(r.message.contains("fast"));
469 assert!(r.message.contains("401"));
470 }
471
472 #[test]
473 fn summarizer_backend_unavailable_translates() {
474 let e = McpError::Summarizer(crate::summarizer::SummarizerError::BackendUnavailable {
475 name: "fast".into(),
476 reason: "network timeout".into(),
477 });
478 let r = e.into_rover_error();
479 assert_eq!(r.code, RoverError::SUMMARIZER_BACKEND_UNAVAILABLE);
480 }
481
482 #[test]
483 fn summarizer_model_error_translates() {
484 let e = McpError::Summarizer(crate::summarizer::SummarizerError::ModelError {
485 name: "fast".into(),
486 reason: "model not found".into(),
487 });
488 let r = e.into_rover_error();
489 assert_eq!(r.code, RoverError::SUMMARIZER_MODEL_ERROR);
490 }
491
492 #[test]
493 fn summarizer_invalid_request_translates() {
494 let e = McpError::Summarizer(crate::summarizer::SummarizerError::InvalidRequest {
495 name: "default".into(),
496 reason: "empty content".into(),
497 });
498 let r = e.into_rover_error();
499 assert_eq!(r.code, RoverError::SUMMARIZER_INVALID_REQUEST);
500 }
501
502 #[test]
503 fn summarizer_no_extractive_for_fallback_translates() {
504 let e = McpError::Summarizer(
505 crate::summarizer::SummarizerError::NoExtractiveBackendForFallback,
506 );
507 let r = e.into_rover_error();
508 assert_eq!(r.code, RoverError::SUMMARIZER_NO_EXTRACTIVE_FOR_FALLBACK);
509 }
510
511 #[test]
512 fn summarizer_storage_inner_translates_to_storage_error_family() {
513 let inner = crate::storage::StorageError::Backend(tokio_rusqlite::Error::ConnectionClosed);
516 let e = McpError::Summarizer(crate::summarizer::SummarizerError::Storage(inner));
517 let r = e.into_rover_error();
518 assert_eq!(r.code, RoverError::STORAGE_ERROR);
519 }
520
521 #[test]
522 fn fetcher_rate_limited_routes_to_rate_limited() {
523 let e = McpError::Fetcher(crate::fetcher::FetcherError::RateLimited {
524 retry_after_secs: 60,
525 });
526 let r = e.into_rover_error();
527 assert_eq!(r.code, RoverError::RATE_LIMITED);
528 assert!(r.message.contains("60"));
529 }
530
531 #[test]
534 fn captioner_no_such_routes_to_typed_code() {
535 use crate::extractor::ExtractorError;
536 use crate::vlm::VlmError;
537 let e = McpError::Extractor(ExtractorError::CaptionerCall {
538 name: "openai".into(),
539 source: Box::new(VlmError::NoSuchCaptioner {
540 name: "openai".into(),
541 }),
542 });
543 let r = e.into_rover_error();
544 assert_eq!(r.code, RoverError::CAPTIONER_NO_SUCH);
545 assert!(r.message.contains("openai"));
546 }
547
548 #[test]
549 fn captioner_not_configured_routes_to_typed_code() {
550 use crate::extractor::ExtractorError;
551 use crate::vlm::VlmError;
552 let e = McpError::Extractor(ExtractorError::CaptionerCall {
553 name: "default".into(),
554 source: Box::new(VlmError::NoCaptionersConfigured),
555 });
556 let r = e.into_rover_error();
557 assert_eq!(r.code, RoverError::CAPTIONER_NOT_CONFIGURED);
558 }
559
560 #[test]
561 fn captioner_local_feature_not_compiled_routes_to_typed_code() {
562 use crate::extractor::ExtractorError;
563 use crate::vlm::VlmError;
564 let e = McpError::Extractor(ExtractorError::CaptionerCall {
565 name: "local".into(),
566 source: Box::new(VlmError::LocalFeatureNotCompiled),
567 });
568 let r = e.into_rover_error();
569 assert_eq!(r.code, RoverError::CAPTIONER_LOCAL_FEATURE_NOT_COMPILED);
570 }
571
572 #[test]
573 fn captioner_rate_limited_routes_to_typed_code() {
574 use crate::extractor::ExtractorError;
575 use crate::vlm::VlmError;
576 let e = McpError::Extractor(ExtractorError::CaptionerCall {
577 name: "openai".into(),
578 source: Box::new(VlmError::RateLimited {
579 name: "openai".into(),
580 }),
581 });
582 let r = e.into_rover_error();
583 assert_eq!(r.code, RoverError::CAPTIONER_RATE_LIMITED);
584 assert!(r.message.contains("openai"));
585 }
586
587 #[test]
588 fn captioner_auth_failed_routes_to_typed_code() {
589 use crate::extractor::ExtractorError;
590 use crate::vlm::VlmError;
591 let e = McpError::Extractor(ExtractorError::CaptionerCall {
592 name: "openai".into(),
593 source: Box::new(VlmError::AuthFailed {
594 name: "openai".into(),
595 }),
596 });
597 let r = e.into_rover_error();
598 assert_eq!(r.code, RoverError::CAPTIONER_AUTH_FAILED);
599 assert!(r.message.contains("openai"));
600 }
601
602 #[test]
603 fn captioner_unavailable_routes_to_backend_unavailable() {
604 use crate::extractor::ExtractorError;
605 use crate::vlm::VlmError;
606 let e = McpError::Extractor(ExtractorError::CaptionerCall {
607 name: "openai".into(),
608 source: Box::new(VlmError::Unavailable {
609 name: "openai".into(),
610 reason: "connection refused".into(),
611 }),
612 });
613 let r = e.into_rover_error();
614 assert_eq!(r.code, RoverError::CAPTIONER_BACKEND_UNAVAILABLE);
615 assert!(r.message.contains("connection refused"));
616 }
617
618 #[test]
619 fn captioner_semaphore_closed_routes_to_backend_unavailable() {
620 use crate::extractor::ExtractorError;
621 use crate::vlm::VlmError;
622 let e = McpError::Extractor(ExtractorError::CaptionerCall {
623 name: "local".into(),
624 source: Box::new(VlmError::SemaphoreClosed),
625 });
626 let r = e.into_rover_error();
627 assert_eq!(r.code, RoverError::CAPTIONER_BACKEND_UNAVAILABLE);
628 }
629
630 #[test]
631 fn captioner_model_error_routes_to_typed_code() {
632 use crate::extractor::ExtractorError;
633 use crate::vlm::VlmError;
634 let e = McpError::Extractor(ExtractorError::CaptionerCall {
635 name: "openai".into(),
636 source: Box::new(VlmError::ModelError {
637 name: "openai".into(),
638 reason: "model not found".into(),
639 }),
640 });
641 let r = e.into_rover_error();
642 assert_eq!(r.code, RoverError::CAPTIONER_MODEL_ERROR);
643 assert!(r.message.contains("model not found"));
644 }
645
646 #[test]
647 fn captioner_storage_inner_routes_to_storage_error() {
648 use crate::extractor::ExtractorError;
649 use crate::storage::StorageError;
650 use crate::vlm::VlmError;
651 let inner = StorageError::Backend(tokio_rusqlite::Error::ConnectionClosed);
652 let e = McpError::Extractor(ExtractorError::CaptionerCall {
653 name: "openai".into(),
654 source: Box::new(VlmError::Storage(inner)),
655 });
656 let r = e.into_rover_error();
657 assert_eq!(r.code, RoverError::STORAGE_ERROR);
658 }
659
660 #[test]
661 fn extractor_non_captioner_errors_still_route_to_extract_failed() {
662 use crate::extractor::ExtractorError;
663 let e = McpError::Extractor(ExtractorError::Output {
664 path: "/no/such".into(),
665 source: std::io::Error::new(std::io::ErrorKind::NotFound, "nope"),
666 });
667 let r = e.into_rover_error();
668 assert_eq!(r.code, RoverError::EXTRACT_FAILED);
669 }
670}