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