use crate::{
Arguments, Context, Identifier, RenderError, RenderedChunk, Template,
Value, ValueStream, value::StreamSource,
};
use bytes::{Bytes, BytesMut};
use futures::{StreamExt, TryFutureExt, TryStreamExt};
use indexmap::indexmap;
use rstest::rstest;
use slumber_util::{assert_err, test_data_dir};
use tokio::fs::File;
use tokio_util::io::ReaderStream;
#[rstest]
#[case::object(
"{{ {'a': 1, 1: 2, ['a',1]: ['b',2]} }}",
vec![
("a", Value::from(1)),
("1", 2.into()),
// Note the whitespace in the key: it was parsed and restringified
("['a', 1]", vec![Value::from("b"), 2.into()].into()),
].into(),
)]
#[case::object_dupe_key(
// Latest entry takes precedence
"{{ {'Mike': 1, name: 2, 10: 3, '10': 4} }}",
vec![("Mike", 2), ("10", 4)].into(),
)]
#[tokio::test]
async fn test_expression(#[case] template: Template, #[case] expected: Value) {
assert_eq!(
template.render_chunks(&TestContext).await.into_chunks(),
vec![RenderedChunk::Dynamic(expected)]
);
}
#[rstest]
#[case::stream(
"{{ file('data.json') }}",
vec![b"{ \"a\": 1, \"b\": 2 }" as &[u8]],
)]
#[case::text(
// Multiple chunks are chained together
"text: {{ file('data.json') }}",
vec![b"text: " as &[u8], b"{ \"a\": 1, \"b\": 2 }"],
)]
#[case::binary(
"{{ invalid_utf8 }} {{ file('data.json') }}",
vec![b"\xc3\x28" as &[u8], b" ", b"{ \"a\": 1, \"b\": 2 }"]
)]
#[tokio::test]
async fn test_render_stream(
#[case] template: Template,
#[case] expected: Vec<&'static [u8]>,
) {
let stream = template
.render_chunks_stream(&TestContext)
.await
.try_into_stream()
.unwrap();
let chunks = stream.try_collect::<Vec<Bytes>>().await.unwrap();
assert_eq!(chunks, expected);
}
#[tokio::test]
async fn test_render_stream_chunk_error() {
let template: Template = "{{ unknown() }}".into();
let result = template
.render_chunks_stream(&TestContext)
.await
.try_into_stream()
.map(|_| "stream");
assert_err(result, "unknown(): Unknown function");
}
#[tokio::test]
async fn test_render_stream_collect_error() {
let template: Template = "{{ file('fake.txt') }}".into();
let stream = template
.render_chunks_stream(&TestContext)
.await
.try_into_stream()
.unwrap();
assert_err(
stream.try_collect::<BytesMut>().await,
if cfg!(unix) {
"No such file or directory"
} else {
"The system cannot find the file specified"
},
);
}
#[rstest]
#[case::null(serde_json::Value::Null, Value::Null)]
#[case::bool_true(serde_json::Value::Bool(true), Value::Boolean(true))]
#[case::bool_false(serde_json::Value::Bool(false), Value::Boolean(false))]
#[case::number_positive_int(serde_json::json!(42), Value::Integer(42))]
#[case::number_negative_int(serde_json::json!(-17), Value::Integer(-17))]
#[case::number_zero(serde_json::json!(0), Value::Integer(0))]
#[case::number_float(serde_json::json!(1.23), Value::Float(1.23))]
#[case::number_negative_float(serde_json::json!(-2.5), Value::Float(-2.5))]
#[case::number_zero_float(serde_json::json!(0.0), Value::Float(0.0))]
#[case::string_empty(serde_json::json!(""), "".into())]
#[case::string_simple(serde_json::json!("hello"), "hello".into())]
#[case::string_with_spaces(serde_json::json!("hello world"), "hello world".into())]
#[case::string_with_unicode(serde_json::json!("héllo 🌍"), "héllo 🌍".into())]
#[case::string_with_escapes(serde_json::json!("line1\nline2\ttab"), "line1\nline2\ttab".into())]
#[case::array(
serde_json::json!([null, true, 42, "hello"]),
Value::Array(vec![
Value::Null,
Value::Boolean(true),
Value::Integer(42),
"hello".into(),
])
)]
#[case::array_numbers(serde_json::json!([1, 2, 3]), vec![1, 2, 3].into())]
#[case::array_nested(
serde_json::json!([[1, 2], [3, 4]]),
vec![Value::from(vec![1, 2]), Value::from(vec![3, 4])].into()
)]
#[case::object(
serde_json::json!({"name": "John", "age": 30, "active": true}),
Value::Object(indexmap! {
"name".into() => "John".into(),
"age".into() => Value::Integer(30),
"active".into() => Value::Boolean(true),
})
)]
#[case::object_nested(
serde_json::json!({"user": {"name": "Alice", "scores": [95, 87]}}),
Value::Object(indexmap! {
"user".into() => Value::Object(indexmap! {
"name".into() => "Alice".into(),
"scores".into() =>
Value::Array(vec![Value::Integer(95), Value::Integer(87)]),
})
})
)]
fn test_from_json(#[case] json: serde_json::Value, #[case] expected: Value) {
let actual = Value::from_json(json);
assert_eq!(actual, expected);
}
#[rstest]
#[case::one_arg("{{ 1 | identity() }}", "1")]
#[case::multiple_args("{{ 'cd' | concat('ab') }}", "abcd")]
#[case::kwargs("{{ 'cd' | concat('ab', reverse=true) }}", "dcba")]
#[tokio::test]
async fn test_pipe(#[case] template: Template, #[case] expected: &str) {
assert_eq!(
template.render_string(&TestContext).await.unwrap(),
expected
);
}
#[rstest]
#[case::unknown_function("{{ fake() }}", "fake(): Unknown function")]
#[case::extra_arg(
"{{ identity('a', 'b') }}",
"identity(): Extra arguments 'b'"
)]
#[case::missing_arg("{{ add(1) }}", "add(): Not enough arguments")]
#[case::arg_render(
// Argument fails to render
"{{ add(f(), 2) }}",
"add(): argument 0=f(): f(): Unknown function"
)]
#[case::arg_convert(
// Argument renders but doesn't convert to what the func wants
"{{ add(1, 'b') }}",
"add(): argument 1='b': Expected integer"
)]
#[tokio::test]
async fn test_function_error(
#[case] template: Template,
#[case] expected_error: &str,
) {
assert_err(template.render_string(&TestContext).await, expected_error);
}
#[derive(Debug, Default)]
struct TestContext;
impl Context<Value> for TestContext {
async fn get_field(
&self,
identifier: &Identifier,
) -> Result<Value, RenderError> {
match identifier.as_str() {
"name" => Ok("Mike".into()),
"array" => Ok(vec!["a", "b", "c"].into()),
"invalid_utf8" => Ok(b"\xc3\x28".into()),
_ => Err(RenderError::FieldUnknown {
field: identifier.clone(),
}),
}
}
async fn call(
&self,
function_name: &Identifier,
arguments: Arguments<'_, Self>,
) -> Result<Value, RenderError> {
<Self as Context<ValueStream>>::call(self, function_name, arguments)
.and_then(ValueStream::resolve)
.await
}
}
impl Context<ValueStream> for TestContext {
async fn get_field(
&self,
identifier: &Identifier,
) -> Result<ValueStream, RenderError> {
<Self as Context<Value>>::get_field(self, identifier)
.await
.map(ValueStream::from)
}
async fn call(
&self,
function_name: &Identifier,
mut arguments: Arguments<'_, Self>,
) -> Result<ValueStream, RenderError> {
match function_name.as_str() {
"identity" => {
let value: Value = arguments.pop_position()?;
arguments.ensure_consumed()?;
Ok(value.into())
}
"add" => {
let a: i64 = arguments.pop_position()?;
let b: i64 = arguments.pop_position()?;
arguments.ensure_consumed()?;
Ok((a + b).into())
}
"concat" => {
let mut a: String = arguments.pop_position()?;
let b: String = arguments.pop_position()?;
let reverse: bool = arguments.pop_keyword("reverse")?;
arguments.ensure_consumed()?;
a.push_str(&b);
if reverse {
Ok(a.chars().rev().collect::<String>().into())
} else {
Ok(a.into())
}
}
"file" => {
let file_name: String = arguments.pop_position()?;
arguments.ensure_consumed()?;
let path = test_data_dir().join(file_name);
let stream = File::open(path.clone())
.map_ok(ReaderStream::new)
.try_flatten_stream()
.map_err(RenderError::other)
.boxed();
Ok(ValueStream::Stream {
source: StreamSource::File { path },
stream,
})
}
_ => Err(RenderError::FunctionUnknown),
}
}
}