diff --git a/crates/txtx-addon-kit/src/types/functions.rs b/crates/txtx-addon-kit/src/types/functions.rs index 27f94fb11..9371c3af4 100644 --- a/crates/txtx-addon-kit/src/types/functions.rs +++ b/crates/txtx-addon-kit/src/types/functions.rs @@ -86,7 +86,7 @@ pub fn arg_checker_with_ctx( } // we don't have an "any" type, so if the array is of type null, we won't check types if let Type::Array(inner) = typing { - if let Type::Null(_) = **inner { + if matches!(**inner, Type::Null | Type::TypedNull(_)) { has_type_match = true; break; } diff --git a/crates/txtx-addon-kit/src/types/mod.rs b/crates/txtx-addon-kit/src/types/mod.rs index ebd92bc5c..d96fe5f83 100644 --- a/crates/txtx-addon-kit/src/types/mod.rs +++ b/crates/txtx-addon-kit/src/types/mod.rs @@ -33,6 +33,7 @@ pub mod functions; pub mod package; pub mod signers; pub mod stores; +pub mod type_compatibility; pub mod types; pub const CACHED_NONCE: &str = "cached_nonce"; diff --git a/crates/txtx-addon-kit/src/types/tests/mod.rs b/crates/txtx-addon-kit/src/types/tests/mod.rs index a357fbd56..874ede1e3 100644 --- a/crates/txtx-addon-kit/src/types/tests/mod.rs +++ b/crates/txtx-addon-kit/src/types/tests/mod.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use crate::helpers::fs::FileLocation; use crate::types::AuthorizationContext; -use super::types::Value; +use super::types::{Type, Value}; use serde_json::json; use serde_json::Value as JsonValue; use test_case::test_case; @@ -72,3 +72,164 @@ fn test_auth_context_get_path_from_str(path_str: &str, expected: &str) { let result = auth_context.get_file_location_from_path_buf(&PathBuf::from(path_str)).unwrap(); assert_eq!(result.to_string(), expected); } + +// ============================================================================= +// Type::Null and Type::TypedNull serialization/deserialization tests +// ============================================================================= + +/// Test that Type serialization produces expected string format +#[test_case(Type::null(), "null"; "untyped null")] +#[test_case(Type::typed_null(Type::String), "null"; "typed null string")] +#[test_case(Type::typed_null(Type::Integer), "null"; "typed null integer")] +#[test_case(Type::typed_null(Type::Bool), "null"; "typed null bool")] +#[test_case(Type::typed_null(Type::Array(Box::new(Type::Integer))), "null"; "typed null array")] +#[test_case(Type::typed_null(Type::typed_null(Type::String)), "null>"; "nested typed null")] +#[test_case(Type::Array(Box::new(Type::typed_null(Type::String))), "array[null]"; "array of typed null")] +fn test_type_null_to_string_format(typ: Type, expected_str: &str) { + assert_eq!(typ.to_string(), expected_str); +} + +/// Test that Type string parsing produces correct types +#[test_case("null", Type::null(); "parse untyped null")] +#[test_case("null", Type::typed_null(Type::String); "parse typed null string")] +#[test_case("null", Type::typed_null(Type::Integer); "parse typed null integer")] +#[test_case("null", Type::typed_null(Type::Array(Box::new(Type::Integer))); "parse typed null array")] +#[test_case("array[null]", Type::Array(Box::new(Type::typed_null(Type::String))); "parse array of typed null")] +#[test_case("array[string]", Type::Array(Box::new(Type::String)); "parse array string")] +fn test_type_null_parsing(input: &str, expected: Type) { + let parsed = Type::try_from(input.to_string()).unwrap(); + assert_eq!(parsed, expected); +} + +/// Test full serde roundtrip for Type (serialize to JSON, deserialize back) +#[test_case(Type::null(); "roundtrip untyped null")] +#[test_case(Type::typed_null(Type::String); "roundtrip typed null string")] +#[test_case(Type::typed_null(Type::Array(Box::new(Type::String))); "roundtrip typed null array")] +#[test_case(Type::typed_null(Type::typed_null(Type::Integer)); "roundtrip nested typed null")] +#[test_case(Type::Array(Box::new(Type::typed_null(Type::String))); "roundtrip array of typed null")] +fn test_type_null_serde_roundtrip(typ: Type) { + let serialized = serde_json::to_string(&typ).unwrap(); + let deserialized: Type = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, typ); +} + +/// Test that Type::Null acts as wildcard in type compatibility matching +#[test] +fn test_type_null_wildcard_pattern() { + // Type::Array(Box::new(Type::Null)) is used as "any array" pattern + let any_array_pattern = Type::Array(Box::new(Type::Null)); + let string_array = Type::Array(Box::new(Type::String)); + let int_array = Type::Array(Box::new(Type::Integer)); + + // The pattern match used in type_compatibility.rs + fn is_any_array_pattern(t: &Type) -> bool { + matches!(t, Type::Array(inner) if matches!(**inner, Type::Null | Type::TypedNull(_))) + } + + assert!(is_any_array_pattern(&any_array_pattern)); + assert!(!is_any_array_pattern(&string_array)); + assert!(!is_any_array_pattern(&int_array)); +} + +// ============================================================================= +// Deep nesting tests +// ============================================================================= + +/// Test deeply nested array types +#[test_case("array[array[string]]", + Type::Array(Box::new(Type::Array(Box::new(Type::String)))); + "two level array nesting")] +#[test_case("array[array[array[integer]]]", + Type::Array(Box::new(Type::Array(Box::new(Type::Array(Box::new(Type::Integer)))))); + "three level array nesting")] +fn test_deep_array_nesting(input: &str, expected: Type) { + let parsed = Type::try_from(input.to_string()).unwrap(); + assert_eq!(parsed, expected); + // Verify roundtrip + assert_eq!(parsed.to_string(), input); +} + +/// Test deeply nested null types +#[test_case("null>>", + Type::typed_null(Type::typed_null(Type::typed_null(Type::String))); + "three level null nesting")] +fn test_deep_null_nesting(input: &str, expected: Type) { + let parsed = Type::try_from(input.to_string()).unwrap(); + assert_eq!(parsed, expected); + // Verify roundtrip + assert_eq!(parsed.to_string(), input); +} + +/// Test cross-nested types (array containing null, null containing array) +#[test_case("array[array[null]]", + Type::Array(Box::new(Type::Array(Box::new(Type::typed_null(Type::Integer))))); + "array of array of typed null")] +#[test_case("null>", + Type::typed_null(Type::typed_null(Type::Array(Box::new(Type::String)))); + "null of null of array")] +#[test_case("array[null]>]", + Type::Array(Box::new(Type::typed_null(Type::Array(Box::new(Type::typed_null(Type::String)))))); + "alternating array and null nesting")] +fn test_cross_nesting(input: &str, expected: Type) { + let parsed = Type::try_from(input.to_string()).unwrap(); + assert_eq!(parsed, expected); + // Verify roundtrip + assert_eq!(parsed.to_string(), input); +} + +// ============================================================================= +// Error handling tests +// ============================================================================= + +/// Test that empty inner types produce clear errors +#[test_case("null<>", "empty inner type"; "empty null inner")] +#[test_case("array[]", "empty inner type"; "empty array inner")] +#[test_case("addon()", "empty addon id"; "empty addon id")] +fn test_empty_inner_type_errors(input: &str, expected_error_contains: &str) { + let result = Type::try_from(input.to_string()); + assert!(result.is_err(), "Expected error for input: {}", input); + let err = result.unwrap_err(); + assert!( + err.contains(expected_error_contains), + "Error '{}' should contain '{}'", + err, + expected_error_contains + ); +} + +/// Test that invalid inner types produce contextual errors +#[test_case("null", "invalid type in null"; "invalid null inner")] +#[test_case("array[bad_type]", "invalid type in array[bad_type]"; "invalid array inner")] +fn test_invalid_inner_type_errors(input: &str, expected_error_contains: &str) { + let result = Type::try_from(input.to_string()); + assert!(result.is_err(), "Expected error for input: {}", input); + let err = result.unwrap_err(); + assert!( + err.contains(expected_error_contains), + "Error '{}' should contain '{}'", + err, + expected_error_contains + ); +} + +/// Test that malformed type strings produce errors +#[test_case("nullstring>"; "malformed null")] +#[test_case("unknown_type"; "completely unknown type")] +fn test_malformed_type_errors(input: &str) { + let result = Type::try_from(input.to_string()); + assert!(result.is_err(), "Expected error for malformed input: {}", input); +} + +/// Test error propagation through nested parsing +#[test] +fn test_nested_error_propagation() { + // Error in deeply nested type should propagate with context + let result = Type::try_from("array[null]".to_string()); + assert!(result.is_err()); + let err = result.unwrap_err(); + // Should contain context about where the error occurred + assert!(err.contains("array["), "Error should mention outer array context"); + assert!(err.contains("invalid"), "Error should mention the invalid type"); +} diff --git a/crates/txtx-addon-kit/src/types/type_compatibility.rs b/crates/txtx-addon-kit/src/types/type_compatibility.rs new file mode 100644 index 000000000..8d9178afb --- /dev/null +++ b/crates/txtx-addon-kit/src/types/type_compatibility.rs @@ -0,0 +1,83 @@ +use super::types::{Type, Value}; + +/// Type compatibility checking for txtx types +pub struct TypeChecker; + +impl TypeChecker { + /// Check if a value matches any of the expected types + pub fn matches_any(value: &Value, expected_types: &[Type]) -> bool { + expected_types.iter().any(|expected| Self::matches(value, expected)) + } + + /// Check if a value matches a specific type + pub fn matches(value: &Value, expected_type: &Type) -> bool { + match (value.get_type(), expected_type) { + // Both are addons - any addon matches any addon type + // We don't check the specific addon ID for flexibility + (Type::Addon(_), Type::Addon(_)) => true, + + // Empty arrays match any array type + (Type::Array(_), _) if value.expect_array().is_empty() => true, + + // Array with null inner type accepts any array + // This is our "any array" pattern + (_, Type::Array(inner)) if matches!(**inner, Type::Null | Type::TypedNull(_)) => true, + + // Otherwise require exact type match + (actual_type, expected) => actual_type.eq(expected), + } + } + + /// Check if two types are compatible (for type checking without values) + pub fn types_compatible(actual: &Type, expected: &Type) -> bool { + match (actual, expected) { + // Any addon type matches any other addon type + (Type::Addon(_), Type::Addon(_)) => true, + + // Array with null inner type accepts any array + (Type::Array(_), Type::Array(inner)) if matches!(**inner, Type::Null | Type::TypedNull(_)) => true, + + // Otherwise require exact match + _ => actual == expected, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_addon_compatibility() { + let value = Value::addon(vec![1, 2, 3], "addon1"); + let addon_type = Type::Addon("addon2".to_string()); + + assert!(TypeChecker::matches(&value, &addon_type)); + } + + #[test] + fn test_empty_array_compatibility() { + let value = Value::array(vec![]); + let array_type = Type::Array(Box::new(Type::String)); + + assert!(TypeChecker::matches(&value, &array_type)); + } + + #[test] + fn test_any_array_compatibility() { + let value = Value::array(vec![Value::string("test".to_string())]); + let any_array_type = Type::Array(Box::new(Type::Null)); + + assert!(TypeChecker::matches(&value, &any_array_type)); + } + + #[test] + fn test_exact_type_match() { + let value = Value::string("test".to_string()); + let string_type = Type::String; + let int_type = Type::Integer; + + assert!(TypeChecker::matches(&value, &string_type)); + assert!(!TypeChecker::matches(&value, &int_type)); + } +} \ No newline at end of file diff --git a/crates/txtx-addon-kit/src/types/types.rs b/crates/txtx-addon-kit/src/types/types.rs index 6dd664925..5a20591d3 100644 --- a/crates/txtx-addon-kit/src/types/types.rs +++ b/crates/txtx-addon-kit/src/types/types.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Map, Value as JsonValue}; use std::collections::VecDeque; use std::fmt::{self, Debug}; +use strum::Display as StrumDisplay; use crate::helpers::hcl::{ collect_constructs_references_from_block, collect_constructs_references_from_expression, @@ -1021,17 +1022,29 @@ impl fmt::Debug for AddonData { } } -#[derive(Clone, Debug, Eq, PartialEq, Hash)] +#[derive(Clone, Debug, Eq, PartialEq, Hash, StrumDisplay)] pub enum Type { + #[strum(serialize = "bool")] Bool, - Null(Option>), + #[strum(serialize = "null")] + Null, + #[strum(to_string = "null<{0}>")] + TypedNull(Box), + #[strum(serialize = "integer")] Integer, + #[strum(serialize = "float")] Float, + #[strum(serialize = "string")] String, + #[strum(serialize = "buffer")] Buffer, + #[strum(to_string = "object")] Object(ObjectDefinition), + #[strum(to_string = "addon({0})")] Addon(String), + #[strum(to_string = "array[{0}]")] Array(Box), + #[strum(to_string = "map")] Map(ObjectDefinition), } @@ -1046,10 +1059,10 @@ impl Type { Type::Float } pub fn null() -> Type { - Type::Null(None) + Type::Null } pub fn typed_null(inner: Type) -> Type { - Type::Null(Some(Box::new(inner))) + Type::TypedNull(Box::new(inner)) } pub fn bool() -> Type { Type::Bool @@ -1099,15 +1112,14 @@ impl Type { match &self { Type::Bool => value.as_bool().map(|_| ()).ok_or_else(|| mismatch_err("bool"))?, - Type::Null(inner) => { - if let Some(inner) = inner { - value - .as_null() - .map(|_| ()) - .ok_or_else(|| mismatch_err(&format!("null<{}>", inner.to_string())))? - } else { - value.as_null().map(|_| ()).ok_or_else(|| mismatch_err("null"))? - } + Type::Null => { + value.as_null().map(|_| ()).ok_or_else(|| mismatch_err("null"))? + } + Type::TypedNull(inner) => { + value + .as_null() + .map(|_| ()) + .ok_or_else(|| mismatch_err(&format!("null<{}>", inner)))? } Type::Integer => { value.as_integer().map(|_| ()).ok_or_else(|| mismatch_err("integer"))? @@ -1275,29 +1287,6 @@ impl Type { } } -impl Type { - pub fn to_string(&self) -> String { - match self { - Type::Bool => "bool".into(), - Type::Null(inner) => { - if let Some(inner) = inner { - format!("null<{}>", inner.to_string()) - } else { - "null".into() - } - } - Type::Integer => "integer".into(), - Type::Float => "float".into(), - Type::String => "string".into(), - Type::Buffer => "buffer".into(), - Type::Object(_) => "object".into(), - Type::Addon(addon) => format!("addon({})", addon), - Type::Array(typing) => format!("array[{}]", typing.to_string()), - Type::Map(_) => "map".into(), - } - } -} - impl Default for Type { fn default() -> Self { Type::string() @@ -1317,19 +1306,30 @@ impl TryFrom for Type { if other == "null" { return Ok(Type::null()); } - if other.starts_with("null<") && other.ends_with(">") { - let mut inner = other.replace("null<", ""); - inner = inner.replace(">", ""); - let inner_type = Type::try_from(inner)?; + if let Some(inner) = other.strip_prefix("null<").and_then(|s| s.strip_suffix(">")) + { + if inner.is_empty() { + return Err("invalid type: null<> (empty inner type)".to_string()); + } + let inner_type = Type::try_from(inner.to_string()) + .map_err(|e| format!("invalid type in null<{}>: {}", inner, e))?; return Ok(Type::typed_null(inner_type)); - } else if other.starts_with("array[") && other.ends_with("]") { - let mut inner = other.replace("array[", ""); - inner = inner.replace("]", ""); - return Type::try_from(inner); - } else if other.starts_with("addon(") { - let mut inner = other.replace("addon(", ""); - inner = inner.replace(")", ""); - Type::addon(&inner) + } else if let Some(inner) = + other.strip_prefix("array[").and_then(|s| s.strip_suffix("]")) + { + if inner.is_empty() { + return Err("invalid type: array[] (empty inner type)".to_string()); + } + let inner_type = Type::try_from(inner.to_string()) + .map_err(|e| format!("invalid type in array[{}]: {}", inner, e))?; + return Ok(Type::array(inner_type)); + } else if let Some(inner) = + other.strip_prefix("addon(").and_then(|s| s.strip_suffix(")")) + { + if inner.is_empty() { + return Err("invalid type: addon() (empty addon id)".to_string()); + } + Type::addon(inner) } else { return Err(format!("invalid type: {}", other)); }