Skip to main content

hyperstack_interpreter/
versioned.rs

1//! Versioned AST loader with automatic migration support.
2//!
3//! This module provides:
4//! - Version detection from raw JSON
5//! - Deserialization routing to the correct version
6//! - Automatic migration to the latest AST format
7//!
8//! # Usage
9//!
10//! ```rust,ignore
11//! use hyperstack_interpreter::versioned::{load_stack_spec, load_stream_spec};
12//!
13//! let stack = load_stack_spec(&json_string)?;
14//! let stream = load_stream_spec(&json_string)?;
15//! ```
16
17use serde::Deserialize;
18use serde_json::Value;
19use std::fmt;
20
21use crate::ast::{SerializableStackSpec, SerializableStreamSpec, CURRENT_AST_VERSION};
22
23/// Error type for versioned AST loading failures.
24#[derive(Debug, Clone)]
25pub enum VersionedLoadError {
26    /// The JSON could not be parsed
27    InvalidJson(String),
28    /// The AST version is not supported
29    UnsupportedVersion(String),
30    /// The AST structure is invalid for the detected version
31    InvalidStructure(String),
32}
33
34impl fmt::Display for VersionedLoadError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            VersionedLoadError::InvalidJson(msg) => {
38                write!(f, "Invalid JSON: {}", msg)
39            }
40            VersionedLoadError::UnsupportedVersion(version) => {
41                write!(
42                    f,
43                    "Unsupported AST version: {}. Latest supported version: {}. \
44                     Older versions are supported via automatic migration.",
45                    version, CURRENT_AST_VERSION
46                )
47            }
48            VersionedLoadError::InvalidStructure(msg) => {
49                write!(f, "Invalid AST structure: {}", msg)
50            }
51        }
52    }
53}
54
55impl std::error::Error for VersionedLoadError {}
56
57/// Load a stack spec from JSON with automatic version detection and migration.
58///
59/// This function:
60/// 1. Detects the AST version from the JSON
61/// 2. Deserializes the appropriate version
62/// 3. Migrates to the latest format if needed
63///
64/// # Arguments
65///
66/// * `json` - The JSON string containing the AST
67///
68/// # Returns
69///
70/// The deserialized and migrated `SerializableStackSpec`
71///
72/// # Example
73///
74/// ```rust,ignore
75/// let json = std::fs::read_to_string("MyStack.stack.json")?;
76/// let spec = load_stack_spec(&json)?;
77/// ```
78pub fn load_stack_spec(json: &str) -> Result<SerializableStackSpec, VersionedLoadError> {
79    // Parse raw JSON to detect version
80    let raw: Value =
81        serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?;
82
83    // Extract version - default to "0.0.1" if not present (backwards compatibility)
84    let version = raw
85        .get("ast_version")
86        .and_then(|v| v.as_str())
87        .unwrap_or("0.0.1");
88
89    // Route to appropriate deserializer based on version
90    match version {
91        v if v == CURRENT_AST_VERSION => {
92            // Current version - deserialize directly
93            serde_json::from_value::<SerializableStackSpec>(raw)
94                .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))
95        }
96        // Add migration arms for old versions here, e.g.:
97        // "0.0.1" => { migrate_v1_to_latest(raw) }
98        _ => {
99            // Unknown version
100            Err(VersionedLoadError::UnsupportedVersion(version.to_string()))
101        }
102    }
103}
104
105/// Load a stream spec from JSON with automatic version detection and migration.
106///
107/// Similar to `load_stack_spec` but for entity/stream specs.
108///
109/// # Arguments
110///
111/// * `json` - The JSON string containing the AST
112///
113/// # Returns
114///
115/// The deserialized and migrated `SerializableStreamSpec`
116pub fn load_stream_spec(json: &str) -> Result<SerializableStreamSpec, VersionedLoadError> {
117    // Parse raw JSON to detect version
118    let raw: Value =
119        serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?;
120
121    // Extract version - default to "0.0.1" if not present (backwards compatibility)
122    let version = raw
123        .get("ast_version")
124        .and_then(|v| v.as_str())
125        .unwrap_or("0.0.1");
126
127    // Route to appropriate deserializer based on version
128    match version {
129        v if v == CURRENT_AST_VERSION => {
130            // Current version - deserialize directly
131            serde_json::from_value::<SerializableStreamSpec>(raw)
132                .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))
133        }
134        // Add migration arms for old versions here, e.g.:
135        // "0.0.1" => { migrate_v1_to_latest(raw) }
136        _ => {
137            // Unknown version
138            Err(VersionedLoadError::UnsupportedVersion(version.to_string()))
139        }
140    }
141}
142
143/// Versioned wrapper for SerializableStackSpec.
144///
145/// This enum allows deserializing multiple AST versions and then
146/// converting them to the latest format via `into_latest()`.
147///
148/// ⚠️ IMPORTANT: This enum requires the `ast_version` field to be present in JSON.
149/// It does NOT handle version-less (legacy) JSON files. For loading real-world ASTs
150/// that may lack the `ast_version` field, use `load_stack_spec()` instead.
151///
152/// Note: Only Deserialize is derived to avoid duplicate `ast_version` keys
153/// (the inner struct already has this field, and we only use this for loading).
154#[derive(Debug, Clone, Deserialize)]
155#[serde(tag = "ast_version")]
156pub enum VersionedStackSpec {
157    #[serde(rename = "0.0.1")]
158    V1(SerializableStackSpec),
159}
160
161impl VersionedStackSpec {
162    /// Convert the versioned spec to the latest format.
163    ///
164    /// ⚠️ WARNING: This returns the spec with its original `ast_version` field unchanged.
165    /// If you need round-trip safety (e.g., serialize then deserialize), use `load_stack_spec`
166    /// instead, which properly sets `ast_version` to `CURRENT_AST_VERSION`.
167    pub fn into_latest(self) -> SerializableStackSpec {
168        match self {
169            VersionedStackSpec::V1(spec) => spec,
170        }
171    }
172}
173
174/// Versioned wrapper for SerializableStreamSpec.
175///
176/// This enum allows deserializing multiple AST versions and then
177/// converting them to the latest format via `into_latest()`.
178///
179/// ⚠️ IMPORTANT: This enum requires the `ast_version` field to be present in JSON.
180/// It does NOT handle version-less (legacy) JSON files. For loading real-world ASTs
181/// that may lack the `ast_version` field, use `load_stream_spec()` instead.
182///
183/// Note: Only Deserialize is derived to avoid duplicate `ast_version` keys
184/// (the inner struct already has this field, and we only use this for loading).
185#[derive(Debug, Clone, Deserialize)]
186#[serde(tag = "ast_version")]
187pub enum VersionedStreamSpec {
188    #[serde(rename = "0.0.1")]
189    V1(SerializableStreamSpec),
190}
191
192impl VersionedStreamSpec {
193    /// Convert the versioned spec to the latest format.
194    ///
195    /// ⚠️ WARNING: This returns the spec with its original `ast_version` field unchanged.
196    /// If you need round-trip safety (e.g., serialize then deserialize), use `load_stream_spec`
197    /// instead, which properly sets `ast_version` to `CURRENT_AST_VERSION`.
198    pub fn into_latest(self) -> SerializableStreamSpec {
199        match self {
200            VersionedStreamSpec::V1(spec) => spec,
201        }
202    }
203}
204
205/// Detect the AST version from a JSON string without full deserialization.
206///
207/// This is useful for logging, debugging, or routing decisions.
208///
209/// # Arguments
210///
211/// * `json` - The JSON string containing the AST
212///
213/// # Returns
214///
215/// The detected version string, or `"0.0.1"` if the field is absent (backwards compatibility default).
216///
217/// # Example
218///
219/// ```rust,ignore
220/// let version = detect_ast_version(&json)?;
221/// println!("AST version: {}", version);
222/// ```
223pub fn detect_ast_version(json: &str) -> Result<String, VersionedLoadError> {
224    let raw: Value =
225        serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?;
226
227    Ok(raw
228        .get("ast_version")
229        .and_then(|v| v.as_str())
230        .map(|s| s.to_string())
231        .unwrap_or_else(|| "0.0.1".to_string()))
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_load_stack_spec_v1() {
240        let json = r#"
241        {
242            "ast_version": "0.0.1",
243            "stack_name": "TestStack",
244            "program_ids": [],
245            "idls": [],
246            "entities": [],
247            "pdas": {},
248            "instructions": []
249        }
250        "#;
251
252        let result = load_stack_spec(json);
253        assert!(result.is_ok());
254        let spec = result.unwrap();
255        assert_eq!(spec.stack_name, "TestStack");
256        assert_eq!(spec.ast_version, CURRENT_AST_VERSION);
257    }
258
259    #[test]
260    fn test_load_stack_spec_no_version_defaults_to_v1() {
261        // Test backwards compatibility - no ast_version field should default to 0.0.1
262        let json = r#"
263        {
264            "stack_name": "TestStack",
265            "program_ids": [],
266            "idls": [],
267            "entities": [],
268            "pdas": {},
269            "instructions": []
270        }
271        "#;
272
273        let result = load_stack_spec(json);
274        assert!(result.is_ok());
275        let spec = result.unwrap();
276        assert_eq!(spec.stack_name, "TestStack");
277        assert_eq!(spec.ast_version, CURRENT_AST_VERSION);
278    }
279
280    #[test]
281    fn test_load_stack_spec_unsupported_version() {
282        let json = r#"
283        {
284            "ast_version": "99.0.0",
285            "stack_name": "TestStack",
286            "program_ids": [],
287            "idls": [],
288            "entities": [],
289            "pdas": {},
290            "instructions": []
291        }
292        "#;
293
294        let result = load_stack_spec(json);
295        assert!(result.is_err());
296        match result.unwrap_err() {
297            VersionedLoadError::UnsupportedVersion(v) => assert_eq!(v, "99.0.0"),
298            _ => panic!("Expected UnsupportedVersion error"),
299        }
300    }
301
302    #[test]
303    fn test_load_stream_spec_v1() {
304        let json = r#"
305        {
306            "ast_version": "0.0.1",
307            "state_name": "TestEntity",
308            "identity": {"primary_keys": ["id"], "lookup_indexes": []},
309            "handlers": [],
310            "sections": [],
311            "field_mappings": {},
312            "resolver_hooks": [],
313            "instruction_hooks": [],
314            "resolver_specs": [],
315            "computed_fields": [],
316            "computed_field_specs": [],
317            "views": []
318        }
319        "#;
320
321        let result = load_stream_spec(json);
322        assert!(result.is_ok());
323        let spec = result.unwrap();
324        assert_eq!(spec.state_name, "TestEntity");
325        assert_eq!(spec.ast_version, CURRENT_AST_VERSION);
326    }
327
328    #[test]
329    fn test_load_stream_spec_no_version_defaults_to_v1() {
330        // Test backwards compatibility - no ast_version field should default to 0.0.1
331        let json = r#"
332        {
333            "state_name": "TestEntity",
334            "identity": {"primary_keys": ["id"], "lookup_indexes": []},
335            "handlers": [],
336            "sections": [],
337            "field_mappings": {},
338            "resolver_hooks": [],
339            "instruction_hooks": [],
340            "resolver_specs": [],
341            "computed_fields": [],
342            "computed_field_specs": [],
343            "views": []
344        }
345        "#;
346
347        let result = load_stream_spec(json);
348        assert!(result.is_ok());
349        let spec = result.unwrap();
350        assert_eq!(spec.state_name, "TestEntity");
351        assert_eq!(spec.ast_version, CURRENT_AST_VERSION);
352    }
353
354    #[test]
355    fn test_load_stream_spec_unsupported_version() {
356        let json = r#"
357        {
358            "ast_version": "99.0.0",
359            "state_name": "TestEntity",
360            "identity": {"primary_keys": ["id"], "lookup_indexes": []},
361            "handlers": [],
362            "sections": [],
363            "field_mappings": {},
364            "resolver_hooks": [],
365            "instruction_hooks": [],
366            "resolver_specs": [],
367            "computed_fields": [],
368            "computed_field_specs": [],
369            "views": []
370        }
371        "#;
372
373        let result = load_stream_spec(json);
374        assert!(result.is_err());
375        match result.unwrap_err() {
376            VersionedLoadError::UnsupportedVersion(v) => assert_eq!(v, "99.0.0"),
377            _ => panic!("Expected UnsupportedVersion error"),
378        }
379    }
380
381    #[test]
382    fn test_detect_ast_version() {
383        let json = r#"{"ast_version": "0.0.1", "stack_name": "Test"}"#;
384        assert_eq!(detect_ast_version(json).unwrap(), "0.0.1");
385
386        let json_no_version = r#"{"stack_name": "Test"}"#;
387        assert_eq!(detect_ast_version(json_no_version).unwrap(), "0.0.1");
388    }
389
390    /// Verifies that the AST version constant matches the hyperstack-macros crate.
391    /// This test ensures both crates stay in sync.
392    #[test]
393    fn test_ast_version_sync_with_macros() {
394        // Read the hyperstack-macros' types.rs file
395        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
396        let macros_types_path = std::path::Path::new(&manifest_dir)
397            .join("..") // Go up to workspace root
398            .join("hyperstack-macros")
399            .join("src")
400            .join("ast")
401            .join("types.rs");
402
403        // Verify the file exists before attempting to read
404        assert!(
405            macros_types_path.exists(),
406            "Cannot find hyperstack-macros source file at {:?}. \
407             This test requires the source tree to be available.",
408            macros_types_path
409        );
410
411        let content = std::fs::read_to_string(&macros_types_path)
412            .expect("Failed to read hyperstack-macros/src/ast/types.rs");
413
414        // Parse the CURRENT_AST_VERSION constant
415        let version_line = content
416            .lines()
417            .find(|line| line.contains("pub const CURRENT_AST_VERSION"))
418            .expect("CURRENT_AST_VERSION not found in hyperstack-macros");
419
420        let version_str = version_line
421            .split('=')
422            .nth(1)
423            .and_then(|rhs| rhs.split('"').nth(1))
424            .expect("Failed to parse version string");
425
426        assert_eq!(
427            version_str, CURRENT_AST_VERSION,
428            "AST version mismatch! interpreter has '{}', hyperstack-macros has '{}'. \
429             Both crates must have the same CURRENT_AST_VERSION. \
430             Update both files when bumping the version.",
431            CURRENT_AST_VERSION, version_str
432        );
433    }
434}