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// Plugin Schema
18// ============================================================================
19
20/// A plugin's schema with methods and child summaries.
21///
22/// Children are represented as summaries (namespace, description, hash) rather
23/// than full recursive schemas. This enables lazy traversal - clients can fetch
24/// child schemas individually via `{namespace}.schema`.
25///
26/// - Leaf plugins have `children = None`
27/// - Hub plugins have `children = Some([ChildSummary, ...])`
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
29pub struct PluginSchema {
30    /// The plugin's namespace (e.g., "echo", "plexus")
31    pub namespace: String,
32
33    /// The plugin's version (e.g., "1.0.0")
34    pub version: String,
35
36    /// Short description of the plugin (max 15 words)
37    pub description: String,
38
39    /// Detailed description of the plugin (optional)
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub long_description: Option<String>,
42
43    /// Hash of ONLY this plugin's methods (ignores children)
44    /// Changes when method signatures, names, or descriptions change
45    pub self_hash: String,
46
47    /// Hash of ONLY child plugin hashes (None for leaf plugins)
48    /// Changes when any child's hash changes (recursively)
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub children_hash: Option<String>,
51
52    /// Composite hash = hash(self_hash + children_hash)
53    /// Use this if you want a single hash for the entire subtree
54    /// Backward compatible with previous single-hash system
55    pub hash: String,
56
57    /// Methods exposed by this plugin
58    pub methods: Vec<MethodSchema>,
59
60    /// Child plugin summaries (None = leaf plugin, Some = hub plugin)
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub children: Option<Vec<ChildSummary>>,
63}
64
65/// Result of a schema query - either full plugin or single method
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
67#[serde(untagged)]
68pub enum SchemaResult {
69    /// Full plugin schema (when no method specified)
70    Plugin(PluginSchema),
71    /// Single method schema (when method specified)
72    Method(MethodSchema),
73}
74
75/// Schema for a single method exposed by a plugin
76#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
77pub struct MethodSchema {
78    /// Method name (e.g., "echo", "check")
79    pub name: String,
80
81    /// Human-readable description of what this method does
82    pub description: String,
83
84    /// Content hash of the method definition (for cache invalidation)
85    /// Generated by hashing the method signature within hub-macro
86    pub hash: String,
87
88    /// JSON Schema for the method's parameters (None if no params)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub params: Option<schemars::Schema>,
91
92    /// JSON Schema for the method's return type (None if not specified)
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub returns: Option<schemars::Schema>,
95
96    /// Whether this method streams multiple events (true) or returns a single result (false)
97    ///
98    /// - `streaming: true` → returns `AsyncGenerator<T>` (multiple events)
99    /// - `streaming: false` → returns `Promise<T>` (single event, collected)
100    ///
101    /// All methods use the same streaming protocol under the hood, but this flag
102    /// tells clients how to present the result.
103    #[serde(default)]
104    pub streaming: bool,
105
106    /// Whether this method supports bidirectional communication
107    ///
108    /// When true, the server can send requests to the client during method execution
109    /// and wait for responses (e.g., confirmations, prompts, selections).
110    #[serde(default)]
111    pub bidirectional: bool,
112
113    /// JSON Schema for the request type sent from server to client
114    ///
115    /// Only relevant when `bidirectional: true`. Describes the structure of
116    /// requests the server may send during method execution.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub request_type: Option<schemars::Schema>,
119
120    /// JSON Schema for the response type sent from client to server
121    ///
122    /// Only relevant when `bidirectional: true`. Describes the structure of
123    /// responses the client should send in reply to server requests.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub response_type: Option<schemars::Schema>,
126}
127
128impl PluginSchema {
129    /// Compute all three hashes (self, children, composite)
130    fn compute_hashes(
131        methods: &[MethodSchema],
132        children: Option<&[ChildSummary]>,
133    ) -> (String, Option<String>, String) {
134        use std::collections::hash_map::DefaultHasher;
135        use std::hash::{Hash, Hasher};
136
137        // Compute self_hash (methods only)
138        let mut self_hasher = DefaultHasher::new();
139        for m in methods {
140            m.hash.hash(&mut self_hasher);
141        }
142        let self_hash = format!("{:016x}", self_hasher.finish());
143
144        // Compute children_hash (children only)
145        let children_hash = children.map(|kids| {
146            let mut children_hasher = DefaultHasher::new();
147            for c in kids {
148                c.hash.hash(&mut children_hasher);
149            }
150            format!("{:016x}", children_hasher.finish())
151        });
152
153        // Compute composite hash (both)
154        let mut composite_hasher = DefaultHasher::new();
155        self_hash.hash(&mut composite_hasher);
156        if let Some(ref ch) = children_hash {
157            ch.hash(&mut composite_hasher);
158        }
159        let hash = format!("{:016x}", composite_hasher.finish());
160
161        (self_hash, children_hash, hash)
162    }
163
164    /// Validate no name collisions exist within a plugin
165    ///
166    /// Checks for:
167    /// - Duplicate method names
168    /// - Duplicate child names (for hubs)
169    /// - Method/child name collisions (for hubs)
170    ///
171    /// Panics if a collision is detected (system error).
172    fn validate_no_collisions(
173        namespace: &str,
174        methods: &[MethodSchema],
175        children: Option<&[ChildSummary]>,
176    ) {
177        use std::collections::HashSet;
178
179        let mut seen: HashSet<&str> = HashSet::new();
180
181        // Check method names
182        for m in methods {
183            if !seen.insert(&m.name) {
184                panic!(
185                    "Name collision in plugin '{}': duplicate method '{}'",
186                    namespace, m.name
187                );
188            }
189        }
190
191        // Check child names (and collisions with methods)
192        if let Some(kids) = children {
193            for c in kids {
194                if !seen.insert(&c.namespace) {
195                    // Could be duplicate child or collision with method
196                    let collision_type = if methods.iter().any(|m| m.name == c.namespace) {
197                        "method/child collision"
198                    } else {
199                        "duplicate child"
200                    };
201                    panic!(
202                        "Name collision in plugin '{}': {} for '{}'",
203                        namespace, collision_type, c.namespace
204                    );
205                }
206            }
207        }
208    }
209
210    /// Create a new leaf plugin schema (no children)
211    pub fn leaf(
212        namespace: impl Into<String>,
213        version: impl Into<String>,
214        description: impl Into<String>,
215        methods: Vec<MethodSchema>,
216    ) -> Self {
217        let namespace = namespace.into();
218        Self::validate_no_collisions(&namespace, &methods, None);
219        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, None);
220        Self {
221            namespace,
222            version: version.into(),
223            description: description.into(),
224            long_description: None,
225            self_hash,
226            children_hash,
227            hash,
228            methods,
229            children: None,
230        }
231    }
232
233    /// Create a new leaf plugin schema with long description
234    pub fn leaf_with_long_description(
235        namespace: impl Into<String>,
236        version: impl Into<String>,
237        description: impl Into<String>,
238        long_description: impl Into<String>,
239        methods: Vec<MethodSchema>,
240    ) -> Self {
241        let namespace = namespace.into();
242        Self::validate_no_collisions(&namespace, &methods, None);
243        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, None);
244        Self {
245            namespace,
246            version: version.into(),
247            description: description.into(),
248            long_description: Some(long_description.into()),
249            self_hash,
250            children_hash,
251            hash,
252            methods,
253            children: None,
254        }
255    }
256
257    /// Create a new hub plugin schema (with child summaries)
258    pub fn hub(
259        namespace: impl Into<String>,
260        version: impl Into<String>,
261        description: impl Into<String>,
262        methods: Vec<MethodSchema>,
263        children: Vec<ChildSummary>,
264    ) -> Self {
265        let namespace = namespace.into();
266        Self::validate_no_collisions(&namespace, &methods, Some(&children));
267        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, Some(&children));
268        Self {
269            namespace,
270            version: version.into(),
271            description: description.into(),
272            long_description: None,
273            self_hash,
274            children_hash,
275            hash,
276            methods,
277            children: Some(children),
278        }
279    }
280
281    /// Create a new hub plugin schema with long description
282    pub fn hub_with_long_description(
283        namespace: impl Into<String>,
284        version: impl Into<String>,
285        description: impl Into<String>,
286        long_description: impl Into<String>,
287        methods: Vec<MethodSchema>,
288        children: Vec<ChildSummary>,
289    ) -> Self {
290        let namespace = namespace.into();
291        Self::validate_no_collisions(&namespace, &methods, Some(&children));
292        let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, Some(&children));
293        Self {
294            namespace,
295            version: version.into(),
296            description: description.into(),
297            long_description: Some(long_description.into()),
298            self_hash,
299            children_hash,
300            hash,
301            methods,
302            children: Some(children),
303        }
304    }
305
306    /// Check if this is a hub (has children)
307    pub fn is_hub(&self) -> bool {
308        self.children.is_some()
309    }
310
311    /// Check if this is a leaf (no children)
312    pub fn is_leaf(&self) -> bool {
313        self.children.is_none()
314    }
315}
316
317/// Summary of a child plugin
318#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
319pub struct ChildSummary {
320    /// The child's namespace
321    pub namespace: String,
322
323    /// Human-readable description
324    pub description: String,
325
326    /// Content hash for cache invalidation
327    pub hash: String,
328}
329
330/// Schema summary containing only hashes (for cache validation)
331#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
332pub struct PluginHashes {
333    pub namespace: String,
334    pub self_hash: String,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub children_hash: Option<String>,
337    pub hash: String,
338    /// Child plugin hashes (for recursive checking)
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub children: Option<Vec<ChildHashes>>,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
344pub struct ChildHashes {
345    pub namespace: String,
346    pub hash: String,
347}
348
349impl MethodSchema {
350    /// Create a new method schema with name, description, and hash
351    ///
352    /// The hash should be computed from the method definition string
353    /// within the hub-macro at compile time.
354    pub fn new(
355        name: impl Into<String>,
356        description: impl Into<String>,
357        hash: impl Into<String>,
358    ) -> Self {
359        Self {
360            name: name.into(),
361            description: description.into(),
362            hash: hash.into(),
363            params: None,
364            returns: None,
365            streaming: false,
366            bidirectional: false,
367            request_type: None,
368            response_type: None,
369        }
370    }
371
372    /// Add parameter schema
373    pub fn with_params(mut self, params: schemars::Schema) -> Self {
374        self.params = Some(params);
375        self
376    }
377
378    /// Add return type schema
379    pub fn with_returns(mut self, returns: schemars::Schema) -> Self {
380        self.returns = Some(returns);
381        self
382    }
383
384    /// Set the streaming flag
385    ///
386    /// - `true` → method streams multiple events (use `AsyncGenerator<T>`)
387    /// - `false` → method returns single result (use `Promise<T>`)
388    pub fn with_streaming(mut self, streaming: bool) -> Self {
389        self.streaming = streaming;
390        self
391    }
392
393    /// Set whether this method supports bidirectional communication
394    ///
395    /// When true, the server can send requests to the client during method
396    /// execution and wait for responses.
397    pub fn with_bidirectional(mut self, bidirectional: bool) -> Self {
398        self.bidirectional = bidirectional;
399        self
400    }
401
402    /// Set the JSON Schema for server-to-client request types
403    ///
404    /// Only relevant when `bidirectional: true`. Use `schema_for!(YourRequestType)`
405    /// to generate the schema.
406    pub fn with_request_type(mut self, schema: schemars::Schema) -> Self {
407        self.request_type = Some(schema);
408        self
409    }
410
411    /// Set the JSON Schema for client-to-server response types
412    ///
413    /// Only relevant when `bidirectional: true`. Use `schema_for!(YourResponseType)`
414    /// to generate the schema.
415    pub fn with_response_type(mut self, schema: schemars::Schema) -> Self {
416        self.response_type = Some(schema);
417        self
418    }
419
420    /// Configure method for standard bidirectional communication
421    ///
422    /// Sets `bidirectional: true` and configures request/response types to use
423    /// `StandardRequest` and `StandardResponse`, which support common UI patterns
424    /// like confirmations, prompts, and selections.
425    pub fn with_standard_bidirectional(self) -> Self {
426        self.with_bidirectional(true)
427            .with_request_type(schema_for!(StandardRequest).into())
428            .with_response_type(schema_for!(StandardResponse).into())
429    }
430}
431
432// ============================================================================
433// JSON Schema Types
434// ============================================================================
435
436/// A complete JSON Schema with metadata
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct Schema {
439    /// The JSON Schema specification version
440    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none", default)]
441    pub schema_version: Option<String>,
442
443    /// Title of the schema
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub title: Option<String>,
446
447    /// Description of what this schema represents
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub description: Option<String>,
450
451    /// The schema type (typically "object" for root, can be string or array)
452    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
453    pub schema_type: Option<serde_json::Value>,
454
455    /// Properties for object types
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub properties: Option<HashMap<String, SchemaProperty>>,
458
459    /// Required properties
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub required: Option<Vec<String>>,
462
463    /// Enum variants (for discriminated unions)
464    #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
465    pub one_of: Option<Vec<Schema>>,
466
467    /// Schema definitions (for $defs or definitions)
468    #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
469    pub defs: Option<HashMap<String, serde_json::Value>>,
470
471    /// Any additional schema properties
472    #[serde(flatten)]
473    pub additional: HashMap<String, serde_json::Value>,
474}
475
476/// Schema type enumeration
477#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
478#[serde(rename_all = "lowercase")]
479pub enum SchemaType {
480    Object,
481    Array,
482    String,
483    Number,
484    Integer,
485    Boolean,
486    Null,
487}
488
489/// A property definition in a schema
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct SchemaProperty {
492    /// The type of this property (can be a single type or array of types for nullable)
493    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
494    pub property_type: Option<serde_json::Value>,
495
496    /// Description of this property
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub description: Option<String>,
499
500    /// Format hint (e.g., "uuid", "date-time", "email")
501    #[serde(skip_serializing_if = "Option::is_none")]
502    pub format: Option<String>,
503
504    /// For array types, the schema of items
505    #[serde(skip_serializing_if = "Option::is_none")]
506    pub items: Option<Box<SchemaProperty>>,
507
508    /// For object types, nested properties
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub properties: Option<HashMap<String, SchemaProperty>>,
511
512    /// Required properties (for object types)
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub required: Option<Vec<String>>,
515
516    /// Default value for this property
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub default: Option<serde_json::Value>,
519
520    /// Enum values if this is an enum
521    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
522    pub enum_values: Option<Vec<serde_json::Value>>,
523
524    /// Reference to another schema definition
525    #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
526    pub reference: Option<String>,
527
528    /// Any additional property metadata
529    #[serde(flatten)]
530    pub additional: HashMap<String, serde_json::Value>,
531}
532
533impl Schema {
534    /// Create a new schema with basic metadata
535    pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
536        Self {
537            schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
538            title: Some(title.into()),
539            description: Some(description.into()),
540            schema_type: None,
541            properties: None,
542            required: None,
543            one_of: None,
544            defs: None,
545            additional: HashMap::new(),
546        }
547    }
548
549    /// Create an object schema
550    pub fn object() -> Self {
551        Self {
552            schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
553            title: None,
554            description: None,
555            schema_type: Some(serde_json::json!("object")),
556            properties: Some(HashMap::new()),
557            required: None,
558            one_of: None,
559            defs: None,
560            additional: HashMap::new(),
561        }
562    }
563
564    /// Add a property to this schema
565    pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
566        self.properties
567            .get_or_insert_with(HashMap::new)
568            .insert(name.into(), property);
569        self
570    }
571
572    /// Mark a property as required
573    pub fn with_required(mut self, name: impl Into<String>) -> Self {
574        self.required
575            .get_or_insert_with(Vec::new)
576            .push(name.into());
577        self
578    }
579
580    /// Set the description
581    pub fn with_description(mut self, description: impl Into<String>) -> Self {
582        self.description = Some(description.into());
583        self
584    }
585
586    /// Extract a single method's schema from the oneOf array
587    ///
588    /// Searches the oneOf variants for a method matching the given name.
589    /// Returns the variant schema if found, None otherwise.
590    pub fn get_method_schema(&self, method_name: &str) -> Option<Schema> {
591        let variants = self.one_of.as_ref()?;
592
593        for variant in variants {
594            // Check if this variant has a "method" property with const or enum
595            if let Some(props) = &variant.properties {
596                if let Some(method_prop) = props.get("method") {
597                    // Try "const" first (schemars uses this for literal values)
598                    if let Some(const_val) = method_prop.additional.get("const") {
599                        if const_val.as_str() == Some(method_name) {
600                            return Some(variant.clone());
601                        }
602                    }
603                    // Fall back to enum_values
604                    if let Some(enum_vals) = &method_prop.enum_values {
605                        if enum_vals.first().and_then(|v| v.as_str()) == Some(method_name) {
606                            return Some(variant.clone());
607                        }
608                    }
609                }
610            }
611        }
612        None
613    }
614
615    /// List all method names from the oneOf array
616    pub fn list_methods(&self) -> Vec<String> {
617        let Some(variants) = &self.one_of else {
618            return Vec::new();
619        };
620
621        variants
622            .iter()
623            .filter_map(|variant| {
624                let props = variant.properties.as_ref()?;
625                let method_prop = props.get("method")?;
626
627                // Try "const" first
628                if let Some(const_val) = method_prop.additional.get("const") {
629                    return const_val.as_str().map(String::from);
630                }
631                // Fall back to enum_values
632                method_prop
633                    .enum_values
634                    .as_ref()?
635                    .first()?
636                    .as_str()
637                    .map(String::from)
638            })
639            .collect()
640    }
641}
642
643impl SchemaProperty {
644    /// Create a string property
645    pub fn string() -> Self {
646        Self {
647            property_type: Some(serde_json::json!("string")),
648            description: None,
649            format: None,
650            items: None,
651            properties: None,
652            required: None,
653            default: None,
654            enum_values: None,
655            reference: None,
656            additional: HashMap::new(),
657        }
658    }
659
660    /// Create a UUID property (string with format)
661    pub fn uuid() -> Self {
662        Self {
663            property_type: Some(serde_json::json!("string")),
664            description: None,
665            format: Some("uuid".to_string()),
666            items: None,
667            properties: None,
668            required: None,
669            default: None,
670            enum_values: None,
671            reference: None,
672            additional: HashMap::new(),
673        }
674    }
675
676    /// Create an integer property
677    pub fn integer() -> Self {
678        Self {
679            property_type: Some(serde_json::json!("integer")),
680            description: None,
681            format: None,
682            items: None,
683            properties: None,
684            required: None,
685            default: None,
686            enum_values: None,
687            reference: None,
688            additional: HashMap::new(),
689        }
690    }
691
692    /// Create an object property
693    pub fn object() -> Self {
694        Self {
695            property_type: Some(serde_json::json!("object")),
696            description: None,
697            format: None,
698            items: None,
699            properties: Some(HashMap::new()),
700            required: None,
701            default: None,
702            enum_values: None,
703            reference: None,
704            additional: HashMap::new(),
705        }
706    }
707
708    /// Create an array property
709    pub fn array(items: SchemaProperty) -> Self {
710        Self {
711            property_type: Some(serde_json::json!("array")),
712            description: None,
713            format: None,
714            items: Some(Box::new(items)),
715            properties: None,
716            required: None,
717            default: None,
718            enum_values: None,
719            reference: None,
720            additional: HashMap::new(),
721        }
722    }
723
724    /// Add a description
725    pub fn with_description(mut self, description: impl Into<String>) -> Self {
726        self.description = Some(description.into());
727        self
728    }
729
730    /// Add a default value
731    pub fn with_default(mut self, default: serde_json::Value) -> Self {
732        self.default = Some(default);
733        self
734    }
735
736    /// Add nested properties (for object types)
737    pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
738        self.properties
739            .get_or_insert_with(HashMap::new)
740            .insert(name.into(), property);
741        self
742    }
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    #[test]
750    fn test_schema_creation() {
751        let schema = Schema::object()
752            .with_property("id", SchemaProperty::uuid().with_description("The unique identifier"))
753            .with_property("name", SchemaProperty::string().with_description("The name"))
754            .with_required("id");
755
756        assert_eq!(schema.schema_type, Some(serde_json::json!("object")));
757        assert!(schema.properties.is_some());
758        assert_eq!(schema.required, Some(vec!["id".to_string()]));
759    }
760
761    #[test]
762    fn test_serialization() {
763        let schema = Schema::object()
764            .with_property("id", SchemaProperty::uuid());
765
766        let json = serde_json::to_string_pretty(&schema).unwrap();
767        assert!(json.contains("uuid"));
768    }
769
770    #[test]
771    fn test_self_hash_changes_on_method_change() {
772        let schema1 = PluginSchema::leaf(
773            "test",
774            "1.0",
775            "desc",
776            vec![MethodSchema::new("foo", "bar", "hash1")],
777        );
778
779        let schema2 = PluginSchema::leaf(
780            "test",
781            "1.0",
782            "desc",
783            vec![MethodSchema::new("foo", "baz", "hash2")],  // Changed description
784        );
785
786        assert_ne!(schema1.self_hash, schema2.self_hash, "self_hash should change when methods change");
787        assert_eq!(schema1.children_hash, schema2.children_hash, "children_hash should stay same (both None)");
788        assert_ne!(schema1.hash, schema2.hash, "composite hash should change");
789    }
790
791    #[test]
792    fn test_children_hash_changes_on_child_change() {
793        let child1 = ChildSummary {
794            namespace: "child".into(),
795            description: "desc".into(),
796            hash: "old_hash".into(),
797        };
798
799        let child2 = ChildSummary {
800            namespace: "child".into(),
801            description: "desc".into(),
802            hash: "new_hash".into(),
803        };
804
805        let schema1 = PluginSchema::hub(
806            "parent",
807            "1.0",
808            "desc",
809            vec![],
810            vec![child1],
811        );
812
813        let schema2 = PluginSchema::hub(
814            "parent",
815            "1.0",
816            "desc",
817            vec![],
818            vec![child2],
819        );
820
821        assert_eq!(schema1.self_hash, schema2.self_hash, "self_hash should stay same (no methods changed)");
822        assert_ne!(schema1.children_hash, schema2.children_hash, "children_hash should change when child hash changes");
823        assert_ne!(schema1.hash, schema2.hash, "composite hash should change");
824    }
825
826    #[test]
827    fn test_leaf_has_no_children_hash() {
828        let schema = PluginSchema::leaf(
829            "leaf",
830            "1.0",
831            "desc",
832            vec![MethodSchema::new("method", "desc", "hash")],
833        );
834
835        assert!(schema.children_hash.is_none(), "leaf plugin should have None for children_hash");
836        assert_ne!(schema.self_hash, schema.hash, "leaf plugin's composite hash is hash(self_hash), not equal to self_hash");
837    }
838}