hyperstack-interpreter 0.6.9

AST transformation runtime and VM for HyperStack streaming pipelines
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
//! Versioned AST loader with automatic migration support.
//!
//! This module provides:
//! - Version detection from raw JSON
//! - Deserialization routing to the correct version
//! - Automatic migration to the latest AST format
//!
//! # Usage
//!
//! ```rust,ignore
//! use hyperstack_interpreter::versioned::{load_stack_spec, load_stream_spec};
//!
//! let stack = load_stack_spec(&json_string)?;
//! let stream = load_stream_spec(&json_string)?;
//! ```

use serde::Deserialize;
use serde_json::Value;
use std::fmt;

use crate::ast::{SerializableStackSpec, SerializableStreamSpec, CURRENT_AST_VERSION};

/// Error type for versioned AST loading failures.
#[derive(Debug, Clone)]
pub enum VersionedLoadError {
    /// The JSON could not be parsed
    InvalidJson(String),
    /// The AST version is not supported
    UnsupportedVersion(String),
    /// The AST structure is invalid for the detected version
    InvalidStructure(String),
}

impl fmt::Display for VersionedLoadError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            VersionedLoadError::InvalidJson(msg) => {
                write!(f, "Invalid JSON: {}", msg)
            }
            VersionedLoadError::UnsupportedVersion(version) => {
                write!(
                    f,
                    "Unsupported AST version: {}. Latest supported version: {}. \
                     Older versions are supported via automatic migration.",
                    version, CURRENT_AST_VERSION
                )
            }
            VersionedLoadError::InvalidStructure(msg) => {
                write!(f, "Invalid AST structure: {}", msg)
            }
        }
    }
}

impl std::error::Error for VersionedLoadError {}

/// Load a stack spec from JSON with automatic version detection and migration.
///
/// This function:
/// 1. Detects the AST version from the JSON
/// 2. Deserializes the appropriate version
/// 3. Migrates to the latest format if needed
///
/// # Arguments
///
/// * `json` - The JSON string containing the AST
///
/// # Returns
///
/// The deserialized and migrated `SerializableStackSpec`
///
/// # Example
///
/// ```rust,ignore
/// let json = std::fs::read_to_string("MyStack.stack.json")?;
/// let spec = load_stack_spec(&json)?;
/// ```
pub fn load_stack_spec(json: &str) -> Result<SerializableStackSpec, VersionedLoadError> {
    // Parse raw JSON to detect version
    let raw: Value =
        serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?;

    // Extract version - default to "0.0.1" if not present (backwards compatibility)
    let version = raw
        .get("ast_version")
        .and_then(|v| v.as_str())
        .unwrap_or("0.0.1");

    // Route to appropriate deserializer based on version
    match version {
        v if v == CURRENT_AST_VERSION => {
            // Current version - deserialize directly
            serde_json::from_value::<SerializableStackSpec>(raw)
                .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))
        }
        // Add migration arms for old versions here, e.g.:
        // "0.0.1" => { migrate_v1_to_latest(raw) }
        _ => {
            // Unknown version
            Err(VersionedLoadError::UnsupportedVersion(version.to_string()))
        }
    }
}

/// Load a stream spec from JSON with automatic version detection and migration.
///
/// Similar to `load_stack_spec` but for entity/stream specs.
///
/// # Arguments
///
/// * `json` - The JSON string containing the AST
///
/// # Returns
///
/// The deserialized and migrated `SerializableStreamSpec`
pub fn load_stream_spec(json: &str) -> Result<SerializableStreamSpec, VersionedLoadError> {
    // Parse raw JSON to detect version
    let raw: Value =
        serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?;

    // Extract version - default to "0.0.1" if not present (backwards compatibility)
    let version = raw
        .get("ast_version")
        .and_then(|v| v.as_str())
        .unwrap_or("0.0.1");

    // Route to appropriate deserializer based on version
    match version {
        v if v == CURRENT_AST_VERSION => {
            // Current version - deserialize directly
            serde_json::from_value::<SerializableStreamSpec>(raw)
                .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))
        }
        // Add migration arms for old versions here, e.g.:
        // "0.0.1" => { migrate_v1_to_latest(raw) }
        _ => {
            // Unknown version
            Err(VersionedLoadError::UnsupportedVersion(version.to_string()))
        }
    }
}

/// Versioned wrapper for SerializableStackSpec.
///
/// This enum allows deserializing multiple AST versions and then
/// converting them to the latest format via `into_latest()`.
///
/// ⚠️ IMPORTANT: This enum requires the `ast_version` field to be present in JSON.
/// It does NOT handle version-less (legacy) JSON files. For loading real-world ASTs
/// that may lack the `ast_version` field, use `load_stack_spec()` instead.
///
/// Note: Only Deserialize is derived to avoid duplicate `ast_version` keys
/// (the inner struct already has this field, and we only use this for loading).
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "ast_version")]
pub enum VersionedStackSpec {
    #[serde(rename = "0.0.1")]
    V1(SerializableStackSpec),
}

impl VersionedStackSpec {
    /// Convert the versioned spec to the latest format.
    ///
    /// ⚠️ WARNING: This returns the spec with its original `ast_version` field unchanged.
    /// If you need round-trip safety (e.g., serialize then deserialize), use `load_stack_spec`
    /// instead, which properly sets `ast_version` to `CURRENT_AST_VERSION`.
    pub fn into_latest(self) -> SerializableStackSpec {
        match self {
            VersionedStackSpec::V1(spec) => spec,
        }
    }
}

/// Versioned wrapper for SerializableStreamSpec.
///
/// This enum allows deserializing multiple AST versions and then
/// converting them to the latest format via `into_latest()`.
///
/// ⚠️ IMPORTANT: This enum requires the `ast_version` field to be present in JSON.
/// It does NOT handle version-less (legacy) JSON files. For loading real-world ASTs
/// that may lack the `ast_version` field, use `load_stream_spec()` instead.
///
/// Note: Only Deserialize is derived to avoid duplicate `ast_version` keys
/// (the inner struct already has this field, and we only use this for loading).
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "ast_version")]
pub enum VersionedStreamSpec {
    #[serde(rename = "0.0.1")]
    V1(SerializableStreamSpec),
}

impl VersionedStreamSpec {
    /// Convert the versioned spec to the latest format.
    ///
    /// ⚠️ WARNING: This returns the spec with its original `ast_version` field unchanged.
    /// If you need round-trip safety (e.g., serialize then deserialize), use `load_stream_spec`
    /// instead, which properly sets `ast_version` to `CURRENT_AST_VERSION`.
    pub fn into_latest(self) -> SerializableStreamSpec {
        match self {
            VersionedStreamSpec::V1(spec) => spec,
        }
    }
}

/// Detect the AST version from a JSON string without full deserialization.
///
/// This is useful for logging, debugging, or routing decisions.
///
/// # Arguments
///
/// * `json` - The JSON string containing the AST
///
/// # Returns
///
/// The detected version string, or `"0.0.1"` if the field is absent (backwards compatibility default).
///
/// # Example
///
/// ```rust,ignore
/// let version = detect_ast_version(&json)?;
/// println!("AST version: {}", version);
/// ```
pub fn detect_ast_version(json: &str) -> Result<String, VersionedLoadError> {
    let raw: Value =
        serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?;

    Ok(raw
        .get("ast_version")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .unwrap_or_else(|| "0.0.1".to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_load_stack_spec_v1() {
        let json = r#"
        {
            "ast_version": "0.0.1",
            "stack_name": "TestStack",
            "program_ids": [],
            "idls": [],
            "entities": [],
            "pdas": {},
            "instructions": []
        }
        "#;

        let result = load_stack_spec(json);
        assert!(result.is_ok());
        let spec = result.unwrap();
        assert_eq!(spec.stack_name, "TestStack");
        assert_eq!(spec.ast_version, CURRENT_AST_VERSION);
    }

    #[test]
    fn test_load_stack_spec_no_version_defaults_to_v1() {
        // Test backwards compatibility - no ast_version field should default to 0.0.1
        let json = r#"
        {
            "stack_name": "TestStack",
            "program_ids": [],
            "idls": [],
            "entities": [],
            "pdas": {},
            "instructions": []
        }
        "#;

        let result = load_stack_spec(json);
        assert!(result.is_ok());
        let spec = result.unwrap();
        assert_eq!(spec.stack_name, "TestStack");
        assert_eq!(spec.ast_version, CURRENT_AST_VERSION);
    }

    #[test]
    fn test_load_stack_spec_unsupported_version() {
        let json = r#"
        {
            "ast_version": "99.0.0",
            "stack_name": "TestStack",
            "program_ids": [],
            "idls": [],
            "entities": [],
            "pdas": {},
            "instructions": []
        }
        "#;

        let result = load_stack_spec(json);
        assert!(result.is_err());
        match result.unwrap_err() {
            VersionedLoadError::UnsupportedVersion(v) => assert_eq!(v, "99.0.0"),
            _ => panic!("Expected UnsupportedVersion error"),
        }
    }

    #[test]
    fn test_load_stream_spec_v1() {
        let json = r#"
        {
            "ast_version": "0.0.1",
            "state_name": "TestEntity",
            "identity": {"primary_keys": ["id"], "lookup_indexes": []},
            "handlers": [],
            "sections": [],
            "field_mappings": {},
            "resolver_hooks": [],
            "instruction_hooks": [],
            "resolver_specs": [],
            "computed_fields": [],
            "computed_field_specs": [],
            "views": []
        }
        "#;

        let result = load_stream_spec(json);
        assert!(result.is_ok());
        let spec = result.unwrap();
        assert_eq!(spec.state_name, "TestEntity");
        assert_eq!(spec.ast_version, CURRENT_AST_VERSION);
    }

    #[test]
    fn test_load_stream_spec_no_version_defaults_to_v1() {
        // Test backwards compatibility - no ast_version field should default to 0.0.1
        let json = r#"
        {
            "state_name": "TestEntity",
            "identity": {"primary_keys": ["id"], "lookup_indexes": []},
            "handlers": [],
            "sections": [],
            "field_mappings": {},
            "resolver_hooks": [],
            "instruction_hooks": [],
            "resolver_specs": [],
            "computed_fields": [],
            "computed_field_specs": [],
            "views": []
        }
        "#;

        let result = load_stream_spec(json);
        assert!(result.is_ok());
        let spec = result.unwrap();
        assert_eq!(spec.state_name, "TestEntity");
        assert_eq!(spec.ast_version, CURRENT_AST_VERSION);
    }

    #[test]
    fn test_load_stream_spec_unsupported_version() {
        let json = r#"
        {
            "ast_version": "99.0.0",
            "state_name": "TestEntity",
            "identity": {"primary_keys": ["id"], "lookup_indexes": []},
            "handlers": [],
            "sections": [],
            "field_mappings": {},
            "resolver_hooks": [],
            "instruction_hooks": [],
            "resolver_specs": [],
            "computed_fields": [],
            "computed_field_specs": [],
            "views": []
        }
        "#;

        let result = load_stream_spec(json);
        assert!(result.is_err());
        match result.unwrap_err() {
            VersionedLoadError::UnsupportedVersion(v) => assert_eq!(v, "99.0.0"),
            _ => panic!("Expected UnsupportedVersion error"),
        }
    }

    #[test]
    fn test_detect_ast_version() {
        let json = r#"{"ast_version": "0.0.1", "stack_name": "Test"}"#;
        assert_eq!(detect_ast_version(json).unwrap(), "0.0.1");

        let json_no_version = r#"{"stack_name": "Test"}"#;
        assert_eq!(detect_ast_version(json_no_version).unwrap(), "0.0.1");
    }

    /// Verifies that the AST version constant matches the hyperstack-macros crate.
    /// This test ensures both crates stay in sync.
    #[test]
    fn test_ast_version_sync_with_macros() {
        // Read the hyperstack-macros' types.rs file
        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
        let macros_types_path = std::path::Path::new(&manifest_dir)
            .join("..") // Go up to workspace root
            .join("hyperstack-macros")
            .join("src")
            .join("ast")
            .join("types.rs");

        // Verify the file exists before attempting to read
        assert!(
            macros_types_path.exists(),
            "Cannot find hyperstack-macros source file at {:?}. \
             This test requires the source tree to be available.",
            macros_types_path
        );

        let content = std::fs::read_to_string(&macros_types_path)
            .expect("Failed to read hyperstack-macros/src/ast/types.rs");

        // Parse the CURRENT_AST_VERSION constant
        let version_line = content
            .lines()
            .find(|line| line.contains("pub const CURRENT_AST_VERSION"))
            .expect("CURRENT_AST_VERSION not found in hyperstack-macros");

        let version_str = version_line
            .split('=')
            .nth(1)
            .and_then(|rhs| rhs.split('"').nth(1))
            .expect("Failed to parse version string");

        assert_eq!(
            version_str, CURRENT_AST_VERSION,
            "AST version mismatch! interpreter has '{}', hyperstack-macros has '{}'. \
             Both crates must have the same CURRENT_AST_VERSION. \
             Update both files when bumping the version.",
            CURRENT_AST_VERSION, version_str
        );
    }
}