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}