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