Skip to main content

zai_rs/mcp/
requests.rs

1//! Strongly typed request models for every supported MCP tool.
2
3use serde::{Deserialize, Serialize};
4
5use crate::ZaiResult;
6
7pub(crate) trait McpRequest {
8    fn validate(&self) -> ZaiResult<()>;
9}
10
11fn validate_required(fields: &[(&'static str, &str)]) -> ZaiResult<()> {
12    if let Some((name, _)) = fields.iter().find(|(_, value)| value.trim().is_empty()) {
13        return Err(crate::client::validation::invalid(format!(
14            "MCP request field `{name}` must not be empty"
15        )));
16    }
17    Ok(())
18}
19
20fn validate_optional(name: &'static str, value: Option<&str>) -> ZaiResult<()> {
21    if value.is_some_and(|value| value.trim().is_empty()) {
22        return Err(crate::client::validation::invalid(format!(
23            "MCP request field `{name}` must not be empty when provided"
24        )));
25    }
26    Ok(())
27}
28
29macro_rules! impl_redacted_debug {
30    ($request:ty, redacted[$($redacted:ident),* $(,)?], visible[$($visible:ident),* $(,)?]) => {
31        impl std::fmt::Debug for $request {
32            fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33                let mut debug = formatter.debug_struct(stringify!($request));
34                $(debug.field(stringify!($redacted), &"[REDACTED]");)*
35                $(debug.field(stringify!($visible), &self.$visible);)*
36                debug.finish()
37            }
38        }
39    };
40}
41
42/// Web-search result summary size.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45#[non_exhaustive]
46pub enum SearchContentSize {
47    /// Balanced summaries, typically 400–600 words.
48    Medium,
49    /// Maximum context, typically up to 2,500 words.
50    High,
51}
52
53/// Search region hint.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[non_exhaustive]
56pub enum SearchLocation {
57    /// Chinese-region results (`cn`).
58    #[serde(rename = "cn")]
59    China,
60    /// Non-Chinese-region results (`us`).
61    #[serde(rename = "us")]
62    International,
63}
64
65/// Search recency filter.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[non_exhaustive]
68pub enum SearchRecency {
69    /// Limit results to the previous day.
70    #[serde(rename = "oneDay")]
71    OneDay,
72    /// Limit results to the previous week.
73    #[serde(rename = "oneWeek")]
74    OneWeek,
75    /// Limit results to the previous month.
76    #[serde(rename = "oneMonth")]
77    OneMonth,
78    /// Limit results to the previous year.
79    #[serde(rename = "oneYear")]
80    OneYear,
81    /// Do not apply a recency limit.
82    #[serde(rename = "noLimit")]
83    NoLimit,
84}
85
86/// Complete request for `web_search_prime`.
87#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct WebSearchRequest {
89    #[serde(rename = "search_query")]
90    query: String,
91    #[serde(
92        rename = "search_domain_filter",
93        skip_serializing_if = "Option::is_none"
94    )]
95    domain: Option<String>,
96    #[serde(
97        rename = "search_recency_filter",
98        skip_serializing_if = "Option::is_none"
99    )]
100    recency: Option<SearchRecency>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    content_size: Option<SearchContentSize>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    location: Option<SearchLocation>,
105}
106
107impl_redacted_debug!(
108    WebSearchRequest,
109    redacted[query, domain],
110    visible[recency, content_size, location]
111);
112
113impl WebSearchRequest {
114    /// Create a web-search request using server defaults for optional fields.
115    ///
116    /// Search queries should normally be no longer than 70 characters.
117    pub fn new(query: impl Into<String>) -> Self {
118        Self {
119            query: query.into(),
120            domain: None,
121            recency: None,
122            content_size: None,
123            location: None,
124        }
125    }
126
127    /// Prefer results from the supplied domain.
128    ///
129    /// The upstream service treats this as a search constraint, but callers
130    /// should still validate returned URLs when strict domain isolation matters.
131    pub fn domain(mut self, domain: impl Into<String>) -> Self {
132        self.domain = Some(domain.into());
133        self
134    }
135
136    /// Restrict results to a relative time window.
137    pub fn recency(mut self, recency: SearchRecency) -> Self {
138        self.recency = Some(recency);
139        self
140    }
141
142    /// Select the amount of summary text returned for each result.
143    pub fn content_size(mut self, content_size: SearchContentSize) -> Self {
144        self.content_size = Some(content_size);
145        self
146    }
147
148    /// Supply the search-region hint used to rank results.
149    pub fn location(mut self, location: SearchLocation) -> Self {
150        self.location = Some(location);
151        self
152    }
153}
154
155impl McpRequest for WebSearchRequest {
156    fn validate(&self) -> ZaiResult<()> {
157        validate_required(&[("search_query", &self.query)])?;
158        if self.query.chars().count() > 70 {
159            return Err(crate::client::validation::invalid(
160                "MCP search_query must not exceed 70 characters",
161            ));
162        }
163        validate_optional("search_domain_filter", self.domain.as_deref())
164    }
165}
166
167/// Web-reader output format.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "lowercase")]
170#[non_exhaustive]
171pub enum WebReaderFormat {
172    /// Return GitHub-Flavored Markdown unless separately disabled.
173    Markdown,
174    /// Return plain text.
175    Text,
176}
177
178/// Complete request for `webReader`.
179#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct WebReaderRequest {
181    url: String,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    return_format: Option<WebReaderFormat>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    retain_images: Option<bool>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    with_links_summary: Option<bool>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    with_images_summary: Option<bool>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    keep_img_data_url: Option<bool>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    no_gfm: Option<bool>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    no_cache: Option<bool>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    timeout: Option<u32>,
198}
199
200impl_redacted_debug!(
201    WebReaderRequest,
202    redacted[url],
203    visible[
204        return_format,
205        retain_images,
206        with_links_summary,
207        with_images_summary,
208        keep_img_data_url,
209        no_gfm,
210        no_cache,
211        timeout,
212    ]
213);
214
215impl WebReaderRequest {
216    /// Create a page-reader request using server defaults.
217    pub fn new(url: impl Into<String>) -> Self {
218        Self {
219            url: url.into(),
220            return_format: None,
221            retain_images: None,
222            with_links_summary: None,
223            with_images_summary: None,
224            keep_img_data_url: None,
225            no_gfm: None,
226            no_cache: None,
227            timeout: None,
228        }
229    }
230
231    /// Set the returned content format.
232    pub fn format(mut self, format: WebReaderFormat) -> Self {
233        self.return_format = Some(format);
234        self
235    }
236
237    /// Choose whether image references remain in the extracted content.
238    pub fn retain_images(mut self, retain: bool) -> Self {
239        self.retain_images = Some(retain);
240        self
241    }
242
243    /// Include the page's link summary.
244    pub fn links_summary(mut self, include: bool) -> Self {
245        self.with_links_summary = Some(include);
246        self
247    }
248
249    /// Include the page's image summary.
250    pub fn images_summary(mut self, include: bool) -> Self {
251        self.with_images_summary = Some(include);
252        self
253    }
254
255    /// Preserve inline image data URLs instead of removing them.
256    pub fn keep_image_data_urls(mut self, keep: bool) -> Self {
257        self.keep_img_data_url = Some(keep);
258        self
259    }
260
261    /// Enable or disable GitHub-Flavored Markdown conversion.
262    pub fn github_flavored_markdown(mut self, enabled: bool) -> Self {
263        self.no_gfm = Some(!enabled);
264        self
265    }
266
267    /// Enable or bypass the upstream page cache.
268    pub fn cache(mut self, enabled: bool) -> Self {
269        self.no_cache = Some(!enabled);
270        self
271    }
272
273    /// Set the upstream page-fetch timeout in seconds.
274    ///
275    /// This is distinct from [`McpClient::with_tool_timeout`](super::McpClient::with_tool_timeout),
276    /// which bounds the complete MCP operation on the client side.
277    pub fn timeout_seconds(mut self, timeout: u32) -> Self {
278        self.timeout = Some(timeout);
279        self
280    }
281}
282
283impl McpRequest for WebReaderRequest {
284    fn validate(&self) -> ZaiResult<()> {
285        validate_required(&[("url", &self.url)])?;
286        let url = url::Url::parse(&self.url)
287            .map_err(|_| crate::client::validation::invalid("invalid web-reader URL"))?;
288        if !matches!(url.scheme(), "http" | "https") {
289            return Err(crate::client::validation::invalid(
290                "web-reader URL must use the http or https scheme",
291            ));
292        }
293        if !url.username().is_empty() || url.password().is_some() {
294            return Err(crate::client::validation::invalid(
295                "web-reader URL must not contain user information",
296            ));
297        }
298        if self.timeout == Some(0) {
299            return Err(crate::client::validation::invalid(
300                "web-reader timeout must be greater than zero",
301            ));
302        }
303        Ok(())
304    }
305}
306
307/// Repository-search response language.
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(rename_all = "lowercase")]
310#[non_exhaustive]
311pub enum RepositoryLanguage {
312    /// Request a Chinese response.
313    Zh,
314    /// Request an English response.
315    En,
316}
317
318/// Complete request for `search_doc`.
319#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct SearchDocRequest {
321    repo_name: String,
322    query: String,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    language: Option<RepositoryLanguage>,
325}
326
327impl_redacted_debug!(
328    SearchDocRequest,
329    redacted[repo_name, query],
330    visible[language]
331);
332
333impl SearchDocRequest {
334    /// Create a repository search for `owner/repository`.
335    pub fn new(repository: impl Into<String>, query: impl Into<String>) -> Self {
336        Self {
337            repo_name: repository.into(),
338            query: query.into(),
339            language: None,
340        }
341    }
342
343    /// Select the response language.
344    pub fn language(mut self, language: RepositoryLanguage) -> Self {
345        self.language = Some(language);
346        self
347    }
348}
349
350impl McpRequest for SearchDocRequest {
351    fn validate(&self) -> ZaiResult<()> {
352        validate_required(&[("repo_name", &self.repo_name), ("query", &self.query)])
353    }
354}
355
356/// Complete request for `get_repo_structure`.
357#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct RepoStructureRequest {
359    repo_name: String,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    dir_path: Option<String>,
362}
363
364impl_redacted_debug!(RepoStructureRequest, redacted[repo_name, dir_path], visible[]);
365
366impl RepoStructureRequest {
367    /// Create a request for the root tree of `owner/repository`.
368    pub fn new(repository: impl Into<String>) -> Self {
369        Self {
370            repo_name: repository.into(),
371            dir_path: None,
372        }
373    }
374
375    /// Inspect a repository-relative directory instead of the root.
376    pub fn directory(mut self, directory: impl Into<String>) -> Self {
377        self.dir_path = Some(directory.into());
378        self
379    }
380}
381
382impl McpRequest for RepoStructureRequest {
383    fn validate(&self) -> ZaiResult<()> {
384        validate_required(&[("repo_name", &self.repo_name)])?;
385        validate_optional("dir_path", self.dir_path.as_deref())
386    }
387}
388
389/// Complete request for `read_file`.
390#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct ReadRepoFileRequest {
392    repo_name: String,
393    file_path: String,
394}
395
396impl_redacted_debug!(ReadRepoFileRequest, redacted[repo_name, file_path], visible[]);
397
398impl ReadRepoFileRequest {
399    /// Create a request for a repository-relative file path.
400    pub fn new(repository: impl Into<String>, path: impl Into<String>) -> Self {
401        Self {
402            repo_name: repository.into(),
403            file_path: path.into(),
404        }
405    }
406}
407
408impl McpRequest for ReadRepoFileRequest {
409    fn validate(&self) -> ZaiResult<()> {
410        validate_required(&[
411            ("repo_name", &self.repo_name),
412            ("file_path", &self.file_path),
413        ])
414    }
415}
416
417/// Output generated by `ui_to_artifact`.
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "lowercase")]
420#[non_exhaustive]
421pub enum UiArtifactOutput {
422    /// Generate frontend implementation code.
423    Code,
424    /// Generate an AI prompt for recreating the interface.
425    Prompt,
426    /// Generate a design specification.
427    #[serde(rename = "spec")]
428    Specification,
429    /// Generate a natural-language description.
430    Description,
431}
432
433/// Complete request for `ui_to_artifact`.
434#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
435pub struct UiToArtifactRequest {
436    image_source: String,
437    output_type: UiArtifactOutput,
438    prompt: String,
439}
440
441impl_redacted_debug!(
442    UiToArtifactRequest,
443    redacted[image_source, prompt],
444    visible[output_type]
445);
446
447impl UiToArtifactRequest {
448    /// Create a UI-conversion request from a local image path or remote URL.
449    pub fn new(
450        image_source: impl Into<String>,
451        output_type: UiArtifactOutput,
452        prompt: impl Into<String>,
453    ) -> Self {
454        Self {
455            image_source: image_source.into(),
456            output_type,
457            prompt: prompt.into(),
458        }
459    }
460}
461
462/// Complete request for `extract_text_from_screenshot`.
463#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
464pub struct ExtractTextRequest {
465    image_source: String,
466    prompt: String,
467    #[serde(skip_serializing_if = "Option::is_none")]
468    programming_language: Option<String>,
469}
470
471impl_redacted_debug!(
472    ExtractTextRequest,
473    redacted[image_source, prompt, programming_language],
474    visible[]
475);
476
477impl ExtractTextRequest {
478    /// Create an OCR request from a local image path or remote URL.
479    pub fn new(image_source: impl Into<String>, prompt: impl Into<String>) -> Self {
480        Self {
481            image_source: image_source.into(),
482            prompt: prompt.into(),
483            programming_language: None,
484        }
485    }
486
487    /// Hint at the programming language shown in a code screenshot.
488    pub fn programming_language(mut self, language: impl Into<String>) -> Self {
489        self.programming_language = Some(language.into());
490        self
491    }
492}
493
494/// Complete request for `diagnose_error_screenshot`.
495#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
496pub struct DiagnoseErrorRequest {
497    image_source: String,
498    prompt: String,
499    #[serde(skip_serializing_if = "Option::is_none")]
500    context: Option<String>,
501}
502
503impl_redacted_debug!(
504    DiagnoseErrorRequest,
505    redacted[image_source, prompt, context],
506    visible[]
507);
508
509impl DiagnoseErrorRequest {
510    /// Create an error-diagnosis request from a local image path or remote URL.
511    pub fn new(image_source: impl Into<String>, prompt: impl Into<String>) -> Self {
512        Self {
513            image_source: image_source.into(),
514            prompt: prompt.into(),
515            context: None,
516        }
517    }
518
519    /// Describe when or where the error occurred.
520    pub fn context(mut self, context: impl Into<String>) -> Self {
521        self.context = Some(context.into());
522        self
523    }
524}
525
526/// Complete request for `understand_technical_diagram`.
527#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
528pub struct UnderstandDiagramRequest {
529    image_source: String,
530    prompt: String,
531    #[serde(skip_serializing_if = "Option::is_none")]
532    diagram_type: Option<String>,
533}
534
535impl_redacted_debug!(
536    UnderstandDiagramRequest,
537    redacted[image_source, prompt, diagram_type],
538    visible[]
539);
540
541impl UnderstandDiagramRequest {
542    /// Create a technical-diagram request from a local image path or remote URL.
543    pub fn new(image_source: impl Into<String>, prompt: impl Into<String>) -> Self {
544        Self {
545            image_source: image_source.into(),
546            prompt: prompt.into(),
547            diagram_type: None,
548        }
549    }
550
551    /// Hint at the diagram type, such as `architecture`, `uml`, or `sequence`.
552    pub fn diagram_type(mut self, diagram_type: impl Into<String>) -> Self {
553        self.diagram_type = Some(diagram_type.into());
554        self
555    }
556}
557
558/// Complete request for `analyze_data_visualization`.
559#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
560pub struct AnalyzeVisualizationRequest {
561    image_source: String,
562    prompt: String,
563    #[serde(skip_serializing_if = "Option::is_none")]
564    analysis_focus: Option<String>,
565}
566
567impl_redacted_debug!(
568    AnalyzeVisualizationRequest,
569    redacted[image_source, prompt, analysis_focus],
570    visible[]
571);
572
573impl AnalyzeVisualizationRequest {
574    /// Create a visualization-analysis request from a local image path or URL.
575    pub fn new(image_source: impl Into<String>, prompt: impl Into<String>) -> Self {
576        Self {
577            image_source: image_source.into(),
578            prompt: prompt.into(),
579            analysis_focus: None,
580        }
581    }
582
583    /// Narrow the analysis to an area such as trends, anomalies, or comparisons.
584    pub fn focus(mut self, focus: impl Into<String>) -> Self {
585        self.analysis_focus = Some(focus.into());
586        self
587    }
588}
589
590/// Complete request for `ui_diff_check`.
591#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
592pub struct UiDiffRequest {
593    expected_image_source: String,
594    actual_image_source: String,
595    prompt: String,
596}
597
598impl_redacted_debug!(
599    UiDiffRequest,
600    redacted[expected_image_source, actual_image_source, prompt],
601    visible[]
602);
603
604impl UiDiffRequest {
605    /// Create a comparison between expected and actual UI screenshots.
606    ///
607    /// Each image source may be a local path or a remote URL.
608    pub fn new(
609        expected_image_source: impl Into<String>,
610        actual_image_source: impl Into<String>,
611        prompt: impl Into<String>,
612    ) -> Self {
613        Self {
614            expected_image_source: expected_image_source.into(),
615            actual_image_source: actual_image_source.into(),
616            prompt: prompt.into(),
617        }
618    }
619}
620
621/// Complete request for `analyze_image`.
622#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
623pub struct AnalyzeImageRequest {
624    image_source: String,
625    prompt: String,
626}
627
628impl_redacted_debug!(AnalyzeImageRequest, redacted[image_source, prompt], visible[]);
629
630impl AnalyzeImageRequest {
631    /// Create a general image-analysis request from a local path or remote URL.
632    pub fn new(image_source: impl Into<String>, prompt: impl Into<String>) -> Self {
633        Self {
634            image_source: image_source.into(),
635            prompt: prompt.into(),
636        }
637    }
638}
639
640/// Complete request for `analyze_video`.
641#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
642pub struct AnalyzeVideoRequest {
643    video_source: String,
644    prompt: String,
645}
646
647impl_redacted_debug!(AnalyzeVideoRequest, redacted[video_source, prompt], visible[]);
648
649impl AnalyzeVideoRequest {
650    /// Create a video-analysis request from a local path or remote URL.
651    ///
652    /// The Vision MCP accepts MP4, MOV, and M4V inputs up to 8 MB.
653    pub fn new(video_source: impl Into<String>, prompt: impl Into<String>) -> Self {
654        Self {
655            video_source: video_source.into(),
656            prompt: prompt.into(),
657        }
658    }
659}
660
661macro_rules! impl_required_mcp_request {
662    ($request:ty => $($field:ident),+ $(,)?) => {
663        impl McpRequest for $request {
664            fn validate(&self) -> ZaiResult<()> {
665                validate_required(&[$((stringify!($field), &self.$field)),+])
666            }
667        }
668    };
669}
670
671impl_required_mcp_request!(UiToArtifactRequest => image_source, prompt);
672impl_required_mcp_request!(UiDiffRequest => expected_image_source, actual_image_source, prompt);
673impl_required_mcp_request!(AnalyzeImageRequest => image_source, prompt);
674impl_required_mcp_request!(AnalyzeVideoRequest => video_source, prompt);
675
676impl McpRequest for ExtractTextRequest {
677    fn validate(&self) -> ZaiResult<()> {
678        validate_required(&[
679            ("image_source", &self.image_source),
680            ("prompt", &self.prompt),
681        ])?;
682        validate_optional("programming_language", self.programming_language.as_deref())
683    }
684}
685
686impl McpRequest for DiagnoseErrorRequest {
687    fn validate(&self) -> ZaiResult<()> {
688        validate_required(&[
689            ("image_source", &self.image_source),
690            ("prompt", &self.prompt),
691        ])?;
692        validate_optional("context", self.context.as_deref())
693    }
694}
695
696impl McpRequest for UnderstandDiagramRequest {
697    fn validate(&self) -> ZaiResult<()> {
698        validate_required(&[
699            ("image_source", &self.image_source),
700            ("prompt", &self.prompt),
701        ])?;
702        validate_optional("diagram_type", self.diagram_type.as_deref())
703    }
704}
705
706impl McpRequest for AnalyzeVisualizationRequest {
707    fn validate(&self) -> ZaiResult<()> {
708        validate_required(&[
709            ("image_source", &self.image_source),
710            ("prompt", &self.prompt),
711        ])?;
712        validate_optional("analysis_focus", self.analysis_focus.as_deref())
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use serde_json::{Value, json, to_value};
720
721    #[test]
722    fn default_requests_omit_every_optional_field() {
723        assert_eq!(
724            to_value(WebSearchRequest::new("rust")).unwrap(),
725            json!({"search_query": "rust"})
726        );
727        assert_eq!(
728            to_value(WebReaderRequest::new("https://example.com")).unwrap(),
729            json!({"url": "https://example.com"})
730        );
731        assert_eq!(
732            to_value(SearchDocRequest::new("owner/repo", "query")).unwrap(),
733            json!({"repo_name": "owner/repo", "query": "query"})
734        );
735        assert_eq!(
736            to_value(RepoStructureRequest::new("owner/repo")).unwrap(),
737            json!({"repo_name": "owner/repo"})
738        );
739    }
740
741    #[test]
742    fn web_search_serializes_every_captured_field() {
743        let request = WebSearchRequest::new("Rust rmcp")
744            .domain("docs.rs")
745            .recency(SearchRecency::OneMonth)
746            .content_size(SearchContentSize::High)
747            .location(SearchLocation::International);
748        assert_eq!(
749            to_value(request).unwrap(),
750            json!({
751                "search_query": "Rust rmcp",
752                "search_domain_filter": "docs.rs",
753                "search_recency_filter": "oneMonth",
754                "content_size": "high",
755                "location": "us"
756            })
757        );
758
759        let recencies = [
760            (SearchRecency::OneDay, "oneDay"),
761            (SearchRecency::OneWeek, "oneWeek"),
762            (SearchRecency::OneMonth, "oneMonth"),
763            (SearchRecency::OneYear, "oneYear"),
764            (SearchRecency::NoLimit, "noLimit"),
765        ];
766        for (value, expected) in recencies {
767            assert_eq!(to_value(value).unwrap(), Value::String(expected.to_owned()));
768        }
769
770        let content_sizes = [
771            (SearchContentSize::Medium, "medium"),
772            (SearchContentSize::High, "high"),
773        ];
774        for (value, expected) in content_sizes {
775            assert_eq!(to_value(value).unwrap(), Value::String(expected.to_owned()));
776        }
777
778        let locations = [
779            (SearchLocation::China, "cn"),
780            (SearchLocation::International, "us"),
781        ];
782        for (value, expected) in locations {
783            assert_eq!(to_value(value).unwrap(), Value::String(expected.to_owned()));
784        }
785    }
786
787    #[test]
788    fn web_reader_serializes_every_captured_field() {
789        let request = WebReaderRequest::new("https://example.com")
790            .format(WebReaderFormat::Text)
791            .retain_images(false)
792            .links_summary(true)
793            .images_summary(true)
794            .keep_image_data_urls(true)
795            .github_flavored_markdown(false)
796            .cache(false)
797            .timeout_seconds(30);
798        assert_eq!(
799            to_value(request).unwrap(),
800            json!({
801                "url": "https://example.com",
802                "return_format": "text",
803                "retain_images": false,
804                "with_links_summary": true,
805                "with_images_summary": true,
806                "keep_img_data_url": true,
807                "no_gfm": true,
808                "no_cache": true,
809                "timeout": 30
810            })
811        );
812
813        assert_eq!(
814            to_value(WebReaderFormat::Markdown).unwrap(),
815            Value::String("markdown".to_owned())
816        );
817        assert_eq!(
818            to_value(WebReaderFormat::Text).unwrap(),
819            Value::String("text".to_owned())
820        );
821    }
822
823    #[test]
824    fn zread_requests_match_all_three_live_schemas() {
825        assert_eq!(
826            to_value(
827                SearchDocRequest::new("owner/repo", "transport").language(RepositoryLanguage::En)
828            )
829            .unwrap(),
830            json!({"repo_name": "owner/repo", "query": "transport", "language": "en"})
831        );
832        assert_eq!(
833            to_value(RepoStructureRequest::new("owner/repo").directory("src")).unwrap(),
834            json!({"repo_name": "owner/repo", "dir_path": "src"})
835        );
836        assert_eq!(
837            to_value(ReadRepoFileRequest::new("owner/repo", "README.md")).unwrap(),
838            json!({"repo_name": "owner/repo", "file_path": "README.md"})
839        );
840
841        assert_eq!(
842            to_value(RepositoryLanguage::Zh).unwrap(),
843            Value::String("zh".to_owned())
844        );
845        assert_eq!(
846            to_value(RepositoryLanguage::En).unwrap(),
847            Value::String("en".to_owned())
848        );
849    }
850
851    #[test]
852    fn all_eight_vision_requests_match_live_schemas() {
853        assert_eq!(
854            to_value(UiToArtifactRequest::new(
855                "ui.png",
856                UiArtifactOutput::Specification,
857                "write a spec"
858            ))
859            .unwrap(),
860            json!({"image_source": "ui.png", "output_type": "spec", "prompt": "write a spec"})
861        );
862        assert_eq!(
863            to_value(
864                ExtractTextRequest::new("terminal.png", "extract").programming_language("rust")
865            )
866            .unwrap(),
867            json!({"image_source": "terminal.png", "prompt": "extract", "programming_language": "rust"})
868        );
869        assert_eq!(
870            to_value(DiagnoseErrorRequest::new("error.png", "diagnose").context("cargo build"))
871                .unwrap(),
872            json!({"image_source": "error.png", "prompt": "diagnose", "context": "cargo build"})
873        );
874        assert_eq!(
875            to_value(
876                UnderstandDiagramRequest::new("diagram.png", "explain")
877                    .diagram_type("architecture")
878            )
879            .unwrap(),
880            json!({"image_source": "diagram.png", "prompt": "explain", "diagram_type": "architecture"})
881        );
882        assert_eq!(
883            to_value(AnalyzeVisualizationRequest::new("chart.png", "analyze").focus("trends"))
884                .unwrap(),
885            json!({"image_source": "chart.png", "prompt": "analyze", "analysis_focus": "trends"})
886        );
887        assert_eq!(
888            to_value(UiDiffRequest::new("expected.png", "actual.png", "compare")).unwrap(),
889            json!({
890                "expected_image_source": "expected.png",
891                "actual_image_source": "actual.png",
892                "prompt": "compare"
893            })
894        );
895        assert_eq!(
896            to_value(AnalyzeImageRequest::new("photo.png", "describe")).unwrap(),
897            json!({"image_source": "photo.png", "prompt": "describe"})
898        );
899        assert_eq!(
900            to_value(AnalyzeVideoRequest::new("clip.mp4", "summarize")).unwrap(),
901            json!({"video_source": "clip.mp4", "prompt": "summarize"})
902        );
903    }
904
905    #[test]
906    fn vision_defaults_omit_every_optional_field() {
907        assert_eq!(
908            to_value(ExtractTextRequest::new("terminal.png", "extract")).unwrap(),
909            json!({"image_source": "terminal.png", "prompt": "extract"})
910        );
911        assert_eq!(
912            to_value(DiagnoseErrorRequest::new("error.png", "diagnose")).unwrap(),
913            json!({"image_source": "error.png", "prompt": "diagnose"})
914        );
915        assert_eq!(
916            to_value(UnderstandDiagramRequest::new("diagram.png", "explain")).unwrap(),
917            json!({"image_source": "diagram.png", "prompt": "explain"})
918        );
919        assert_eq!(
920            to_value(AnalyzeVisualizationRequest::new("chart.png", "analyze")).unwrap(),
921            json!({"image_source": "chart.png", "prompt": "analyze"})
922        );
923    }
924
925    #[test]
926    fn ui_artifact_output_serializes_all_schema_enum_values() {
927        let cases = [
928            (UiArtifactOutput::Code, "code"),
929            (UiArtifactOutput::Prompt, "prompt"),
930            (UiArtifactOutput::Specification, "spec"),
931            (UiArtifactOutput::Description, "description"),
932        ];
933        for (value, expected) in cases {
934            assert_eq!(to_value(value).unwrap(), Value::String(expected.to_owned()));
935        }
936    }
937
938    #[test]
939    fn request_validation_rejects_blank_required_fields() {
940        assert!(WebSearchRequest::new(" ").validate().is_err());
941        assert!(WebSearchRequest::new("x".repeat(71)).validate().is_err());
942        assert!(SearchDocRequest::new("owner/repo", "").validate().is_err());
943        assert!(AnalyzeImageRequest::new("", "describe").validate().is_err());
944        assert!(
945            ExtractTextRequest::new("image.png", "extract")
946                .programming_language(" ")
947                .validate()
948                .is_err()
949        );
950    }
951
952    #[test]
953    fn web_reader_requires_http_url_and_positive_timeout() {
954        assert!(
955            WebReaderRequest::new("file:///tmp/page.html")
956                .validate()
957                .is_err()
958        );
959        assert!(
960            WebReaderRequest::new("https://example.com")
961                .timeout_seconds(0)
962                .validate()
963                .is_err()
964        );
965        assert!(
966            WebReaderRequest::new("https://example.com")
967                .timeout_seconds(30)
968                .validate()
969                .is_ok()
970        );
971        assert!(
972            WebReaderRequest::new("https://user:password@example.com/private")
973                .validate()
974                .is_err()
975        );
976    }
977
978    #[test]
979    fn request_debug_output_redacts_queries_paths_and_media_sources() {
980        let requests = [
981            format!(
982                "{:?}",
983                WebSearchRequest::new("private-query").domain("private.example")
984            ),
985            format!(
986                "{:?}",
987                WebReaderRequest::new("https://private.example/page")
988            ),
989            format!(
990                "{:?}",
991                SearchDocRequest::new("private/repository", "private-doc-query")
992            ),
993            format!(
994                "{:?}",
995                DiagnoseErrorRequest::new("private-image.png", "private-prompt")
996                    .context("private-context")
997            ),
998            format!(
999                "{:?}",
1000                UiDiffRequest::new("private-expected.png", "private-actual.png", "compare")
1001            ),
1002            format!(
1003                "{:?}",
1004                AnalyzeVideoRequest::new("private-video.mp4", "private-video-prompt")
1005            ),
1006        ];
1007        for debug in requests {
1008            for secret in [
1009                "private-query",
1010                "private.example",
1011                "private/repository",
1012                "private-doc-query",
1013                "private-image.png",
1014                "private-prompt",
1015                "private-context",
1016                "private-expected.png",
1017                "private-actual.png",
1018                "private-video.mp4",
1019                "private-video-prompt",
1020            ] {
1021                assert!(!debug.contains(secret), "Debug leaked {secret}");
1022            }
1023        }
1024    }
1025}