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;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14// ============================================================================
15// Plugin Schema
16// ============================================================================
17
18/// A plugin's schema with methods and child summaries.
19///
20/// Children are represented as summaries (namespace, description, hash) rather
21/// than full recursive schemas. This enables lazy traversal - clients can fetch
22/// child schemas individually via `{namespace}.schema`.
23///
24/// - Leaf plugins have `children = None`
25/// - Hub plugins have `children = Some([ChildSummary, ...])`
26#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
27pub struct PluginSchema {
28    /// The plugin's namespace (e.g., "echo", "plexus")
29    pub namespace: String,
30
31    /// The plugin's version (e.g., "1.0.0")
32    pub version: String,
33
34    /// Short description of the plugin (max 15 words)
35    pub description: String,
36
37    /// Detailed description of the plugin (optional)
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub long_description: Option<String>,
40
41    /// Content hash computed from methods + children hashes (for cache invalidation)
42    /// This hash changes when any method or child plugin changes
43    pub hash: String,
44
45    /// Methods exposed by this plugin
46    pub methods: Vec<MethodSchema>,
47
48    /// Child plugin summaries (None = leaf plugin, Some = hub plugin)
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub children: Option<Vec<ChildSummary>>,
51}
52
53/// Result of a schema query - either full plugin or single method
54#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
55#[serde(untagged)]
56pub enum SchemaResult {
57    /// Full plugin schema (when no method specified)
58    Plugin(PluginSchema),
59    /// Single method schema (when method specified)
60    Method(MethodSchema),
61}
62
63/// Schema for a single method exposed by a plugin
64#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
65pub struct MethodSchema {
66    /// Method name (e.g., "echo", "check")
67    pub name: String,
68
69    /// Human-readable description of what this method does
70    pub description: String,
71
72    /// Content hash of the method definition (for cache invalidation)
73    /// Generated by hashing the method signature within hub-macro
74    pub hash: String,
75
76    /// JSON Schema for the method's parameters (None if no params)
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub params: Option<schemars::Schema>,
79
80    /// JSON Schema for the method's return type (None if not specified)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub returns: Option<schemars::Schema>,
83
84    /// Whether this method streams multiple events (true) or returns a single result (false)
85    ///
86    /// - `streaming: true` → returns `AsyncGenerator<T>` (multiple events)
87    /// - `streaming: false` → returns `Promise<T>` (single event, collected)
88    ///
89    /// All methods use the same streaming protocol under the hood, but this flag
90    /// tells clients how to present the result.
91    #[serde(default)]
92    pub streaming: bool,
93}
94
95impl PluginSchema {
96    /// Compute hash from methods and children
97    fn compute_hash(methods: &[MethodSchema], children: Option<&[ChildSummary]>) -> String {
98        use std::collections::hash_map::DefaultHasher;
99        use std::hash::{Hash, Hasher};
100
101        let mut hasher = DefaultHasher::new();
102
103        // Hash all method hashes
104        for m in methods {
105            m.hash.hash(&mut hasher);
106        }
107
108        // Hash all children hashes
109        if let Some(kids) = children {
110            for c in kids {
111                c.hash.hash(&mut hasher);
112            }
113        }
114
115        format!("{:016x}", hasher.finish())
116    }
117
118    /// Validate no name collisions exist within a plugin
119    ///
120    /// Checks for:
121    /// - Duplicate method names
122    /// - Duplicate child names (for hubs)
123    /// - Method/child name collisions (for hubs)
124    ///
125    /// Panics if a collision is detected (system error).
126    fn validate_no_collisions(
127        namespace: &str,
128        methods: &[MethodSchema],
129        children: Option<&[ChildSummary]>,
130    ) {
131        use std::collections::HashSet;
132
133        let mut seen: HashSet<&str> = HashSet::new();
134
135        // Check method names
136        for m in methods {
137            if !seen.insert(&m.name) {
138                panic!(
139                    "Name collision in plugin '{}': duplicate method '{}'",
140                    namespace, m.name
141                );
142            }
143        }
144
145        // Check child names (and collisions with methods)
146        if let Some(kids) = children {
147            for c in kids {
148                if !seen.insert(&c.namespace) {
149                    // Could be duplicate child or collision with method
150                    let collision_type = if methods.iter().any(|m| m.name == c.namespace) {
151                        "method/child collision"
152                    } else {
153                        "duplicate child"
154                    };
155                    panic!(
156                        "Name collision in plugin '{}': {} for '{}'",
157                        namespace, collision_type, c.namespace
158                    );
159                }
160            }
161        }
162    }
163
164    /// Create a new leaf plugin schema (no children)
165    pub fn leaf(
166        namespace: impl Into<String>,
167        version: impl Into<String>,
168        description: impl Into<String>,
169        methods: Vec<MethodSchema>,
170    ) -> Self {
171        let namespace = namespace.into();
172        Self::validate_no_collisions(&namespace, &methods, None);
173        let hash = Self::compute_hash(&methods, None);
174        Self {
175            namespace,
176            version: version.into(),
177            description: description.into(),
178            long_description: None,
179            hash,
180            methods,
181            children: None,
182        }
183    }
184
185    /// Create a new leaf plugin schema with long description
186    pub fn leaf_with_long_description(
187        namespace: impl Into<String>,
188        version: impl Into<String>,
189        description: impl Into<String>,
190        long_description: impl Into<String>,
191        methods: Vec<MethodSchema>,
192    ) -> Self {
193        let namespace = namespace.into();
194        Self::validate_no_collisions(&namespace, &methods, None);
195        let hash = Self::compute_hash(&methods, None);
196        Self {
197            namespace,
198            version: version.into(),
199            description: description.into(),
200            long_description: Some(long_description.into()),
201            hash,
202            methods,
203            children: None,
204        }
205    }
206
207    /// Create a new hub plugin schema (with child summaries)
208    pub fn hub(
209        namespace: impl Into<String>,
210        version: impl Into<String>,
211        description: impl Into<String>,
212        methods: Vec<MethodSchema>,
213        children: Vec<ChildSummary>,
214    ) -> Self {
215        let namespace = namespace.into();
216        Self::validate_no_collisions(&namespace, &methods, Some(&children));
217        let hash = Self::compute_hash(&methods, Some(&children));
218        Self {
219            namespace,
220            version: version.into(),
221            description: description.into(),
222            long_description: None,
223            hash,
224            methods,
225            children: Some(children),
226        }
227    }
228
229    /// Create a new hub plugin schema with long description
230    pub fn hub_with_long_description(
231        namespace: impl Into<String>,
232        version: impl Into<String>,
233        description: impl Into<String>,
234        long_description: impl Into<String>,
235        methods: Vec<MethodSchema>,
236        children: Vec<ChildSummary>,
237    ) -> Self {
238        let namespace = namespace.into();
239        Self::validate_no_collisions(&namespace, &methods, Some(&children));
240        let hash = Self::compute_hash(&methods, Some(&children));
241        Self {
242            namespace,
243            version: version.into(),
244            description: description.into(),
245            long_description: Some(long_description.into()),
246            hash,
247            methods,
248            children: Some(children),
249        }
250    }
251
252    /// Check if this is a hub (has children)
253    pub fn is_hub(&self) -> bool {
254        self.children.is_some()
255    }
256
257    /// Check if this is a leaf (no children)
258    pub fn is_leaf(&self) -> bool {
259        self.children.is_none()
260    }
261}
262
263/// Summary of a child plugin
264#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
265pub struct ChildSummary {
266    /// The child's namespace
267    pub namespace: String,
268
269    /// Human-readable description
270    pub description: String,
271
272    /// Content hash for cache invalidation
273    pub hash: String,
274}
275
276impl MethodSchema {
277    /// Create a new method schema with name, description, and hash
278    ///
279    /// The hash should be computed from the method definition string
280    /// within the hub-macro at compile time.
281    pub fn new(
282        name: impl Into<String>,
283        description: impl Into<String>,
284        hash: impl Into<String>,
285    ) -> Self {
286        Self {
287            name: name.into(),
288            description: description.into(),
289            hash: hash.into(),
290            params: None,
291            returns: None,
292            streaming: false,
293        }
294    }
295
296    /// Add parameter schema
297    pub fn with_params(mut self, params: schemars::Schema) -> Self {
298        self.params = Some(params);
299        self
300    }
301
302    /// Add return type schema
303    pub fn with_returns(mut self, returns: schemars::Schema) -> Self {
304        self.returns = Some(returns);
305        self
306    }
307
308    /// Set the streaming flag
309    ///
310    /// - `true` → method streams multiple events (use `AsyncGenerator<T>`)
311    /// - `false` → method returns single result (use `Promise<T>`)
312    pub fn with_streaming(mut self, streaming: bool) -> Self {
313        self.streaming = streaming;
314        self
315    }
316}
317
318// ============================================================================
319// JSON Schema Types
320// ============================================================================
321
322/// A complete JSON Schema with metadata
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct Schema {
325    /// The JSON Schema specification version
326    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none", default)]
327    pub schema_version: Option<String>,
328
329    /// Title of the schema
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub title: Option<String>,
332
333    /// Description of what this schema represents
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub description: Option<String>,
336
337    /// The schema type (typically "object" for root, can be string or array)
338    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
339    pub schema_type: Option<serde_json::Value>,
340
341    /// Properties for object types
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub properties: Option<HashMap<String, SchemaProperty>>,
344
345    /// Required properties
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub required: Option<Vec<String>>,
348
349    /// Enum variants (for discriminated unions)
350    #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
351    pub one_of: Option<Vec<Schema>>,
352
353    /// Schema definitions (for $defs or definitions)
354    #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
355    pub defs: Option<HashMap<String, serde_json::Value>>,
356
357    /// Any additional schema properties
358    #[serde(flatten)]
359    pub additional: HashMap<String, serde_json::Value>,
360}
361
362/// Schema type enumeration
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
364#[serde(rename_all = "lowercase")]
365pub enum SchemaType {
366    Object,
367    Array,
368    String,
369    Number,
370    Integer,
371    Boolean,
372    Null,
373}
374
375/// A property definition in a schema
376#[derive(Debug, Clone, Serialize, Deserialize)]
377pub struct SchemaProperty {
378    /// The type of this property (can be a single type or array of types for nullable)
379    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
380    pub property_type: Option<serde_json::Value>,
381
382    /// Description of this property
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub description: Option<String>,
385
386    /// Format hint (e.g., "uuid", "date-time", "email")
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub format: Option<String>,
389
390    /// For array types, the schema of items
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub items: Option<Box<SchemaProperty>>,
393
394    /// For object types, nested properties
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub properties: Option<HashMap<String, SchemaProperty>>,
397
398    /// Required properties (for object types)
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub required: Option<Vec<String>>,
401
402    /// Default value for this property
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub default: Option<serde_json::Value>,
405
406    /// Enum values if this is an enum
407    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
408    pub enum_values: Option<Vec<serde_json::Value>>,
409
410    /// Reference to another schema definition
411    #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
412    pub reference: Option<String>,
413
414    /// Any additional property metadata
415    #[serde(flatten)]
416    pub additional: HashMap<String, serde_json::Value>,
417}
418
419impl Schema {
420    /// Create a new schema with basic metadata
421    pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
422        Self {
423            schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
424            title: Some(title.into()),
425            description: Some(description.into()),
426            schema_type: None,
427            properties: None,
428            required: None,
429            one_of: None,
430            defs: None,
431            additional: HashMap::new(),
432        }
433    }
434
435    /// Create an object schema
436    pub fn object() -> Self {
437        Self {
438            schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
439            title: None,
440            description: None,
441            schema_type: Some(serde_json::json!("object")),
442            properties: Some(HashMap::new()),
443            required: None,
444            one_of: None,
445            defs: None,
446            additional: HashMap::new(),
447        }
448    }
449
450    /// Add a property to this schema
451    pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
452        self.properties
453            .get_or_insert_with(HashMap::new)
454            .insert(name.into(), property);
455        self
456    }
457
458    /// Mark a property as required
459    pub fn with_required(mut self, name: impl Into<String>) -> Self {
460        self.required
461            .get_or_insert_with(Vec::new)
462            .push(name.into());
463        self
464    }
465
466    /// Set the description
467    pub fn with_description(mut self, description: impl Into<String>) -> Self {
468        self.description = Some(description.into());
469        self
470    }
471
472    /// Extract a single method's schema from the oneOf array
473    ///
474    /// Searches the oneOf variants for a method matching the given name.
475    /// Returns the variant schema if found, None otherwise.
476    pub fn get_method_schema(&self, method_name: &str) -> Option<Schema> {
477        let variants = self.one_of.as_ref()?;
478
479        for variant in variants {
480            // Check if this variant has a "method" property with const or enum
481            if let Some(props) = &variant.properties {
482                if let Some(method_prop) = props.get("method") {
483                    // Try "const" first (schemars uses this for literal values)
484                    if let Some(const_val) = method_prop.additional.get("const") {
485                        if const_val.as_str() == Some(method_name) {
486                            return Some(variant.clone());
487                        }
488                    }
489                    // Fall back to enum_values
490                    if let Some(enum_vals) = &method_prop.enum_values {
491                        if enum_vals.first().and_then(|v| v.as_str()) == Some(method_name) {
492                            return Some(variant.clone());
493                        }
494                    }
495                }
496            }
497        }
498        None
499    }
500
501    /// List all method names from the oneOf array
502    pub fn list_methods(&self) -> Vec<String> {
503        let Some(variants) = &self.one_of else {
504            return Vec::new();
505        };
506
507        variants
508            .iter()
509            .filter_map(|variant| {
510                let props = variant.properties.as_ref()?;
511                let method_prop = props.get("method")?;
512
513                // Try "const" first
514                if let Some(const_val) = method_prop.additional.get("const") {
515                    return const_val.as_str().map(String::from);
516                }
517                // Fall back to enum_values
518                method_prop
519                    .enum_values
520                    .as_ref()?
521                    .first()?
522                    .as_str()
523                    .map(String::from)
524            })
525            .collect()
526    }
527}
528
529impl SchemaProperty {
530    /// Create a string property
531    pub fn string() -> Self {
532        Self {
533            property_type: Some(serde_json::json!("string")),
534            description: None,
535            format: None,
536            items: None,
537            properties: None,
538            required: None,
539            default: None,
540            enum_values: None,
541            reference: None,
542            additional: HashMap::new(),
543        }
544    }
545
546    /// Create a UUID property (string with format)
547    pub fn uuid() -> Self {
548        Self {
549            property_type: Some(serde_json::json!("string")),
550            description: None,
551            format: Some("uuid".to_string()),
552            items: None,
553            properties: None,
554            required: None,
555            default: None,
556            enum_values: None,
557            reference: None,
558            additional: HashMap::new(),
559        }
560    }
561
562    /// Create an integer property
563    pub fn integer() -> Self {
564        Self {
565            property_type: Some(serde_json::json!("integer")),
566            description: None,
567            format: None,
568            items: None,
569            properties: None,
570            required: None,
571            default: None,
572            enum_values: None,
573            reference: None,
574            additional: HashMap::new(),
575        }
576    }
577
578    /// Create an object property
579    pub fn object() -> Self {
580        Self {
581            property_type: Some(serde_json::json!("object")),
582            description: None,
583            format: None,
584            items: None,
585            properties: Some(HashMap::new()),
586            required: None,
587            default: None,
588            enum_values: None,
589            reference: None,
590            additional: HashMap::new(),
591        }
592    }
593
594    /// Create an array property
595    pub fn array(items: SchemaProperty) -> Self {
596        Self {
597            property_type: Some(serde_json::json!("array")),
598            description: None,
599            format: None,
600            items: Some(Box::new(items)),
601            properties: None,
602            required: None,
603            default: None,
604            enum_values: None,
605            reference: None,
606            additional: HashMap::new(),
607        }
608    }
609
610    /// Add a description
611    pub fn with_description(mut self, description: impl Into<String>) -> Self {
612        self.description = Some(description.into());
613        self
614    }
615
616    /// Add a default value
617    pub fn with_default(mut self, default: serde_json::Value) -> Self {
618        self.default = Some(default);
619        self
620    }
621
622    /// Add nested properties (for object types)
623    pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
624        self.properties
625            .get_or_insert_with(HashMap::new)
626            .insert(name.into(), property);
627        self
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn test_schema_creation() {
637        let schema = Schema::object()
638            .with_property("id", SchemaProperty::uuid().with_description("The unique identifier"))
639            .with_property("name", SchemaProperty::string().with_description("The name"))
640            .with_required("id");
641
642        assert_eq!(schema.schema_type, Some(serde_json::json!("object")));
643        assert!(schema.properties.is_some());
644        assert_eq!(schema.required, Some(vec!["id".to_string()]));
645    }
646
647    #[test]
648    fn test_serialization() {
649        let schema = Schema::object()
650            .with_property("id", SchemaProperty::uuid());
651
652        let json = serde_json::to_string_pretty(&schema).unwrap();
653        assert!(json.contains("uuid"));
654    }
655}