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 super::bidirectional::{StandardRequest, StandardResponse};
15
16// =============================================================================
17// Method Role
18// =============================================================================
19
20/// Describes how a method participates in the activation graph.
21///
22/// Every method on a plugin is exactly one of three kinds:
23///
24/// - `Rpc` — a regular RPC endpoint (the default).
25/// - `StaticChild` — the method returns a child activation by a static name
26///   (no lookup argument). Used by `#[child]`-annotated methods on hubs.
27/// - `DynamicChild { .. }` — the method gates a dynamic child keyed by its
28///   argument. `list_method` optionally names a sibling method that enumerates
29///   available keys, and `search_method` optionally names a sibling method
30///   that searches keys.
31///
32/// This tag is consumed by downstream tooling (synapse, synapse-cc,
33/// introspection clients) to reconstruct the child graph without a separate
34/// side-table. Today's macros emit `MethodRole::Rpc` for every method; IR-3
35/// populates child roles from `#[child]` annotations.
36///
37/// # Wire back-compat
38///
39/// Added in IR-2. Serde defaults to `Rpc` for pre-IR schemas.
40/// `#[non_exhaustive]` reserves space for future variants without breaking
41/// downstream match arms.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
43#[serde(tag = "kind", rename_all = "snake_case")]
44#[non_exhaustive]
45pub enum MethodRole {
46    /// Method is an RPC endpoint (the default for ordinary methods).
47    Rpc,
48    /// Method returns a child activation by static name (no lookup arg).
49    StaticChild,
50    /// Method gates a dynamic child keyed by its argument.
51    DynamicChild {
52        /// Optional sibling method that lists available keys.
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        list_method: Option<String>,
55        /// Optional sibling method that searches available keys.
56        #[serde(default, skip_serializing_if = "Option::is_none")]
57        search_method: Option<String>,
58    },
59}
60
61impl Default for MethodRole {
62    fn default() -> Self {
63        MethodRole::Rpc
64    }
65}
66
67// =============================================================================
68// Deprecation Info
69// =============================================================================
70
71/// Structured deprecation metadata attached to a `MethodSchema`.
72///
73/// Downstream consumers (CLI help, docs generators, IDEs) use these fields
74/// to surface migration guidance to users.
75///
76/// # Example
77///
78/// ```
79/// use plexus_core::DeprecationInfo;
80///
81/// let info = DeprecationInfo {
82///     since: "0.5".into(),
83///     removed_in: "0.6".into(),
84///     message: "Use `new_method` instead.".into(),
85/// };
86/// ```
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
88pub struct DeprecationInfo {
89    /// The plexus-core version at which deprecation began (e.g., `"0.5"`).
90    pub since: String,
91    /// The plexus-core version planned for removal (e.g., `"0.6"`).
92    ///
93    /// Not binding — serves as a consumer-visible hint.
94    pub removed_in: String,
95    /// Human-readable migration guidance.
96    pub message: String,
97}
98
99// =============================================================================
100// Param Schema
101// =============================================================================
102
103/// Per-parameter metadata for a method's parameters.
104///
105/// `MethodSchema.params` already carries the fine-grained JSON Schema for the
106/// combined parameter object. `ParamSchema` carries orthogonal, parameter-
107/// scoped metadata that doesn't fit on a JSON Schema node — currently just
108/// deprecation info (IR-5).
109///
110/// The `name` field matches the parameter identifier in the method signature
111/// so consumers can correlate entries against the `params` JSON Schema's
112/// `properties` map.
113///
114/// Added in IR-5. Defaults to an empty list on `MethodSchema` so pre-IR
115/// schemas deserialize cleanly.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
117pub struct ParamSchema {
118    /// Parameter name, matching the identifier in the method signature.
119    pub name: String,
120    /// If set, this parameter is deprecated.
121    ///
122    /// Populated by `#[deprecated(...)]` (+ optional
123    /// `#[plexus_macros::removed_in("...")]`) on the parameter in the
124    /// method signature (IR-5).
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub deprecation: Option<DeprecationInfo>,
127}
128
129impl ParamSchema {
130    /// Create a new `ParamSchema` carrying just a name and no metadata.
131    pub fn new(name: impl Into<String>) -> Self {
132        Self {
133            name: name.into(),
134            deprecation: None,
135        }
136    }
137
138    /// Attach deprecation metadata for this parameter.
139    pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
140        self.deprecation = Some(info);
141        self
142    }
143}
144
145// =============================================================================
146// Return Shape
147// =============================================================================
148
149/// Describes the structural shape of a method's return type.
150///
151/// Orthogonal to the fine-grained JSON Schema stored in `MethodSchema.returns`:
152/// that schema describes the inner type; this tag describes the wrapping.
153///
154/// - `Bare` — `T`
155/// - `Option` — `Option<T>`
156/// - `Result` — `Result<T, E>`
157/// - `Vec` — `Vec<T>`
158/// - `Stream` — a stream of `T` (e.g., `AsyncGenerator<T>`)
159/// - `ResultOption` — `Result<Option<T>, E>`
160///
161/// Added in IR-2 as an optional, additive field on `MethodSchema`. Consumers
162/// that don't care can ignore it; those generating language bindings use it to
163/// pick the right idiom (e.g., TypeScript `T | null` for `Option`).
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
165#[serde(rename_all = "snake_case")]
166#[non_exhaustive]
167pub enum ReturnShape {
168    /// `T` — the return type is used as-is.
169    Bare,
170    /// `Option<T>` — the return may be null/absent.
171    Option,
172    /// `Result<T, E>` — the return may be an error.
173    Result,
174    /// `Vec<T>` — the return is a list.
175    Vec,
176    /// A stream of `T` events.
177    Stream,
178    /// `Result<Option<T>, E>` — common pattern for fallible lookups.
179    ResultOption,
180}
181
182// =============================================================================
183// HTTP Method Enum
184// =============================================================================
185
186/// HTTP method for REST endpoint routing
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
188#[serde(rename_all = "UPPERCASE")]
189pub enum HttpMethod {
190    /// GET: Idempotent read operations with no side effects
191    Get,
192    /// POST: Create operations or non-idempotent actions (default)
193    Post,
194    /// PUT: Replace/update operations (idempotent)
195    Put,
196    /// DELETE: Remove operations (idempotent)
197    Delete,
198    /// PATCH: Partial update operations
199    Patch,
200}
201
202impl Default for HttpMethod {
203    fn default() -> Self {
204        HttpMethod::Post
205    }
206}
207
208impl HttpMethod {
209    /// Parse from string (case-insensitive)
210    pub fn from_str(s: &str) -> Option<Self> {
211        match s.to_uppercase().as_str() {
212            "GET" => Some(HttpMethod::Get),
213            "POST" => Some(HttpMethod::Post),
214            "PUT" => Some(HttpMethod::Put),
215            "DELETE" => Some(HttpMethod::Delete),
216            "PATCH" => Some(HttpMethod::Patch),
217            _ => None,
218        }
219    }
220
221    /// Convert to uppercase string
222    pub fn as_str(&self) -> &'static str {
223        match self {
224            HttpMethod::Get => "GET",
225            HttpMethod::Post => "POST",
226            HttpMethod::Put => "PUT",
227            HttpMethod::Delete => "DELETE",
228            HttpMethod::Patch => "PATCH",
229        }
230    }
231}
232
233// ============================================================================
234// Plugin Schema
235// ============================================================================
236
237/// A plugin's schema with methods and child summaries.
238///
239/// Children are represented as summaries (namespace, description, hash) rather
240/// than full recursive schemas. This enables lazy traversal - clients can fetch
241/// child schemas individually via `{namespace}.schema`.
242///
243/// - Leaf plugins have `children = None`
244/// - Hub plugins have `children = Some([ChildSummary, ...])`
245#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
246pub struct PluginSchema {
247    /// The plugin's namespace (e.g., "echo", "plexus")
248    pub namespace: String,
249
250    /// The plugin's version (e.g., "1.0.0")
251    pub version: String,
252
253    /// Short description of the plugin (max 15 words)
254    pub description: String,
255
256    /// Detailed description of the plugin (optional)
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub long_description: Option<String>,
259
260    /// Hash of ONLY this plugin's methods (ignores children)
261    /// Changes when method signatures, names, or descriptions change
262    pub self_hash: String,
263
264    /// Hash of ONLY child plugin hashes (None for leaf plugins)
265    /// Changes when any child's hash changes (recursively)
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub children_hash: Option<String>,
268
269    /// Composite hash = hash(self_hash + children_hash)
270    /// Use this if you want a single hash for the entire subtree
271    /// Backward compatible with previous single-hash system
272    pub hash: String,
273
274    /// Methods exposed by this plugin
275    pub methods: Vec<MethodSchema>,
276
277    /// Child plugin summaries (None = leaf plugin, Some = hub plugin)
278    ///
279    /// # Deprecated (IR-4)
280    ///
281    /// This side-table is deterministically derived from the method list's
282    /// `MethodRole` tags (one `ChildSummary` per non-`Rpc` method). It stays
283    /// on the wire for back-compat during the 0.5 transition window and is
284    /// slated for removal in 0.6.
285    ///
286    /// Consumers reading child metadata should switch to iterating
287    /// `methods` and filtering by `role != MethodRole::Rpc`. The name field
288    /// on each `MethodSchema` is the child's namespace.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    #[deprecated(
291        since = "0.5",
292        note = "Derive from MethodRole on MethodSchema. Field will be removed in 0.7."
293    )]
294    pub children: Option<Vec<ChildSummary>>,
295
296    /// JSON Schema for the HTTP request type this activation extracts from incoming connections.
297    ///
298    /// Present when the activation declares `request = MyRequest` in `#[plexus::activation(...)]`.
299    /// The schema includes `x-plexus-source` extension fields on each property describing
300    /// where each field is sourced from (cookie, header, query param, peer address, etc.).
301    ///
302    /// Clients can use this to understand what request data the activation expects and
303    /// to generate appropriate authentication/context documentation.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub request: Option<serde_json::Value>,
306
307    /// If set, this whole activation is deprecated.
308    ///
309    /// Added in IR-5. Defaults to `None` via `#[serde(default)]` so pre-IR
310    /// schemas deserialize cleanly.
311    ///
312    /// Populated by the `#[deprecated(...)]` attribute on the `impl
313    /// Activation for Foo` block (IR-5).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub deprecation: Option<DeprecationInfo>,
316}
317
318/// Result of a schema query - either full plugin or single method
319#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
320#[serde(untagged)]
321pub enum SchemaResult {
322    /// Full plugin schema (when no method specified)
323    Plugin(PluginSchema),
324    /// Single method schema (when method specified)
325    Method(MethodSchema),
326}
327
328/// Schema for a single method exposed by a plugin
329#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
330pub struct MethodSchema {
331    /// Method name (e.g., "echo", "check")
332    pub name: String,
333
334    /// Human-readable description of what this method does
335    pub description: String,
336
337    /// Content hash of the method definition (for cache invalidation)
338    /// Generated by hashing the method signature within hub-macro
339    pub hash: String,
340
341    /// JSON Schema for the method's parameters (None if no params)
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub params: Option<schemars::Schema>,
344
345    /// JSON Schema for the method's return type (None if not specified)
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub returns: Option<schemars::Schema>,
348
349    /// Whether this method streams multiple events (true) or returns a single result (false)
350    ///
351    /// - `streaming: true` → returns `AsyncGenerator<T>` (multiple events)
352    /// - `streaming: false` → returns `Promise<T>` (single event, collected)
353    ///
354    /// All methods use the same streaming protocol under the hood, but this flag
355    /// tells clients how to present the result.
356    #[serde(default)]
357    pub streaming: bool,
358
359    /// Whether this method supports bidirectional communication
360    ///
361    /// When true, the server can send requests to the client during method execution
362    /// and wait for responses (e.g., confirmations, prompts, selections).
363    #[serde(default)]
364    pub bidirectional: bool,
365
366    /// HTTP method for REST endpoints (GET, POST, PUT, DELETE, PATCH)
367    ///
368    /// This field is used by the HTTP gateway to determine which HTTP method
369    /// to use when exposing this method as a REST endpoint. Defaults to POST
370    /// for backward compatibility.
371    ///
372    /// - GET: Idempotent read operations (no side effects)
373    /// - POST: Create operations or non-idempotent actions (default)
374    /// - PUT: Replace/update operations (idempotent)
375    /// - DELETE: Remove operations (idempotent)
376    /// - PATCH: Partial update operations
377    #[serde(default)]
378    pub http_method: HttpMethod,
379
380    /// JSON Schema for the request type sent from server to client
381    ///
382    /// Only relevant when `bidirectional: true`. Describes the structure of
383    /// requests the server may send during method execution.
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub request_type: Option<schemars::Schema>,
386
387    /// JSON Schema for the response type sent from client to server
388    ///
389    /// Only relevant when `bidirectional: true`. Describes the structure of
390    /// responses the client should send in reply to server requests.
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub response_type: Option<schemars::Schema>,
393
394    /// How this method participates in the activation graph.
395    ///
396    /// Added in IR-2. Defaults to `MethodRole::Rpc` via `#[serde(default)]`
397    /// so pre-IR schemas deserialize cleanly.
398    ///
399    /// Populated by the `#[plexus::method]` / `#[child]` macros (IR-3).
400    #[serde(default)]
401    pub role: MethodRole,
402
403    /// If set, this method is deprecated.
404    ///
405    /// Added in IR-2. Defaults to `None` via `#[serde(default)]` so pre-IR
406    /// schemas deserialize cleanly.
407    ///
408    /// Populated by the `#[deprecated(...)]` attribute on the underlying
409    /// method (IR-5).
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub deprecation: Option<DeprecationInfo>,
412
413    /// Structural shape of the method's return type (e.g., `Option`, `Vec`,
414    /// `Stream`).
415    ///
416    /// Orthogonal to `returns`, which holds the fine-grained JSON Schema of
417    /// the inner type. Added in IR-2 as an optional, additive field. `None`
418    /// means "not populated" (the wire format supports pre-IR schemas that
419    /// omit this field entirely).
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub return_shape: Option<ReturnShape>,
422
423    /// Per-parameter metadata (currently just deprecation).
424    ///
425    /// Added in IR-5. Defaults to an empty vec via `#[serde(default)]` so
426    /// pre-IR schemas deserialize cleanly. Only parameters that carry
427    /// metadata appear in this list — absence means "no metadata" for that
428    /// parameter, not a bug.
429    ///
430    /// Populated by the `#[deprecated(...)]` attribute on individual
431    /// parameters (IR-5).
432    #[serde(default, skip_serializing_if = "Vec::is_empty")]
433    pub params_meta: Vec<ParamSchema>,
434}
435
436impl PluginSchema {
437    /// Compute all three hashes (self, children, composite)
438    fn compute_hashes(
439        methods: &[MethodSchema],
440        children: Option<&[ChildSummary]>,
441    ) -> (String, Option<String>, String) {
442        use std::collections::hash_map::DefaultHasher;
443        use std::hash::{Hash, Hasher};
444
445        // Compute self_hash (methods only)
446        let mut self_hasher = DefaultHasher::new();
447        for m in methods {
448            m.hash.hash(&mut self_hasher);
449        }
450        let self_hash = format!("{:016x}", self_hasher.finish());
451
452        // Compute children_hash (children only)
453        let children_hash = children.map(|kids| {
454            let mut children_hasher = DefaultHasher::new();
455            for c in kids {
456                c.hash.hash(&mut children_hasher);
457            }
458            format!("{:016x}", children_hasher.finish())
459        });
460
461        // Compute composite hash (both)
462        let mut composite_hasher = DefaultHasher::new();
463        self_hash.hash(&mut composite_hasher);
464        if let Some(ref ch) = children_hash {
465            ch.hash(&mut composite_hasher);
466        }
467        let hash = format!("{:016x}", composite_hasher.finish());
468
469        (self_hash, children_hash, hash)
470    }
471
472    /// Validate no name collisions exist within a plugin
473    ///
474    /// Checks for:
475    /// - Duplicate method names
476    /// - Duplicate child names (for hubs)
477    /// - Method/child name collisions for `Rpc`-role methods (for hubs)
478    ///
479    /// Panics if a collision is detected (system error).
480    ///
481    /// # IR-4 relaxation
482    ///
483    /// As of IR-4, a method with `MethodRole::StaticChild` or
484    /// `MethodRole::DynamicChild { .. }` that shares a name with a
485    /// `ChildSummary` entry is **not** a collision — it's the same child
486    /// surfaced via two wire representations (the role-tagged method list
487    /// and the deprecated `children` side-table). Only `Rpc`-role methods
488    /// whose name matches a child summary are flagged.
489    fn validate_no_collisions(
490        namespace: &str,
491        methods: &[MethodSchema],
492        children: Option<&[ChildSummary]>,
493    ) {
494        use std::collections::HashSet;
495
496        let mut seen: HashSet<&str> = HashSet::new();
497
498        // Check method names
499        for m in methods {
500            if !seen.insert(&m.name) {
501                panic!(
502                    "Name collision in plugin '{}': duplicate method '{}'",
503                    namespace, m.name
504                );
505            }
506        }
507
508        // Check child names (and collisions with methods)
509        if let Some(kids) = children {
510            for c in kids {
511                if !seen.insert(&c.namespace) {
512                    // IR-4: a role-tagged child method whose name matches a
513                    // child summary is expected by construction (the two
514                    // wire-surfaces describe the same child). Skip silently.
515                    let colliding_method =
516                        methods.iter().find(|m| m.name == c.namespace);
517                    if let Some(m) = colliding_method {
518                        if matches!(
519                            m.role,
520                            MethodRole::StaticChild | MethodRole::DynamicChild { .. }
521                        ) {
522                            continue;
523                        }
524                    }
525                    // Could be duplicate child or collision with an Rpc-role method
526                    let collision_type = if colliding_method.is_some() {
527                        "method/child collision"
528                    } else {
529                        "duplicate child"
530                    };
531                    panic!(
532                        "Name collision in plugin '{}': {} for '{}'",
533                        namespace, collision_type, c.namespace
534                    );
535                }
536            }
537        }
538    }
539
540    /// Derive the deprecated `(children, is_hub)` side-table fields from a
541    /// role-tagged method list.
542    ///
543    /// Added in IR-4 as the **centralized shim** that backfills the
544    /// pre-IR `children: Option<Vec<ChildSummary>>` and `is_hub: bool`
545    /// representations from the authoritative `MethodRole` on each
546    /// `MethodSchema`.
547    ///
548    /// # Semantics
549    ///
550    /// One `ChildSummary` is produced per non-`Rpc` method, preserving the
551    /// source order. The shim writes:
552    ///
553    /// | Field | Value |
554    /// |---|---|
555    /// | `namespace` | The method's name. |
556    /// | `description` | The method's `description`. |
557    /// | `hash` | Empty string — the shim does **not** compute child hashes. Callers that want per-child hashes must populate them out-of-band. |
558    ///
559    /// The returned `bool` matches [`PluginSchema::is_hub_by_role`] — `true`
560    /// iff at least one method carries a child role.
561    ///
562    /// # Example
563    ///
564    /// ```
565    /// use plexus_core::plexus::schema::{MethodRole, MethodSchema, PluginSchema};
566    ///
567    /// let methods = vec![
568    ///     MethodSchema::new("ping", "rpc", "h1"),
569    ///     MethodSchema::new("kid",  "static child", "h2")
570    ///         .with_role(MethodRole::StaticChild),
571    /// ];
572    /// let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
573    /// assert_eq!(children.len(), 1);
574    /// assert_eq!(children[0].namespace, "kid");
575    /// assert!(is_hub);
576    /// ```
577    pub fn derive_legacy_fields(
578        methods: &[MethodSchema],
579    ) -> (Vec<ChildSummary>, bool) {
580        let children: Vec<ChildSummary> = methods
581            .iter()
582            .filter(|m| {
583                matches!(
584                    m.role,
585                    MethodRole::StaticChild | MethodRole::DynamicChild { .. }
586                )
587            })
588            .map(|m| ChildSummary {
589                namespace: m.name.clone(),
590                description: m.description.clone(),
591                hash: String::new(),
592            })
593            .collect();
594        let is_hub = !children.is_empty();
595        (children, is_hub)
596    }
597
598    /// Create a new leaf plugin schema (no children)
599    #[allow(deprecated)]
600    pub fn leaf(
601        namespace: impl Into<String>,
602        version: impl Into<String>,
603        description: impl Into<String>,
604        methods: Vec<MethodSchema>,
605    ) -> Self {
606        let namespace = namespace.into();
607        Self::validate_no_collisions(&namespace, &methods, None);
608        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, None);
609        Self {
610            namespace,
611            version: version.into(),
612            description: description.into(),
613            long_description: None,
614            self_hash,
615            children_hash,
616            hash,
617            methods,
618            children: None,
619            request: None,
620            deprecation: None,
621        }
622    }
623
624    /// Create a new leaf plugin schema with long description
625    #[allow(deprecated)]
626    pub fn leaf_with_long_description(
627        namespace: impl Into<String>,
628        version: impl Into<String>,
629        description: impl Into<String>,
630        long_description: impl Into<String>,
631        methods: Vec<MethodSchema>,
632    ) -> Self {
633        let namespace = namespace.into();
634        Self::validate_no_collisions(&namespace, &methods, None);
635        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, None);
636        Self {
637            namespace,
638            version: version.into(),
639            description: description.into(),
640            long_description: Some(long_description.into()),
641            self_hash,
642            children_hash,
643            hash,
644            methods,
645            children: None,
646            request: None,
647            deprecation: None,
648        }
649    }
650
651    /// Create a new hub plugin schema (with child summaries)
652    #[allow(deprecated)]
653    pub fn hub(
654        namespace: impl Into<String>,
655        version: impl Into<String>,
656        description: impl Into<String>,
657        methods: Vec<MethodSchema>,
658        children: Vec<ChildSummary>,
659    ) -> Self {
660        let namespace = namespace.into();
661        Self::validate_no_collisions(&namespace, &methods, Some(&children));
662        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, Some(&children));
663        Self {
664            namespace,
665            version: version.into(),
666            description: description.into(),
667            long_description: None,
668            self_hash,
669            children_hash,
670            hash,
671            methods,
672            children: Some(children),
673            request: None,
674            deprecation: None,
675        }
676    }
677
678    /// Create a new hub plugin schema with long description
679    #[allow(deprecated)]
680    pub fn hub_with_long_description(
681        namespace: impl Into<String>,
682        version: impl Into<String>,
683        description: impl Into<String>,
684        long_description: impl Into<String>,
685        methods: Vec<MethodSchema>,
686        children: Vec<ChildSummary>,
687    ) -> Self {
688        let namespace = namespace.into();
689        Self::validate_no_collisions(&namespace, &methods, Some(&children));
690        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, Some(&children));
691        Self {
692            namespace,
693            version: version.into(),
694            description: description.into(),
695            long_description: Some(long_description.into()),
696            self_hash,
697            children_hash,
698            hash,
699            methods,
700            children: Some(children),
701            request: None,
702            deprecation: None,
703        }
704    }
705
706    /// Check if this is a hub.
707    ///
708    /// Returns `true` iff the plugin exposes child activations. As of IR-2,
709    /// this is derived from **either** source of truth:
710    ///
711    /// 1. Any method tagged with a child `MethodRole` (`StaticChild` or
712    ///    `DynamicChild { .. }`). This is the post-IR-3 authoritative signal.
713    /// 2. The legacy `children: Option<Vec<ChildSummary>>` field is `Some`.
714    ///    Preserved for back-compat during the IR transition window —
715    ///    today's macros populate `children` but not yet `role`.
716    ///
717    /// # Deprecated (IR-4)
718    ///
719    /// The legacy transition-window fallback on `children.is_some()` is
720    /// redundant now that `MethodRole` tags are authoritative. Callers
721    /// should migrate to [`PluginSchema::is_hub_by_role`], which reads
722    /// only role-tagged methods. This method will be removed in 0.7.
723    #[deprecated(
724        since = "0.5",
725        note = "Use `PluginSchema::is_hub_by_role()` which reads MethodRole from methods. This method will be removed in 0.7."
726    )]
727    #[allow(deprecated)]
728    pub fn is_hub(&self) -> bool {
729        self.is_hub_by_role() || self.children.is_some()
730    }
731
732    /// Returns `true` iff any method carries a child `MethodRole`.
733    ///
734    /// This is the **derived query** specified by IR-2: it reads only
735    /// `self.methods`, ignoring the legacy `children` side channel. Use this
736    /// when you want the post-IR-3 authoritative answer without the transition
737    /// fallback that `is_hub()` provides.
738    pub fn is_hub_by_role(&self) -> bool {
739        self.methods.iter().any(|m| {
740            matches!(
741                m.role,
742                MethodRole::StaticChild | MethodRole::DynamicChild { .. }
743            )
744        })
745    }
746
747    /// Check if this is a leaf (no children)
748    #[allow(deprecated)]
749    pub fn is_leaf(&self) -> bool {
750        self.children.is_none()
751    }
752
753    /// Mark this plugin as deprecated.
754    ///
755    /// Added in IR-5. Populates the `deprecation` field with the provided
756    /// `DeprecationInfo`. Populated by the `#[deprecated(...)]` attribute on
757    /// an `impl Activation for Foo` block via `plexus-macros`.
758    pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
759        self.deprecation = Some(info);
760        self
761    }
762}
763
764/// Summary of a child plugin
765#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
766pub struct ChildSummary {
767    /// The child's namespace
768    pub namespace: String,
769
770    /// Human-readable description
771    pub description: String,
772
773    /// Content hash for cache invalidation
774    pub hash: String,
775}
776
777/// Schema summary containing only hashes (for cache validation)
778#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
779pub struct PluginHashes {
780    pub namespace: String,
781    pub self_hash: String,
782    #[serde(skip_serializing_if = "Option::is_none")]
783    pub children_hash: Option<String>,
784    pub hash: String,
785    /// Child plugin hashes (for recursive checking)
786    #[serde(skip_serializing_if = "Option::is_none")]
787    pub children: Option<Vec<ChildHashes>>,
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
791pub struct ChildHashes {
792    pub namespace: String,
793    pub hash: String,
794}
795
796impl MethodSchema {
797    /// Create a new method schema with name, description, and hash
798    ///
799    /// The hash should be computed from the method definition string
800    /// within the hub-macro at compile time.
801    pub fn new(
802        name: impl Into<String>,
803        description: impl Into<String>,
804        hash: impl Into<String>,
805    ) -> Self {
806        Self {
807            name: name.into(),
808            description: description.into(),
809            hash: hash.into(),
810            params: None,
811            returns: None,
812            streaming: false,
813            bidirectional: false,
814            http_method: HttpMethod::default(),
815            request_type: None,
816            response_type: None,
817            role: MethodRole::Rpc,
818            deprecation: None,
819            return_shape: None,
820            params_meta: Vec::new(),
821        }
822    }
823
824    /// Add parameter schema
825    pub fn with_params(mut self, params: schemars::Schema) -> Self {
826        self.params = Some(params);
827        self
828    }
829
830    /// Add return type schema
831    pub fn with_returns(mut self, returns: schemars::Schema) -> Self {
832        self.returns = Some(returns);
833        self
834    }
835
836    /// Set the streaming flag
837    ///
838    /// - `true` → method streams multiple events (use `AsyncGenerator<T>`)
839    /// - `false` → method returns single result (use `Promise<T>`)
840    pub fn with_streaming(mut self, streaming: bool) -> Self {
841        self.streaming = streaming;
842        self
843    }
844
845    /// Set the HTTP method for REST endpoints
846    ///
847    /// Defaults to POST for backward compatibility.
848    ///
849    /// # Guidelines
850    /// - GET: Idempotent read operations with no side effects
851    /// - POST: Create operations or non-idempotent actions
852    /// - PUT: Replace/update operations (idempotent)
853    /// - DELETE: Remove operations (idempotent)
854    /// - PATCH: Partial update operations
855    pub fn with_http_method(mut self, http_method: HttpMethod) -> Self {
856        self.http_method = http_method;
857        self
858    }
859
860    /// Set whether this method supports bidirectional communication
861    ///
862    /// When true, the server can send requests to the client during method
863    /// execution and wait for responses.
864    pub fn with_bidirectional(mut self, bidirectional: bool) -> Self {
865        self.bidirectional = bidirectional;
866        self
867    }
868
869    /// Set the JSON Schema for server-to-client request types
870    ///
871    /// Only relevant when `bidirectional: true`. Use `schema_for!(YourRequestType)`
872    /// to generate the schema.
873    pub fn with_request_type(mut self, schema: schemars::Schema) -> Self {
874        self.request_type = Some(schema);
875        self
876    }
877
878    /// Set the JSON Schema for client-to-server response types
879    ///
880    /// Only relevant when `bidirectional: true`. Use `schema_for!(YourResponseType)`
881    /// to generate the schema.
882    pub fn with_response_type(mut self, schema: schemars::Schema) -> Self {
883        self.response_type = Some(schema);
884        self
885    }
886
887    /// Configure method for standard bidirectional communication
888    ///
889    /// Sets `bidirectional: true` and configures request/response types to use
890    /// `StandardRequest` and `StandardResponse`, which support common UI patterns
891    /// like confirmations, prompts, and selections.
892    pub fn with_standard_bidirectional(self) -> Self {
893        self.with_bidirectional(true)
894            .with_request_type(schema_for!(StandardRequest).into())
895            .with_response_type(schema_for!(StandardResponse).into())
896    }
897
898    /// Set this method's role in the activation graph.
899    ///
900    /// Added in IR-2. Defaults to `MethodRole::Rpc`.
901    pub fn with_role(mut self, role: MethodRole) -> Self {
902        self.role = role;
903        self
904    }
905
906    /// Mark this method as deprecated.
907    ///
908    /// Added in IR-2. Populates the `deprecation` field with the provided
909    /// `DeprecationInfo`.
910    pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
911        self.deprecation = Some(info);
912        self
913    }
914
915    /// Set the structural shape of this method's return type.
916    ///
917    /// Added in IR-2. Orthogonal to `with_returns`, which sets the fine-grained
918    /// JSON Schema.
919    pub fn with_return_shape(mut self, shape: ReturnShape) -> Self {
920        self.return_shape = Some(shape);
921        self
922    }
923
924    /// Attach per-parameter metadata for this method's parameters.
925    ///
926    /// Added in IR-5. Only parameters that carry metadata (e.g. a
927    /// `#[deprecated]` annotation) need appear in `entries`; absence means
928    /// "no metadata" for a given parameter. The consumer correlates entries
929    /// against `self.params` by matching `ParamSchema.name` against the
930    /// `properties` map of the JSON Schema.
931    pub fn with_params_meta(mut self, entries: Vec<ParamSchema>) -> Self {
932        self.params_meta = entries;
933        self
934    }
935}
936
937// ============================================================================
938// JSON Schema Types
939// ============================================================================
940
941/// A complete JSON Schema with metadata
942#[derive(Debug, Clone, Serialize, Deserialize)]
943pub struct Schema {
944    /// The JSON Schema specification version
945    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none", default)]
946    pub schema_version: Option<String>,
947
948    /// Title of the schema
949    #[serde(skip_serializing_if = "Option::is_none")]
950    pub title: Option<String>,
951
952    /// Description of what this schema represents
953    #[serde(skip_serializing_if = "Option::is_none")]
954    pub description: Option<String>,
955
956    /// The schema type (typically "object" for root, can be string or array)
957    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
958    pub schema_type: Option<serde_json::Value>,
959
960    /// Properties for object types
961    #[serde(skip_serializing_if = "Option::is_none")]
962    pub properties: Option<HashMap<String, SchemaProperty>>,
963
964    /// Required properties
965    #[serde(skip_serializing_if = "Option::is_none")]
966    pub required: Option<Vec<String>>,
967
968    /// Enum variants (for discriminated unions)
969    #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
970    pub one_of: Option<Vec<Schema>>,
971
972    /// Schema definitions (for $defs or definitions)
973    #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
974    pub defs: Option<HashMap<String, serde_json::Value>>,
975
976    /// Any additional schema properties
977    #[serde(flatten)]
978    pub additional: HashMap<String, serde_json::Value>,
979}
980
981/// Schema type enumeration
982#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
983#[serde(rename_all = "lowercase")]
984pub enum SchemaType {
985    Object,
986    Array,
987    String,
988    Number,
989    Integer,
990    Boolean,
991    Null,
992}
993
994/// A property definition in a schema
995#[derive(Debug, Clone, Serialize, Deserialize)]
996pub struct SchemaProperty {
997    /// The type of this property (can be a single type or array of types for nullable)
998    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
999    pub property_type: Option<serde_json::Value>,
1000
1001    /// Description of this property
1002    #[serde(skip_serializing_if = "Option::is_none")]
1003    pub description: Option<String>,
1004
1005    /// Format hint (e.g., "uuid", "date-time", "email")
1006    #[serde(skip_serializing_if = "Option::is_none")]
1007    pub format: Option<String>,
1008
1009    /// For array types, the schema of items
1010    #[serde(skip_serializing_if = "Option::is_none")]
1011    pub items: Option<Box<SchemaProperty>>,
1012
1013    /// For object types, nested properties
1014    #[serde(skip_serializing_if = "Option::is_none")]
1015    pub properties: Option<HashMap<String, SchemaProperty>>,
1016
1017    /// Required properties (for object types)
1018    #[serde(skip_serializing_if = "Option::is_none")]
1019    pub required: Option<Vec<String>>,
1020
1021    /// Default value for this property
1022    #[serde(skip_serializing_if = "Option::is_none")]
1023    pub default: Option<serde_json::Value>,
1024
1025    /// Enum values if this is an enum
1026    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
1027    pub enum_values: Option<Vec<serde_json::Value>>,
1028
1029    /// Reference to another schema definition
1030    #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
1031    pub reference: Option<String>,
1032
1033    /// Any additional property metadata
1034    #[serde(flatten)]
1035    pub additional: HashMap<String, serde_json::Value>,
1036}
1037
1038impl Schema {
1039    /// Create a new schema with basic metadata
1040    pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
1041        Self {
1042            schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
1043            title: Some(title.into()),
1044            description: Some(description.into()),
1045            schema_type: None,
1046            properties: None,
1047            required: None,
1048            one_of: None,
1049            defs: None,
1050            additional: HashMap::new(),
1051        }
1052    }
1053
1054    /// Create an object schema
1055    pub fn object() -> Self {
1056        Self {
1057            schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
1058            title: None,
1059            description: None,
1060            schema_type: Some(serde_json::json!("object")),
1061            properties: Some(HashMap::new()),
1062            required: None,
1063            one_of: None,
1064            defs: None,
1065            additional: HashMap::new(),
1066        }
1067    }
1068
1069    /// Add a property to this schema
1070    pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
1071        self.properties
1072            .get_or_insert_with(HashMap::new)
1073            .insert(name.into(), property);
1074        self
1075    }
1076
1077    /// Mark a property as required
1078    pub fn with_required(mut self, name: impl Into<String>) -> Self {
1079        self.required
1080            .get_or_insert_with(Vec::new)
1081            .push(name.into());
1082        self
1083    }
1084
1085    /// Set the description
1086    pub fn with_description(mut self, description: impl Into<String>) -> Self {
1087        self.description = Some(description.into());
1088        self
1089    }
1090
1091    /// Extract a single method's schema from the oneOf array
1092    ///
1093    /// Searches the oneOf variants for a method matching the given name.
1094    /// Returns the variant schema if found, None otherwise.
1095    pub fn get_method_schema(&self, method_name: &str) -> Option<Schema> {
1096        let variants = self.one_of.as_ref()?;
1097
1098        for variant in variants {
1099            // Check if this variant has a "method" property with const or enum
1100            if let Some(props) = &variant.properties {
1101                if let Some(method_prop) = props.get("method") {
1102                    // Try "const" first (schemars uses this for literal values)
1103                    if let Some(const_val) = method_prop.additional.get("const") {
1104                        if const_val.as_str() == Some(method_name) {
1105                            return Some(variant.clone());
1106                        }
1107                    }
1108                    // Fall back to enum_values
1109                    if let Some(enum_vals) = &method_prop.enum_values {
1110                        if enum_vals.first().and_then(|v| v.as_str()) == Some(method_name) {
1111                            return Some(variant.clone());
1112                        }
1113                    }
1114                }
1115            }
1116        }
1117        None
1118    }
1119
1120    /// List all method names from the oneOf array
1121    pub fn list_methods(&self) -> Vec<String> {
1122        let Some(variants) = &self.one_of else {
1123            return Vec::new();
1124        };
1125
1126        variants
1127            .iter()
1128            .filter_map(|variant| {
1129                let props = variant.properties.as_ref()?;
1130                let method_prop = props.get("method")?;
1131
1132                // Try "const" first
1133                if let Some(const_val) = method_prop.additional.get("const") {
1134                    return const_val.as_str().map(String::from);
1135                }
1136                // Fall back to enum_values
1137                method_prop
1138                    .enum_values
1139                    .as_ref()?
1140                    .first()?
1141                    .as_str()
1142                    .map(String::from)
1143            })
1144            .collect()
1145    }
1146}
1147
1148impl SchemaProperty {
1149    /// Create a string property
1150    pub fn string() -> Self {
1151        Self {
1152            property_type: Some(serde_json::json!("string")),
1153            description: None,
1154            format: None,
1155            items: None,
1156            properties: None,
1157            required: None,
1158            default: None,
1159            enum_values: None,
1160            reference: None,
1161            additional: HashMap::new(),
1162        }
1163    }
1164
1165    /// Create a UUID property (string with format)
1166    pub fn uuid() -> Self {
1167        Self {
1168            property_type: Some(serde_json::json!("string")),
1169            description: None,
1170            format: Some("uuid".to_string()),
1171            items: None,
1172            properties: None,
1173            required: None,
1174            default: None,
1175            enum_values: None,
1176            reference: None,
1177            additional: HashMap::new(),
1178        }
1179    }
1180
1181    /// Create an integer property
1182    pub fn integer() -> Self {
1183        Self {
1184            property_type: Some(serde_json::json!("integer")),
1185            description: None,
1186            format: None,
1187            items: None,
1188            properties: None,
1189            required: None,
1190            default: None,
1191            enum_values: None,
1192            reference: None,
1193            additional: HashMap::new(),
1194        }
1195    }
1196
1197    /// Create an object property
1198    pub fn object() -> Self {
1199        Self {
1200            property_type: Some(serde_json::json!("object")),
1201            description: None,
1202            format: None,
1203            items: None,
1204            properties: Some(HashMap::new()),
1205            required: None,
1206            default: None,
1207            enum_values: None,
1208            reference: None,
1209            additional: HashMap::new(),
1210        }
1211    }
1212
1213    /// Create an array property
1214    pub fn array(items: SchemaProperty) -> Self {
1215        Self {
1216            property_type: Some(serde_json::json!("array")),
1217            description: None,
1218            format: None,
1219            items: Some(Box::new(items)),
1220            properties: None,
1221            required: None,
1222            default: None,
1223            enum_values: None,
1224            reference: None,
1225            additional: HashMap::new(),
1226        }
1227    }
1228
1229    /// Add a description
1230    pub fn with_description(mut self, description: impl Into<String>) -> Self {
1231        self.description = Some(description.into());
1232        self
1233    }
1234
1235    /// Add a default value
1236    pub fn with_default(mut self, default: serde_json::Value) -> Self {
1237        self.default = Some(default);
1238        self
1239    }
1240
1241    /// Add nested properties (for object types)
1242    pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
1243        self.properties
1244            .get_or_insert_with(HashMap::new)
1245            .insert(name.into(), property);
1246        self
1247    }
1248}
1249
1250#[cfg(test)]
1251#[allow(deprecated)]
1252mod tests {
1253    use super::*;
1254
1255    #[test]
1256    fn test_schema_creation() {
1257        let schema = Schema::object()
1258            .with_property("id", SchemaProperty::uuid().with_description("The unique identifier"))
1259            .with_property("name", SchemaProperty::string().with_description("The name"))
1260            .with_required("id");
1261
1262        assert_eq!(schema.schema_type, Some(serde_json::json!("object")));
1263        assert!(schema.properties.is_some());
1264        assert_eq!(schema.required, Some(vec!["id".to_string()]));
1265    }
1266
1267    #[test]
1268    fn test_serialization() {
1269        let schema = Schema::object()
1270            .with_property("id", SchemaProperty::uuid());
1271
1272        let json = serde_json::to_string_pretty(&schema).unwrap();
1273        assert!(json.contains("uuid"));
1274    }
1275
1276    #[test]
1277    fn test_self_hash_changes_on_method_change() {
1278        let schema1 = PluginSchema::leaf(
1279            "test",
1280            "1.0",
1281            "desc",
1282            vec![MethodSchema::new("foo", "bar", "hash1")],
1283        );
1284
1285        let schema2 = PluginSchema::leaf(
1286            "test",
1287            "1.0",
1288            "desc",
1289            vec![MethodSchema::new("foo", "baz", "hash2")],  // Changed description
1290        );
1291
1292        assert_ne!(schema1.self_hash, schema2.self_hash, "self_hash should change when methods change");
1293        assert_eq!(schema1.children_hash, schema2.children_hash, "children_hash should stay same (both None)");
1294        assert_ne!(schema1.hash, schema2.hash, "composite hash should change");
1295    }
1296
1297    #[test]
1298    fn test_children_hash_changes_on_child_change() {
1299        let child1 = ChildSummary {
1300            namespace: "child".into(),
1301            description: "desc".into(),
1302            hash: "old_hash".into(),
1303        };
1304
1305        let child2 = ChildSummary {
1306            namespace: "child".into(),
1307            description: "desc".into(),
1308            hash: "new_hash".into(),
1309        };
1310
1311        let schema1 = PluginSchema::hub(
1312            "parent",
1313            "1.0",
1314            "desc",
1315            vec![],
1316            vec![child1],
1317        );
1318
1319        let schema2 = PluginSchema::hub(
1320            "parent",
1321            "1.0",
1322            "desc",
1323            vec![],
1324            vec![child2],
1325        );
1326
1327        assert_eq!(schema1.self_hash, schema2.self_hash, "self_hash should stay same (no methods changed)");
1328        assert_ne!(schema1.children_hash, schema2.children_hash, "children_hash should change when child hash changes");
1329        assert_ne!(schema1.hash, schema2.hash, "composite hash should change");
1330    }
1331
1332    #[test]
1333    fn test_leaf_has_no_children_hash() {
1334        let schema = PluginSchema::leaf(
1335            "leaf",
1336            "1.0",
1337            "desc",
1338            vec![MethodSchema::new("method", "desc", "hash")],
1339        );
1340
1341        assert!(schema.children_hash.is_none(), "leaf plugin should have None for children_hash");
1342        assert_ne!(schema.self_hash, schema.hash, "leaf plugin's composite hash is hash(self_hash), not equal to self_hash");
1343    }
1344
1345    // =========================================================================
1346    // IR-2 tests: MethodRole, DeprecationInfo, is_hub derived query
1347    // =========================================================================
1348
1349    /// AC #5: Deserializing a JSON `MethodSchema` with no `role` or
1350    /// `deprecation` fields yields `MethodRole::Rpc` and `None`.
1351    #[test]
1352    fn ir2_default_role_is_rpc_on_deserialize() {
1353        // Pre-IR MethodSchema shape (no role, no deprecation, no return_shape)
1354        let pre_ir_json = serde_json::json!({
1355            "name": "ping",
1356            "description": "pong",
1357            "hash": "abc"
1358        });
1359
1360        let schema: MethodSchema = serde_json::from_value(pre_ir_json).unwrap();
1361        assert_eq!(schema.role, MethodRole::Rpc);
1362        assert!(schema.deprecation.is_none());
1363        assert!(schema.return_shape.is_none());
1364    }
1365
1366    /// AC #5: And at the PluginSchema level — a full pre-IR schema with
1367    /// multiple methods (none carrying `role`) deserializes cleanly with every
1368    /// method defaulted to `Rpc` and no deprecation.
1369    #[test]
1370    fn ir2_plugin_schema_pre_ir_json_deserializes() {
1371        let pre_ir_json = serde_json::json!({
1372            "namespace": "test",
1373            "version": "1.0",
1374            "description": "legacy schema",
1375            "self_hash": "s1",
1376            "hash": "h1",
1377            "methods": [
1378                { "name": "a", "description": "alpha", "hash": "ah" },
1379                { "name": "b", "description": "beta",  "hash": "bh" }
1380            ]
1381        });
1382
1383        let schema: PluginSchema = serde_json::from_value(pre_ir_json).unwrap();
1384        assert_eq!(schema.methods.len(), 2);
1385        for m in &schema.methods {
1386            assert_eq!(m.role, MethodRole::Rpc);
1387            assert!(m.deprecation.is_none());
1388        }
1389    }
1390
1391    /// AC #6: Serde round-trip covering all `MethodRole` variants —
1392    /// `Rpc`, `StaticChild`, and `DynamicChild { list_method, search_method }`.
1393    #[test]
1394    fn ir2_method_role_roundtrip_all_variants() {
1395        let original = PluginSchema::leaf(
1396            "rt",
1397            "1.0",
1398            "round-trip coverage",
1399            vec![
1400                MethodSchema::new("plain", "rpc", "h1"),
1401                MethodSchema::new("child_a", "static", "h2")
1402                    .with_role(MethodRole::StaticChild),
1403                MethodSchema::new("child_b", "dynamic", "h3").with_role(
1404                    MethodRole::DynamicChild {
1405                        list_method: Some("list_x".into()),
1406                        search_method: Some("search_x".into()),
1407                    },
1408                ),
1409            ],
1410        );
1411
1412        let json = serde_json::to_string(&original).unwrap();
1413        let decoded: PluginSchema = serde_json::from_str(&json).unwrap();
1414
1415        assert_eq!(decoded.methods[0].role, MethodRole::Rpc);
1416        assert_eq!(decoded.methods[1].role, MethodRole::StaticChild);
1417        assert_eq!(
1418            decoded.methods[2].role,
1419            MethodRole::DynamicChild {
1420                list_method: Some("list_x".into()),
1421                search_method: Some("search_x".into()),
1422            }
1423        );
1424
1425        // Also survives when the DynamicChild has no list/search hints.
1426        let bare_dyn = MethodSchema::new("child_c", "dynamic-bare", "h4").with_role(
1427            MethodRole::DynamicChild {
1428                list_method: None,
1429                search_method: None,
1430            },
1431        );
1432        let j2 = serde_json::to_string(&bare_dyn).unwrap();
1433        let d2: MethodSchema = serde_json::from_str(&j2).unwrap();
1434        assert_eq!(
1435            d2.role,
1436            MethodRole::DynamicChild {
1437                list_method: None,
1438                search_method: None,
1439            }
1440        );
1441    }
1442
1443    /// AC #7: Serde round-trip for `DeprecationInfo` on a `MethodSchema`.
1444    #[test]
1445    fn ir2_deprecation_info_roundtrip() {
1446        let info = DeprecationInfo {
1447            since: "0.5".into(),
1448            removed_in: "0.6".into(),
1449            message: "use MethodRole".into(),
1450        };
1451        let method = MethodSchema::new("old", "legacy method", "hx")
1452            .with_deprecation(info.clone());
1453
1454        let json = serde_json::to_string(&method).unwrap();
1455        let decoded: MethodSchema = serde_json::from_str(&json).unwrap();
1456
1457        assert_eq!(decoded.deprecation, Some(info));
1458    }
1459
1460    /// AC #4: `PluginSchema::is_hub_by_role()` — the derived query reads only
1461    /// `methods`, not the legacy `children` field.
1462    ///
1463    /// Covers every row of the acceptance-criteria table.
1464    #[test]
1465    fn ir2_is_hub_by_role_derived_query() {
1466        // Row 1: all Rpc → false
1467        let all_rpc = PluginSchema::leaf(
1468            "p",
1469            "1.0",
1470            "all rpc",
1471            vec![
1472                MethodSchema::new("a", "d", "h1"),
1473                MethodSchema::new("b", "d", "h2"),
1474            ],
1475        );
1476        assert!(!all_rpc.is_hub_by_role());
1477        // And the back-compat `is_hub()` also returns false (no children).
1478        assert!(!all_rpc.is_hub());
1479
1480        // Row 2: at least one StaticChild → true
1481        let static_child = PluginSchema::leaf(
1482            "p",
1483            "1.0",
1484            "has static child",
1485            vec![
1486                MethodSchema::new("a", "d", "h1"),
1487                MethodSchema::new("kid", "d", "h2").with_role(MethodRole::StaticChild),
1488            ],
1489        );
1490        assert!(static_child.is_hub_by_role());
1491        assert!(static_child.is_hub());
1492
1493        // Row 3: at least one DynamicChild → true
1494        let dyn_child = PluginSchema::leaf(
1495            "p",
1496            "1.0",
1497            "has dynamic child",
1498            vec![MethodSchema::new("find", "d", "h1").with_role(
1499                MethodRole::DynamicChild {
1500                    list_method: None,
1501                    search_method: None,
1502                },
1503            )],
1504        );
1505        assert!(dyn_child.is_hub_by_role());
1506        assert!(dyn_child.is_hub());
1507
1508        // Row 4: Mix of Rpc + StaticChild → true
1509        let mixed = PluginSchema::leaf(
1510            "p",
1511            "1.0",
1512            "mixed",
1513            vec![
1514                MethodSchema::new("a", "d", "h1"),
1515                MethodSchema::new("b", "d", "h2"),
1516                MethodSchema::new("k", "d", "h3").with_role(MethodRole::StaticChild),
1517            ],
1518        );
1519        assert!(mixed.is_hub_by_role());
1520        assert!(mixed.is_hub());
1521
1522        // Row 5: empty methods → false
1523        let empty = PluginSchema::leaf("p", "1.0", "empty", vec![]);
1524        assert!(!empty.is_hub_by_role());
1525        assert!(!empty.is_hub());
1526    }
1527
1528    /// The derived query is independent of the legacy `children` side channel
1529    /// — a `PluginSchema::hub(...)` with only `Rpc` methods reports
1530    /// `is_hub_by_role() == false` (children don't count) while `is_hub()` is
1531    /// still `true` (transition-window fallback).
1532    #[test]
1533    fn ir2_is_hub_by_role_ignores_children_field() {
1534        let hub_with_rpc_only = PluginSchema::hub(
1535            "h",
1536            "1.0",
1537            "transition",
1538            vec![MethodSchema::new("a", "d", "ah")],
1539            vec![ChildSummary {
1540                namespace: "kid".into(),
1541                description: "child".into(),
1542                hash: "kh".into(),
1543            }],
1544        );
1545
1546        // The derived query reads only methods — no child role → false.
1547        assert!(!hub_with_rpc_only.is_hub_by_role());
1548        // Back-compat `is_hub()` still reports true via the children fallback.
1549        assert!(hub_with_rpc_only.is_hub());
1550    }
1551
1552    /// `ReturnShape` round-trips cleanly via serde.
1553    #[test]
1554    fn ir2_return_shape_roundtrip() {
1555        for shape in [
1556            ReturnShape::Bare,
1557            ReturnShape::Option,
1558            ReturnShape::Result,
1559            ReturnShape::Vec,
1560            ReturnShape::Stream,
1561            ReturnShape::ResultOption,
1562        ] {
1563            let m = MethodSchema::new("m", "d", "h").with_return_shape(shape.clone());
1564            let j = serde_json::to_string(&m).unwrap();
1565            let d: MethodSchema = serde_json::from_str(&j).unwrap();
1566            assert_eq!(d.return_shape, Some(shape));
1567        }
1568    }
1569
1570    // =========================================================================
1571    // IR-4 tests: derive_legacy_fields, relaxed validate_no_collisions,
1572    // deprecation markers.
1573    // =========================================================================
1574
1575    /// AC #4 (row 1): empty method list → no children, not a hub.
1576    #[test]
1577    fn ir4_derive_empty_methods() {
1578        let (children, is_hub) = PluginSchema::derive_legacy_fields(&[]);
1579        assert!(children.is_empty());
1580        assert!(!is_hub);
1581    }
1582
1583    /// AC #4 (row 2): a single `Rpc` method → no children, not a hub.
1584    #[test]
1585    fn ir4_derive_single_rpc_method() {
1586        let methods = vec![MethodSchema::new("ping", "rpc method", "h1")];
1587        let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1588        assert!(children.is_empty());
1589        assert!(!is_hub);
1590    }
1591
1592    /// AC #4 (row 3): one `StaticChild` method named "body" → one child named
1593    /// "body", `is_hub == true`.
1594    #[test]
1595    fn ir4_derive_single_static_child() {
1596        let methods = vec![
1597            MethodSchema::new("body", "static child", "h1")
1598                .with_role(MethodRole::StaticChild),
1599        ];
1600        let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1601        assert_eq!(children.len(), 1);
1602        assert_eq!(children[0].namespace, "body");
1603        assert_eq!(children[0].description, "static child");
1604        assert_eq!(children[0].hash, "");
1605        assert!(is_hub);
1606    }
1607
1608    /// AC #4 (row 4): one `DynamicChild` method named "planet" → one child
1609    /// named "planet", `is_hub == true`.
1610    #[test]
1611    fn ir4_derive_single_dynamic_child() {
1612        let methods = vec![
1613            MethodSchema::new("planet", "dynamic child", "h1").with_role(
1614                MethodRole::DynamicChild {
1615                    list_method: Some("list_planets".into()),
1616                    search_method: None,
1617                },
1618            ),
1619        ];
1620        let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1621        assert_eq!(children.len(), 1);
1622        assert_eq!(children[0].namespace, "planet");
1623        assert!(is_hub);
1624    }
1625
1626    /// AC #4 (row 5): mix of Rpc + StaticChild → one child, `is_hub == true`.
1627    #[test]
1628    fn ir4_derive_mixed_roles_preserves_order() {
1629        let methods = vec![
1630            MethodSchema::new("ping", "rpc", "h1"),
1631            MethodSchema::new("kid_a", "static a", "h2")
1632                .with_role(MethodRole::StaticChild),
1633            MethodSchema::new("describe", "rpc too", "h3"),
1634            MethodSchema::new("kid_b", "static b", "h4")
1635                .with_role(MethodRole::StaticChild),
1636        ];
1637        let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
1638        // Source-order preservation: kid_a appears before kid_b.
1639        assert_eq!(children.len(), 2);
1640        assert_eq!(children[0].namespace, "kid_a");
1641        assert_eq!(children[1].namespace, "kid_b");
1642        assert!(is_hub);
1643    }
1644
1645    /// IR-4: `derive_legacy_fields`'s `is_hub` result matches
1646    /// [`PluginSchema::is_hub_by_role`] on every method list covered by the
1647    /// acceptance-criteria table.
1648    #[test]
1649    fn ir4_derive_is_hub_matches_is_hub_by_role() {
1650        // Empty methods.
1651        let empty_schema = PluginSchema::leaf("t", "1.0", "d", vec![]);
1652        let (_, is_hub) = PluginSchema::derive_legacy_fields(&empty_schema.methods);
1653        assert_eq!(is_hub, empty_schema.is_hub_by_role());
1654
1655        // All-Rpc methods.
1656        let rpc_schema = PluginSchema::leaf(
1657            "t",
1658            "1.0",
1659            "d",
1660            vec![
1661                MethodSchema::new("a", "d", "h1"),
1662                MethodSchema::new("b", "d", "h2"),
1663            ],
1664        );
1665        let (_, is_hub) = PluginSchema::derive_legacy_fields(&rpc_schema.methods);
1666        assert_eq!(is_hub, rpc_schema.is_hub_by_role());
1667
1668        // StaticChild present.
1669        let static_schema = PluginSchema::leaf(
1670            "t",
1671            "1.0",
1672            "d",
1673            vec![
1674                MethodSchema::new("a", "d", "h1"),
1675                MethodSchema::new("kid", "d", "h2").with_role(MethodRole::StaticChild),
1676            ],
1677        );
1678        let (_, is_hub) = PluginSchema::derive_legacy_fields(&static_schema.methods);
1679        assert_eq!(is_hub, static_schema.is_hub_by_role());
1680        assert!(is_hub);
1681
1682        // DynamicChild present.
1683        let dyn_schema = PluginSchema::leaf(
1684            "t",
1685            "1.0",
1686            "d",
1687            vec![MethodSchema::new("find", "d", "h1").with_role(
1688                MethodRole::DynamicChild {
1689                    list_method: None,
1690                    search_method: None,
1691                },
1692            )],
1693        );
1694        let (_, is_hub) = PluginSchema::derive_legacy_fields(&dyn_schema.methods);
1695        assert_eq!(is_hub, dyn_schema.is_hub_by_role());
1696        assert!(is_hub);
1697    }
1698
1699    /// IR-4 rule 2: `validate_no_collisions` no longer panics when a
1700    /// `StaticChild`-role method shares its name with a `ChildSummary` —
1701    /// that's expected by construction (two wire representations of the
1702    /// same child).
1703    #[test]
1704    fn ir4_no_collision_static_child_method_vs_summary() {
1705        // Same name on both surfaces — used to panic, now accepted.
1706        let schema = PluginSchema::hub(
1707            "hub",
1708            "1.0",
1709            "has static child",
1710            vec![
1711                MethodSchema::new("ping", "rpc", "h1"),
1712                MethodSchema::new("kid", "static child", "h2")
1713                    .with_role(MethodRole::StaticChild),
1714            ],
1715            vec![ChildSummary {
1716                namespace: "kid".into(),
1717                description: "static child".into(),
1718                hash: "kh".into(),
1719            }],
1720        );
1721        // Child stayed on the wire.
1722        #[allow(deprecated)]
1723        let kids = schema.children.as_ref().expect("hub has children");
1724        assert_eq!(kids.len(), 1);
1725        assert_eq!(kids[0].namespace, "kid");
1726        // Method kept its role tag.
1727        assert!(matches!(
1728            schema.methods.iter().find(|m| m.name == "kid").unwrap().role,
1729            MethodRole::StaticChild
1730        ));
1731    }
1732
1733    /// IR-4 rule 2: `validate_no_collisions` also tolerates DynamicChild-role
1734    /// method names that appear in the child summary list.
1735    #[test]
1736    fn ir4_no_collision_dynamic_child_method_vs_summary() {
1737        let schema = PluginSchema::hub(
1738            "hub",
1739            "1.0",
1740            "has dynamic child",
1741            vec![MethodSchema::new("body", "gate", "h1").with_role(
1742                MethodRole::DynamicChild {
1743                    list_method: Some("body_names".into()),
1744                    search_method: None,
1745                },
1746            )],
1747            vec![ChildSummary {
1748                namespace: "body".into(),
1749                description: "gate".into(),
1750                hash: "bh".into(),
1751            }],
1752        );
1753        #[allow(deprecated)]
1754        let kids = schema.children.as_ref().unwrap();
1755        assert_eq!(kids.len(), 1);
1756    }
1757
1758    /// IR-4 rule 2: `validate_no_collisions` still panics when an `Rpc`-role
1759    /// method's name collides with a child summary — that's the case the
1760    /// validation was designed to catch.
1761    #[test]
1762    #[should_panic(expected = "method/child collision")]
1763    fn ir4_collision_rpc_method_vs_summary_still_panics() {
1764        let _ = PluginSchema::hub(
1765            "hub",
1766            "1.0",
1767            "bad hub",
1768            vec![MethodSchema::new("oops", "rpc", "h1")],
1769            vec![ChildSummary {
1770                namespace: "oops".into(),
1771                description: "shadowed".into(),
1772                hash: "oh".into(),
1773            }],
1774        );
1775    }
1776
1777    /// IR-4 AC #3 (spec): reading `PluginSchema.children` outside a
1778    /// `#[allow(deprecated)]` block emits a compiler warning. This fixture
1779    /// uses `#[allow(deprecated)]` to confirm the attribute is required —
1780    /// if it weren't, the `#[deprecated]` annotation is either missing or
1781    /// wrong.
1782    #[test]
1783    fn ir4_deprecated_field_access_requires_allow_attribute() {
1784        let schema = PluginSchema::leaf(
1785            "t",
1786            "1.0",
1787            "d",
1788            vec![MethodSchema::new("a", "b", "h")],
1789        );
1790        // Reading the deprecated field — under `#[allow(deprecated)]` from
1791        // the module-level attribute on the tests module. Removing that
1792        // allow would produce a compiler warning pointing at this line.
1793        let _children = schema.children.clone();
1794        // Calling the deprecated method — same rationale.
1795        let _is_hub = schema.is_hub();
1796    }
1797
1798    /// IR-4 AC #8: `PluginSchema.is_hub()` (deprecated) and
1799    /// `PluginSchema::is_hub_by_role()` agree on every shape currently
1800    /// emitted by substrate activations (methods with role tags, children
1801    /// field populated via hub constructor).
1802    #[test]
1803    fn ir4_is_hub_and_is_hub_by_role_agree_on_role_tagged_methods() {
1804        // Pure-leaf, all Rpc: both false.
1805        let leaf = PluginSchema::leaf(
1806            "t",
1807            "1.0",
1808            "d",
1809            vec![MethodSchema::new("a", "d", "h1")],
1810        );
1811        assert_eq!(leaf.is_hub(), leaf.is_hub_by_role());
1812        assert!(!leaf.is_hub());
1813
1814        // Hub with role-tagged methods (today's post-IR-3 shape): both true.
1815        let hub_with_roles = PluginSchema::hub(
1816            "h",
1817            "1.0",
1818            "d",
1819            vec![MethodSchema::new("kid", "d", "h1").with_role(MethodRole::StaticChild)],
1820            vec![ChildSummary {
1821                namespace: "kid".into(),
1822                description: "d".into(),
1823                hash: "".into(),
1824            }],
1825        );
1826        assert_eq!(hub_with_roles.is_hub(), hub_with_roles.is_hub_by_role());
1827        assert!(hub_with_roles.is_hub());
1828    }
1829}