Skip to main content

plexus_core/plexus/
schema.rs

1/// JSON Schema types with strong typing
2///
3/// This module provides strongly-typed JSON Schema structures that plugins
4/// use to describe their methods and parameters.
5///
6/// Schema generation is fully automatic via schemars. By using proper types
7/// (uuid::Uuid instead of String) and doc comments, schemars generates complete
8/// schemas with format annotations, descriptions, and required arrays.
9
10use schemars::{JsonSchema, schema_for};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14use plexus_auth_core::{
15    AttachmentSite, CredentialFieldMarker, CredentialIssuer, CredentialKind, CredentialMetadata,
16    Scope,
17};
18
19use super::bidirectional::{StandardRequest, StandardResponse};
20
21// =============================================================================
22// Method Role
23// =============================================================================
24
25/// Describes how a method participates in the activation graph.
26///
27/// Every method on a plugin is exactly one of three kinds:
28///
29/// - `Rpc` — a regular RPC endpoint (the default).
30/// - `StaticChild` — the method returns a child activation by a static name
31///   (no lookup argument). Used by `#[child]`-annotated methods on hubs.
32/// - `DynamicChild { .. }` — the method gates a dynamic child keyed by its
33///   argument. `list_method` optionally names a sibling method that enumerates
34///   available keys, and `search_method` optionally names a sibling method
35///   that searches keys.
36///
37/// This tag is consumed by downstream tooling (synapse, synapse-cc,
38/// introspection clients) to reconstruct the child graph without a separate
39/// side-table. Today's macros emit `MethodRole::Rpc` for every method; IR-3
40/// populates child roles from `#[child]` annotations.
41///
42/// # Wire back-compat
43///
44/// Added in IR-2. Serde defaults to `Rpc` for pre-IR schemas.
45/// `#[non_exhaustive]` reserves space for future variants without breaking
46/// downstream match arms.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(tag = "kind", rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum MethodRole {
51    /// Method is an RPC endpoint (the default for ordinary methods).
52    Rpc,
53    /// Method returns a child activation by static name (no lookup arg).
54    StaticChild,
55    /// Method gates a dynamic child keyed by its argument.
56    DynamicChild {
57        /// Optional sibling method that lists available keys.
58        #[serde(default, skip_serializing_if = "Option::is_none")]
59        list_method: Option<String>,
60        /// Optional sibling method that searches available keys.
61        #[serde(default, skip_serializing_if = "Option::is_none")]
62        search_method: Option<String>,
63    },
64}
65
66impl Default for MethodRole {
67    fn default() -> Self {
68        MethodRole::Rpc
69    }
70}
71
72// =============================================================================
73// Deprecation Info
74// =============================================================================
75
76/// Structured deprecation metadata attached to a `MethodSchema`.
77///
78/// Downstream consumers (CLI help, docs generators, IDEs) use these fields
79/// to surface migration guidance to users.
80///
81/// # Example
82///
83/// ```
84/// use plexus_core::DeprecationInfo;
85///
86/// let info = DeprecationInfo {
87///     since: "0.5".into(),
88///     removed_in: "0.6".into(),
89///     message: "Use `new_method` instead.".into(),
90/// };
91/// ```
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
93pub struct DeprecationInfo {
94    /// The plexus-core version at which deprecation began (e.g., `"0.5"`).
95    pub since: String,
96    /// The plexus-core version planned for removal (e.g., `"0.6"`).
97    ///
98    /// Not binding — serves as a consumer-visible hint.
99    pub removed_in: String,
100    /// Human-readable migration guidance.
101    pub message: String,
102}
103
104// =============================================================================
105// Param Schema
106// =============================================================================
107
108/// Per-parameter metadata for a method's parameters.
109///
110/// `MethodSchema.params` already carries the fine-grained JSON Schema for the
111/// combined parameter object. `ParamSchema` carries orthogonal, parameter-
112/// scoped metadata that doesn't fit on a JSON Schema node — currently just
113/// deprecation info (IR-5).
114///
115/// The `name` field matches the parameter identifier in the method signature
116/// so consumers can correlate entries against the `params` JSON Schema's
117/// `properties` map.
118///
119/// Added in IR-5. Defaults to an empty list on `MethodSchema` so pre-IR
120/// schemas deserialize cleanly.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
122pub struct ParamSchema {
123    /// Parameter name, matching the identifier in the method signature.
124    pub name: String,
125    /// If set, this parameter is deprecated.
126    ///
127    /// Populated by `#[deprecated(...)]` (+ optional
128    /// `#[plexus_macros::removed_in("...")]`) on the parameter in the
129    /// method signature (IR-5).
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub deprecation: Option<DeprecationInfo>,
132}
133
134impl ParamSchema {
135    /// Create a new `ParamSchema` carrying just a name and no metadata.
136    pub fn new(name: impl Into<String>) -> Self {
137        Self {
138            name: name.into(),
139            deprecation: None,
140        }
141    }
142
143    /// Attach deprecation metadata for this parameter.
144    pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
145        self.deprecation = Some(info);
146        self
147    }
148}
149
150// =============================================================================
151// Credential projections (AUTHZ-CRED-CORE-3)
152// =============================================================================
153
154/// One entry per credential-bearing field in a method's return type.
155///
156/// Pinned by `AUTHZ-CRED-S01-output.md` §4 and `AUTHZ-CRED-CORE-3` §"Required
157/// behavior". Projected onto `MethodSchema.credentials` at schema-build time
158/// from the `CredentialFieldMarker` registry the `#[derive(Credentials)]` macro
159/// emits per credential-bearing type (`AUTHZ-CRED-MACRO-1`).
160///
161/// # Field path semantics (Tier B Q-IR-1)
162///
163/// `field_path` is the JSON-object path to the credential field within the
164/// method's return type. v1 always uses object-field paths (one segment per
165/// field name walked); array indices and JSON Pointer syntax are intentionally
166/// excluded because credentials always live on object fields in v1, never
167/// inside array elements.
168///
169/// Example: for a return type
170/// `struct LoginResult { session: Credential<String> }`, the field path is
171/// `["session"]`. For a nested case
172/// `struct LoginResult { auth: AuthBundle }` where `AuthBundle` itself
173/// declares a `#[plexus::credential(..)]` field `token: Credential<String>`,
174/// the field path is `["auth", "token"]`.
175///
176/// # Variant tagging
177///
178/// `variant_tag` is `Some(tag)` when the method's return type is an enum and
179/// the credential lives on a single variant. The tag matches the variant
180/// identifier as the macro registry records it (e.g. `"Issued"` for the
181/// `LoginEvent::Issued` variant). `None` means the return type is a struct,
182/// or the credential appears on every variant of the enum (rare).
183///
184/// # Wire back-compat
185///
186/// Added in `AUTHZ-CRED-CORE-3`. Pre-existing readers tolerate `credentials:
187/// []` (the default on `MethodSchema` when no credentials are declared) and
188/// pre-existing IRs that omit the field altogether decode cleanly via
189/// `#[serde(default)]` on the field site.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
191pub struct CredentialFieldDecl {
192    /// JSON-object path to the credential field within the method's return
193    /// type. One segment per field-name walk; pinned to object paths (no
194    /// array indices, no JSON Pointer) per Tier B Q-IR-1.
195    pub field_path: Vec<String>,
196
197    /// Variant tag when the return type is an enum and the credential lives
198    /// on a single variant. `None` for structs.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub variant_tag: Option<String>,
201
202    /// The credential's metadata (kind, attach site, scheme, scopes, expiry,
203    /// refresh/revoke hints, issuer, sensitivity). Carried verbatim so
204    /// consumers embed identical storage-and-attach logic to the runtime.
205    pub metadata: CredentialMetadata,
206}
207
208impl CredentialFieldDecl {
209    /// Construct a `CredentialFieldDecl` by composing a `CredentialFieldMarker`
210    /// from the macro-emitted registry with the runtime-supplied pieces
211    /// (`expires_at` known only at mint time; `issuer` known at schema-build
212    /// time from the originating method's `(Origin, MethodPath)`).
213    ///
214    /// `path_prefix` is the field-path walk to the marker's parent type — for
215    /// a flat struct it is empty; for a nested case where this marker lives
216    /// inside a field of the method's return type the prefix is the path to
217    /// that wrapping field. The marker's own `field` and `variant` are
218    /// appended.
219    pub fn from_marker(
220        marker: &CredentialFieldMarker,
221        path_prefix: &[&str],
222        issuer: CredentialIssuer,
223    ) -> Self {
224        let mut field_path: Vec<String> = path_prefix.iter().map(|s| (*s).to_owned()).collect();
225        field_path.push(marker.field.to_owned());
226        let variant_tag = marker.variant.map(|v| v.to_owned());
227        let metadata = marker.to_metadata(None, issuer);
228        Self {
229            field_path,
230            variant_tag,
231            metadata,
232        }
233    }
234}
235
236/// What a method requires on input — the implicit-derivation projection of
237/// scope tagging and credential-graph linkage onto a per-method filter.
238///
239/// Pinned by `AUTHZ-CRED-S01-output.md` §4 (Q-SELECT-1 resolution: implicit
240/// derivation from scope tagging plus refresh/revoke linkage) and
241/// `AUTHZ-CRED-CORE-3` §"Implicit derivation". This ticket explicitly does
242/// NOT add a `#[plexus::method(requires_credential = { .. })]` attribute
243/// surface — the proposal from `AUTHZ-CRED-S01-output` §10 is superseded by
244/// the implicit-derivation approach pinned here.
245///
246/// # Matching semantics (consumer-side)
247///
248/// A candidate credential matches this `RequiredCredential` iff:
249/// 1. `kind`: if `Some(k)`, the candidate's `CredentialMetadata.kind`
250///    matches `k` (or the kind-subsumption table per `AUTHZ-CRED-CORE-1`
251///    accepts the substitution; e.g. `OauthAccess <: Bearer`). If `None`,
252///    any kind whose scope set matches is acceptable.
253/// 2. `scopes`: each scope in this set must be wildcard-matched by the
254///    candidate's `CredentialMetadata.scopes`. The wildcard rules belong to
255///    the `Scope` type itself.
256/// 3. `site_hint`: when populated, prefers the candidate whose
257///    `CredentialMetadata.attach_as` equals the hint. Advisory only — a
258///    candidate without the hint is not rejected.
259///
260/// # Wire back-compat
261///
262/// Added in `AUTHZ-CRED-CORE-3`. Pre-existing readers tolerate
263/// `requires_credential: null` (omitted on the wire when `None`) and
264/// pre-existing IRs that omit the field altogether decode cleanly via
265/// `#[serde(default)]` on the field site.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
267pub struct RequiredCredential {
268    /// Specific kind a candidate credential must have (e.g.,
269    /// `OauthRefresh`), or `None` for "any kind whose scope set matches".
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub kind: Option<CredentialKind>,
272
273    /// Required scope set. A candidate credential's metadata scopes must
274    /// wildcard-match each scope in this set.
275    #[serde(default, skip_serializing_if = "Vec::is_empty")]
276    pub scopes: Vec<Scope>,
277
278    /// Preferred attach site for the client to use when the candidate
279    /// credential has multiple alternates. Advisory only.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub site_hint: Option<AttachmentSite>,
282}
283
284impl RequiredCredential {
285    /// Derive a `RequiredCredential` from a method's required scope tagging
286    /// (per `AUTHZ-S01-output` §4). The `kind` field is left `None` — any
287    /// kind whose scope set wildcard-matches `scope` is acceptable.
288    ///
289    /// Used when a method declares `#[plexus::method(scope = "...")]` (or
290    /// the framework derives an implicit scope from the method's path).
291    pub fn from_method_scope(scope: Scope) -> Self {
292        Self {
293            kind: None,
294            scopes: vec![scope],
295            site_hint: None,
296        }
297    }
298
299    /// Derive a `RequiredCredential` for a method that appears as the target
300    /// of another credential's `metadata.refresh_via` or `metadata.revoke_via`
301    /// (per `AUTHZ-CRED-CORE-3` §"Implicit derivation" row 3). The `kind`
302    /// field narrows to the issuing credential's kind so the selection step
303    /// picks the right kind (e.g., `OauthRefresh` for the
304    /// `auth.refresh` call on an `OauthAccess` credential per the OAuth
305    /// flow described in `AUTHZ-CRED-S01-output` §7.2).
306    pub fn from_refresh_revoke_target(kind: CredentialKind, scopes: Vec<Scope>) -> Self {
307        Self {
308            kind: Some(kind),
309            scopes,
310            site_hint: None,
311        }
312    }
313}
314
315// =============================================================================
316// Return Shape
317// =============================================================================
318
319/// Describes the structural shape of a method's return type.
320///
321/// Orthogonal to the fine-grained JSON Schema stored in `MethodSchema.returns`:
322/// that schema describes the inner type; this tag describes the wrapping.
323///
324/// - `Bare` — `T`
325/// - `Option` — `Option<T>`
326/// - `Result` — `Result<T, E>`
327/// - `Vec` — `Vec<T>`
328/// - `Stream` — a stream of `T` (e.g., `AsyncGenerator<T>`)
329/// - `ResultOption` — `Result<Option<T>, E>`
330///
331/// Added in IR-2 as an optional, additive field on `MethodSchema`. Consumers
332/// that don't care can ignore it; those generating language bindings use it to
333/// pick the right idiom (e.g., TypeScript `T | null` for `Option`).
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
335#[serde(rename_all = "snake_case")]
336#[non_exhaustive]
337pub enum ReturnShape {
338    /// `T` — the return type is used as-is.
339    Bare,
340    /// `Option<T>` — the return may be null/absent.
341    Option,
342    /// `Result<T, E>` — the return may be an error.
343    Result,
344    /// `Vec<T>` — the return is a list.
345    Vec,
346    /// A stream of `T` events.
347    Stream,
348    /// `Result<Option<T>, E>` — common pattern for fallible lookups.
349    ResultOption,
350}
351
352// =============================================================================
353// HTTP Method Enum
354// =============================================================================
355
356/// HTTP method for REST endpoint routing
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
358#[serde(rename_all = "UPPERCASE")]
359pub enum HttpMethod {
360    /// GET: Idempotent read operations with no side effects
361    Get,
362    /// POST: Create operations or non-idempotent actions (default)
363    Post,
364    /// PUT: Replace/update operations (idempotent)
365    Put,
366    /// DELETE: Remove operations (idempotent)
367    Delete,
368    /// PATCH: Partial update operations
369    Patch,
370}
371
372impl Default for HttpMethod {
373    fn default() -> Self {
374        HttpMethod::Post
375    }
376}
377
378impl HttpMethod {
379    /// Parse from string (case-insensitive)
380    pub fn from_str(s: &str) -> Option<Self> {
381        match s.to_uppercase().as_str() {
382            "GET" => Some(HttpMethod::Get),
383            "POST" => Some(HttpMethod::Post),
384            "PUT" => Some(HttpMethod::Put),
385            "DELETE" => Some(HttpMethod::Delete),
386            "PATCH" => Some(HttpMethod::Patch),
387            _ => None,
388        }
389    }
390
391    /// Convert to uppercase string
392    pub fn as_str(&self) -> &'static str {
393        match self {
394            HttpMethod::Get => "GET",
395            HttpMethod::Post => "POST",
396            HttpMethod::Put => "PUT",
397            HttpMethod::Delete => "DELETE",
398            HttpMethod::Patch => "PATCH",
399        }
400    }
401}
402
403// ============================================================================
404// Plugin Schema
405// ============================================================================
406
407/// A plugin's schema with methods and child summaries.
408///
409/// Children are represented as summaries (namespace, description, hash) rather
410/// than full recursive schemas. This enables lazy traversal - clients can fetch
411/// child schemas individually via `{namespace}.schema`.
412///
413/// - Leaf plugins have `children = None`
414/// - Hub plugins have `children = Some([ChildSummary, ...])`
415#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
416pub struct PluginSchema {
417    /// The plugin's namespace (e.g., "echo", "plexus")
418    pub namespace: String,
419
420    /// The plugin's version (e.g., "1.0.0")
421    pub version: String,
422
423    /// Short description of the plugin (max 15 words)
424    pub description: String,
425
426    /// Detailed description of the plugin (optional)
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub long_description: Option<String>,
429
430    /// Hash of ONLY this plugin's methods (ignores children)
431    /// Changes when method signatures, names, or descriptions change
432    pub self_hash: String,
433
434    /// Hash of ONLY child plugin hashes (None for leaf plugins)
435    /// Changes when any child's hash changes (recursively)
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub children_hash: Option<String>,
438
439    /// Composite hash = hash(self_hash + children_hash)
440    /// Use this if you want a single hash for the entire subtree
441    /// Backward compatible with previous single-hash system
442    pub hash: String,
443
444    /// Methods exposed by this plugin
445    pub methods: Vec<MethodSchema>,
446
447    /// Child plugin summaries (None = leaf plugin, Some = hub plugin)
448    ///
449    /// # Deprecated (IR-4)
450    ///
451    /// This side-table is deterministically derived from the method list's
452    /// `MethodRole` tags (one `ChildSummary` per non-`Rpc` method). It stays
453    /// on the wire for back-compat during the 0.5 transition window and is
454    /// slated for removal in 0.6.
455    ///
456    /// Consumers reading child metadata should switch to iterating
457    /// `methods` and filtering by `role != MethodRole::Rpc`. The name field
458    /// on each `MethodSchema` is the child's namespace.
459    #[serde(skip_serializing_if = "Option::is_none")]
460    #[deprecated(
461        since = "0.5",
462        note = "Derive from MethodRole on MethodSchema. Field will be removed in 0.7."
463    )]
464    pub children: Option<Vec<ChildSummary>>,
465
466    /// JSON Schema for the HTTP request type this activation extracts from incoming connections.
467    ///
468    /// Present when the activation declares `request = MyRequest` in `#[plexus::activation(...)]`.
469    /// The schema includes `x-plexus-source` extension fields on each property describing
470    /// where each field is sourced from (cookie, header, query param, peer address, etc.).
471    ///
472    /// Clients can use this to understand what request data the activation expects and
473    /// to generate appropriate authentication/context documentation.
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub request: Option<serde_json::Value>,
476
477    /// If set, this whole activation is deprecated.
478    ///
479    /// Added in IR-5. Defaults to `None` via `#[serde(default)]` so pre-IR
480    /// schemas deserialize cleanly.
481    ///
482    /// Populated by the `#[deprecated(...)]` attribute on the `impl
483    /// Activation for Foo` block (IR-5).
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub deprecation: Option<DeprecationInfo>,
486}
487
488/// Result of a schema query - either full plugin or single method
489#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
490#[serde(untagged)]
491pub enum SchemaResult {
492    /// Full plugin schema (when no method specified)
493    Plugin(PluginSchema),
494    /// Single method schema (when method specified)
495    Method(MethodSchema),
496}
497
498/// Schema for a single method exposed by a plugin
499#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
500pub struct MethodSchema {
501    /// Method name (e.g., "echo", "check")
502    pub name: String,
503
504    /// Human-readable description of what this method does
505    pub description: String,
506
507    /// Content hash of the method definition (for cache invalidation)
508    /// Generated by hashing the method signature within hub-macro
509    pub hash: String,
510
511    /// JSON Schema for the method's parameters (None if no params)
512    #[serde(skip_serializing_if = "Option::is_none")]
513    pub params: Option<schemars::Schema>,
514
515    /// JSON Schema for the method's return type (None if not specified)
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub returns: Option<schemars::Schema>,
518
519    /// Whether this method streams multiple events (true) or returns a single result (false)
520    ///
521    /// - `streaming: true` → returns `AsyncGenerator<T>` (multiple events)
522    /// - `streaming: false` → returns `Promise<T>` (single event, collected)
523    ///
524    /// All methods use the same streaming protocol under the hood, but this flag
525    /// tells clients how to present the result.
526    #[serde(default)]
527    pub streaming: bool,
528
529    /// Whether this method supports bidirectional communication
530    ///
531    /// When true, the server can send requests to the client during method execution
532    /// and wait for responses (e.g., confirmations, prompts, selections).
533    #[serde(default)]
534    pub bidirectional: bool,
535
536    /// HTTP method for REST endpoints (GET, POST, PUT, DELETE, PATCH)
537    ///
538    /// This field is used by the HTTP gateway to determine which HTTP method
539    /// to use when exposing this method as a REST endpoint. Defaults to POST
540    /// for backward compatibility.
541    ///
542    /// - GET: Idempotent read operations (no side effects)
543    /// - POST: Create operations or non-idempotent actions (default)
544    /// - PUT: Replace/update operations (idempotent)
545    /// - DELETE: Remove operations (idempotent)
546    /// - PATCH: Partial update operations
547    #[serde(default)]
548    pub http_method: HttpMethod,
549
550    /// JSON Schema for the request type sent from server to client
551    ///
552    /// Only relevant when `bidirectional: true`. Describes the structure of
553    /// requests the server may send during method execution.
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub request_type: Option<schemars::Schema>,
556
557    /// JSON Schema for the response type sent from client to server
558    ///
559    /// Only relevant when `bidirectional: true`. Describes the structure of
560    /// responses the client should send in reply to server requests.
561    #[serde(skip_serializing_if = "Option::is_none")]
562    pub response_type: Option<schemars::Schema>,
563
564    /// How this method participates in the activation graph.
565    ///
566    /// Added in IR-2. Defaults to `MethodRole::Rpc` via `#[serde(default)]`
567    /// so pre-IR schemas deserialize cleanly.
568    ///
569    /// Populated by the `#[plexus::method]` / `#[child]` macros (IR-3).
570    #[serde(default)]
571    pub role: MethodRole,
572
573    /// If set, this method is deprecated.
574    ///
575    /// Added in IR-2. Defaults to `None` via `#[serde(default)]` so pre-IR
576    /// schemas deserialize cleanly.
577    ///
578    /// Populated by the `#[deprecated(...)]` attribute on the underlying
579    /// method (IR-5).
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub deprecation: Option<DeprecationInfo>,
582
583    /// Structural shape of the method's return type (e.g., `Option`, `Vec`,
584    /// `Stream`).
585    ///
586    /// Orthogonal to `returns`, which holds the fine-grained JSON Schema of
587    /// the inner type. Added in IR-2 as an optional, additive field. `None`
588    /// means "not populated" (the wire format supports pre-IR schemas that
589    /// omit this field entirely).
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    pub return_shape: Option<ReturnShape>,
592
593    /// Per-parameter metadata (currently just deprecation).
594    ///
595    /// Added in IR-5. Defaults to an empty vec via `#[serde(default)]` so
596    /// pre-IR schemas deserialize cleanly. Only parameters that carry
597    /// metadata appear in this list — absence means "no metadata" for that
598    /// parameter, not a bug.
599    ///
600    /// Populated by the `#[deprecated(...)]` attribute on individual
601    /// parameters (IR-5).
602    #[serde(default, skip_serializing_if = "Vec::is_empty")]
603    pub params_meta: Vec<ParamSchema>,
604
605    /// Credential-bearing fields in this method's return type.
606    ///
607    /// One entry per `#[plexus::credential(...)]`-annotated field on the
608    /// return-type struct/enum, in stable declaration order. Empty when
609    /// the return type contains no credentials.
610    ///
611    /// Added in `AUTHZ-CRED-CORE-3`. Defaults to an empty vec via
612    /// `#[serde(default)]` so pre-IR schemas deserialize cleanly. The
613    /// `skip_serializing_if = "Vec::is_empty"` clause keeps the wire JSON
614    /// shape unchanged for methods with no credential-bearing fields
615    /// (back-compat per the ticket's "Wire-format back-compat" table).
616    ///
617    /// Populated at schema-build time from the `CredentialFieldMarker`
618    /// registry emitted by `#[derive(Credentials)]` (`AUTHZ-CRED-MACRO-1`).
619    #[serde(default, skip_serializing_if = "Vec::is_empty")]
620    pub credentials: Vec<CredentialFieldDecl>,
621
622    /// What credential this method requires on input (if any).
623    ///
624    /// Derived implicitly from the method's scope tagging and the
625    /// credential-graph linkage at schema-build time (per
626    /// `AUTHZ-CRED-CORE-3` §"Implicit derivation"):
627    ///
628    /// - `scope = "..."` → `RequiredCredential::from_method_scope(scope)`.
629    /// - `public` method → `None` (the absence of this field).
630    /// - target of `refresh_via`/`revoke_via` of another credential →
631    ///   `RequiredCredential::from_refresh_revoke_target(kind, scopes)`.
632    ///
633    /// Added in `AUTHZ-CRED-CORE-3`. Defaults to `None` via
634    /// `#[serde(default)]` so pre-IR schemas deserialize cleanly. The
635    /// `skip_serializing_if = "Option::is_none"` clause keeps the wire JSON
636    /// shape unchanged for public methods and methods with no scope-derived
637    /// requirement.
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub requires_credential: Option<RequiredCredential>,
640}
641
642impl PluginSchema {
643    /// Compute all three hashes (self, children, composite)
644    fn compute_hashes(
645        methods: &[MethodSchema],
646        children: Option<&[ChildSummary]>,
647    ) -> (String, Option<String>, String) {
648        use std::collections::hash_map::DefaultHasher;
649        use std::hash::{Hash, Hasher};
650
651        // Compute self_hash (methods only)
652        let mut self_hasher = DefaultHasher::new();
653        for m in methods {
654            m.hash.hash(&mut self_hasher);
655        }
656        let self_hash = format!("{:016x}", self_hasher.finish());
657
658        // Compute children_hash (children only)
659        let children_hash = children.map(|kids| {
660            let mut children_hasher = DefaultHasher::new();
661            for c in kids {
662                c.hash.hash(&mut children_hasher);
663            }
664            format!("{:016x}", children_hasher.finish())
665        });
666
667        // Compute composite hash (both)
668        let mut composite_hasher = DefaultHasher::new();
669        self_hash.hash(&mut composite_hasher);
670        if let Some(ref ch) = children_hash {
671            ch.hash(&mut composite_hasher);
672        }
673        let hash = format!("{:016x}", composite_hasher.finish());
674
675        (self_hash, children_hash, hash)
676    }
677
678    /// Inspect each method's `returns` JSON Schema for the framework-
679    /// reserved `_credentials` top-level field name; emit a tracing
680    /// warning when a collision is detected (AUTHZ-CRED-CORE-2
681    /// acceptance criterion #8). Returns the number of collisions found,
682    /// so callers can chain into a metrics counter or assertion if they
683    /// want one.
684    ///
685    /// The framework reserves the `_credentials` top-level field name
686    /// for the credential sidecar (see
687    /// `crate::plexus::credential_envelope`). Backends that define a
688    /// domain field of the same name have it shadowed at dispatch time;
689    /// the warning surfaces that fact at build time so backends can
690    /// rename.
691    fn warn_on_credentials_field_collisions(
692        namespace: &str,
693        methods: &[MethodSchema],
694    ) -> usize {
695        let mut count = 0;
696        for m in methods {
697            let Some(returns_schema) = &m.returns else { continue };
698            let Ok(returns_json) = serde_json::to_value(returns_schema) else { continue };
699            if super::credential_envelope::check_returns_schema_for_credentials_collision(
700                namespace,
701                &m.name,
702                &returns_json,
703            ) {
704                count += 1;
705            }
706        }
707        count
708    }
709
710    /// Validate no name collisions exist within a plugin
711    ///
712    /// Checks for:
713    /// - Duplicate method names
714    /// - Duplicate child names (for hubs)
715    /// - Method/child name collisions for `Rpc`-role methods (for hubs)
716    ///
717    /// Panics if a collision is detected (system error).
718    ///
719    /// # IR-4 relaxation
720    ///
721    /// As of IR-4, a method with `MethodRole::StaticChild` or
722    /// `MethodRole::DynamicChild { .. }` that shares a name with a
723    /// `ChildSummary` entry is **not** a collision — it's the same child
724    /// surfaced via two wire representations (the role-tagged method list
725    /// and the deprecated `children` side-table). Only `Rpc`-role methods
726    /// whose name matches a child summary are flagged.
727    fn validate_no_collisions(
728        namespace: &str,
729        methods: &[MethodSchema],
730        children: Option<&[ChildSummary]>,
731    ) {
732        use std::collections::HashSet;
733
734        let mut seen: HashSet<&str> = HashSet::new();
735
736        // Check method names
737        for m in methods {
738            if !seen.insert(&m.name) {
739                panic!(
740                    "Name collision in plugin '{}': duplicate method '{}'",
741                    namespace, m.name
742                );
743            }
744        }
745
746        // Check child names (and collisions with methods)
747        if let Some(kids) = children {
748            for c in kids {
749                if !seen.insert(&c.namespace) {
750                    // IR-4: a role-tagged child method whose name matches a
751                    // child summary is expected by construction (the two
752                    // wire-surfaces describe the same child). Skip silently.
753                    let colliding_method =
754                        methods.iter().find(|m| m.name == c.namespace);
755                    if let Some(m) = colliding_method {
756                        if matches!(
757                            m.role,
758                            MethodRole::StaticChild | MethodRole::DynamicChild { .. }
759                        ) {
760                            continue;
761                        }
762                    }
763                    // Could be duplicate child or collision with an Rpc-role method
764                    let collision_type = if colliding_method.is_some() {
765                        "method/child collision"
766                    } else {
767                        "duplicate child"
768                    };
769                    panic!(
770                        "Name collision in plugin '{}': {} for '{}'",
771                        namespace, collision_type, c.namespace
772                    );
773                }
774            }
775        }
776    }
777
778    /// Derive the deprecated `(children, is_hub)` side-table fields from a
779    /// role-tagged method list.
780    ///
781    /// Added in IR-4 as the **centralized shim** that backfills the
782    /// pre-IR `children: Option<Vec<ChildSummary>>` and `is_hub: bool`
783    /// representations from the authoritative `MethodRole` on each
784    /// `MethodSchema`.
785    ///
786    /// # Semantics
787    ///
788    /// One `ChildSummary` is produced per non-`Rpc` method, preserving the
789    /// source order. The shim writes:
790    ///
791    /// | Field | Value |
792    /// |---|---|
793    /// | `namespace` | The method's name. |
794    /// | `description` | The method's `description`. |
795    /// | `hash` | Empty string — the shim does **not** compute child hashes. Callers that want per-child hashes must populate them out-of-band. |
796    ///
797    /// The returned `bool` matches [`PluginSchema::is_hub_by_role`] — `true`
798    /// iff at least one method carries a child role.
799    ///
800    /// # Example
801    ///
802    /// ```
803    /// use plexus_core::plexus::schema::{MethodRole, MethodSchema, PluginSchema};
804    ///
805    /// let methods = vec![
806    ///     MethodSchema::new("ping", "rpc", "h1"),
807    ///     MethodSchema::new("kid",  "static child", "h2")
808    ///         .with_role(MethodRole::StaticChild),
809    /// ];
810    /// let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
811    /// assert_eq!(children.len(), 1);
812    /// assert_eq!(children[0].namespace, "kid");
813    /// assert!(is_hub);
814    /// ```
815    pub fn derive_legacy_fields(
816        methods: &[MethodSchema],
817    ) -> (Vec<ChildSummary>, bool) {
818        let children: Vec<ChildSummary> = methods
819            .iter()
820            .filter(|m| {
821                matches!(
822                    m.role,
823                    MethodRole::StaticChild | MethodRole::DynamicChild { .. }
824                )
825            })
826            .map(|m| ChildSummary {
827                namespace: m.name.clone(),
828                description: m.description.clone(),
829                hash: String::new(),
830            })
831            .collect();
832        let is_hub = !children.is_empty();
833        (children, is_hub)
834    }
835
836    /// Create a new leaf plugin schema (no children)
837    #[allow(deprecated)]
838    pub fn leaf(
839        namespace: impl Into<String>,
840        version: impl Into<String>,
841        description: impl Into<String>,
842        methods: Vec<MethodSchema>,
843    ) -> Self {
844        let namespace = namespace.into();
845        Self::validate_no_collisions(&namespace, &methods, None);
846        // AUTHZ-CRED-CORE-2 acceptance criterion #8: warn at schema-build
847        // time when a method's return-type schema declares a top-level
848        // field named `_credentials` (reserved by the framework's
849        // credential sidecar).
850        let _ = Self::warn_on_credentials_field_collisions(&namespace, &methods);
851        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, None);
852        Self {
853            namespace,
854            version: version.into(),
855            description: description.into(),
856            long_description: None,
857            self_hash,
858            children_hash,
859            hash,
860            methods,
861            children: None,
862            request: None,
863            deprecation: None,
864        }
865    }
866
867    /// Create a new leaf plugin schema with long description
868    #[allow(deprecated)]
869    pub fn leaf_with_long_description(
870        namespace: impl Into<String>,
871        version: impl Into<String>,
872        description: impl Into<String>,
873        long_description: impl Into<String>,
874        methods: Vec<MethodSchema>,
875    ) -> Self {
876        let namespace = namespace.into();
877        Self::validate_no_collisions(&namespace, &methods, None);
878        // AUTHZ-CRED-CORE-2 AC #8: see PluginSchema::leaf.
879        let _ = Self::warn_on_credentials_field_collisions(&namespace, &methods);
880        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, None);
881        Self {
882            namespace,
883            version: version.into(),
884            description: description.into(),
885            long_description: Some(long_description.into()),
886            self_hash,
887            children_hash,
888            hash,
889            methods,
890            children: None,
891            request: None,
892            deprecation: None,
893        }
894    }
895
896    /// Create a new hub plugin schema (with child summaries)
897    #[allow(deprecated)]
898    pub fn hub(
899        namespace: impl Into<String>,
900        version: impl Into<String>,
901        description: impl Into<String>,
902        methods: Vec<MethodSchema>,
903        children: Vec<ChildSummary>,
904    ) -> Self {
905        let namespace = namespace.into();
906        Self::validate_no_collisions(&namespace, &methods, Some(&children));
907        // AUTHZ-CRED-CORE-2 AC #8: see PluginSchema::leaf.
908        let _ = Self::warn_on_credentials_field_collisions(&namespace, &methods);
909        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, Some(&children));
910        Self {
911            namespace,
912            version: version.into(),
913            description: description.into(),
914            long_description: None,
915            self_hash,
916            children_hash,
917            hash,
918            methods,
919            children: Some(children),
920            request: None,
921            deprecation: None,
922        }
923    }
924
925    /// Create a new hub plugin schema with long description
926    #[allow(deprecated)]
927    pub fn hub_with_long_description(
928        namespace: impl Into<String>,
929        version: impl Into<String>,
930        description: impl Into<String>,
931        long_description: impl Into<String>,
932        methods: Vec<MethodSchema>,
933        children: Vec<ChildSummary>,
934    ) -> Self {
935        let namespace = namespace.into();
936        Self::validate_no_collisions(&namespace, &methods, Some(&children));
937        // AUTHZ-CRED-CORE-2 AC #8: see PluginSchema::leaf.
938        let _ = Self::warn_on_credentials_field_collisions(&namespace, &methods);
939        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, Some(&children));
940        Self {
941            namespace,
942            version: version.into(),
943            description: description.into(),
944            long_description: Some(long_description.into()),
945            self_hash,
946            children_hash,
947            hash,
948            methods,
949            children: Some(children),
950            request: None,
951            deprecation: None,
952        }
953    }
954
955    /// Check if this is a hub.
956    ///
957    /// Returns `true` iff the plugin exposes child activations. As of IR-2,
958    /// this is derived from **either** source of truth:
959    ///
960    /// 1. Any method tagged with a child `MethodRole` (`StaticChild` or
961    ///    `DynamicChild { .. }`). This is the post-IR-3 authoritative signal.
962    /// 2. The legacy `children: Option<Vec<ChildSummary>>` field is `Some`.
963    ///    Preserved for back-compat during the IR transition window —
964    ///    today's macros populate `children` but not yet `role`.
965    ///
966    /// # Deprecated (IR-4)
967    ///
968    /// The legacy transition-window fallback on `children.is_some()` is
969    /// redundant now that `MethodRole` tags are authoritative. Callers
970    /// should migrate to [`PluginSchema::is_hub_by_role`], which reads
971    /// only role-tagged methods. This method will be removed in 0.7.
972    #[deprecated(
973        since = "0.5",
974        note = "Use `PluginSchema::is_hub_by_role()` which reads MethodRole from methods. This method will be removed in 0.7."
975    )]
976    #[allow(deprecated)]
977    pub fn is_hub(&self) -> bool {
978        self.is_hub_by_role() || self.children.is_some()
979    }
980
981    /// Returns `true` iff any method carries a child `MethodRole`.
982    ///
983    /// This is the **derived query** specified by IR-2: it reads only
984    /// `self.methods`, ignoring the legacy `children` side channel. Use this
985    /// when you want the post-IR-3 authoritative answer without the transition
986    /// fallback that `is_hub()` provides.
987    pub fn is_hub_by_role(&self) -> bool {
988        self.methods.iter().any(|m| {
989            matches!(
990                m.role,
991                MethodRole::StaticChild | MethodRole::DynamicChild { .. }
992            )
993        })
994    }
995
996    /// Check if this is a leaf (no children)
997    #[allow(deprecated)]
998    pub fn is_leaf(&self) -> bool {
999        self.children.is_none()
1000    }
1001
1002    /// Mark this plugin as deprecated.
1003    ///
1004    /// Added in IR-5. Populates the `deprecation` field with the provided
1005    /// `DeprecationInfo`. Populated by the `#[deprecated(...)]` attribute on
1006    /// an `impl Activation for Foo` block via `plexus-macros`.
1007    pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
1008        self.deprecation = Some(info);
1009        self
1010    }
1011}
1012
1013/// Summary of a child plugin
1014#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1015pub struct ChildSummary {
1016    /// The child's namespace
1017    pub namespace: String,
1018
1019    /// Human-readable description
1020    pub description: String,
1021
1022    /// Content hash for cache invalidation
1023    pub hash: String,
1024}
1025
1026/// Schema summary containing only hashes (for cache validation)
1027#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1028pub struct PluginHashes {
1029    pub namespace: String,
1030    pub self_hash: String,
1031    #[serde(skip_serializing_if = "Option::is_none")]
1032    pub children_hash: Option<String>,
1033    pub hash: String,
1034    /// Child plugin hashes (for recursive checking)
1035    #[serde(skip_serializing_if = "Option::is_none")]
1036    pub children: Option<Vec<ChildHashes>>,
1037}
1038
1039#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1040pub struct ChildHashes {
1041    pub namespace: String,
1042    pub hash: String,
1043}
1044
1045impl MethodSchema {
1046    /// Create a new method schema with name, description, and hash
1047    ///
1048    /// The hash should be computed from the method definition string
1049    /// within the hub-macro at compile time.
1050    pub fn new(
1051        name: impl Into<String>,
1052        description: impl Into<String>,
1053        hash: impl Into<String>,
1054    ) -> Self {
1055        Self {
1056            name: name.into(),
1057            description: description.into(),
1058            hash: hash.into(),
1059            params: None,
1060            returns: None,
1061            streaming: false,
1062            bidirectional: false,
1063            http_method: HttpMethod::default(),
1064            request_type: None,
1065            response_type: None,
1066            role: MethodRole::Rpc,
1067            deprecation: None,
1068            return_shape: None,
1069            params_meta: Vec::new(),
1070            credentials: Vec::new(),
1071            requires_credential: None,
1072        }
1073    }
1074
1075    /// Add parameter schema
1076    pub fn with_params(mut self, params: schemars::Schema) -> Self {
1077        self.params = Some(params);
1078        self
1079    }
1080
1081    /// Add return type schema
1082    pub fn with_returns(mut self, returns: schemars::Schema) -> Self {
1083        self.returns = Some(returns);
1084        self
1085    }
1086
1087    /// Set the streaming flag
1088    ///
1089    /// - `true` → method streams multiple events (use `AsyncGenerator<T>`)
1090    /// - `false` → method returns single result (use `Promise<T>`)
1091    pub fn with_streaming(mut self, streaming: bool) -> Self {
1092        self.streaming = streaming;
1093        self
1094    }
1095
1096    /// Set the HTTP method for REST endpoints
1097    ///
1098    /// Defaults to POST for backward compatibility.
1099    ///
1100    /// # Guidelines
1101    /// - GET: Idempotent read operations with no side effects
1102    /// - POST: Create operations or non-idempotent actions
1103    /// - PUT: Replace/update operations (idempotent)
1104    /// - DELETE: Remove operations (idempotent)
1105    /// - PATCH: Partial update operations
1106    pub fn with_http_method(mut self, http_method: HttpMethod) -> Self {
1107        self.http_method = http_method;
1108        self
1109    }
1110
1111    /// Set whether this method supports bidirectional communication
1112    ///
1113    /// When true, the server can send requests to the client during method
1114    /// execution and wait for responses.
1115    pub fn with_bidirectional(mut self, bidirectional: bool) -> Self {
1116        self.bidirectional = bidirectional;
1117        self
1118    }
1119
1120    /// Set the JSON Schema for server-to-client request types
1121    ///
1122    /// Only relevant when `bidirectional: true`. Use `schema_for!(YourRequestType)`
1123    /// to generate the schema.
1124    pub fn with_request_type(mut self, schema: schemars::Schema) -> Self {
1125        self.request_type = Some(schema);
1126        self
1127    }
1128
1129    /// Set the JSON Schema for client-to-server response types
1130    ///
1131    /// Only relevant when `bidirectional: true`. Use `schema_for!(YourResponseType)`
1132    /// to generate the schema.
1133    pub fn with_response_type(mut self, schema: schemars::Schema) -> Self {
1134        self.response_type = Some(schema);
1135        self
1136    }
1137
1138    /// Configure method for standard bidirectional communication
1139    ///
1140    /// Sets `bidirectional: true` and configures request/response types to use
1141    /// `StandardRequest` and `StandardResponse`, which support common UI patterns
1142    /// like confirmations, prompts, and selections.
1143    pub fn with_standard_bidirectional(self) -> Self {
1144        self.with_bidirectional(true)
1145            .with_request_type(schema_for!(StandardRequest).into())
1146            .with_response_type(schema_for!(StandardResponse).into())
1147    }
1148
1149    /// Set this method's role in the activation graph.
1150    ///
1151    /// Added in IR-2. Defaults to `MethodRole::Rpc`.
1152    pub fn with_role(mut self, role: MethodRole) -> Self {
1153        self.role = role;
1154        self
1155    }
1156
1157    /// Mark this method as deprecated.
1158    ///
1159    /// Added in IR-2. Populates the `deprecation` field with the provided
1160    /// `DeprecationInfo`.
1161    pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
1162        self.deprecation = Some(info);
1163        self
1164    }
1165
1166    /// Set the structural shape of this method's return type.
1167    ///
1168    /// Added in IR-2. Orthogonal to `with_returns`, which sets the fine-grained
1169    /// JSON Schema.
1170    pub fn with_return_shape(mut self, shape: ReturnShape) -> Self {
1171        self.return_shape = Some(shape);
1172        self
1173    }
1174
1175    /// Attach per-parameter metadata for this method's parameters.
1176    ///
1177    /// Added in IR-5. Only parameters that carry metadata (e.g. a
1178    /// `#[deprecated]` annotation) need appear in `entries`; absence means
1179    /// "no metadata" for a given parameter. The consumer correlates entries
1180    /// against `self.params` by matching `ParamSchema.name` against the
1181    /// `properties` map of the JSON Schema.
1182    pub fn with_params_meta(mut self, entries: Vec<ParamSchema>) -> Self {
1183        self.params_meta = entries;
1184        self
1185    }
1186
1187    /// Attach the credential-field projection for this method's return type.
1188    ///
1189    /// Added in `AUTHZ-CRED-CORE-3`. The build-path supplies one
1190    /// `CredentialFieldDecl` per `#[plexus::credential(...)]`-annotated
1191    /// field in the return type, in stable declaration order. Absence (the
1192    /// default empty vec) means the return type carries no credentials.
1193    ///
1194    /// The constructor `CredentialFieldDecl::from_marker` composes one
1195    /// entry from a `CredentialFieldMarker` (emitted by the
1196    /// `#[derive(Credentials)]` macro) plus the runtime-known issuer.
1197    pub fn with_credentials(mut self, credentials: Vec<CredentialFieldDecl>) -> Self {
1198        self.credentials = credentials;
1199        self
1200    }
1201
1202    /// Attach the implicit-derived `requires_credential` filter for this
1203    /// method.
1204    ///
1205    /// Added in `AUTHZ-CRED-CORE-3`. The build-path supplies a
1206    /// `RequiredCredential` derived from either (a) the method's `scope`
1207    /// attribute or (b) the method's appearance as a `refresh_via` /
1208    /// `revoke_via` target of some other credential in the schema. Public
1209    /// methods leave this field as `None` (the default).
1210    ///
1211    /// `RequiredCredential::from_method_scope` and
1212    /// `RequiredCredential::from_refresh_revoke_target` are the two derivation
1213    /// entry points.
1214    pub fn with_requires_credential(mut self, req: RequiredCredential) -> Self {
1215        self.requires_credential = Some(req);
1216        self
1217    }
1218}
1219
1220// ============================================================================
1221// JSON Schema Types
1222// ============================================================================
1223
1224/// A complete JSON Schema with metadata
1225#[derive(Debug, Clone, Serialize, Deserialize)]
1226pub struct Schema {
1227    /// The JSON Schema specification version
1228    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none", default)]
1229    pub schema_version: Option<String>,
1230
1231    /// Title of the schema
1232    #[serde(skip_serializing_if = "Option::is_none")]
1233    pub title: Option<String>,
1234
1235    /// Description of what this schema represents
1236    #[serde(skip_serializing_if = "Option::is_none")]
1237    pub description: Option<String>,
1238
1239    /// The schema type (typically "object" for root, can be string or array)
1240    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
1241    pub schema_type: Option<serde_json::Value>,
1242
1243    /// Properties for object types
1244    #[serde(skip_serializing_if = "Option::is_none")]
1245    pub properties: Option<HashMap<String, SchemaProperty>>,
1246
1247    /// Required properties
1248    #[serde(skip_serializing_if = "Option::is_none")]
1249    pub required: Option<Vec<String>>,
1250
1251    /// Enum variants (for discriminated unions)
1252    #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
1253    pub one_of: Option<Vec<Schema>>,
1254
1255    /// Schema definitions (for $defs or definitions)
1256    #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
1257    pub defs: Option<HashMap<String, serde_json::Value>>,
1258
1259    /// Any additional schema properties
1260    #[serde(flatten)]
1261    pub additional: HashMap<String, serde_json::Value>,
1262}
1263
1264/// Schema type enumeration
1265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1266#[serde(rename_all = "lowercase")]
1267pub enum SchemaType {
1268    Object,
1269    Array,
1270    String,
1271    Number,
1272    Integer,
1273    Boolean,
1274    Null,
1275}
1276
1277/// A property definition in a schema
1278#[derive(Debug, Clone, Serialize, Deserialize)]
1279pub struct SchemaProperty {
1280    /// The type of this property (can be a single type or array of types for nullable)
1281    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
1282    pub property_type: Option<serde_json::Value>,
1283
1284    /// Description of this property
1285    #[serde(skip_serializing_if = "Option::is_none")]
1286    pub description: Option<String>,
1287
1288    /// Format hint (e.g., "uuid", "date-time", "email")
1289    #[serde(skip_serializing_if = "Option::is_none")]
1290    pub format: Option<String>,
1291
1292    /// For array types, the schema of items
1293    #[serde(skip_serializing_if = "Option::is_none")]
1294    pub items: Option<Box<SchemaProperty>>,
1295
1296    /// For object types, nested properties
1297    #[serde(skip_serializing_if = "Option::is_none")]
1298    pub properties: Option<HashMap<String, SchemaProperty>>,
1299
1300    /// Required properties (for object types)
1301    #[serde(skip_serializing_if = "Option::is_none")]
1302    pub required: Option<Vec<String>>,
1303
1304    /// Default value for this property
1305    #[serde(skip_serializing_if = "Option::is_none")]
1306    pub default: Option<serde_json::Value>,
1307
1308    /// Enum values if this is an enum
1309    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
1310    pub enum_values: Option<Vec<serde_json::Value>>,
1311
1312    /// Reference to another schema definition
1313    #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
1314    pub reference: Option<String>,
1315
1316    /// Any additional property metadata
1317    #[serde(flatten)]
1318    pub additional: HashMap<String, serde_json::Value>,
1319}
1320
1321impl Schema {
1322    /// Create a new schema with basic metadata
1323    pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
1324        Self {
1325            schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
1326            title: Some(title.into()),
1327            description: Some(description.into()),
1328            schema_type: None,
1329            properties: None,
1330            required: None,
1331            one_of: None,
1332            defs: None,
1333            additional: HashMap::new(),
1334        }
1335    }
1336
1337    /// Create an object schema
1338    pub fn object() -> Self {
1339        Self {
1340            schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
1341            title: None,
1342            description: None,
1343            schema_type: Some(serde_json::json!("object")),
1344            properties: Some(HashMap::new()),
1345            required: None,
1346            one_of: None,
1347            defs: None,
1348            additional: HashMap::new(),
1349        }
1350    }
1351
1352    /// Add a property to this schema
1353    pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
1354        self.properties
1355            .get_or_insert_with(HashMap::new)
1356            .insert(name.into(), property);
1357        self
1358    }
1359
1360    /// Mark a property as required
1361    pub fn with_required(mut self, name: impl Into<String>) -> Self {
1362        self.required
1363            .get_or_insert_with(Vec::new)
1364            .push(name.into());
1365        self
1366    }
1367
1368    /// Set the description
1369    pub fn with_description(mut self, description: impl Into<String>) -> Self {
1370        self.description = Some(description.into());
1371        self
1372    }
1373
1374    /// Extract a single method's schema from the oneOf array
1375    ///
1376    /// Searches the oneOf variants for a method matching the given name.
1377    /// Returns the variant schema if found, None otherwise.
1378    pub fn get_method_schema(&self, method_name: &str) -> Option<Schema> {
1379        let variants = self.one_of.as_ref()?;
1380
1381        for variant in variants {
1382            // Check if this variant has a "method" property with const or enum
1383            if let Some(props) = &variant.properties {
1384                if let Some(method_prop) = props.get("method") {
1385                    // Try "const" first (schemars uses this for literal values)
1386                    if let Some(const_val) = method_prop.additional.get("const") {
1387                        if const_val.as_str() == Some(method_name) {
1388                            return Some(variant.clone());
1389                        }
1390                    }
1391                    // Fall back to enum_values
1392                    if let Some(enum_vals) = &method_prop.enum_values {
1393                        if enum_vals.first().and_then(|v| v.as_str()) == Some(method_name) {
1394                            return Some(variant.clone());
1395                        }
1396                    }
1397                }
1398            }
1399        }
1400        None
1401    }
1402
1403    /// List all method names from the oneOf array
1404    pub fn list_methods(&self) -> Vec<String> {
1405        let Some(variants) = &self.one_of else {
1406            return Vec::new();
1407        };
1408
1409        variants
1410            .iter()
1411            .filter_map(|variant| {
1412                let props = variant.properties.as_ref()?;
1413                let method_prop = props.get("method")?;
1414
1415                // Try "const" first
1416                if let Some(const_val) = method_prop.additional.get("const") {
1417                    return const_val.as_str().map(String::from);
1418                }
1419                // Fall back to enum_values
1420                method_prop
1421                    .enum_values
1422                    .as_ref()?
1423                    .first()?
1424                    .as_str()
1425                    .map(String::from)
1426            })
1427            .collect()
1428    }
1429}
1430
1431impl SchemaProperty {
1432    /// Create a string property
1433    pub fn string() -> Self {
1434        Self {
1435            property_type: Some(serde_json::json!("string")),
1436            description: None,
1437            format: None,
1438            items: None,
1439            properties: None,
1440            required: None,
1441            default: None,
1442            enum_values: None,
1443            reference: None,
1444            additional: HashMap::new(),
1445        }
1446    }
1447
1448    /// Create a UUID property (string with format)
1449    pub fn uuid() -> Self {
1450        Self {
1451            property_type: Some(serde_json::json!("string")),
1452            description: None,
1453            format: Some("uuid".to_string()),
1454            items: None,
1455            properties: None,
1456            required: None,
1457            default: None,
1458            enum_values: None,
1459            reference: None,
1460            additional: HashMap::new(),
1461        }
1462    }
1463
1464    /// Create an integer property
1465    pub fn integer() -> Self {
1466        Self {
1467            property_type: Some(serde_json::json!("integer")),
1468            description: None,
1469            format: None,
1470            items: None,
1471            properties: None,
1472            required: None,
1473            default: None,
1474            enum_values: None,
1475            reference: None,
1476            additional: HashMap::new(),
1477        }
1478    }
1479
1480    /// Create an object property
1481    pub fn object() -> Self {
1482        Self {
1483            property_type: Some(serde_json::json!("object")),
1484            description: None,
1485            format: None,
1486            items: None,
1487            properties: Some(HashMap::new()),
1488            required: None,
1489            default: None,
1490            enum_values: None,
1491            reference: None,
1492            additional: HashMap::new(),
1493        }
1494    }
1495
1496    /// Create an array property
1497    pub fn array(items: SchemaProperty) -> Self {
1498        Self {
1499            property_type: Some(serde_json::json!("array")),
1500            description: None,
1501            format: None,
1502            items: Some(Box::new(items)),
1503            properties: None,
1504            required: None,
1505            default: None,
1506            enum_values: None,
1507            reference: None,
1508            additional: HashMap::new(),
1509        }
1510    }
1511
1512    /// Add a description
1513    pub fn with_description(mut self, description: impl Into<String>) -> Self {
1514        self.description = Some(description.into());
1515        self
1516    }
1517
1518    /// Add a default value
1519    pub fn with_default(mut self, default: serde_json::Value) -> Self {
1520        self.default = Some(default);
1521        self
1522    }
1523
1524    /// Add nested properties (for object types)
1525    pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
1526        self.properties
1527            .get_or_insert_with(HashMap::new)
1528            .insert(name.into(), property);
1529        self
1530    }
1531}
1532
1533#[cfg(test)]
1534#[allow(deprecated)]
1535mod tests {
1536    use super::*;
1537
1538    #[test]
1539    fn test_schema_creation() {
1540        let schema = Schema::object()
1541            .with_property("id", SchemaProperty::uuid().with_description("The unique identifier"))
1542            .with_property("name", SchemaProperty::string().with_description("The name"))
1543            .with_required("id");
1544
1545        assert_eq!(schema.schema_type, Some(serde_json::json!("object")));
1546        assert!(schema.properties.is_some());
1547        assert_eq!(schema.required, Some(vec!["id".to_string()]));
1548    }
1549
1550    #[test]
1551    fn test_serialization() {
1552        let schema = Schema::object()
1553            .with_property("id", SchemaProperty::uuid());
1554
1555        let json = serde_json::to_string_pretty(&schema).unwrap();
1556        assert!(json.contains("uuid"));
1557    }
1558
1559    #[test]
1560    fn test_self_hash_changes_on_method_change() {
1561        let schema1 = PluginSchema::leaf(
1562            "test",
1563            "1.0",
1564            "desc",
1565            vec![MethodSchema::new("foo", "bar", "hash1")],
1566        );
1567
1568        let schema2 = PluginSchema::leaf(
1569            "test",
1570            "1.0",
1571            "desc",
1572            vec![MethodSchema::new("foo", "baz", "hash2")],  // Changed description
1573        );
1574
1575        assert_ne!(schema1.self_hash, schema2.self_hash, "self_hash should change when methods change");
1576        assert_eq!(schema1.children_hash, schema2.children_hash, "children_hash should stay same (both None)");
1577        assert_ne!(schema1.hash, schema2.hash, "composite hash should change");
1578    }
1579
1580    #[test]
1581    fn test_children_hash_changes_on_child_change() {
1582        let child1 = ChildSummary {
1583            namespace: "child".into(),
1584            description: "desc".into(),
1585            hash: "old_hash".into(),
1586        };
1587
1588        let child2 = ChildSummary {
1589            namespace: "child".into(),
1590            description: "desc".into(),
1591            hash: "new_hash".into(),
1592        };
1593
1594        let schema1 = PluginSchema::hub(
1595            "parent",
1596            "1.0",
1597            "desc",
1598            vec![],
1599            vec![child1],
1600        );
1601
1602        let schema2 = PluginSchema::hub(
1603            "parent",
1604            "1.0",
1605            "desc",
1606            vec![],
1607            vec![child2],
1608        );
1609
1610        assert_eq!(schema1.self_hash, schema2.self_hash, "self_hash should stay same (no methods changed)");
1611        assert_ne!(schema1.children_hash, schema2.children_hash, "children_hash should change when child hash changes");
1612        assert_ne!(schema1.hash, schema2.hash, "composite hash should change");
1613    }
1614
1615    #[test]
1616    fn test_leaf_has_no_children_hash() {
1617        let schema = PluginSchema::leaf(
1618            "leaf",
1619            "1.0",
1620            "desc",
1621            vec![MethodSchema::new("method", "desc", "hash")],
1622        );
1623
1624        assert!(schema.children_hash.is_none(), "leaf plugin should have None for children_hash");
1625        assert_ne!(schema.self_hash, schema.hash, "leaf plugin's composite hash is hash(self_hash), not equal to self_hash");
1626    }
1627
1628    // =========================================================================
1629    // IR-2 tests: MethodRole, DeprecationInfo, is_hub derived query
1630    // =========================================================================
1631
1632    /// AC #5: Deserializing a JSON `MethodSchema` with no `role` or
1633    /// `deprecation` fields yields `MethodRole::Rpc` and `None`.
1634    #[test]
1635    fn ir2_default_role_is_rpc_on_deserialize() {
1636        // Pre-IR MethodSchema shape (no role, no deprecation, no return_shape)
1637        let pre_ir_json = serde_json::json!({
1638            "name": "ping",
1639            "description": "pong",
1640            "hash": "abc"
1641        });
1642
1643        let schema: MethodSchema = serde_json::from_value(pre_ir_json).unwrap();
1644        assert_eq!(schema.role, MethodRole::Rpc);
1645        assert!(schema.deprecation.is_none());
1646        assert!(schema.return_shape.is_none());
1647    }
1648
1649    /// AC #5: And at the PluginSchema level — a full pre-IR schema with
1650    /// multiple methods (none carrying `role`) deserializes cleanly with every
1651    /// method defaulted to `Rpc` and no deprecation.
1652    #[test]
1653    fn ir2_plugin_schema_pre_ir_json_deserializes() {
1654        let pre_ir_json = serde_json::json!({
1655            "namespace": "test",
1656            "version": "1.0",
1657            "description": "legacy schema",
1658            "self_hash": "s1",
1659            "hash": "h1",
1660            "methods": [
1661                { "name": "a", "description": "alpha", "hash": "ah" },
1662                { "name": "b", "description": "beta",  "hash": "bh" }
1663            ]
1664        });
1665
1666        let schema: PluginSchema = serde_json::from_value(pre_ir_json).unwrap();
1667        assert_eq!(schema.methods.len(), 2);
1668        for m in &schema.methods {
1669            assert_eq!(m.role, MethodRole::Rpc);
1670            assert!(m.deprecation.is_none());
1671        }
1672    }
1673
1674    /// AC #6: Serde round-trip covering all `MethodRole` variants —
1675    /// `Rpc`, `StaticChild`, and `DynamicChild { list_method, search_method }`.
1676    #[test]
1677    fn ir2_method_role_roundtrip_all_variants() {
1678        let original = PluginSchema::leaf(
1679            "rt",
1680            "1.0",
1681            "round-trip coverage",
1682            vec![
1683                MethodSchema::new("plain", "rpc", "h1"),
1684                MethodSchema::new("child_a", "static", "h2")
1685                    .with_role(MethodRole::StaticChild),
1686                MethodSchema::new("child_b", "dynamic", "h3").with_role(
1687                    MethodRole::DynamicChild {
1688                        list_method: Some("list_x".into()),
1689                        search_method: Some("search_x".into()),
1690                    },
1691                ),
1692            ],
1693        );
1694
1695        let json = serde_json::to_string(&original).unwrap();
1696        let decoded: PluginSchema = serde_json::from_str(&json).unwrap();
1697
1698        assert_eq!(decoded.methods[0].role, MethodRole::Rpc);
1699        assert_eq!(decoded.methods[1].role, MethodRole::StaticChild);
1700        assert_eq!(
1701            decoded.methods[2].role,
1702            MethodRole::DynamicChild {
1703                list_method: Some("list_x".into()),
1704                search_method: Some("search_x".into()),
1705            }
1706        );
1707
1708        // Also survives when the DynamicChild has no list/search hints.
1709        let bare_dyn = MethodSchema::new("child_c", "dynamic-bare", "h4").with_role(
1710            MethodRole::DynamicChild {
1711                list_method: None,
1712                search_method: None,
1713            },
1714        );
1715        let j2 = serde_json::to_string(&bare_dyn).unwrap();
1716        let d2: MethodSchema = serde_json::from_str(&j2).unwrap();
1717        assert_eq!(
1718            d2.role,
1719            MethodRole::DynamicChild {
1720                list_method: None,
1721                search_method: None,
1722            }
1723        );
1724    }
1725
1726    /// AC #7: Serde round-trip for `DeprecationInfo` on a `MethodSchema`.
1727    #[test]
1728    fn ir2_deprecation_info_roundtrip() {
1729        let info = DeprecationInfo {
1730            since: "0.5".into(),
1731            removed_in: "0.6".into(),
1732            message: "use MethodRole".into(),
1733        };
1734        let method = MethodSchema::new("old", "legacy method", "hx")
1735            .with_deprecation(info.clone());
1736
1737        let json = serde_json::to_string(&method).unwrap();
1738        let decoded: MethodSchema = serde_json::from_str(&json).unwrap();
1739
1740        assert_eq!(decoded.deprecation, Some(info));
1741    }
1742
1743    /// AC #4: `PluginSchema::is_hub_by_role()` — the derived query reads only
1744    /// `methods`, not the legacy `children` field.
1745    ///
1746    /// Covers every row of the acceptance-criteria table.
1747    #[test]
1748    fn ir2_is_hub_by_role_derived_query() {
1749        // Row 1: all Rpc → false
1750        let all_rpc = PluginSchema::leaf(
1751            "p",
1752            "1.0",
1753            "all rpc",
1754            vec![
1755                MethodSchema::new("a", "d", "h1"),
1756                MethodSchema::new("b", "d", "h2"),
1757            ],
1758        );
1759        assert!(!all_rpc.is_hub_by_role());
1760        // And the back-compat `is_hub()` also returns false (no children).
1761        assert!(!all_rpc.is_hub());
1762
1763        // Row 2: at least one StaticChild → true
1764        let static_child = PluginSchema::leaf(
1765            "p",
1766            "1.0",
1767            "has static child",
1768            vec![
1769                MethodSchema::new("a", "d", "h1"),
1770                MethodSchema::new("kid", "d", "h2").with_role(MethodRole::StaticChild),
1771            ],
1772        );
1773        assert!(static_child.is_hub_by_role());
1774        assert!(static_child.is_hub());
1775
1776        // Row 3: at least one DynamicChild → true
1777        let dyn_child = PluginSchema::leaf(
1778            "p",
1779            "1.0",
1780            "has dynamic child",
1781            vec![MethodSchema::new("find", "d", "h1").with_role(
1782                MethodRole::DynamicChild {
1783                    list_method: None,
1784                    search_method: None,
1785                },
1786            )],
1787        );
1788        assert!(dyn_child.is_hub_by_role());
1789        assert!(dyn_child.is_hub());
1790
1791        // Row 4: Mix of Rpc + StaticChild → true
1792        let mixed = PluginSchema::leaf(
1793            "p",
1794            "1.0",
1795            "mixed",
1796            vec![
1797                MethodSchema::new("a", "d", "h1"),
1798                MethodSchema::new("b", "d", "h2"),
1799                MethodSchema::new("k", "d", "h3").with_role(MethodRole::StaticChild),
1800            ],
1801        );
1802        assert!(mixed.is_hub_by_role());
1803        assert!(mixed.is_hub());
1804
1805        // Row 5: empty methods → false
1806        let empty = PluginSchema::leaf("p", "1.0", "empty", vec![]);
1807        assert!(!empty.is_hub_by_role());
1808        assert!(!empty.is_hub());
1809    }
1810
1811    /// The derived query is independent of the legacy `children` side channel
1812    /// — a `PluginSchema::hub(...)` with only `Rpc` methods reports
1813    /// `is_hub_by_role() == false` (children don't count) while `is_hub()` is
1814    /// still `true` (transition-window fallback).
1815    #[test]
1816    fn ir2_is_hub_by_role_ignores_children_field() {
1817        let hub_with_rpc_only = PluginSchema::hub(
1818            "h",
1819            "1.0",
1820            "transition",
1821            vec![MethodSchema::new("a", "d", "ah")],
1822            vec![ChildSummary {
1823                namespace: "kid".into(),
1824                description: "child".into(),
1825                hash: "kh".into(),
1826            }],
1827        );
1828
1829        // The derived query reads only methods — no child role → false.
1830        assert!(!hub_with_rpc_only.is_hub_by_role());
1831        // Back-compat `is_hub()` still reports true via the children fallback.
1832        assert!(hub_with_rpc_only.is_hub());
1833    }
1834
1835    /// `ReturnShape` round-trips cleanly via serde.
1836    #[test]
1837    fn ir2_return_shape_roundtrip() {
1838        for shape in [
1839            ReturnShape::Bare,
1840            ReturnShape::Option,
1841            ReturnShape::Result,
1842            ReturnShape::Vec,
1843            ReturnShape::Stream,
1844            ReturnShape::ResultOption,
1845        ] {
1846            let m = MethodSchema::new("m", "d", "h").with_return_shape(shape.clone());
1847            let j = serde_json::to_string(&m).unwrap();
1848            let d: MethodSchema = serde_json::from_str(&j).unwrap();
1849            assert_eq!(d.return_shape, Some(shape));
1850        }
1851    }
1852
1853    // =========================================================================
1854    // IR-4 tests: derive_legacy_fields, relaxed validate_no_collisions,
1855    // deprecation markers.
1856    // =========================================================================
1857
1858    /// AC #4 (row 1): empty method list → no children, not a hub.
1859    #[test]
1860    fn ir4_derive_empty_methods() {
1861        let (children, is_hub) = PluginSchema::derive_legacy_fields(&[]);
1862        assert!(children.is_empty());
1863        assert!(!is_hub);
1864    }
1865
1866    /// AC #4 (row 2): a single `Rpc` method → no children, not a hub.
1867    #[test]
1868    fn ir4_derive_single_rpc_method() {
1869        let methods = vec![MethodSchema::new("ping", "rpc method", "h1")];
1870        let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1871        assert!(children.is_empty());
1872        assert!(!is_hub);
1873    }
1874
1875    /// AC #4 (row 3): one `StaticChild` method named "body" → one child named
1876    /// "body", `is_hub == true`.
1877    #[test]
1878    fn ir4_derive_single_static_child() {
1879        let methods = vec![
1880            MethodSchema::new("body", "static child", "h1")
1881                .with_role(MethodRole::StaticChild),
1882        ];
1883        let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1884        assert_eq!(children.len(), 1);
1885        assert_eq!(children[0].namespace, "body");
1886        assert_eq!(children[0].description, "static child");
1887        assert_eq!(children[0].hash, "");
1888        assert!(is_hub);
1889    }
1890
1891    /// AC #4 (row 4): one `DynamicChild` method named "planet" → one child
1892    /// named "planet", `is_hub == true`.
1893    #[test]
1894    fn ir4_derive_single_dynamic_child() {
1895        let methods = vec![
1896            MethodSchema::new("planet", "dynamic child", "h1").with_role(
1897                MethodRole::DynamicChild {
1898                    list_method: Some("list_planets".into()),
1899                    search_method: None,
1900                },
1901            ),
1902        ];
1903        let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1904        assert_eq!(children.len(), 1);
1905        assert_eq!(children[0].namespace, "planet");
1906        assert!(is_hub);
1907    }
1908
1909    /// AC #4 (row 5): mix of Rpc + StaticChild → one child, `is_hub == true`.
1910    #[test]
1911    fn ir4_derive_mixed_roles_preserves_order() {
1912        let methods = vec![
1913            MethodSchema::new("ping", "rpc", "h1"),
1914            MethodSchema::new("kid_a", "static a", "h2")
1915                .with_role(MethodRole::StaticChild),
1916            MethodSchema::new("describe", "rpc too", "h3"),
1917            MethodSchema::new("kid_b", "static b", "h4")
1918                .with_role(MethodRole::StaticChild),
1919        ];
1920        let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1921        // Source-order preservation: kid_a appears before kid_b.
1922        assert_eq!(children.len(), 2);
1923        assert_eq!(children[0].namespace, "kid_a");
1924        assert_eq!(children[1].namespace, "kid_b");
1925        assert!(is_hub);
1926    }
1927
1928    /// IR-4: `derive_legacy_fields`'s `is_hub` result matches
1929    /// [`PluginSchema::is_hub_by_role`] on every method list covered by the
1930    /// acceptance-criteria table.
1931    #[test]
1932    fn ir4_derive_is_hub_matches_is_hub_by_role() {
1933        // Empty methods.
1934        let empty_schema = PluginSchema::leaf("t", "1.0", "d", vec![]);
1935        let (_, is_hub) = PluginSchema::derive_legacy_fields(&empty_schema.methods);
1936        assert_eq!(is_hub, empty_schema.is_hub_by_role());
1937
1938        // All-Rpc methods.
1939        let rpc_schema = PluginSchema::leaf(
1940            "t",
1941            "1.0",
1942            "d",
1943            vec![
1944                MethodSchema::new("a", "d", "h1"),
1945                MethodSchema::new("b", "d", "h2"),
1946            ],
1947        );
1948        let (_, is_hub) = PluginSchema::derive_legacy_fields(&rpc_schema.methods);
1949        assert_eq!(is_hub, rpc_schema.is_hub_by_role());
1950
1951        // StaticChild present.
1952        let static_schema = PluginSchema::leaf(
1953            "t",
1954            "1.0",
1955            "d",
1956            vec![
1957                MethodSchema::new("a", "d", "h1"),
1958                MethodSchema::new("kid", "d", "h2").with_role(MethodRole::StaticChild),
1959            ],
1960        );
1961        let (_, is_hub) = PluginSchema::derive_legacy_fields(&static_schema.methods);
1962        assert_eq!(is_hub, static_schema.is_hub_by_role());
1963        assert!(is_hub);
1964
1965        // DynamicChild present.
1966        let dyn_schema = PluginSchema::leaf(
1967            "t",
1968            "1.0",
1969            "d",
1970            vec![MethodSchema::new("find", "d", "h1").with_role(
1971                MethodRole::DynamicChild {
1972                    list_method: None,
1973                    search_method: None,
1974                },
1975            )],
1976        );
1977        let (_, is_hub) = PluginSchema::derive_legacy_fields(&dyn_schema.methods);
1978        assert_eq!(is_hub, dyn_schema.is_hub_by_role());
1979        assert!(is_hub);
1980    }
1981
1982    /// IR-4 rule 2: `validate_no_collisions` no longer panics when a
1983    /// `StaticChild`-role method shares its name with a `ChildSummary` —
1984    /// that's expected by construction (two wire representations of the
1985    /// same child).
1986    #[test]
1987    fn ir4_no_collision_static_child_method_vs_summary() {
1988        // Same name on both surfaces — used to panic, now accepted.
1989        let schema = PluginSchema::hub(
1990            "hub",
1991            "1.0",
1992            "has static child",
1993            vec![
1994                MethodSchema::new("ping", "rpc", "h1"),
1995                MethodSchema::new("kid", "static child", "h2")
1996                    .with_role(MethodRole::StaticChild),
1997            ],
1998            vec![ChildSummary {
1999                namespace: "kid".into(),
2000                description: "static child".into(),
2001                hash: "kh".into(),
2002            }],
2003        );
2004        // Child stayed on the wire.
2005        #[allow(deprecated)]
2006        let kids = schema.children.as_ref().expect("hub has children");
2007        assert_eq!(kids.len(), 1);
2008        assert_eq!(kids[0].namespace, "kid");
2009        // Method kept its role tag.
2010        assert!(matches!(
2011            schema.methods.iter().find(|m| m.name == "kid").unwrap().role,
2012            MethodRole::StaticChild
2013        ));
2014    }
2015
2016    /// IR-4 rule 2: `validate_no_collisions` also tolerates DynamicChild-role
2017    /// method names that appear in the child summary list.
2018    #[test]
2019    fn ir4_no_collision_dynamic_child_method_vs_summary() {
2020        let schema = PluginSchema::hub(
2021            "hub",
2022            "1.0",
2023            "has dynamic child",
2024            vec![MethodSchema::new("body", "gate", "h1").with_role(
2025                MethodRole::DynamicChild {
2026                    list_method: Some("body_names".into()),
2027                    search_method: None,
2028                },
2029            )],
2030            vec![ChildSummary {
2031                namespace: "body".into(),
2032                description: "gate".into(),
2033                hash: "bh".into(),
2034            }],
2035        );
2036        #[allow(deprecated)]
2037        let kids = schema.children.as_ref().unwrap();
2038        assert_eq!(kids.len(), 1);
2039    }
2040
2041    /// IR-4 rule 2: `validate_no_collisions` still panics when an `Rpc`-role
2042    /// method's name collides with a child summary — that's the case the
2043    /// validation was designed to catch.
2044    #[test]
2045    #[should_panic(expected = "method/child collision")]
2046    fn ir4_collision_rpc_method_vs_summary_still_panics() {
2047        let _ = PluginSchema::hub(
2048            "hub",
2049            "1.0",
2050            "bad hub",
2051            vec![MethodSchema::new("oops", "rpc", "h1")],
2052            vec![ChildSummary {
2053                namespace: "oops".into(),
2054                description: "shadowed".into(),
2055                hash: "oh".into(),
2056            }],
2057        );
2058    }
2059
2060    /// IR-4 AC #3 (spec): reading `PluginSchema.children` outside a
2061    /// `#[allow(deprecated)]` block emits a compiler warning. This fixture
2062    /// uses `#[allow(deprecated)]` to confirm the attribute is required —
2063    /// if it weren't, the `#[deprecated]` annotation is either missing or
2064    /// wrong.
2065    #[test]
2066    fn ir4_deprecated_field_access_requires_allow_attribute() {
2067        let schema = PluginSchema::leaf(
2068            "t",
2069            "1.0",
2070            "d",
2071            vec![MethodSchema::new("a", "b", "h")],
2072        );
2073        // Reading the deprecated field — under `#[allow(deprecated)]` from
2074        // the module-level attribute on the tests module. Removing that
2075        // allow would produce a compiler warning pointing at this line.
2076        let _children = schema.children.clone();
2077        // Calling the deprecated method — same rationale.
2078        let _is_hub = schema.is_hub();
2079    }
2080
2081    /// AUTHZ-CRED-CORE-2 AC #8: `PluginSchema::leaf` runs the
2082    /// schema-build warning hook for the framework-reserved
2083    /// `_credentials` top-level field name. The hook itself emits a
2084    /// `tracing::warn!` line, which is not directly assertable here
2085    /// without a tracing subscriber; we exercise the predicate path
2086    /// through schema construction (no panic, schema still constructed)
2087    /// and assert that the underlying check function is wired in.
2088    #[test]
2089    fn cred_core_2_ac8_leaf_constructor_does_not_panic_on_collision() {
2090        // Build a returns schema with a top-level `_credentials` field —
2091        // the framework-reserved name.
2092        let returns_collision_schema: schemars::Schema = serde_json::from_value(
2093            serde_json::json!({
2094                "type": "object",
2095                "properties": {
2096                    "user_id":      { "type": "string" },
2097                    "_credentials": { "type": "object" }
2098                }
2099            }),
2100        )
2101        .unwrap();
2102
2103        // The leaf constructor consults the warning hook but does NOT
2104        // fail — the field is shadowed at dispatch time, and the
2105        // warning is the user-visible diagnostic. Constructing the
2106        // schema must still succeed (additive behavior).
2107        let schema = PluginSchema::leaf(
2108            "auth",
2109            "1.0",
2110            "test",
2111            vec![MethodSchema::new("login", "logs in", "h1").with_returns(returns_collision_schema)],
2112        );
2113        // Plugin still built.
2114        assert_eq!(schema.namespace, "auth");
2115        assert_eq!(schema.methods.len(), 1);
2116
2117        // And the inverse: a schema without the collision does not
2118        // panic either.
2119        let no_collision_schema: schemars::Schema = serde_json::from_value(
2120            serde_json::json!({ "type": "object", "properties": { "user_id": { "type": "string" } } }),
2121        )
2122        .unwrap();
2123        let schema_clean = PluginSchema::leaf(
2124            "auth",
2125            "1.0",
2126            "test",
2127            vec![MethodSchema::new("ping", "pings", "h2").with_returns(no_collision_schema)],
2128        );
2129        assert_eq!(schema_clean.methods.len(), 1);
2130    }
2131
2132    /// IR-4 AC #8: `PluginSchema.is_hub()` (deprecated) and
2133    /// `PluginSchema::is_hub_by_role()` agree on every shape currently
2134    /// emitted by substrate activations (methods with role tags, children
2135    /// field populated via hub constructor).
2136    #[test]
2137    fn ir4_is_hub_and_is_hub_by_role_agree_on_role_tagged_methods() {
2138        // Pure-leaf, all Rpc: both false.
2139        let leaf = PluginSchema::leaf(
2140            "t",
2141            "1.0",
2142            "d",
2143            vec![MethodSchema::new("a", "d", "h1")],
2144        );
2145        assert_eq!(leaf.is_hub(), leaf.is_hub_by_role());
2146        assert!(!leaf.is_hub());
2147
2148        // Hub with role-tagged methods (today's post-IR-3 shape): both true.
2149        let hub_with_roles = PluginSchema::hub(
2150            "h",
2151            "1.0",
2152            "d",
2153            vec![MethodSchema::new("kid", "d", "h1").with_role(MethodRole::StaticChild)],
2154            vec![ChildSummary {
2155                namespace: "kid".into(),
2156                description: "d".into(),
2157                hash: "".into(),
2158            }],
2159        );
2160        assert_eq!(hub_with_roles.is_hub(), hub_with_roles.is_hub_by_role());
2161        assert!(hub_with_roles.is_hub());
2162    }
2163
2164    // =========================================================================
2165    // AUTHZ-CRED-CORE-3 tests: CredentialFieldDecl, RequiredCredential,
2166    // MethodSchema.credentials / .requires_credential projections.
2167    // =========================================================================
2168
2169    use plexus_auth_core::{
2170        AttachmentSite, CredentialFieldMarker, CredentialIssuer, CredentialKind,
2171        CredentialMetadata, CredentialScheme, HeaderName, MethodPath, Origin, Scope,
2172    };
2173
2174    fn sample_issuer() -> CredentialIssuer {
2175        CredentialIssuer::new(
2176            Origin::new("ws://localhost:4444"),
2177            MethodPath::try_new("auth.login").unwrap(),
2178        )
2179    }
2180
2181    fn sample_marker_single() -> CredentialFieldMarker {
2182        CredentialFieldMarker::new(
2183            None,
2184            "session",
2185            CredentialKind::Bearer,
2186            AttachmentSite::Header {
2187                name: HeaderName::try_new("authorization").unwrap(),
2188            },
2189            Some(CredentialScheme::new("Bearer ")),
2190            vec![Scope::new("cone.send_message")],
2191            Some(MethodPath::try_new("auth.refresh").unwrap()),
2192            Some(MethodPath::try_new("auth.logout").unwrap()),
2193        )
2194    }
2195
2196    /// AC #1: A method whose return type contains one `Credential<T>` field
2197    /// produces a `MethodSchema` whose `credentials` field has one
2198    /// `CredentialFieldDecl` entry with the correct field path and metadata.
2199    #[test]
2200    fn cred_core_3_ac1_single_credential_projection() {
2201        let marker = sample_marker_single();
2202        let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
2203
2204        // Field path is the marker's single field name.
2205        assert_eq!(decl.field_path, vec!["session".to_string()]);
2206        // No variant tag for a struct return type.
2207        assert!(decl.variant_tag.is_none());
2208        // Metadata composes correctly from the marker.
2209        assert_eq!(decl.metadata.kind, CredentialKind::Bearer);
2210        assert_eq!(
2211            decl.metadata.attach_as,
2212            AttachmentSite::Header {
2213                name: HeaderName::try_new("authorization").unwrap(),
2214            }
2215        );
2216        assert_eq!(decl.metadata.scheme, Some(CredentialScheme::new("Bearer ")));
2217        assert_eq!(decl.metadata.scopes, vec![Scope::new("cone.send_message")]);
2218        assert_eq!(
2219            decl.metadata.refresh_via,
2220            Some(MethodPath::try_new("auth.refresh").unwrap())
2221        );
2222        assert_eq!(
2223            decl.metadata.revoke_via,
2224            Some(MethodPath::try_new("auth.logout").unwrap())
2225        );
2226        assert_eq!(decl.metadata.issuer, sample_issuer());
2227
2228        // Project into a MethodSchema and confirm the field is populated.
2229        let method = MethodSchema::new("login", "logs in", "h_login").with_credentials(vec![decl]);
2230        assert_eq!(method.credentials.len(), 1);
2231        assert_eq!(method.credentials[0].field_path, vec!["session".to_string()]);
2232    }
2233
2234    /// AC #2: A method whose return type contains multiple `Credential<T>`
2235    /// fields produces a `MethodSchema` whose `credentials` field has one
2236    /// entry per credential, in stable order.
2237    #[test]
2238    fn cred_core_3_ac2_multiple_credentials_stable_order() {
2239        let m1 = CredentialFieldMarker::new(
2240            None,
2241            "access",
2242            CredentialKind::OauthAccess,
2243            AttachmentSite::Header {
2244                name: HeaderName::try_new("authorization").unwrap(),
2245            },
2246            Some(CredentialScheme::new("Bearer ")),
2247            vec![Scope::new("cone.send")],
2248            Some(MethodPath::try_new("auth.refresh").unwrap()),
2249            None,
2250        );
2251        let m2 = CredentialFieldMarker::new(
2252            None,
2253            "refresh",
2254            CredentialKind::OauthRefresh,
2255            AttachmentSite::Header {
2256                name: HeaderName::try_new("authorization").unwrap(),
2257            },
2258            None,
2259            vec![Scope::new("auth.refresh")],
2260            None,
2261            None,
2262        );
2263
2264        let decls = vec![
2265            CredentialFieldDecl::from_marker(&m1, &[], sample_issuer()),
2266            CredentialFieldDecl::from_marker(&m2, &[], sample_issuer()),
2267        ];
2268        let method = MethodSchema::new("login", "logs in", "h_oauth").with_credentials(decls);
2269
2270        // Two entries, in declaration order.
2271        assert_eq!(method.credentials.len(), 2);
2272        assert_eq!(method.credentials[0].field_path, vec!["access".to_string()]);
2273        assert_eq!(method.credentials[0].metadata.kind, CredentialKind::OauthAccess);
2274        assert_eq!(method.credentials[1].field_path, vec!["refresh".to_string()]);
2275        assert_eq!(method.credentials[1].metadata.kind, CredentialKind::OauthRefresh);
2276    }
2277
2278    /// AC #3: A method whose return type is an enum with credentials only on
2279    /// one variant produces a `MethodSchema` with the correct `variant_tag`
2280    /// set on each entry.
2281    #[test]
2282    fn cred_core_3_ac3_enum_variant_tag_set() {
2283        let marker = CredentialFieldMarker::new(
2284            Some("Issued"),
2285            "session",
2286            CredentialKind::Bearer,
2287            AttachmentSite::Header {
2288                name: HeaderName::try_new("authorization").unwrap(),
2289            },
2290            Some(CredentialScheme::new("Bearer ")),
2291            vec![Scope::new("cone.send_message")],
2292            None,
2293            None,
2294        );
2295        let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
2296        assert_eq!(decl.variant_tag, Some("Issued".to_string()));
2297        assert_eq!(decl.field_path, vec!["session".to_string()]);
2298
2299        let method = MethodSchema::new("login", "logs in", "h_login").with_credentials(vec![decl]);
2300        assert_eq!(method.credentials[0].variant_tag, Some("Issued".to_string()));
2301    }
2302
2303    /// `CredentialFieldDecl::from_marker` honors a non-empty path prefix
2304    /// (nested-field walk).
2305    #[test]
2306    fn cred_core_3_from_marker_with_nested_path_prefix() {
2307        let marker = sample_marker_single();
2308        let decl = CredentialFieldDecl::from_marker(&marker, &["auth"], sample_issuer());
2309        assert_eq!(decl.field_path, vec!["auth".to_string(), "session".to_string()]);
2310    }
2311
2312    /// AC #4: A method tagged `#[plexus::method(public)]` produces a
2313    /// `MethodSchema` whose `requires_credential` is `None`.
2314    ///
2315    /// "Public" maps to "no implicit derivation occurs"; the schema-build
2316    /// surface simply doesn't call `with_requires_credential`, leaving the
2317    /// field at its `None` default.
2318    #[test]
2319    fn cred_core_3_ac4_public_method_requires_credential_none() {
2320        let method = MethodSchema::new("auth.login", "logs in", "h_login");
2321        assert!(method.requires_credential.is_none());
2322
2323        // After round-trip the field also serializes/deserializes as None
2324        // (omitted on the wire).
2325        let json = serde_json::to_value(&method).unwrap();
2326        assert!(
2327            !json
2328                .as_object()
2329                .unwrap()
2330                .contains_key("requires_credential"),
2331            "requires_credential must be omitted from wire JSON when None, got {json}"
2332        );
2333        let decoded: MethodSchema = serde_json::from_value(json).unwrap();
2334        assert!(decoded.requires_credential.is_none());
2335    }
2336
2337    /// AC #5: A method tagged with a scope produces a `MethodSchema` whose
2338    /// `requires_credential.scopes` contains that scope and whose
2339    /// `requires_credential.kind` is `None`.
2340    #[test]
2341    fn cred_core_3_ac5_scoped_method_implicit_requires_credential() {
2342        let req = RequiredCredential::from_method_scope(Scope::new("cone.send_message"));
2343        assert!(req.kind.is_none());
2344        assert_eq!(req.scopes, vec![Scope::new("cone.send_message")]);
2345        assert!(req.site_hint.is_none());
2346
2347        let method = MethodSchema::new("send", "sends a message", "h_send")
2348            .with_requires_credential(req.clone());
2349        assert_eq!(method.requires_credential.as_ref().unwrap(), &req);
2350    }
2351
2352    /// AC #6: A method whose path appears as the `refresh_via` or
2353    /// `revoke_via` of any credential in the schema produces a `MethodSchema`
2354    /// whose `requires_credential.kind` matches the issuing credential's
2355    /// kind.
2356    #[test]
2357    fn cred_core_3_ac6_refresh_target_narrows_kind() {
2358        // The OAuth refresh flow: the access credential carries
2359        // `refresh_via = auth.refresh`. The implicit-derivation rule
2360        // narrows the requires_credential of `auth.refresh` to
2361        // `OauthRefresh`.
2362        let req = RequiredCredential::from_refresh_revoke_target(
2363            CredentialKind::OauthRefresh,
2364            vec![Scope::new("auth.refresh")],
2365        );
2366        assert_eq!(req.kind, Some(CredentialKind::OauthRefresh));
2367        assert_eq!(req.scopes, vec![Scope::new("auth.refresh")]);
2368
2369        let refresh_method =
2370            MethodSchema::new("refresh", "refreshes a token", "h_refresh")
2371                .with_requires_credential(req.clone());
2372        assert_eq!(
2373            refresh_method.requires_credential.as_ref().unwrap().kind,
2374            Some(CredentialKind::OauthRefresh)
2375        );
2376    }
2377
2378    /// AC #6 variant: site_hint can be threaded through if a build-path
2379    /// derivation step has a preferred attach site (e.g., the OAuth refresh
2380    /// path that knows the refresh token is attached via header).
2381    #[test]
2382    fn cred_core_3_required_credential_site_hint_preserved() {
2383        let mut req = RequiredCredential::from_refresh_revoke_target(
2384            CredentialKind::OauthRefresh,
2385            vec![Scope::new("auth.refresh")],
2386        );
2387        let hint = AttachmentSite::Header {
2388            name: HeaderName::try_new("authorization").unwrap(),
2389        };
2390        req.site_hint = Some(hint.clone());
2391        let method = MethodSchema::new("refresh", "refreshes", "h_refresh")
2392            .with_requires_credential(req);
2393
2394        let json = serde_json::to_string(&method).unwrap();
2395        let decoded: MethodSchema = serde_json::from_str(&json).unwrap();
2396        assert_eq!(
2397            decoded.requires_credential.as_ref().unwrap().site_hint,
2398            Some(hint)
2399        );
2400    }
2401
2402    /// AC #7: A pre-existing schema consumer (synapse IR builder,
2403    /// hub-codegen) decodes the new schema fields with their additive
2404    /// defaults and continues to function.
2405    ///
2406    /// Tested by deserializing a pre-CRED-CORE-3 JSON shape (no
2407    /// `credentials`, no `requires_credential` fields) and asserting the
2408    /// defaults are applied.
2409    #[test]
2410    fn cred_core_3_ac7_pre_ir_json_deserializes_with_empty_defaults() {
2411        let pre_ir_json = serde_json::json!({
2412            "name": "ping",
2413            "description": "pong",
2414            "hash": "abc"
2415        });
2416        let schema: MethodSchema = serde_json::from_value(pre_ir_json).unwrap();
2417        assert!(schema.credentials.is_empty());
2418        assert!(schema.requires_credential.is_none());
2419    }
2420
2421    /// AC #7 variant: a pre-IR PluginSchema with multiple methods
2422    /// deserializes cleanly with every method's credential projections
2423    /// defaulted.
2424    #[test]
2425    fn cred_core_3_ac7_pre_ir_plugin_schema_deserializes() {
2426        let pre_ir_json = serde_json::json!({
2427            "namespace": "legacy",
2428            "version": "1.0",
2429            "description": "no credentials",
2430            "self_hash": "s1",
2431            "hash": "h1",
2432            "methods": [
2433                { "name": "a", "description": "alpha", "hash": "ah" },
2434                { "name": "b", "description": "beta",  "hash": "bh" }
2435            ]
2436        });
2437        let plugin: PluginSchema = serde_json::from_value(pre_ir_json).unwrap();
2438        for m in &plugin.methods {
2439            assert!(m.credentials.is_empty());
2440            assert!(m.requires_credential.is_none());
2441        }
2442    }
2443
2444    /// AC #8: The `_info` capability advertisement carries the new fields
2445    /// when populated.
2446    ///
2447    /// `_info` reads `MethodSchema`'s serde representation directly. We
2448    /// verify the wire JSON contains the new fields when populated.
2449    #[test]
2450    fn cred_core_3_ac8_info_advertisement_carries_populated_fields() {
2451        let marker = sample_marker_single();
2452        let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
2453        let req = RequiredCredential::from_method_scope(Scope::new("cone.send_message"));
2454        let method = MethodSchema::new("login", "logs in", "h_login")
2455            .with_credentials(vec![decl])
2456            .with_requires_credential(req);
2457
2458        let json = serde_json::to_value(&method).unwrap();
2459        let obj = json.as_object().unwrap();
2460        assert!(
2461            obj.contains_key("credentials"),
2462            "populated credentials must appear in wire JSON"
2463        );
2464        assert!(
2465            obj.contains_key("requires_credential"),
2466            "populated requires_credential must appear in wire JSON"
2467        );
2468        // And the entry shape is decodeable.
2469        let decoded: MethodSchema = serde_json::from_value(json).unwrap();
2470        assert_eq!(decoded.credentials.len(), 1);
2471        assert_eq!(decoded.credentials[0].field_path, vec!["session".to_string()]);
2472        assert_eq!(
2473            decoded.requires_credential.as_ref().unwrap().scopes,
2474            vec![Scope::new("cone.send_message")]
2475        );
2476    }
2477
2478    /// AC #11 (no regression): a method that does not return credentials and
2479    /// has no scope-derived requirement has identical wire JSON to a pre-IR
2480    /// schema for the same method (the new fields are omitted on the wire).
2481    #[test]
2482    fn cred_core_3_ac11_methods_without_credentials_have_unchanged_wire_shape() {
2483        let method = MethodSchema::new("ping", "pong", "h_ping");
2484        let json = serde_json::to_value(&method).unwrap();
2485        let obj = json.as_object().unwrap();
2486        // No credentials key, no requires_credential key.
2487        assert!(!obj.contains_key("credentials"));
2488        assert!(!obj.contains_key("requires_credential"));
2489
2490        // And the round-trip preserves the empty defaults.
2491        let decoded: MethodSchema = serde_json::from_value(json).unwrap();
2492        assert!(decoded.credentials.is_empty());
2493        assert!(decoded.requires_credential.is_none());
2494    }
2495
2496    /// Round-trip coverage: a full `MethodSchema` with both projections
2497    /// populated round-trips through serde without losing fields.
2498    #[test]
2499    fn cred_core_3_full_method_roundtrip_preserves_credentials_and_requires() {
2500        let marker = sample_marker_single();
2501        let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
2502        let req = RequiredCredential::from_method_scope(Scope::new("cone.send_message"));
2503        let method = MethodSchema::new("login", "logs in", "h_login")
2504            .with_credentials(vec![decl.clone()])
2505            .with_requires_credential(req.clone());
2506
2507        let json = serde_json::to_string(&method).unwrap();
2508        let decoded: MethodSchema = serde_json::from_str(&json).unwrap();
2509
2510        assert_eq!(decoded.credentials.len(), 1);
2511        assert_eq!(decoded.credentials[0], decl);
2512        assert_eq!(decoded.requires_credential.as_ref().unwrap(), &req);
2513    }
2514
2515    /// `CredentialFieldDecl` round-trips via serde — pinned independently
2516    /// of MethodSchema so consumers reading the decl in isolation (e.g.
2517    /// `AUTHZ-CRED-IR-1` Haskell decoder testing parity) have a
2518    /// trustworthy shape contract.
2519    #[test]
2520    fn cred_core_3_credential_field_decl_roundtrip() {
2521        let marker = sample_marker_single();
2522        let original = CredentialFieldDecl::from_marker(&marker, &["envelope"], sample_issuer());
2523        let json = serde_json::to_string(&original).unwrap();
2524        let parsed: CredentialFieldDecl = serde_json::from_str(&json).unwrap();
2525        assert_eq!(parsed, original);
2526    }
2527
2528    /// `RequiredCredential` round-trips via serde — pinned independently
2529    /// for the same cross-stack reason as `CredentialFieldDecl`.
2530    #[test]
2531    fn cred_core_3_required_credential_roundtrip() {
2532        let req = RequiredCredential {
2533            kind: Some(CredentialKind::OauthRefresh),
2534            scopes: vec![Scope::new("auth.refresh")],
2535            site_hint: Some(AttachmentSite::Header {
2536                name: HeaderName::try_new("authorization").unwrap(),
2537            }),
2538        };
2539        let json = serde_json::to_string(&req).unwrap();
2540        let parsed: RequiredCredential = serde_json::from_str(&json).unwrap();
2541        assert_eq!(parsed, req);
2542    }
2543
2544    /// `RequiredCredential` with `kind: None, scopes: [...], site_hint:
2545    /// None` (the `from_method_scope` shape) serializes to a compact wire
2546    /// form that omits the absent fields.
2547    #[test]
2548    fn cred_core_3_required_credential_compact_wire_shape() {
2549        let req = RequiredCredential::from_method_scope(Scope::new("cone.send"));
2550        let json = serde_json::to_value(&req).unwrap();
2551        let obj = json.as_object().unwrap();
2552        // Only the scopes field is present on the wire.
2553        assert!(!obj.contains_key("kind"));
2554        assert!(obj.contains_key("scopes"));
2555        assert!(!obj.contains_key("site_hint"));
2556    }
2557}