Skip to main content

rover/mcp/
error.rs

1//! Internal MCP-layer errors. Translated to `RoverError` before crossing
2//! the tool boundary.
3
4use 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    /// Translate to the stable wire envelope.
51    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                    // Borrowed inner errors — route through the same code
184                    // each outer variant produces.
185                    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
215/// Map a [`crate::vlm::VlmError`] to the appropriate stable MCP wire code.
216fn 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
265/// Convenience: log + translate. Use this at the tool boundary.
266pub(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        // url::ParseError doesn't have a no-arg constructor, so build by parsing a bad URL.
330        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        // Build a synthetic StorageError via rusqlite::Error (no DB connection needed).
341        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        // The inlined inner-Storage arm should produce the same code constant
511        // as the outer McpError::Storage arm.
512        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    // VLM / captioner error routing tests.
529
530    #[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}