1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45#[non_exhaustive]
46pub enum SearchContentSize {
47 Medium,
49 High,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[non_exhaustive]
56pub enum SearchLocation {
57 #[serde(rename = "cn")]
59 China,
60 #[serde(rename = "us")]
62 International,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[non_exhaustive]
68pub enum SearchRecency {
69 #[serde(rename = "oneDay")]
71 OneDay,
72 #[serde(rename = "oneWeek")]
74 OneWeek,
75 #[serde(rename = "oneMonth")]
77 OneMonth,
78 #[serde(rename = "oneYear")]
80 OneYear,
81 #[serde(rename = "noLimit")]
83 NoLimit,
84}
85
86#[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 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 pub fn domain(mut self, domain: impl Into<String>) -> Self {
132 self.domain = Some(domain.into());
133 self
134 }
135
136 pub fn recency(mut self, recency: SearchRecency) -> Self {
138 self.recency = Some(recency);
139 self
140 }
141
142 pub fn content_size(mut self, content_size: SearchContentSize) -> Self {
144 self.content_size = Some(content_size);
145 self
146 }
147
148 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "lowercase")]
170#[non_exhaustive]
171pub enum WebReaderFormat {
172 Markdown,
174 Text,
176}
177
178#[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 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 pub fn format(mut self, format: WebReaderFormat) -> Self {
233 self.return_format = Some(format);
234 self
235 }
236
237 pub fn retain_images(mut self, retain: bool) -> Self {
239 self.retain_images = Some(retain);
240 self
241 }
242
243 pub fn links_summary(mut self, include: bool) -> Self {
245 self.with_links_summary = Some(include);
246 self
247 }
248
249 pub fn images_summary(mut self, include: bool) -> Self {
251 self.with_images_summary = Some(include);
252 self
253 }
254
255 pub fn keep_image_data_urls(mut self, keep: bool) -> Self {
257 self.keep_img_data_url = Some(keep);
258 self
259 }
260
261 pub fn github_flavored_markdown(mut self, enabled: bool) -> Self {
263 self.no_gfm = Some(!enabled);
264 self
265 }
266
267 pub fn cache(mut self, enabled: bool) -> Self {
269 self.no_cache = Some(!enabled);
270 self
271 }
272
273 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(rename_all = "lowercase")]
310#[non_exhaustive]
311pub enum RepositoryLanguage {
312 Zh,
314 En,
316}
317
318#[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 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 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#[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 pub fn new(repository: impl Into<String>) -> Self {
369 Self {
370 repo_name: repository.into(),
371 dir_path: None,
372 }
373 }
374
375 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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "lowercase")]
420#[non_exhaustive]
421pub enum UiArtifactOutput {
422 Code,
424 Prompt,
426 #[serde(rename = "spec")]
428 Specification,
429 Description,
431}
432
433#[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 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#[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 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 pub fn programming_language(mut self, language: impl Into<String>) -> Self {
489 self.programming_language = Some(language.into());
490 self
491 }
492}
493
494#[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 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 pub fn context(mut self, context: impl Into<String>) -> Self {
521 self.context = Some(context.into());
522 self
523 }
524}
525
526#[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 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 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#[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 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 pub fn focus(mut self, focus: impl Into<String>) -> Self {
585 self.analysis_focus = Some(focus.into());
586 self
587 }
588}
589
590#[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 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#[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 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#[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 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}