Skip to main content

hub_codegen/
ir.rs

1//! Intermediate Representation types
2//!
3//! These types match Synapse's IR output format exactly for deserialization.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Generator tool version information
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct GeneratorInfo {
12    /// Tool name (e.g., "synapse", "synapse-cc")
13    pub gi_tool: String,
14    /// Version string (e.g., "0.2.0.0")
15    pub gi_version: String,
16}
17
18/// Generation metadata tracking the full toolchain
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct GenerationMetadata {
22    /// All tools in the generation chain
23    pub gm_generators: Vec<GeneratorInfo>,
24    /// ISO 8601 timestamp of generation
25    pub gm_timestamp: String,
26    /// IR format version
27    pub gm_ir_version: String,
28}
29
30/// Top-level IR structure (matches Synapse output)
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct IR {
34    /// IR format version
35    pub ir_version: String,
36    /// Backend name (e.g., "substrate", "plexus")
37    pub ir_backend: String,
38    /// Plexus hash for versioning (optional, computed from schema tree)
39    #[serde(default)]
40    pub ir_hash: Option<String>,
41    /// Generation toolchain metadata
42    #[serde(default)]
43    pub ir_metadata: Option<GenerationMetadata>,
44    /// Named type definitions (structs, enums, aliases)
45    pub ir_types: HashMap<String, TypeDef>,
46    /// Method definitions keyed by full path (e.g., "cone.chat")
47    pub ir_methods: HashMap<String, MethodDef>,
48    /// Plugin -> method names mapping
49    pub ir_plugins: HashMap<String, Vec<String>>,
50}
51
52/// Type definition
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct TypeDef {
56    pub td_name: String,
57    pub td_namespace: String,
58    #[serde(default)]
59    pub td_description: Option<String>,
60    pub td_kind: TypeKind,
61}
62
63impl TypeDef {
64    /// Compute the fully qualified type name
65    pub fn full_name(&self) -> String {
66        format!("{}.{}", self.td_namespace, self.td_name)
67    }
68}
69
70/// Kind of type (Haskell-style tagged union)
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(tag = "tag")]
73pub enum TypeKind {
74    /// Struct with named fields
75    KindStruct {
76        #[serde(rename = "ksFields")]
77        ks_fields: Vec<FieldDef>,
78    },
79    /// Tagged union (discriminated by "type" field)
80    KindEnum {
81        /// Field that discriminates (e.g., "type")
82        #[serde(rename = "keDiscriminator")]
83        ke_discriminator: String,
84        #[serde(rename = "keVariants")]
85        ke_variants: Vec<VariantDef>,
86    },
87    /// Type alias
88    KindAlias {
89        #[serde(rename = "kaTarget")]
90        ka_target: TypeRef,
91    },
92    /// Primitive type
93    KindPrimitive {
94        #[serde(rename = "kpType")]
95        kp_type: String,
96        #[serde(rename = "kpFormat")]
97        kp_format: Option<String>,
98    },
99    /// String enum (simple enum with string values)
100    KindStringEnum {
101        #[serde(rename = "kseValues")]
102        kse_values: Vec<String>,
103    },
104}
105
106/// Field in a struct
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct FieldDef {
110    pub fd_name: String,
111    pub fd_type: TypeRef,
112    #[serde(default)]
113    pub fd_description: Option<String>,
114    #[serde(default)]
115    pub fd_required: bool,
116    #[serde(default)]
117    pub fd_default: Option<serde_json::Value>,
118}
119
120/// Variant in an enum
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct VariantDef {
124    pub vd_name: String,
125    #[serde(default)]
126    pub vd_description: Option<String>,
127    #[serde(default)]
128    pub vd_fields: Vec<FieldDef>,
129}
130
131/// Qualified name for type references (namespace.localName)
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133#[serde(rename_all = "camelCase")]
134pub struct QualifiedName {
135    pub qn_namespace: String,
136    pub qn_local_name: String,
137}
138
139impl QualifiedName {
140    /// Get the full qualified name as "namespace.localName" or just "localName" if namespace is empty
141    pub fn full_name(&self) -> String {
142        if self.qn_namespace.is_empty() {
143            self.qn_local_name.clone()
144        } else {
145            format!("{}.{}", self.qn_namespace, self.qn_local_name)
146        }
147    }
148
149    /// Get the namespace, returning None if empty
150    pub fn namespace(&self) -> Option<&str> {
151        if self.qn_namespace.is_empty() {
152            None
153        } else {
154            Some(&self.qn_namespace)
155        }
156    }
157
158    /// Get the local name
159    pub fn local_name(&self) -> &str {
160        &self.qn_local_name
161    }
162}
163
164/// Reference to a type (Haskell-style tagged union with contents)
165///
166/// Haskell Aeson emits:
167/// - Variants with data: {"tag": "RefNamed", "contents": {...}}
168/// - Unit variants: {"tag": "RefAny"} (no contents field)
169///
170/// We use a custom deserializer to handle both cases.
171#[derive(Debug, Clone, Serialize)]
172pub enum TypeRef {
173    /// Named type reference
174    RefNamed(QualifiedName),
175    /// Primitive type with optional format
176    RefPrimitive(String, Option<String>),
177    /// Array type
178    RefArray(Box<TypeRef>),
179    /// Optional type
180    RefOptional(Box<TypeRef>),
181    /// Intentionally dynamic (serde_json::Value) - accepts any JSON, no warning
182    RefAny,
183    /// Unknown type (schema gap) - should warn
184    RefUnknown,
185}
186
187impl<'de> serde::Deserialize<'de> for TypeRef {
188    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
189    where
190        D: serde::Deserializer<'de>,
191    {
192        use serde::de::Error;
193
194        let value = serde_json::Value::deserialize(deserializer)?;
195        let obj = value.as_object().ok_or_else(|| D::Error::custom("expected object"))?;
196
197        let tag = obj.get("tag")
198            .and_then(|v| v.as_str())
199            .ok_or_else(|| D::Error::custom("missing tag field"))?;
200
201        match tag {
202            "RefNamed" => {
203                let contents = obj.get("contents")
204                    .ok_or_else(|| D::Error::custom("RefNamed requires contents"))?;
205                let qname: QualifiedName = serde_json::from_value(contents.clone())
206                    .map_err(|e| D::Error::custom(format!("RefNamed contents must be QualifiedName: {}", e)))?;
207                Ok(TypeRef::RefNamed(qname))
208            }
209            "RefPrimitive" => {
210                let contents = obj.get("contents")
211                    .and_then(|v| v.as_array())
212                    .ok_or_else(|| D::Error::custom("RefPrimitive requires array contents"))?;
213                let prim = contents.get(0)
214                    .and_then(|v| v.as_str())
215                    .ok_or_else(|| D::Error::custom("RefPrimitive[0] must be string"))?;
216                let format = contents.get(1)
217                    .and_then(|v| v.as_str())
218                    .map(|s| s.to_string());
219                Ok(TypeRef::RefPrimitive(prim.to_string(), format))
220            }
221            "RefArray" => {
222                let contents = obj.get("contents")
223                    .ok_or_else(|| D::Error::custom("RefArray requires contents"))?;
224                let inner: TypeRef = serde_json::from_value(contents.clone())
225                    .map_err(|e| D::Error::custom(format!("RefArray inner: {}", e)))?;
226                Ok(TypeRef::RefArray(Box::new(inner)))
227            }
228            "RefOptional" => {
229                let contents = obj.get("contents")
230                    .ok_or_else(|| D::Error::custom("RefOptional requires contents"))?;
231                let inner: TypeRef = serde_json::from_value(contents.clone())
232                    .map_err(|e| D::Error::custom(format!("RefOptional inner: {}", e)))?;
233                Ok(TypeRef::RefOptional(Box::new(inner)))
234            }
235            "RefAny" => Ok(TypeRef::RefAny),
236            "RefUnknown" => Ok(TypeRef::RefUnknown),
237            other => Err(D::Error::custom(format!("unknown TypeRef tag: {}", other))),
238        }
239    }
240}
241
242/// Method definition
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct MethodDef {
246    pub md_name: String,
247    pub md_full_path: String,
248    pub md_namespace: String,
249    #[serde(default)]
250    pub md_description: Option<String>,
251    #[serde(default)]
252    pub md_streaming: bool,
253    #[serde(default)]
254    pub md_params: Vec<ParamDef>,
255    pub md_returns: TypeRef,
256    /// Bidirectional channel type parameter T.
257    ///
258    /// When a method uses `BidirChannel<StandardRequest<T>, StandardResponse<T>>` or
259    /// `Arc<StandardBidirChannel>` (the T=Value default), this field describes T.
260    ///
261    /// - `None`  → the method is not bidirectional, OR it uses the default
262    ///             `T = serde_json::Value` (i.e., `StandardBidirChannel`)
263    /// - `Some(TypeRef::RefAny)` → bidirectional with T=Value (explicit marker)
264    /// - `Some(TypeRef::RefNamed(...))` → bidirectional with a specific T type
265    ///
266    /// # Schema field
267    ///
268    /// The synapse IR builder populates this from the `"bidirType"` field in the
269    /// method schema JSON (emitted when `bidirectional: true` in `MethodSchema`
270    /// with a non-Value `request_type`).  When the schema only has
271    /// `bidirectional: true` but no `bidir_type` field, `None` is emitted.
272    #[serde(default)]
273    pub md_bidir_type: Option<TypeRef>,
274}
275
276/// Parameter definition
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct ParamDef {
280    pub pd_name: String,
281    pub pd_type: TypeRef,
282    #[serde(default)]
283    pub pd_description: Option<String>,
284    #[serde(default)]
285    pub pd_required: bool,
286    #[serde(default)]
287    pub pd_default: Option<serde_json::Value>,
288}
289
290// === Helper methods for code generation ===
291
292impl TypeRef {
293    /// Convert to TypeScript type string (fully qualified - joins namespace.Name as NamespaceName)
294    pub fn to_ts(&self) -> String {
295        match self {
296            TypeRef::RefNamed(qname) => to_upper_camel(&qname.full_name()),
297            TypeRef::RefPrimitive(prim, format) => primitive_to_ts(prim, format.as_deref()),
298            TypeRef::RefArray(inner) => format!("{}[]", inner.to_ts()),
299            TypeRef::RefOptional(inner) => format!("{} | null", inner.to_ts()),
300            TypeRef::RefAny => "unknown".to_string(),     // Intentionally dynamic
301            TypeRef::RefUnknown => "unknown".to_string(), // Schema gap (will warn)
302        }
303    }
304
305    /// Convert to TypeScript type string within a namespace context
306    /// Always uses local name - cross-namespace types are handled via imports
307    pub fn to_ts_in_namespace(&self, current_namespace: &str) -> String {
308        match self {
309            TypeRef::RefNamed(qname) => {
310                // Always use local name - imports handle cross-namespace references
311                to_upper_camel(qname.local_name())
312            }
313            TypeRef::RefPrimitive(prim, format) => primitive_to_ts(prim, format.as_deref()),
314            TypeRef::RefArray(inner) => format!("{}[]", inner.to_ts_in_namespace(current_namespace)),
315            TypeRef::RefOptional(inner) => format!("{} | null", inner.to_ts_in_namespace(current_namespace)),
316            TypeRef::RefAny => "unknown".to_string(),
317            TypeRef::RefUnknown => "unknown".to_string(),
318        }
319    }
320
321    /// Get the namespace from a RefNamed, if qualified
322    pub fn get_namespace(&self) -> Option<&str> {
323        match self {
324            TypeRef::RefNamed(qname) => qname.namespace(),
325            _ => None,
326        }
327    }
328
329    /// Check if this is an unknown type (schema gap that should warn)
330    pub fn is_unknown(&self) -> bool {
331        matches!(self, TypeRef::RefUnknown)
332    }
333
334    /// Check if this contains an unknown type anywhere
335    pub fn contains_unknown(&self) -> bool {
336        match self {
337            TypeRef::RefUnknown => true,
338            TypeRef::RefArray(inner) => inner.contains_unknown(),
339            TypeRef::RefOptional(inner) => inner.contains_unknown(),
340            _ => false,
341        }
342    }
343}
344
345fn primitive_to_ts(prim: &str, format: Option<&str>) -> String {
346    match (prim, format) {
347        ("string", Some("uuid")) => "string".to_string(), // UUID as string
348        ("string", _) => "string".to_string(),
349        ("integer", _) | ("number", _) => "number".to_string(),
350        ("boolean", _) => "boolean".to_string(),
351        ("array", _) => "unknown[]".to_string(),
352        ("object", _) => "Record<string, unknown>".to_string(),
353        _ => "unknown".to_string(),
354    }
355}
356
357fn to_upper_camel(s: &str) -> String {
358    // Handle namespace-qualified types like "cone.ListResult" → "ConeListResult"
359    s.split('.')
360        .map(|part| {
361            let mut result = String::new();
362            let mut capitalize = true;
363            for c in part.chars() {
364                if c == '_' || c == '-' {
365                    capitalize = true;
366                } else if capitalize {
367                    result.push(c.to_ascii_uppercase());
368                    capitalize = false;
369                } else {
370                    result.push(c);
371                }
372            }
373            result
374        })
375        .collect::<Vec<_>>()
376        .join("")
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_qualified_name() {
385        // Test with namespace
386        let qn = QualifiedName {
387            qn_namespace: "cone".to_string(),
388            qn_local_name: "UUID".to_string(),
389        };
390        assert_eq!(qn.full_name(), "cone.UUID");
391        assert_eq!(qn.namespace(), Some("cone"));
392        assert_eq!(qn.local_name(), "UUID");
393
394        // Test without namespace (empty)
395        let qn_no_ns = QualifiedName {
396            qn_namespace: "".to_string(),
397            qn_local_name: "LocalType".to_string(),
398        };
399        assert_eq!(qn_no_ns.full_name(), "LocalType");
400        assert_eq!(qn_no_ns.namespace(), None);
401        assert_eq!(qn_no_ns.local_name(), "LocalType");
402    }
403
404    #[test]
405    fn test_qualified_name_deserialization() {
406        // Test deserializing v2.0 format with QualifiedName
407        let json = r#"{
408            "tag": "RefNamed",
409            "contents": {
410                "qnNamespace": "cone",
411                "qnLocalName": "UUID"
412            }
413        }"#;
414        let type_ref: TypeRef = serde_json::from_str(json).unwrap();
415
416        if let TypeRef::RefNamed(qname) = type_ref {
417            assert_eq!(qname.qn_namespace, "cone");
418            assert_eq!(qname.qn_local_name, "UUID");
419            assert_eq!(qname.full_name(), "cone.UUID");
420        } else {
421            panic!("Expected RefNamed variant");
422        }
423    }
424
425    #[test]
426    fn test_type_ref_to_ts() {
427        let chat_event = TypeRef::RefNamed(QualifiedName {
428            qn_namespace: "".to_string(),
429            qn_local_name: "ChatEvent".to_string(),
430        });
431        assert_eq!(chat_event.to_ts(), "ChatEvent");
432
433        assert_eq!(TypeRef::RefPrimitive("string".to_string(), None).to_ts(), "string");
434        assert_eq!(TypeRef::RefPrimitive("string".to_string(), Some("uuid".to_string())).to_ts(), "string");
435        assert_eq!(TypeRef::RefPrimitive("integer".to_string(), Some("int64".to_string())).to_ts(), "number");
436
437        let node = TypeRef::RefNamed(QualifiedName {
438            qn_namespace: "".to_string(),
439            qn_local_name: "Node".to_string(),
440        });
441        assert_eq!(TypeRef::RefArray(Box::new(node)).to_ts(), "Node[]");
442
443        let pos = TypeRef::RefNamed(QualifiedName {
444            qn_namespace: "".to_string(),
445            qn_local_name: "Pos".to_string(),
446        });
447        assert_eq!(TypeRef::RefOptional(Box::new(pos)).to_ts(), "Pos | null");
448
449        assert_eq!(TypeRef::RefAny.to_ts(), "unknown");     // Intentional - no warning
450        assert_eq!(TypeRef::RefUnknown.to_ts(), "unknown"); // Schema gap - will warn
451    }
452
453    #[test]
454    fn test_unknown_detection() {
455        assert!(!TypeRef::RefAny.is_unknown());
456        assert!(TypeRef::RefUnknown.is_unknown());
457
458        let foo = TypeRef::RefNamed(QualifiedName {
459            qn_namespace: "".to_string(),
460            qn_local_name: "Foo".to_string(),
461        });
462        assert!(!foo.is_unknown());
463
464        // contains_unknown
465        assert!(!TypeRef::RefAny.contains_unknown());
466        assert!(TypeRef::RefUnknown.contains_unknown());
467        assert!(TypeRef::RefArray(Box::new(TypeRef::RefUnknown)).contains_unknown());
468        assert!(!TypeRef::RefArray(Box::new(TypeRef::RefAny)).contains_unknown());
469    }
470
471    /// Test that `md_bidir_type` is correctly deserialized from IR JSON.
472    ///
473    /// Verifies three cases:
474    /// 1. Field absent       → `None` (legacy IR / non-bidir method)
475    /// 2. `null`             → `None` (explicit null from synapse)
476    /// 3. `{"tag":"RefAny"}` → `Some(TypeRef::RefAny)` (T=Value bidir)
477    /// 4. `{"tag":"RefNamed",...}` → `Some(TypeRef::RefNamed(...))` (specific T)
478    #[test]
479    fn test_method_def_bidir_type_deserialization() {
480        // 1. Field absent → None (backward compatibility)
481        let json_no_field = r#"{
482            "mdName": "wizard",
483            "mdFullPath": "interactive.wizard",
484            "mdNamespace": "interactive",
485            "mdStreaming": true,
486            "mdParams": [],
487            "mdReturns": {"tag": "RefAny"}
488        }"#;
489        let method: MethodDef = serde_json::from_str(json_no_field).unwrap();
490        assert!(method.md_bidir_type.is_none(), "absent field should default to None");
491
492        // 2. Explicit null → None
493        let json_null = r#"{
494            "mdName": "wizard",
495            "mdFullPath": "interactive.wizard",
496            "mdNamespace": "interactive",
497            "mdStreaming": true,
498            "mdParams": [],
499            "mdReturns": {"tag": "RefAny"},
500            "mdBidirType": null
501        }"#;
502        let method: MethodDef = serde_json::from_str(json_null).unwrap();
503        assert!(method.md_bidir_type.is_none(), "null should deserialize to None");
504
505        // 3. RefAny → Some(TypeRef::RefAny)  (standard bidirectional, T=Value)
506        let json_ref_any = r#"{
507            "mdName": "wizard",
508            "mdFullPath": "interactive.wizard",
509            "mdNamespace": "interactive",
510            "mdStreaming": true,
511            "mdParams": [],
512            "mdReturns": {"tag": "RefAny"},
513            "mdBidirType": {"tag": "RefAny"}
514        }"#;
515        let method: MethodDef = serde_json::from_str(json_ref_any).unwrap();
516        assert!(
517            matches!(method.md_bidir_type, Some(TypeRef::RefAny)),
518            "RefAny tag should deserialize to Some(RefAny)"
519        );
520
521        // 4. RefNamed → Some(TypeRef::RefNamed(...))  (typed bidirectional)
522        let json_ref_named = r#"{
523            "mdName": "wizard",
524            "mdFullPath": "interactive.wizard",
525            "mdNamespace": "interactive",
526            "mdStreaming": true,
527            "mdParams": [],
528            "mdReturns": {"tag": "RefAny"},
529            "mdBidirType": {
530                "tag": "RefNamed",
531                "contents": {
532                    "qnNamespace": "interactive",
533                    "qnLocalName": "WizardRequest"
534                }
535            }
536        }"#;
537        let method: MethodDef = serde_json::from_str(json_ref_named).unwrap();
538        if let Some(TypeRef::RefNamed(qn)) = method.md_bidir_type {
539            assert_eq!(qn.qn_namespace, "interactive");
540            assert_eq!(qn.qn_local_name, "WizardRequest");
541        } else {
542            panic!("Expected Some(RefNamed(...)) for typed bidirectional");
543        }
544    }
545}