Skip to main content

protocol_droid/domain/a2a/agent_card/
builder.rs

1//! The builder for [`AgentCard`].
2//!
3//! The builder is the only way to get an `AgentCard` once the
4//! optional fields land. Every mutation happens on a builder;
5//! the `build` method is the only way to read out a
6//! value-object, and the only place the spec's required-field
7//! invariants are checked.
8
9use std::collections::BTreeMap;
10use std::sync::Arc;
11
12use url::Url;
13
14use crate::domain::a2a::agent_card::error::AgentCardError;
15use crate::domain::a2a::agent_card::security_requirement::SecurityRequirement;
16use crate::domain::a2a::agent_card::security_scheme::SecurityScheme;
17use crate::domain::a2a::agent_card::types::{
18    AgentCapabilities, AgentCard, AgentExtension, AgentInterface, AgentProvider, AgentSkill,
19};
20
21/// The builder for [`AgentCard`].
22///
23/// The builder is a sequence of `with_*` calls the caller makes
24/// consciously; the [`build`](Self::build) method is the only
25/// way to get an `AgentCard`, and it checks the invariants the
26/// spec mandates at the end.
27#[derive(Debug, Default)]
28pub struct AgentCardBuilder {
29    name: Option<Arc<str>>,
30    description: Option<Arc<str>>,
31    version: Option<Arc<str>>,
32    supported_interfaces: Vec<AgentInterface>,
33    provider: Option<AgentProvider>,
34    documentation_url: Option<Url>,
35    icon_url: Option<Url>,
36    capabilities: AgentCapabilities,
37    default_input_modes: Vec<Arc<str>>,
38    default_output_modes: Vec<Arc<str>>,
39    skills: Vec<AgentSkill>,
40    security_schemes: BTreeMap<Arc<str>, SecurityScheme>,
41    security_requirements: Vec<SecurityRequirement>,
42}
43
44impl AgentCardBuilder {
45    /// Starts a new builder with no fields set.
46    #[must_use]
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Sets the agent's human-readable name.
52    #[must_use]
53    pub fn with_name(mut self, name: impl Into<Arc<str>>) -> Self {
54        self.name = Some(name.into());
55        self
56    }
57
58    /// Sets the agent's human-readable description.
59    #[must_use]
60    pub fn with_description(mut self, description: impl Into<Arc<str>>) -> Self {
61        self.description = Some(description.into());
62        self
63    }
64
65    /// Sets the agent's version string.
66    #[must_use]
67    pub fn with_version(mut self, version: impl Into<Arc<str>>) -> Self {
68        self.version = Some(version.into());
69        self
70    }
71
72    /// Adds an interface the agent publishes. The first call is
73    /// the preferred interface; subsequent calls are fallbacks
74    /// in the order the spec reads.
75    #[must_use]
76    pub fn with_interface(mut self, interface: AgentInterface) -> Self {
77        self.supported_interfaces.push(interface);
78        self
79    }
80
81    /// Sets the service provider of the agent. Optional in the
82    /// spec; a builder that does not call this method produces
83    /// a card with no provider field on the wire.
84    #[must_use]
85    pub fn with_provider(mut self, provider: AgentProvider) -> Self {
86        self.provider = Some(provider);
87        self
88    }
89
90    /// Sets the URL providing additional documentation about the
91    /// agent. Optional in the spec; absent on the wire unless
92    /// this method is called.
93    #[must_use]
94    pub fn with_documentation_url(mut self, url: Url) -> Self {
95        self.documentation_url = Some(url);
96        self
97    }
98
99    /// Sets the URL to an icon for the agent. Optional in the
100    /// spec; absent on the wire unless this method is called.
101    /// UI clients surface this as the agent's avatar in a
102    /// directory listing.
103    #[must_use]
104    pub fn with_icon_url(mut self, url: Url) -> Self {
105        self.icon_url = Some(url);
106        self
107    }
108
109    /// Sets the capability set.
110    #[must_use]
111    pub fn with_capabilities(mut self, capabilities: AgentCapabilities) -> Self {
112        self.capabilities = capabilities;
113        self
114    }
115
116    /// Adds a single protocol extension the agent supports.
117    /// Convenience for the common "one extension at a time"
118    /// pattern; a caller that wants to set several at once
119    /// builds the [`AgentCapabilities`] directly and uses
120    /// [`with_capabilities`](Self::with_capabilities).
121    #[must_use]
122    pub fn with_extension(mut self, extension: AgentExtension) -> Self {
123        self.capabilities.extensions.push(extension);
124        self
125    }
126
127    /// Registers a security scheme under `name`. The same
128    /// name is the key in
129    /// [`AgentCard::security_schemes`](super::AgentCard::security_schemes)
130    /// and the key a client uses in
131    /// [`SecurityRequirement`](super::SecurityRequirement)
132    /// to demand that scheme. The wire form is `OpenAPI 3.x`.
133    #[must_use]
134    pub fn with_security_scheme(
135        mut self,
136        name: impl Into<Arc<str>>,
137        scheme: SecurityScheme,
138    ) -> Self {
139        self.security_schemes.insert(name.into(), scheme);
140        self
141    }
142
143    /// Adds a security requirement to the agent's
144    /// published list. The requirements are tried in
145    /// the order they are added; a client picks the
146    /// first one it can satisfy.
147    #[must_use]
148    pub fn with_security_requirement(mut self, requirement: SecurityRequirement) -> Self {
149        self.security_requirements.push(requirement);
150        self
151    }
152
153    /// Adds a default input mode.
154    #[must_use]
155    pub fn with_input_mode(mut self, mode: impl Into<Arc<str>>) -> Self {
156        self.default_input_modes.push(mode.into());
157        self
158    }
159
160    /// Adds a default output mode.
161    #[must_use]
162    pub fn with_output_mode(mut self, mode: impl Into<Arc<str>>) -> Self {
163        self.default_output_modes.push(mode.into());
164        self
165    }
166
167    /// Adds a skill the agent advertises.
168    #[must_use]
169    pub fn with_skill(mut self, skill: AgentSkill) -> Self {
170        self.skills.push(skill);
171        self
172    }
173
174    /// Builds the card, checking the spec's required-field
175    /// invariants.
176    ///
177    /// # Errors
178    ///
179    /// [`AgentCardError`] on the first invariant failure. The
180    /// error names the field so the caller can fix the input.
181    pub fn build(self) -> Result<AgentCard, AgentCardError> {
182        let name = self.name.as_ref().ok_or(AgentCardError::EmptyName)?.clone();
183        if name.is_empty() {
184            return Err(AgentCardError::EmptyName);
185        }
186        let description = self
187            .description
188            .as_ref()
189            .ok_or(AgentCardError::EmptyDescription)?
190            .clone();
191        if description.is_empty() {
192            return Err(AgentCardError::EmptyDescription);
193        }
194        let version = self
195            .version
196            .as_ref()
197            .ok_or(AgentCardError::EmptyVersion)?
198            .clone();
199        if version.is_empty() {
200            return Err(AgentCardError::EmptyVersion);
201        }
202        self.validate_top_level()?;
203        self.validate_skills()?;
204        self.validate_provider_extensions_and_security()?;
205        Ok(AgentCard {
206            name,
207            description,
208            version,
209            supported_interfaces: self.supported_interfaces,
210            provider: self.provider,
211            documentation_url: self.documentation_url,
212            icon_url: self.icon_url,
213            capabilities: self.capabilities,
214            default_input_modes: self.default_input_modes,
215            default_output_modes: self.default_output_modes,
216            skills: self.skills,
217            security_schemes: self.security_schemes,
218            security_requirements: self.security_requirements,
219        })
220    }
221
222    /// Validates the "top-level" fields that are not
223    /// part of a sub-list: the supported interfaces,
224    /// the input modes, and the output modes.
225    const fn validate_top_level(&self) -> Result<(), AgentCardError> {
226        if self.supported_interfaces.is_empty() {
227            return Err(AgentCardError::NoSupportedInterfaces);
228        }
229        if self.default_input_modes.is_empty() {
230            return Err(AgentCardError::NoInputModes);
231        }
232        if self.default_output_modes.is_empty() {
233            return Err(AgentCardError::NoOutputModes);
234        }
235        Ok(())
236    }
237
238    /// Validates the `skills` list — every skill's
239    /// `id`, `name`, and `description` are non-empty.
240    fn validate_skills(&self) -> Result<(), AgentCardError> {
241        for (index, skill) in self.skills.iter().enumerate() {
242            if skill.id.is_empty() {
243                return Err(AgentCardError::EmptySkillField { index, field: "id" });
244            }
245            if skill.name.is_empty() {
246                return Err(AgentCardError::EmptySkillField {
247                    index,
248                    field: "name",
249                });
250            }
251            if skill.description.is_empty() {
252                return Err(AgentCardError::EmptySkillField {
253                    index,
254                    field: "description",
255                });
256            }
257        }
258        Ok(())
259    }
260
261    /// Validates the optional `provider`, the
262    /// capability `extensions`, and the
263    /// `security_schemes` map. The three are grouped
264    /// because they share the "is this map / option
265    /// shape" pre-condition — every entry the
266    /// consumer added needs at least one non-empty
267    /// field — and the per-section pattern would
268    /// duplicate the early-return on empty.
269    fn validate_provider_extensions_and_security(&self) -> Result<(), AgentCardError> {
270        if let Some(provider) = &self.provider
271            && provider.organization.is_empty()
272        {
273            return Err(AgentCardError::EmptyProviderOrganization);
274        }
275        for (index, extension) in self.capabilities.extensions.iter().enumerate() {
276            if extension.uri.is_empty() {
277                return Err(AgentCardError::EmptyExtensionField {
278                    index,
279                    field: "uri",
280                });
281            }
282            if extension.description.is_empty() {
283                return Err(AgentCardError::EmptyExtensionField {
284                    index,
285                    field: "description",
286                });
287            }
288        }
289        for (name, scheme) in &self.security_schemes {
290            if name.is_empty() {
291                return Err(AgentCardError::EmptySecuritySchemeName);
292            }
293            match scheme {
294                SecurityScheme::ApiKey(api_key) => {
295                    if api_key.name.is_empty() {
296                        return Err(AgentCardError::EmptyAPIKeyParameterName {
297                            name: Arc::clone(name),
298                        });
299                    }
300                }
301                SecurityScheme::Http(http) => {
302                    if http.scheme.is_empty() {
303                        return Err(AgentCardError::EmptyHTTPAuthScheme {
304                            name: Arc::clone(name),
305                        });
306                    }
307                }
308                SecurityScheme::OAuth2(oauth2) => {
309                    if !oauth2.flows.has_any() {
310                        return Err(AgentCardError::EmptyOAuthFlows {
311                            name: Arc::clone(name),
312                        });
313                    }
314                }
315                SecurityScheme::OpenIdConnect(_) | SecurityScheme::MutualTls(_) => {
316                    // OpenIdConnectSecurityScheme has no
317                    // builder-time invariants beyond the
318                    // required `open_id_connect_url`, which
319                    // is enforced by `url::Url::parse` at the
320                    // call site. MutualTlsSecurityScheme has
321                    // no required fields at all. Both are
322                    // accepted as-is.
323                }
324            }
325        }
326        Ok(())
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    #![expect(
333        clippy::expect_used,
334        reason = "tests assert on fallible calls with named failure messages; propagating errors would replace a clear failure with noise"
335    )]
336
337    use std::sync::Arc;
338
339    use super::{AgentCard, AgentCardBuilder};
340    use crate::domain::a2a::A2aVersion;
341    use crate::domain::a2a::agent_card::error::AgentCardError;
342    use crate::domain::a2a::agent_card::types::{AgentSkill, JSONRPC};
343
344    type TestError = Box<dyn std::error::Error>;
345
346    fn sample_interface() -> Result<crate::domain::a2a::agent_card::types::AgentInterface, TestError>
347    {
348        Ok(crate::domain::a2a::agent_card::types::AgentInterface {
349            url: url::Url::parse("https://example.com/a2a/v1")?,
350            protocol_binding: JSONRPC.clone(),
351            protocol_version: A2aVersion::V1_0,
352            tenant: None,
353        })
354    }
355
356    fn sample_skill() -> AgentSkill {
357        AgentSkill {
358            id: Arc::from("research"),
359            name: Arc::from("Research"),
360            description: Arc::from("Look things up."),
361            tags: vec![],
362        }
363    }
364
365    fn sample_card() -> Result<AgentCard, TestError> {
366        Ok(AgentCardBuilder::new()
367            .with_name("Research Agent")
368            .with_description("Looks things up on the web.")
369            .with_version("1.0.0")
370            .with_interface(sample_interface()?)
371            .with_capabilities(crate::domain::a2a::agent_card::types::AgentCapabilities::default())
372            .with_input_mode("text/plain")
373            .with_output_mode("text/plain")
374            .with_skill(sample_skill())
375            .build()?)
376    }
377
378    // --- builder invariants ------------------------------------------
379
380    #[test]
381    fn empty_name_is_rejected() -> Result<(), TestError> {
382        let err = AgentCardBuilder::new()
383            .with_name("")
384            .with_description("d")
385            .with_version("1.0.0")
386            .with_interface(sample_interface()?)
387            .with_input_mode("text/plain")
388            .with_output_mode("text/plain")
389            .build()
390            .expect_err("an empty name must be refused");
391        assert_eq!(err, AgentCardError::EmptyName);
392        Ok(())
393    }
394
395    #[test]
396    fn missing_name_is_rejected() -> Result<(), TestError> {
397        let err = AgentCardBuilder::new()
398            .with_description("d")
399            .with_version("1.0.0")
400            .with_interface(sample_interface()?)
401            .with_input_mode("text/plain")
402            .with_output_mode("text/plain")
403            .build()
404            .expect_err("a missing name must be refused");
405        assert_eq!(err, AgentCardError::EmptyName);
406        Ok(())
407    }
408
409    #[test]
410    fn empty_description_is_rejected() -> Result<(), TestError> {
411        let err = AgentCardBuilder::new()
412            .with_name("n")
413            .with_description("")
414            .with_version("1.0.0")
415            .with_interface(sample_interface()?)
416            .with_input_mode("text/plain")
417            .with_output_mode("text/plain")
418            .build()
419            .expect_err("an empty description must be refused");
420        assert_eq!(err, AgentCardError::EmptyDescription);
421        Ok(())
422    }
423
424    #[test]
425    fn empty_version_is_rejected() -> Result<(), TestError> {
426        let err = AgentCardBuilder::new()
427            .with_name("n")
428            .with_description("d")
429            .with_version("")
430            .with_interface(sample_interface()?)
431            .with_input_mode("text/plain")
432            .with_output_mode("text/plain")
433            .build()
434            .expect_err("an empty version must be refused");
435        assert_eq!(err, AgentCardError::EmptyVersion);
436        Ok(())
437    }
438
439    #[test]
440    fn no_supported_interfaces_is_rejected() {
441        let err = AgentCardBuilder::new()
442            .with_name("n")
443            .with_description("d")
444            .with_version("1.0.0")
445            .with_input_mode("text/plain")
446            .with_output_mode("text/plain")
447            .build()
448            .expect_err("zero interfaces must be refused");
449        assert_eq!(err, AgentCardError::NoSupportedInterfaces);
450    }
451
452    #[test]
453    fn no_input_modes_is_rejected() -> Result<(), TestError> {
454        let err = AgentCardBuilder::new()
455            .with_name("n")
456            .with_description("d")
457            .with_version("1.0.0")
458            .with_interface(sample_interface()?)
459            .with_output_mode("text/plain")
460            .build()
461            .expect_err("zero input modes must be refused");
462        assert_eq!(err, AgentCardError::NoInputModes);
463        Ok(())
464    }
465
466    #[test]
467    fn no_output_modes_is_rejected() -> Result<(), TestError> {
468        let err = AgentCardBuilder::new()
469            .with_name("n")
470            .with_description("d")
471            .with_version("1.0.0")
472            .with_interface(sample_interface()?)
473            .with_input_mode("text/plain")
474            .build()
475            .expect_err("zero output modes must be refused");
476        assert_eq!(err, AgentCardError::NoOutputModes);
477        Ok(())
478    }
479
480    #[test]
481    fn empty_skill_id_is_rejected() -> Result<(), TestError> {
482        let bad = AgentSkill {
483            id: Arc::from(""),
484            name: Arc::from("Research"),
485            description: Arc::from("Look things up."),
486            tags: vec![],
487        };
488        let err = AgentCardBuilder::new()
489            .with_name("n")
490            .with_description("d")
491            .with_version("1.0.0")
492            .with_interface(sample_interface()?)
493            .with_input_mode("text/plain")
494            .with_output_mode("text/plain")
495            .with_skill(bad)
496            .build()
497            .expect_err("an empty skill id must be refused");
498        assert_eq!(
499            err,
500            AgentCardError::EmptySkillField {
501                index: 0,
502                field: "id"
503            }
504        );
505        Ok(())
506    }
507
508    #[test]
509    fn valid_card_builds() -> Result<(), TestError> {
510        let card = sample_card()?;
511        assert_eq!(card.name.as_ref(), "Research Agent");
512        assert_eq!(card.supported_interfaces.len(), 1);
513        assert_eq!(card.skills.len(), 1);
514        Ok(())
515    }
516
517    // --- optional fields ---------------------------------------------
518
519    #[test]
520    fn valid_provider_builds() -> Result<(), TestError> {
521        let card = AgentCardBuilder::new()
522            .with_name("n")
523            .with_description("d")
524            .with_version("1.0.0")
525            .with_interface(sample_interface()?)
526            .with_provider(super::AgentProvider {
527                url: url::Url::parse("https://example.com")?,
528                organization: Arc::from("Example Co"),
529            })
530            .with_input_mode("text/plain")
531            .with_output_mode("text/plain")
532            .build()?;
533        let provider = card
534            .provider
535            .as_ref()
536            .expect("a card built with_provider must carry the provider");
537        assert_eq!(provider.organization.as_ref(), "Example Co");
538        assert_eq!(provider.url.as_str(), "https://example.com/");
539        Ok(())
540    }
541
542    #[test]
543    fn empty_provider_organization_is_rejected() -> Result<(), TestError> {
544        let err = AgentCardBuilder::new()
545            .with_name("n")
546            .with_description("d")
547            .with_version("1.0.0")
548            .with_interface(sample_interface()?)
549            .with_provider(super::AgentProvider {
550                url: url::Url::parse("https://example.com")?,
551                organization: Arc::from(""),
552            })
553            .with_input_mode("text/plain")
554            .with_output_mode("text/plain")
555            .build()
556            .expect_err("an empty provider organization must be refused");
557        assert_eq!(err, AgentCardError::EmptyProviderOrganization);
558        Ok(())
559    }
560
561    #[test]
562    fn unset_provider_carries_none() -> Result<(), TestError> {
563        // A card that does not call `with_provider` must
564        // serialise without a `provider` field. The test
565        // pins both the in-memory shape (`None`) and the
566        // wire shape (the field is absent).
567        let card = sample_card()?;
568        assert!(card.provider.is_none());
569        let value = serde_json::to_value(&card)?;
570        let obj = value
571            .as_object()
572            .expect("the card must serialise to a JSON object");
573        assert!(!obj.contains_key("provider"));
574        Ok(())
575    }
576
577    #[test]
578    fn documentation_url_round_trips() -> Result<(), TestError> {
579        let url = url::Url::parse("https://example.com/docs")?;
580        let card = AgentCardBuilder::new()
581            .with_name("n")
582            .with_description("d")
583            .with_version("1.0.0")
584            .with_interface(sample_interface()?)
585            .with_documentation_url(url.clone())
586            .with_input_mode("text/plain")
587            .with_output_mode("text/plain")
588            .build()?;
589        assert_eq!(card.documentation_url.as_ref(), Some(&url));
590        let json = serde_json::to_string(&card)?;
591        let parsed: AgentCard = serde_json::from_str(&json)?;
592        assert_eq!(parsed, card);
593        Ok(())
594    }
595
596    #[test]
597    fn icon_url_round_trips() -> Result<(), TestError> {
598        let url = url::Url::parse("https://example.com/icon.png")?;
599        let card = AgentCardBuilder::new()
600            .with_name("n")
601            .with_description("d")
602            .with_version("1.0.0")
603            .with_interface(sample_interface()?)
604            .with_icon_url(url.clone())
605            .with_input_mode("text/plain")
606            .with_output_mode("text/plain")
607            .build()?;
608        assert_eq!(card.icon_url.as_ref(), Some(&url));
609        let json = serde_json::to_string(&card)?;
610        let parsed: AgentCard = serde_json::from_str(&json)?;
611        assert_eq!(parsed, card);
612        Ok(())
613    }
614
615    // --- `AgentCapabilities::extensions` ------------------------------
616
617    #[test]
618    fn valid_extension_builds() -> Result<(), TestError> {
619        let card = AgentCardBuilder::new()
620            .with_name("n")
621            .with_description("d")
622            .with_version("1.0.0")
623            .with_interface(sample_interface()?)
624            .with_extension(super::AgentExtension {
625                uri: Arc::from("https://example.com/extension/audio-input"),
626                description: Arc::from("Accepts audio input on the SendMessage payload."),
627                required: false,
628                params: None,
629            })
630            .with_input_mode("text/plain")
631            .with_output_mode("text/plain")
632            .build()?;
633        assert_eq!(card.capabilities.extensions.len(), 1);
634        let ext = card
635            .capabilities
636            .extensions
637            .first()
638            .expect("the card carries exactly one extension");
639        assert_eq!(
640            ext.uri.as_ref(),
641            "https://example.com/extension/audio-input"
642        );
643        assert!(!ext.required);
644        assert!(ext.params.is_none());
645        Ok(())
646    }
647
648    #[test]
649    fn empty_extension_uri_is_rejected() -> Result<(), TestError> {
650        let err = AgentCardBuilder::new()
651            .with_name("n")
652            .with_description("d")
653            .with_version("1.0.0")
654            .with_interface(sample_interface()?)
655            .with_extension(super::AgentExtension {
656                uri: Arc::from(""),
657                description: Arc::from("d"),
658                required: false,
659                params: None,
660            })
661            .with_input_mode("text/plain")
662            .with_output_mode("text/plain")
663            .build()
664            .expect_err("an empty extension uri must be refused");
665        assert_eq!(
666            err,
667            AgentCardError::EmptyExtensionField {
668                index: 0,
669                field: "uri"
670            }
671        );
672        Ok(())
673    }
674
675    #[test]
676    fn empty_extension_description_is_rejected() -> Result<(), TestError> {
677        let err = AgentCardBuilder::new()
678            .with_name("n")
679            .with_description("d")
680            .with_version("1.0.0")
681            .with_interface(sample_interface()?)
682            .with_extension(super::AgentExtension {
683                uri: Arc::from("https://example.com/ext"),
684                description: Arc::from(""),
685                required: false,
686                params: None,
687            })
688            .with_input_mode("text/plain")
689            .with_output_mode("text/plain")
690            .build()
691            .expect_err("an empty extension description must be refused");
692        assert_eq!(
693            err,
694            AgentCardError::EmptyExtensionField {
695                index: 0,
696                field: "description"
697            }
698        );
699        Ok(())
700    }
701
702    #[test]
703    fn extension_params_round_trip_as_json_object() -> Result<(), TestError> {
704        // `params` is `google.protobuf.Struct` in the proto, a
705        // JSON object. The test pins the round-trip via
706        // `serde_json::Map`: set a value, serialise, parse,
707        // assert equality.
708        let mut params = serde_json::Map::new();
709        params.insert(
710            "mimeType".to_owned(),
711            serde_json::Value::String("audio/wav".to_owned()),
712        );
713        let card = AgentCardBuilder::new()
714            .with_name("n")
715            .with_description("d")
716            .with_version("1.0.0")
717            .with_interface(sample_interface()?)
718            .with_extension(super::AgentExtension {
719                uri: Arc::from("https://example.com/ext"),
720                description: Arc::from("d"),
721                required: false,
722                params: Some(params.clone()),
723            })
724            .with_input_mode("text/plain")
725            .with_output_mode("text/plain")
726            .build()?;
727        let value = serde_json::to_value(
728            card.capabilities
729                .extensions
730                .first()
731                .expect("the card carries exactly one extension"),
732        )?;
733        let obj = value
734            .as_object()
735            .expect("extension must serialise to a JSON object");
736        let mime = obj
737            .get("params")
738            .and_then(serde_json::Value::as_object)
739            .and_then(|p| p.get("mimeType"))
740            .and_then(serde_json::Value::as_str)
741            .map(str::to_owned);
742        assert_eq!(mime.as_deref(), Some("audio/wav"));
743        Ok(())
744    }
745
746    // --- security schemes --------------------------------------------
747
748    #[test]
749    fn api_key_security_scheme_round_trips() -> Result<(), TestError> {
750        use super::super::security_scheme::{APIKeyLocation, APIKeySecurityScheme, SecurityScheme};
751
752        let card = AgentCardBuilder::new()
753            .with_name("n")
754            .with_description("d")
755            .with_version("1.0.0")
756            .with_interface(sample_interface()?)
757            .with_security_scheme(
758                "publicRead",
759                SecurityScheme::ApiKey(APIKeySecurityScheme {
760                    description: Some(Arc::from("Read-only API key.")),
761                    in_: APIKeyLocation::Header,
762                    name: Arc::from("X-API-Key"),
763                }),
764            )
765            .with_input_mode("text/plain")
766            .with_output_mode("text/plain")
767            .build()?;
768        let scheme = card
769            .security_schemes
770            .get("publicRead")
771            .expect("the registered scheme must be present in the map");
772        let json = serde_json::to_value(scheme)?;
773        let obj = json
774            .as_object()
775            .expect("the scheme must serialise to a JSON object");
776        assert_eq!(
777            obj.get("type").and_then(serde_json::Value::as_str),
778            Some("apiKey")
779        );
780        assert_eq!(
781            obj.get("in").and_then(serde_json::Value::as_str),
782            Some("header")
783        );
784        assert_eq!(
785            obj.get("name").and_then(serde_json::Value::as_str),
786            Some("X-API-Key")
787        );
788        Ok(())
789    }
790
791    #[test]
792    fn http_security_scheme_round_trips() -> Result<(), TestError> {
793        use super::super::security_scheme::{HTTPAuthSecurityScheme, SecurityScheme};
794
795        let card = AgentCardBuilder::new()
796            .with_name("n")
797            .with_description("d")
798            .with_version("1.0.0")
799            .with_interface(sample_interface()?)
800            .with_security_scheme(
801                "bearerAuth",
802                SecurityScheme::Http(HTTPAuthSecurityScheme {
803                    description: None,
804                    scheme: Arc::from("Bearer"),
805                    bearer_format: Some(Arc::from("JWT")),
806                }),
807            )
808            .with_input_mode("text/plain")
809            .with_output_mode("text/plain")
810            .build()?;
811        let scheme = card
812            .security_schemes
813            .get("bearerAuth")
814            .expect("the registered scheme must be present in the map");
815        let json = serde_json::to_value(scheme)?;
816        let obj = json
817            .as_object()
818            .expect("the scheme must serialise to a JSON object");
819        assert_eq!(
820            obj.get("type").and_then(serde_json::Value::as_str),
821            Some("http")
822        );
823        assert_eq!(
824            obj.get("scheme").and_then(serde_json::Value::as_str),
825            Some("Bearer")
826        );
827        assert_eq!(
828            obj.get("bearerFormat").and_then(serde_json::Value::as_str),
829            Some("JWT")
830        );
831        Ok(())
832    }
833
834    #[test]
835    fn empty_security_scheme_name_is_rejected() -> Result<(), TestError> {
836        use super::super::security_scheme::{APIKeyLocation, APIKeySecurityScheme, SecurityScheme};
837
838        let err = AgentCardBuilder::new()
839            .with_name("n")
840            .with_description("d")
841            .with_version("1.0.0")
842            .with_interface(sample_interface()?)
843            .with_security_scheme(
844                "",
845                SecurityScheme::ApiKey(APIKeySecurityScheme {
846                    description: None,
847                    in_: APIKeyLocation::Header,
848                    name: Arc::from("X-API-Key"),
849                }),
850            )
851            .with_input_mode("text/plain")
852            .with_output_mode("text/plain")
853            .build()
854            .expect_err("an empty scheme name must be refused");
855        assert_eq!(err, AgentCardError::EmptySecuritySchemeName);
856        Ok(())
857    }
858
859    #[test]
860    fn empty_api_key_parameter_name_is_rejected() -> Result<(), TestError> {
861        use super::super::security_scheme::{APIKeyLocation, APIKeySecurityScheme, SecurityScheme};
862
863        let err = AgentCardBuilder::new()
864            .with_name("n")
865            .with_description("d")
866            .with_version("1.0.0")
867            .with_interface(sample_interface()?)
868            .with_security_scheme(
869                "publicRead",
870                SecurityScheme::ApiKey(APIKeySecurityScheme {
871                    description: None,
872                    in_: APIKeyLocation::Header,
873                    name: Arc::from(""),
874                }),
875            )
876            .with_input_mode("text/plain")
877            .with_output_mode("text/plain")
878            .build()
879            .expect_err("an empty API key parameter name must be refused");
880        assert_eq!(
881            err,
882            AgentCardError::EmptyAPIKeyParameterName {
883                name: Arc::from("publicRead"),
884            }
885        );
886        Ok(())
887    }
888
889    #[test]
890    fn empty_http_auth_scheme_is_rejected() -> Result<(), TestError> {
891        use super::super::security_scheme::{HTTPAuthSecurityScheme, SecurityScheme};
892
893        let err = AgentCardBuilder::new()
894            .with_name("n")
895            .with_description("d")
896            .with_version("1.0.0")
897            .with_interface(sample_interface()?)
898            .with_security_scheme(
899                "bearerAuth",
900                SecurityScheme::Http(HTTPAuthSecurityScheme {
901                    description: None,
902                    scheme: Arc::from(""),
903                    bearer_format: None,
904                }),
905            )
906            .with_input_mode("text/plain")
907            .with_output_mode("text/plain")
908            .build()
909            .expect_err("an empty HTTP auth scheme must be refused");
910        assert_eq!(
911            err,
912            AgentCardError::EmptyHTTPAuthScheme {
913                name: Arc::from("bearerAuth"),
914            }
915        );
916        Ok(())
917    }
918
919    #[test]
920    fn unset_security_schemes_carries_empty_map() -> Result<(), TestError> {
921        // A card that does not call `with_security_scheme`
922        // must serialise without a `securitySchemes` field.
923        // The test pins both the in-memory shape (an empty
924        // `BTreeMap`) and the wire shape (the field is
925        // absent — `BTreeMap` is empty so
926        // `skip_serializing_if` drops it).
927        let card = sample_card()?;
928        assert!(card.security_schemes.is_empty());
929        let value = serde_json::to_value(&card)?;
930        let obj = value
931            .as_object()
932            .expect("the card must serialise to a JSON object");
933        assert!(!obj.contains_key("securitySchemes"));
934        Ok(())
935    }
936}