Skip to main content

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