use crate::node::{GkNode, NodeMeta, Port, PortType, Slot, Value};
pub struct Combinations {
meta: NodeMeta,
segments: Vec<Segment>,
modulus: u64,
}
enum Segment {
Charset(Vec<char>),
Literal(String),
}
impl Combinations {
pub fn new(pattern: &str) -> Self {
let mut segments = Vec::new();
let mut modulus: u64 = 1;
for spec in pattern.split(';') {
let chars = parse_charset(spec);
if chars.len() == 1 && !spec.contains('-') {
segments.push(Segment::Literal(chars[0].to_string()));
} else if chars.is_empty() {
segments.push(Segment::Literal(spec.to_string()));
} else {
modulus = modulus.saturating_mul(chars.len() as u64);
segments.push(Segment::Charset(chars));
}
}
Self {
meta: NodeMeta {
name: "combinations".into(),
outs: vec![Port::new("output", PortType::Str)],
ins: vec![Slot::Wire(Port::u64("input"))],
},
segments,
modulus,
}
}
pub fn cardinality(&self) -> u64 {
self.modulus
}
}
impl GkNode for Combinations {
fn meta(&self) -> &NodeMeta {
&self.meta
}
fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
let mut remainder = inputs[0].as_u64() % self.modulus;
let mut result = String::with_capacity(self.segments.len());
for seg in &self.segments {
match seg {
Segment::Literal(s) => result.push_str(s),
Segment::Charset(chars) => {
let radix = chars.len() as u64;
let idx = (remainder % radix) as usize;
result.push(chars[idx]);
remainder /= radix;
}
}
}
outputs[0] = Value::Str(result.into());
}
}
fn parse_charset(spec: &str) -> Vec<char> {
let mut chars = Vec::new();
let spec_chars: Vec<char> = spec.chars().collect();
let mut i = 0;
while i < spec_chars.len() {
if i + 2 < spec_chars.len() && spec_chars[i + 1] == '-' {
let start = spec_chars[i];
let end = spec_chars[i + 2];
for c in start..=end {
chars.push(c);
}
i += 3;
} else {
chars.push(spec_chars[i]);
i += 1;
}
}
chars
}
pub struct NumberToWords {
meta: NodeMeta,
}
impl Default for NumberToWords {
fn default() -> Self {
Self::new()
}
}
impl NumberToWords {
pub fn new() -> Self {
Self {
meta: NodeMeta {
name: "number_to_words".into(),
outs: vec![Port::new("output", PortType::Str)],
ins: vec![Slot::Wire(Port::u64("input"))],
},
}
}
}
impl GkNode for NumberToWords {
fn meta(&self) -> &NodeMeta {
&self.meta
}
fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
outputs[0] = Value::Str(u64_to_words(inputs[0].as_u64()).into());
}
}
const ONES: [&str; 20] = [
"zero", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen",
];
const TENS: [&str; 10] = [
"", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety",
];
const SCALES: [&str; 7] = [
"", "thousand", "million", "billion", "trillion", "quadrillion",
"quintillion",
];
fn u64_to_words(n: u64) -> String {
if n < 20 {
return ONES[n as usize].to_string();
}
let mut parts: Vec<String> = Vec::new();
let mut remaining = n;
let mut scale_idx = 0;
while remaining > 0 {
let chunk = (remaining % 1000) as u32;
if chunk > 0 {
let chunk_words = chunk_to_words(chunk);
if scale_idx > 0 && scale_idx < SCALES.len() {
parts.push(format!("{} {}", chunk_words, SCALES[scale_idx]));
} else {
parts.push(chunk_words);
}
}
remaining /= 1000;
scale_idx += 1;
}
parts.reverse();
parts.join(" ")
}
fn chunk_to_words(n: u32) -> String {
let mut parts = Vec::new();
let hundreds = n / 100;
let remainder = n % 100;
if hundreds > 0 {
parts.push(format!("{} hundred", ONES[hundreds as usize]));
}
if remainder >= 20 {
let tens = remainder / 10;
let ones = remainder % 10;
if ones > 0 {
parts.push(format!("{}-{}", TENS[tens as usize], ONES[ones as usize]));
} else {
parts.push(TENS[tens as usize].to_string());
}
} else if remainder > 0 {
parts.push(ONES[remainder as usize].to_string());
}
parts.join(" ")
}
use crate::dsl::registry::{Arity, FuncCategory, FuncSig, ParamSpec};
use crate::node::SlotType;
pub fn signatures() -> &'static [FuncSig] {
use FuncCategory as C;
&[
FuncSig {
name: "combinations", category: C::String,
outputs: 1, description: "mixed-radix character set mapping",
identity: None, variadic_ctor: None,
params: &[
ParamSpec { name: "input", slot_type: SlotType::Wire, required: true, example: "cycle", constraint: None },
ParamSpec { name: "pattern", slot_type: SlotType::ConstStr, required: true, example: "\"[a-z]+\"", constraint: None },
],
arity: Arity::Fixed,
commutativity: crate::node::Commutativity::Positional,
help: "Map a u64 to a string via mixed-radix indexing into character sets.\nPattern is semicolon-delimited character set specs per position.\nEach spec uses ranges (A-Z, 0-9) or literal characters.\nA single literal (like -) is emitted as-is without consuming a radix digit.\nParameters:\n input — u64 wire input\n pattern — semicolon-separated charset specs\nExample: combinations(cycle, \"0-9;0-9;0-9;-;0-9;0-9;0-9;0-9\") // \"372-8419\"",
default_resolver: None,
output_type: crate::dsl::registry::OutputType::Fixed,
},
FuncSig {
name: "number_to_words", category: C::String, outputs: 1,
description: "spell out number in English",
help: "Convert a u64 to its English word representation.\nExample: 42 becomes \"forty-two\", 1000 becomes \"one thousand\".\nUseful for generating human-readable labels or test data.\nParameters:\n input — u64 wire input",
identity: None, variadic_ctor: None,
params: &[ParamSpec { name: "input", slot_type: SlotType::Wire, required: true, example: "cycle", constraint: None }],
arity: Arity::Fixed,
commutativity: crate::node::Commutativity::Positional,
default_resolver: None,
output_type: crate::dsl::registry::OutputType::Fixed,
},
FuncSig {
name: "hashed_uuid", category: C::String, outputs: 1,
description: "deterministic UUID v4 from u64 seed",
help: "Generate a deterministic UUID v4 string from a u64 seed.\nSame seed always produces the same UUID. The 128 bits are\nderived from xxHash3 with version/variant bits set per RFC 4122.\nExample: hashed_uuid(hash(cycle))",
identity: None, variadic_ctor: None,
params: &[ParamSpec { name: "input", slot_type: SlotType::Wire, required: true, example: "cycle", constraint: None }],
arity: Arity::Fixed,
commutativity: crate::node::Commutativity::Positional,
default_resolver: None,
output_type: crate::dsl::registry::OutputType::Fixed,
},
FuncSig {
name: "char_buf", category: C::String, outputs: 1,
description: "deterministic string from seed + charset + length",
help: "Generate a deterministic string of the given length from a\nseed and character set. Charset uses range syntax: A-Za-z0-9.\nSame seed always produces the same string.\nExample: char_buf(hash(cycle), \"A-Za-z0-9\", 100)",
identity: None, variadic_ctor: None,
params: &[
ParamSpec { name: "seed", slot_type: SlotType::Wire, required: true, example: "cycle", constraint: None },
ParamSpec { name: "charset", slot_type: SlotType::ConstStr, required: true, example: "\"abcdefghijklmnopqrstuvwxyz\"", constraint: None },
ParamSpec { name: "length", slot_type: SlotType::Wire, required: true, example: "cycle", constraint: None },
],
arity: Arity::Fixed,
commutativity: crate::node::Commutativity::Positional,
default_resolver: None,
output_type: crate::dsl::registry::OutputType::Fixed,
},
FuncSig {
name: "file_line_at", category: C::String, outputs: 1,
description: "select a line from a file by index",
help: "Read a file at construction time and return a line at cycle-time index.\nIndex wraps modulo line count so every u64 input is valid.\nFile path is a const string argument.\nParameters:\n index — u64 wire input\n filename — ConstStr path to file\nExample: file_line_at(mod(hash(cycle), 1000), \"words.txt\")",
identity: None, variadic_ctor: None,
params: &[
ParamSpec { name: "index", slot_type: SlotType::Wire, required: true, example: "cycle", constraint: None },
ParamSpec { name: "filename", slot_type: SlotType::ConstStr, required: true, example: "\"test.csv\"", constraint: None },
],
arity: Arity::Fixed,
commutativity: crate::node::Commutativity::Positional,
default_resolver: None,
output_type: crate::dsl::registry::OutputType::Fixed,
},
FuncSig {
name: "str_concat", category: C::String, outputs: 1,
description: "concatenate N inputs as strings",
help: "Concatenate variadic wire inputs into a single string.\nEach value is rendered to its display form: strings pass through,\nnumerics format as decimal, bools as true/false, JSON via to_string.\nThis is the desugared form of `+` between Str-typed operands\nin the DSL: `\"a\" + b + \"c\"` lowers to `str_concat(\"a\", b, \"c\")`.\nParameters:\n input... — wire inputs (any type)\nExample: str_concat(\"id=\", id, \" v=\", val)",
identity: None, variadic_ctor: None,
params: &[
ParamSpec { name: "input", slot_type: SlotType::Wire, required: false, example: "\"hello\"", constraint: None },
],
arity: Arity::VariadicWires { min_wires: 0 },
commutativity: crate::node::Commutativity::Positional,
default_resolver: None,
output_type: crate::dsl::registry::OutputType::Fixed,
},
FuncSig {
name: "str_lower", category: C::String, outputs: 1,
description: "fold a string to lowercase",
help: "Return the lowercase form of the input string using\nUnicode case-folding. Useful for normalizing identifiers at\nupstream interpolation points — e.g. CQL stores unquoted\ntable names lowercased, so a sweep workload baking\n`{source_model}` into a table name should pass it through\n`str_lower(source_model)` so the local label matches the\nstored identifier when JMX / jolokia lookups happen later.\nParameters:\n input — string wire\nExample: const table_lc := str_lower(table)",
identity: None, variadic_ctor: None,
params: &[
ParamSpec { name: "input", slot_type: SlotType::Wire, required: true, example: "\"HELLO\"", constraint: None },
],
arity: Arity::Fixed,
commutativity: crate::node::Commutativity::Positional,
default_resolver: None,
output_type: crate::dsl::registry::OutputType::Fixed,
},
FuncSig {
name: "str_upper", category: C::String, outputs: 1,
description: "fold a string to uppercase",
help: "Return the uppercase form of the input string using\nUnicode case-folding. Mirror of `str_lower`; useful when a\ndownstream consumer (e.g. an enum-string config value) wants\nan uppercase form regardless of how the upstream binding\nwas written.\nParameters:\n input — string wire\nExample: const model_uc := str_upper(source_model)",
identity: None, variadic_ctor: None,
params: &[
ParamSpec { name: "input", slot_type: SlotType::Wire, required: true, example: "\"hello\"", constraint: None },
],
arity: Arity::Fixed,
commutativity: crate::node::Commutativity::Positional,
default_resolver: None,
output_type: crate::dsl::registry::OutputType::Fixed,
},
]
}
pub struct HashedUuid {
meta: NodeMeta,
}
impl HashedUuid {
pub fn new() -> Self {
Self {
meta: NodeMeta {
name: "hashed_uuid".into(),
outs: vec![Port::new("output", PortType::Str)],
ins: vec![Slot::Wire(Port::u64("input"))],
},
}
}
}
impl GkNode for HashedUuid {
fn meta(&self) -> &NodeMeta { &self.meta }
fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
let seed = inputs[0].as_u64();
let h1 = xxhash_rust::xxh3::xxh3_64(&seed.to_le_bytes());
let h2 = xxhash_rust::xxh3::xxh3_64(&h1.to_le_bytes());
let mut bytes = [0u8; 16];
bytes[..8].copy_from_slice(&h1.to_le_bytes());
bytes[8..].copy_from_slice(&h2.to_le_bytes());
bytes[6] = (bytes[6] & 0x0F) | 0x40;
bytes[8] = (bytes[8] & 0x3F) | 0x80;
let uuid = format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5],
bytes[6], bytes[7],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
);
outputs[0] = Value::Str(uuid.into());
}
}
pub struct CharBuf {
meta: NodeMeta,
charset: Vec<char>,
}
impl CharBuf {
pub fn new(charset: &str) -> Self {
let chars: Vec<char> = if charset.is_empty() {
('a'..='z').collect()
} else {
let mut result = Vec::new();
let chars_vec: Vec<char> = charset.chars().collect();
let mut i = 0;
while i < chars_vec.len() {
if i + 2 < chars_vec.len() && chars_vec[i + 1] == '-' {
let start = chars_vec[i];
let end = chars_vec[i + 2];
for c in start..=end {
result.push(c);
}
i += 3;
} else {
result.push(chars_vec[i]);
i += 1;
}
}
if result.is_empty() { ('a'..='z').collect() } else { result }
};
Self {
meta: NodeMeta {
name: "char_buf".into(),
outs: vec![Port::new("output", PortType::Str)],
ins: vec![
Slot::Wire(Port::u64("seed")),
Slot::Wire(Port::u64("length")),
],
},
charset: chars,
}
}
}
impl GkNode for CharBuf {
fn meta(&self) -> &NodeMeta { &self.meta }
fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
let seed = inputs[0].as_u64();
let length = inputs[1].as_u64() as usize;
let n = self.charset.len();
if n == 0 || length == 0 {
outputs[0] = Value::Str(String::new().into());
return;
}
let mut result = String::with_capacity(length);
let mut h = seed;
for _ in 0..length {
h = xxhash_rust::xxh3::xxh3_64(&h.to_le_bytes());
result.push(self.charset[(h as usize) % n]);
}
outputs[0] = Value::Str(result.into());
}
}
pub struct FileLineAt {
meta: NodeMeta,
lines: Vec<String>,
}
impl FileLineAt {
pub fn new(filename: &str) -> Result<Self, String> {
let content = std::fs::read_to_string(filename)
.map_err(|e| format!("failed to read file '{filename}': {e}"))?;
let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
if lines.is_empty() {
return Err(format!("file '{filename}' has no lines"));
}
Ok(Self {
meta: NodeMeta {
name: "file_line_at".into(),
outs: vec![Port::new("output", PortType::Str)],
ins: vec![Slot::Wire(Port::u64("index"))],
},
lines,
})
}
}
impl GkNode for FileLineAt {
fn meta(&self) -> &NodeMeta { &self.meta }
fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
let idx = inputs[0].as_u64() as usize;
outputs[0] = Value::Str(self.lines[idx % self.lines.len()].clone().into());
}
}
pub struct StrConcat {
meta: NodeMeta,
}
impl StrConcat {
pub fn new(wire_count: usize) -> Self {
let inputs: Vec<Port> = (0..wire_count)
.map(|i| Port::new(format!("in_{i}"), PortType::Str))
.collect();
let slots: Vec<Slot> = inputs.iter().map(|p| Slot::Wire(p.clone())).collect();
Self {
meta: NodeMeta {
name: "str_concat".into(),
outs: vec![Port::new("output", PortType::Str)],
ins: slots,
},
}
}
}
fn value_to_display(val: &Value) -> String {
match val {
Value::Str(s) => s.to_string(),
Value::U64(v) => v.to_string(),
Value::F64(v) => v.to_string(),
Value::Bool(v) => v.to_string(),
Value::Json(j) => j.to_string(),
Value::Bytes(b) => String::from_utf8_lossy(b).into_owned(),
_ => format!("{val:?}"),
}
}
impl GkNode for StrConcat {
fn meta(&self) -> &NodeMeta { &self.meta }
fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
let mut out = String::new();
for v in inputs {
out.push_str(&value_to_display(v));
}
outputs[0] = Value::Str(out.into());
}
}
pub struct StrLower {
meta: NodeMeta,
}
impl StrLower {
pub fn new() -> Self {
Self {
meta: NodeMeta {
name: "str_lower".into(),
outs: vec![Port::new("output", PortType::Str)],
ins: vec![Slot::Wire(Port::new("input", PortType::Str))],
},
}
}
}
impl GkNode for StrLower {
fn meta(&self) -> &NodeMeta { &self.meta }
fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
outputs[0] = Value::Str(value_to_display(&inputs[0]).to_lowercase().into());
}
}
pub struct StrUpper {
meta: NodeMeta,
}
impl StrUpper {
pub fn new() -> Self {
Self {
meta: NodeMeta {
name: "str_upper".into(),
outs: vec![Port::new("output", PortType::Str)],
ins: vec![Slot::Wire(Port::new("input", PortType::Str))],
},
}
}
}
impl GkNode for StrUpper {
fn meta(&self) -> &NodeMeta { &self.meta }
fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
outputs[0] = Value::Str(value_to_display(&inputs[0]).to_uppercase().into());
}
}
pub(crate) fn build_node(name: &str, wires: &[crate::assembly::WireRef], _wire_types: &[crate::node::PortType], consts: &[crate::dsl::factory::ConstArg]) -> Option<Result<Box<dyn crate::node::GkNode>, String>> {
match name {
"combinations" => Some(Ok(Box::new(Combinations::new(
consts.first().map(|c| c.as_str()).unwrap_or("a-z"),
)))),
"number_to_words" => Some(Ok(Box::new(NumberToWords::new()))),
"hashed_uuid" => Some(Ok(Box::new(HashedUuid::new()))),
"char_buf" => Some(Ok(Box::new(CharBuf::new(
consts.first().map(|c| c.as_str()).unwrap_or("a-z"),
)))),
"file_line_at" => {
let path = consts.first().map(|c| c.as_str()).unwrap_or("");
Some(FileLineAt::new(path).map(|n| Box::new(n) as Box<dyn crate::node::GkNode>))
}
"str_concat" => Some(Ok(Box::new(StrConcat::new(wires.len())))),
"str_lower" => Some(Ok(Box::new(StrLower::new()))),
"str_upper" => Some(Ok(Box::new(StrUpper::new()))),
_ => None,
}
}
crate::register_nodes!(signatures, build_node);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn combinations_digits() {
let node = Combinations::new("0-9;0-9;0-9");
let mut out = [Value::None];
node.eval(&[Value::U64(123)], &mut out);
let s = out[0].as_str();
assert_eq!(s.len(), 3);
assert!(s.chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn combinations_with_separator() {
let node = Combinations::new("0-9;0-9;0-9;-;0-9;0-9;0-9");
let mut out = [Value::None];
node.eval(&[Value::U64(0)], &mut out);
let s = out[0].as_str();
assert_eq!(s.len(), 7); assert_eq!(&s[3..4], "-");
}
#[test]
fn combinations_alpha() {
let node = Combinations::new("A-Z;A-Z;A-Z");
let mut out = [Value::None];
node.eval(&[Value::U64(0)], &mut out);
assert_eq!(out[0].as_str(), "AAA");
node.eval(&[Value::U64(1)], &mut out);
assert_eq!(out[0].as_str(), "BAA");
}
#[test]
fn combinations_cardinality() {
let node = Combinations::new("0-9;0-9;-;A-Z");
assert_eq!(node.cardinality(), 2600);
}
#[test]
fn combinations_deterministic() {
let node = Combinations::new("A-Z;0-9");
let mut out1 = [Value::None];
let mut out2 = [Value::None];
node.eval(&[Value::U64(42)], &mut out1);
node.eval(&[Value::U64(42)], &mut out2);
assert_eq!(out1[0].as_str(), out2[0].as_str());
}
#[test]
fn combinations_wraps() {
let node = Combinations::new("0-9");
let mut out = [Value::None];
node.eval(&[Value::U64(0)], &mut out);
let a = out[0].as_str().to_string();
node.eval(&[Value::U64(10)], &mut out);
assert_eq!(out[0].as_str(), &a, "should wrap at cardinality");
}
#[test]
fn number_to_words_zero() {
assert_eq!(u64_to_words(0), "zero");
}
#[test]
fn number_to_words_teens() {
assert_eq!(u64_to_words(1), "one");
assert_eq!(u64_to_words(11), "eleven");
assert_eq!(u64_to_words(19), "nineteen");
}
#[test]
fn number_to_words_tens() {
assert_eq!(u64_to_words(20), "twenty");
assert_eq!(u64_to_words(42), "forty-two");
assert_eq!(u64_to_words(99), "ninety-nine");
}
#[test]
fn number_to_words_hundreds() {
assert_eq!(u64_to_words(100), "one hundred");
assert_eq!(u64_to_words(123), "one hundred twenty-three");
assert_eq!(u64_to_words(500), "five hundred");
}
#[test]
fn number_to_words_thousands() {
assert_eq!(u64_to_words(1000), "one thousand");
assert_eq!(u64_to_words(1001), "one thousand one");
assert_eq!(u64_to_words(12345), "twelve thousand three hundred forty-five");
}
#[test]
fn number_to_words_millions() {
assert_eq!(u64_to_words(1_000_000), "one million");
assert_eq!(
u64_to_words(1_234_567),
"one million two hundred thirty-four thousand five hundred sixty-seven"
);
}
#[test]
fn number_to_words_large() {
let s = u64_to_words(1_000_000_000_000);
assert!(s.starts_with("one trillion"), "got: {s}");
}
#[test]
fn number_to_words_node() {
let node = NumberToWords::new();
let mut out = [Value::None];
node.eval(&[Value::U64(42)], &mut out);
assert_eq!(out[0].as_str(), "forty-two");
}
#[test]
fn str_concat_basic() {
let node = StrConcat::new(2);
let mut out = [Value::None];
node.eval(
&[Value::Str("hello ".into()), Value::Str("world".into())],
&mut out,
);
assert_eq!(out[0].as_str(), "hello world");
}
#[test]
fn str_concat_mixed_types() {
let node = StrConcat::new(4);
let mut out = [Value::None];
node.eval(
&[
Value::Str("id=".into()),
Value::U64(42),
Value::Str(" v=".into()),
Value::F64(3.14),
],
&mut out,
);
assert_eq!(out[0].as_str(), "id=42 v=3.14");
}
#[test]
fn str_concat_empty() {
let node = StrConcat::new(0);
let mut out = [Value::None];
node.eval(&[], &mut out);
assert_eq!(out[0].as_str(), "");
}
#[test]
fn str_lower_ascii_and_unicode() {
let node = StrLower::new();
let mut out = [Value::None];
node.eval(&[Value::Str("OTHER_M8".into())], &mut out);
assert_eq!(out[0].as_str(), "other_m8");
node.eval(&[Value::Str("ÄPFEL".into())], &mut out);
assert_eq!(out[0].as_str(), "äpfel");
}
#[test]
fn str_lower_idempotent_on_already_lowercase() {
let node = StrLower::new();
let mut out = [Value::None];
node.eval(&[Value::Str("fknn_oat_other".into())], &mut out);
assert_eq!(out[0].as_str(), "fknn_oat_other");
}
#[test]
fn str_upper_ascii_and_unicode() {
let node = StrUpper::new();
let mut out = [Value::None];
node.eval(&[Value::Str("other_m8".into())], &mut out);
assert_eq!(out[0].as_str(), "OTHER_M8");
node.eval(&[Value::Str("äpfel".into())], &mut out);
assert_eq!(out[0].as_str(), "ÄPFEL");
}
#[test]
fn str_lower_accepts_non_string_via_display() {
let node = StrLower::new();
let mut out = [Value::None];
node.eval(&[Value::U64(123)], &mut out);
assert_eq!(out[0].as_str(), "123");
node.eval(&[Value::Bool(true)], &mut out);
assert_eq!(out[0].as_str(), "true");
}
}