arete-interpreter 0.20.2

AST transformation runtime and VM for Arete streaming pipelines
Documentation
//! Versioned entity IR 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 arete_interpreter::versioned::load_stream_spec;
//!
//! let stream = load_stream_spec(&json_string)?;
//! ```

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

use crate::ast::{SerializableStreamSpec, COMPATIBLE_AST_VERSIONS, 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 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. Compatible older
    // versions deserialize directly: every change since them is additive with
    // serde defaults, so no migration step is required.
    match version {
        v if v == CURRENT_AST_VERSION || COMPATIBLE_AST_VERSIONS.contains(&v) => {
            serde_json::from_value::<SerializableStreamSpec>(raw)
                .map(|mut spec| {
                    // Normalize so round-tripped specs carry the current version.
                    spec.ast_version = CURRENT_AST_VERSION.to_string();
                    spec
                })
                .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))
        }
        // Add migration arms for structurally-incompatible old versions here.
        _ => {
            // Unknown version
            Err(VersionedLoadError::UnsupportedVersion(version.to_string()))
        }
    }
}

/// 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),
    #[serde(rename = "0.0.2")]
    V2(SerializableStreamSpec),
    #[serde(rename = "0.0.3")]
    V3(SerializableStreamSpec),
    #[serde(rename = "0.0.4")]
    V4(SerializableStreamSpec),
    #[serde(rename = "0.0.5")]
    V5(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)
            | VersionedStreamSpec::V2(spec)
            | VersionedStreamSpec::V3(spec)
            | VersionedStreamSpec::V4(spec)
            | VersionedStreamSpec::V5(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_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_v2_without_new_field_metadata() {
        let json = r#"
        {
            "ast_version": "0.0.2",
            "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_v3_without_instruction_amount_hints() {
        let json = r#"
        {
            "ast_version": "0.0.3",
            "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 arete-macros crate.
    /// This test ensures both crates stay in sync.
    #[test]
    fn test_ast_version_sync_with_macros() {
        // Read the arete-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("arete-macros")
            .join("src")
            .join("ast")
            .join("types.rs");

        // Verify the file exists before attempting to read
        assert!(
            macros_types_path.exists(),
            "Cannot find arete-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 arete-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 arete-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 '{}', arete-macros has '{}'. \
             Both crates must have the same CURRENT_AST_VERSION. \
             Update both files when bumping the version.",
            CURRENT_AST_VERSION, version_str
        );
    }
}