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::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                    // Borrowed inner errors — route through the same code
187                    // each outer variant produces.
188                    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
218/// Map a [`crate::vlm::VlmError`] to the appropriate stable MCP wire code.
219fn 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
268/// Convenience: log + translate. Use this at the tool boundary.
269pub(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        // url::ParseError doesn't have a no-arg constructor, so build by parsing a bad URL.
333        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        // Build a synthetic StorageError via rusqlite::Error (no DB connection needed).
344        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        // The inlined inner-Storage arm should produce the same code constant
514        // as the outer McpError::Storage arm.
515        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    // VLM / captioner error routing tests.
532
533    #[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}