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