From 32e51a5b355ebae0d0f0418db891d0fd3b75e82e Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 5 Apr 2026 12:11:09 +0100 Subject: [PATCH 1/6] test(decode): expand coverage and split helper tests --- src/compact/tests.rs | 21 +- src/decode.rs | 11 +- src/decode/accumulate/build.rs | 3 + src/decode/accumulate/build/tests.rs | 177 +++++++++++ src/decode/accumulate/insert/tests.rs | 17 + src/decode/accumulate/process/tests.rs | 409 +++++++++++++++++++++++++ src/decode/scan.rs | 2 + src/decode/scan/parts.rs | 4 +- src/decode/tests/flat.rs | 136 +++++++- src/decode/tests/keys.rs | 62 ++++ src/decode/tests/mod.rs | 19 +- src/decode/tests/parts.rs | 53 ++++ src/decode/tests/scalar_helpers.rs | 37 +++ src/decode/tests/scanner.rs | 104 ++++++- src/key_path/tests.rs | 13 + src/structured_scan/tests.rs | 9 + 16 files changed, 1060 insertions(+), 17 deletions(-) create mode 100644 src/decode/accumulate/build/tests.rs create mode 100644 src/decode/tests/keys.rs create mode 100644 src/decode/tests/parts.rs create mode 100644 src/decode/tests/scalar_helpers.rs diff --git a/src/compact/tests.rs b/src/compact/tests.rs index 7f2bc64..1c82ac6 100644 --- a/src/compact/tests.rs +++ b/src/compact/tests.rs @@ -1,4 +1,4 @@ -use super::{compact, node_to_value}; +use super::{compact, node_to_object, node_to_value}; use crate::internal::node::Node; use crate::value::Value; @@ -102,3 +102,22 @@ fn compact_preserves_named_overflow_keys_while_dropping_undefined_children() { ) ); } + +#[test] +fn node_conversion_helpers_cover_arrays_scalars_and_undefined_values() { + assert_eq!(node_to_value(Node::Undefined), Value::Null); + + assert_eq!( + node_to_object(Node::Array(vec![scalar("a"), Node::Undefined])), + [ + ("0".to_owned(), Value::String("a".to_owned())), + ("1".to_owned(), Value::Null), + ] + .into() + ); + + assert_eq!( + node_to_object(scalar("root")), + [("0".to_owned(), Value::String("root".to_owned()))].into() + ); +} diff --git a/src/decode.rs b/src/decode.rs index 07263d3..3a3371c 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -20,14 +20,13 @@ use self::structured::decode_from_pairs_map; #[cfg(test)] use self::accumulate::combine_with_limit; #[cfg(test)] -use self::flat::{FlatValues, ParsedFlatValue}; +use self::flat::{DefaultAccumulator, FlatValues, ParsedFlatValue, value_list_length_for_combine}; #[cfg(test)] -use self::keys::{dot_to_bracket_top_level, find_recoverable_balanced_open}; +use self::keys::{dot_to_bracket_top_level, find_recoverable_balanced_open, parse_keys}; #[cfg(test)] -use self::scalar::{decode_scalar, interpret_numeric_entities}; -#[cfg(test)] -use self::scan::ScannedPart; - +use self::scalar::{ + decode_component, decode_scalar, interpret_numeric_entities, interpret_numeric_entities_in_node, +}; /// Decodes a query string into an ordered object map. /// /// The result preserves flat outputs when the input contains no structured key diff --git a/src/decode/accumulate/build.rs b/src/decode/accumulate/build.rs index 67ae0e8..12ec18b 100644 --- a/src/decode/accumulate/build.rs +++ b/src/decode/accumulate/build.rs @@ -326,3 +326,6 @@ pub(super) fn build_custom_value( Ok(ParsedFlatValue::parsed(value, true)) } + +#[cfg(test)] +mod tests; diff --git a/src/decode/accumulate/build/tests.rs b/src/decode/accumulate/build/tests.rs new file mode 100644 index 0000000..ed2ebd2 --- /dev/null +++ b/src/decode/accumulate/build/tests.rs @@ -0,0 +1,177 @@ +use super::{ + DirectBuiltValue, build_custom_value, build_default_value, build_direct_value, + parse_list_value, parse_list_value_default_scanned, +}; +use crate::decode::flat::{DefaultStorageMode, ParsedFlatValue}; +use crate::decode::scan::ScannedPart; +use crate::internal::node::Node; +use crate::options::{Charset, DecodeOptions}; +use crate::value::Value; +use crate::{DecodeDecoder, DecodeKind}; + +fn scalar(value: &str) -> Node { + Node::scalar(Value::String(value.to_owned())) +} + +#[test] +fn list_builders_cover_limit_overflow_and_segment_decoding() { + let overflow = parse_list_value( + "a,b,c", + &DecodeOptions::new().with_comma(true).with_list_limit(2), + 0, + ) + .unwrap(); + assert_eq!( + overflow, + Node::OverflowObject { + entries: [ + ("0".to_owned(), scalar("a")), + ("1".to_owned(), scalar("b")), + ("2".to_owned(), scalar("c")), + ] + .into(), + max_index: 2, + } + ); + + let error = parse_list_value( + "tail", + &DecodeOptions::new() + .with_list_limit(1) + .with_throw_on_limit_exceeded(true), + 1, + ) + .unwrap_err(); + assert!(error.is_list_limit_exceeded()); + assert_eq!(error.list_limit(), Some(1)); + + let scanned = parse_list_value_default_scanned( + "%26%2365%3B,b%20c", + ScannedPart::new("a=%26%2365%3B,b%20c"), + Charset::Iso88591, + &DecodeOptions::new() + .with_comma(true) + .with_interpret_numeric_entities(true), + 0, + ) + .unwrap(); + assert!(matches!( + scanned, + ParsedFlatValue::Concrete(Value::Array(values)) + if values == vec![ + Value::String("A".to_owned()), + Value::String("b c".to_owned()), + ] + )); + + let error = parse_list_value_default_scanned( + "tail", + ScannedPart::new("a=tail"), + Charset::Utf8, + &DecodeOptions::new() + .with_list_limit(1) + .with_throw_on_limit_exceeded(true), + 1, + ) + .unwrap_err(); + assert!(error.is_list_limit_exceeded()); + assert_eq!(error.list_limit(), Some(1)); +} + +#[test] +fn direct_and_custom_builders_cover_suffix_and_null_paths() { + let default_value = build_default_value( + Some("plain"), + ScannedPart::new("a=plain"), + Charset::Utf8, + &DecodeOptions::new(), + 0, + DefaultStorageMode::ForceParsed, + ) + .unwrap(); + assert!(matches!(default_value, ParsedFlatValue::Parsed { .. })); + + let direct_value = build_direct_value( + None, + ScannedPart::new("a"), + Charset::Utf8, + &DecodeOptions::new().with_strict_null_handling(true), + 0, + ) + .unwrap(); + assert!(matches!( + direct_value, + DirectBuiltValue::Concrete(Value::Null) + )); + + let custom_null = build_custom_value( + None, + ScannedPart::new("a"), + Charset::Utf8, + &DecodeOptions::new().with_strict_null_handling(true), + 0, + ) + .unwrap(); + assert_eq!(custom_null.into_node(), Node::Value(Value::Null)); + + let custom_empty = build_custom_value( + None, + ScannedPart::new("a"), + Charset::Utf8, + &DecodeOptions::new(), + 0, + ) + .unwrap(); + assert_eq!(custom_empty.into_node(), scalar("")); + + let custom_wrapped = build_custom_value( + Some("1,2"), + ScannedPart::new("tags[]=1,2"), + Charset::Utf8, + &DecodeOptions::new().with_comma(true), + 0, + ) + .unwrap(); + assert!(matches!( + custom_wrapped, + ParsedFlatValue::Parsed { + node: Node::Array(items), + needs_compaction: true, + } if matches!(items.as_slice(), [Node::Array(_)]) + )); +} + +#[test] +fn builder_limit_and_custom_array_decode_edges_are_covered() { + let error = parse_list_value( + "a,b", + &DecodeOptions::new() + .with_comma(true) + .with_list_limit(1) + .with_throw_on_limit_exceeded(true), + 0, + ) + .unwrap_err(); + assert!(error.is_list_limit_exceeded()); + assert_eq!(error.list_limit(), Some(1)); + + let decoded_array = build_custom_value( + Some("a,b"), + ScannedPart::new("tags=a,b"), + Charset::Utf8, + &DecodeOptions::new() + .with_comma(true) + .with_decoder(Some(DecodeDecoder::new( + |input, _charset, kind| match kind { + DecodeKind::Key => input.to_owned(), + DecodeKind::Value => input.to_ascii_uppercase(), + }, + ))), + 0, + ) + .unwrap(); + assert_eq!( + decoded_array.into_node(), + Node::Array(vec![scalar("A"), scalar("B")]) + ); +} diff --git a/src/decode/accumulate/insert/tests.rs b/src/decode/accumulate/insert/tests.rs index f20f539..d4202ad 100644 --- a/src/decode/accumulate/insert/tests.rs +++ b/src/decode/accumulate/insert/tests.rs @@ -165,3 +165,20 @@ fn default_insert_keeps_concrete_storage_until_parsing_is_required() { .unwrap(); assert!(stores_parsed_value_with_compaction(&parsed_values, "a")); } + +#[test] +fn default_insert_first_ignores_late_parsed_values_for_existing_concrete_keys() { + let mut values = FlatValues::Concrete([("a".to_owned(), scalar("1"))].into()); + insert_default_value( + &mut values, + "a".to_owned(), + ParsedFlatValue::parsed(Node::Array(vec![Node::scalar(scalar("2"))]), true), + &DecodeOptions::new().with_duplicates(Duplicates::First), + ) + .unwrap(); + + let FlatValues::Concrete(entries) = values else { + panic!("expected concrete values to remain in place") + }; + assert_eq!(entries.get("a"), Some(&scalar("1"))); +} diff --git a/src/decode/accumulate/process/tests.rs b/src/decode/accumulate/process/tests.rs index 3e53769..8eb4731 100644 --- a/src/decode/accumulate/process/tests.rs +++ b/src/decode/accumulate/process/tests.rs @@ -303,3 +303,412 @@ fn query_part_wrappers_cover_soft_limits_and_custom_first_duplicates() { Node::scalar(scalar("ONE")) ); } + +#[test] +fn default_and_plain_accumulators_cover_remaining_duplicate_promotions() { + let last_options = DecodeOptions::new() + .with_duplicates(Duplicates::Last) + .with_comma(true) + .with_list_limit(1); + let mut last_values = DefaultAccumulator::direct(); + let mut token_count = 0usize; + let mut has_any_structured_syntax = false; + process_scanned_part_default_accumulator( + ScannedPart::new("a=1"), + Charset::Utf8, + &last_options, + &mut last_values, + &mut token_count, + &mut has_any_structured_syntax, + ) + .unwrap(); + process_scanned_part_default_accumulator( + ScannedPart::new("a=2,3"), + Charset::Utf8, + &last_options, + &mut last_values, + &mut token_count, + &mut has_any_structured_syntax, + ) + .unwrap(); + assert!( + matches!(last_values, DefaultAccumulator::Parsed(entries) if entries.contains_key("a")) + ); + + let combine_options = DecodeOptions::new() + .with_duplicates(Duplicates::Combine) + .with_comma(true) + .with_list_limit(1); + let mut combine_values = DefaultAccumulator::direct(); + let mut combine_tokens = 0usize; + let mut combine_structure = false; + process_scanned_part_default_accumulator( + ScannedPart::new("a="), + Charset::Utf8, + &combine_options, + &mut combine_values, + &mut combine_tokens, + &mut combine_structure, + ) + .unwrap(); + process_scanned_part_default_accumulator( + ScannedPart::new("a=2,3"), + Charset::Utf8, + &combine_options, + &mut combine_values, + &mut combine_tokens, + &mut combine_structure, + ) + .unwrap(); + assert!( + matches!(combine_values, DefaultAccumulator::Parsed(entries) if entries.contains_key("a")) + ); + + let mut plain_values = DefaultAccumulator::direct(); + let mut plain_tokens = 0usize; + process_plain_part_default( + "a=1", + Some(1), + &DecodeOptions::new().with_duplicates(Duplicates::Last), + &mut plain_values, + &mut plain_tokens, + ) + .unwrap(); + process_plain_part_default( + "a=2", + Some(1), + &DecodeOptions::new().with_duplicates(Duplicates::Last), + &mut plain_values, + &mut plain_tokens, + ) + .unwrap(); + let DefaultAccumulator::Direct(entries) = &plain_values else { + panic!("expected direct plain storage") + }; + assert_eq!(entries.get("a"), Some(&scalar("2"))); + + let mut promoted_plain_values = DefaultAccumulator::direct(); + let mut promoted_plain_tokens = 0usize; + let promote_options = DecodeOptions::new() + .with_duplicates(Duplicates::Combine) + .with_list_limit(1); + process_plain_part_default( + "a=", + Some(1), + &promote_options, + &mut promoted_plain_values, + &mut promoted_plain_tokens, + ) + .unwrap(); + process_plain_part_default( + "a=tail", + Some(1), + &promote_options, + &mut promoted_plain_values, + &mut promoted_plain_tokens, + ) + .unwrap(); + assert!(matches!( + promoted_plain_values, + DefaultAccumulator::Parsed(entries) if entries.contains_key("a") + )); + + let mut parsed_plain_values = DefaultAccumulator::Parsed(Default::default()); + let mut parsed_plain_tokens = 0usize; + process_plain_part_default( + "b=1", + Some(1), + &DecodeOptions::new(), + &mut parsed_plain_values, + &mut parsed_plain_tokens, + ) + .unwrap(); + let DefaultAccumulator::Parsed(entries) = parsed_plain_values else { + panic!("expected parsed plain storage") + }; + assert_eq!( + entries.get("b").unwrap().clone().into_node(), + Node::scalar(scalar("1")) + ); +} + +#[test] +fn prefer_concrete_and_custom_paths_cover_remaining_storage_modes() { + let last_options = DecodeOptions::new().with_duplicates(Duplicates::Last); + let mut prefer_concrete = FlatValues::Concrete([("a".to_owned(), scalar("1"))].into()); + let mut token_count = 0usize; + let mut has_any_structured_syntax = false; + process_scanned_part_default_with_mode( + ScannedPart::new("a=2"), + Charset::Utf8, + &last_options, + &mut prefer_concrete, + &mut token_count, + &mut has_any_structured_syntax, + DefaultStorageMode::PreferConcrete, + ) + .unwrap(); + let FlatValues::Concrete(entries) = &prefer_concrete else { + panic!("expected concrete values") + }; + assert_eq!(entries.get("a"), Some(&scalar("2"))); + + let combine_options = DecodeOptions::new() + .with_duplicates(Duplicates::Combine) + .with_comma(true) + .with_list_limit(1); + let mut promoted = + FlatValues::Concrete([("a".to_owned(), scalar(String::new().as_str()))].into()); + let mut promote_tokens = 0usize; + let mut promote_structure = false; + process_scanned_part_default_with_mode( + ScannedPart::new("a=1,2"), + Charset::Utf8, + &combine_options, + &mut promoted, + &mut promote_tokens, + &mut promote_structure, + DefaultStorageMode::PreferConcrete, + ) + .unwrap(); + assert!(stores_parsed_value(&promoted, "a")); + + let decoder = DecodeDecoder::new(|input, _charset, kind| match kind { + DecodeKind::Key => input.to_owned(), + DecodeKind::Value => input.to_ascii_uppercase(), + }); + let custom_options = DecodeOptions::new() + .with_charset_sentinel(true) + .with_comma(true) + .with_duplicates(Duplicates::Combine) + .with_decoder(Some(decoder)); + let mut custom_values = FlatValues::parsed(); + let mut custom_tokens = 0usize; + let mut custom_structure = false; + process_scanned_part_custom( + ScannedPart::new("utf8=%E2%9C%93"), + Charset::Utf8, + &custom_options, + &mut custom_values, + &mut custom_tokens, + &mut custom_structure, + ) + .unwrap(); + assert!(custom_values.is_empty()); + assert_eq!(custom_tokens, 1); + + process_scanned_part_custom( + ScannedPart::new("letters=a"), + Charset::Utf8, + &custom_options, + &mut custom_values, + &mut custom_tokens, + &mut custom_structure, + ) + .unwrap(); + process_scanned_part_custom( + ScannedPart::new("letters=b,c"), + Charset::Utf8, + &custom_options, + &mut custom_values, + &mut custom_tokens, + &mut custom_structure, + ) + .unwrap(); + let FlatValues::Parsed(entries) = custom_values else { + panic!("expected parsed custom values") + }; + assert_eq!(entries.get("letters").unwrap().list_length_for_combine(), 3); + + let mut already_structured = true; + update_structured_syntax_flag( + ScannedPart::new("flat=1"), + "flat", + &DecodeOptions::new(), + &mut already_structured, + ); + assert!(already_structured); +} + +#[test] +fn duplicate_modes_cover_remaining_default_and_custom_processing_paths() { + let direct_last_options = DecodeOptions::new() + .with_duplicates(Duplicates::Last) + .with_comma(true) + .with_list_limit(1); + let mut direct_last = DefaultAccumulator::direct(); + let mut direct_last_tokens = 0usize; + let mut direct_last_structure = false; + process_scanned_part_default_accumulator( + ScannedPart::new("a=1"), + Charset::Utf8, + &direct_last_options, + &mut direct_last, + &mut direct_last_tokens, + &mut direct_last_structure, + ) + .unwrap(); + process_scanned_part_default_accumulator( + ScannedPart::new("a=2,3"), + Charset::Utf8, + &direct_last_options, + &mut direct_last, + &mut direct_last_tokens, + &mut direct_last_structure, + ) + .unwrap(); + assert!(matches!( + direct_last, + DefaultAccumulator::Parsed(entries) if entries.contains_key("a") + )); + + let direct_combine_options = DecodeOptions::new() + .with_duplicates(Duplicates::Combine) + .with_comma(true) + .with_list_limit(8); + let mut direct_combine = DefaultAccumulator::direct(); + let mut direct_combine_tokens = 0usize; + let mut direct_combine_structure = false; + process_scanned_part_default_accumulator( + ScannedPart::new("a=seed"), + Charset::Utf8, + &direct_combine_options, + &mut direct_combine, + &mut direct_combine_tokens, + &mut direct_combine_structure, + ) + .unwrap(); + process_scanned_part_default_accumulator( + ScannedPart::new("a=2,3"), + Charset::Utf8, + &direct_combine_options, + &mut direct_combine, + &mut direct_combine_tokens, + &mut direct_combine_structure, + ) + .unwrap(); + assert!(matches!(direct_combine, DefaultAccumulator::Direct(_))); + + let mut parsed_default = DefaultAccumulator::Parsed(Default::default()); + let mut parsed_default_tokens = 0usize; + let mut parsed_default_structure = false; + process_scanned_part_default_accumulator( + ScannedPart::new("a=1,2"), + Charset::Utf8, + &DecodeOptions::new() + .with_duplicates(Duplicates::Last) + .with_comma(true), + &mut parsed_default, + &mut parsed_default_tokens, + &mut parsed_default_structure, + ) + .unwrap(); + assert!(matches!( + parsed_default, + DefaultAccumulator::Parsed(entries) if entries.contains_key("a") + )); + + let mut first_concrete = FlatValues::Concrete([("a".to_owned(), scalar("1"))].into()); + let mut first_tokens = 0usize; + let mut first_structure = false; + process_scanned_part_default_with_mode( + ScannedPart::new("a=ignored"), + Charset::Utf8, + &DecodeOptions::new().with_duplicates(Duplicates::First), + &mut first_concrete, + &mut first_tokens, + &mut first_structure, + DefaultStorageMode::PreferConcrete, + ) + .unwrap(); + let FlatValues::Concrete(first_entries) = &first_concrete else { + panic!("expected concrete values to stay unchanged") + }; + assert_eq!(first_entries.get("a"), Some(&scalar("1"))); + + let mut prefer_last = FlatValues::Concrete([("a".to_owned(), scalar("1"))].into()); + let mut prefer_last_tokens = 0usize; + let mut prefer_last_structure = false; + process_scanned_part_default_with_mode( + ScannedPart::new("a=2,3"), + Charset::Utf8, + &direct_last_options, + &mut prefer_last, + &mut prefer_last_tokens, + &mut prefer_last_structure, + DefaultStorageMode::PreferConcrete, + ) + .unwrap(); + assert!(stores_parsed_value(&prefer_last, "a")); + + let mut prefer_combine = FlatValues::Concrete([("a".to_owned(), scalar("1"))].into()); + let mut prefer_combine_tokens = 0usize; + let mut prefer_combine_structure = false; + process_scanned_part_default_with_mode( + ScannedPart::new("a=2,3"), + Charset::Utf8, + &DecodeOptions::new() + .with_duplicates(Duplicates::Combine) + .with_comma(true) + .with_list_limit(1), + &mut prefer_combine, + &mut prefer_combine_tokens, + &mut prefer_combine_structure, + DefaultStorageMode::PreferConcrete, + ) + .unwrap(); + assert!(stores_parsed_value(&prefer_combine, "a")); + + let mut custom_last = FlatValues::parsed(); + let mut custom_last_tokens = 0usize; + let mut custom_last_structure = false; + let custom_last_options = DecodeOptions::new() + .with_duplicates(Duplicates::Last) + .with_decoder(Some(DecodeDecoder::new( + |input, _charset, kind| match kind { + DecodeKind::Key => input.to_owned(), + DecodeKind::Value => input.to_ascii_uppercase(), + }, + ))); + process_scanned_part_custom( + ScannedPart::new("name=one"), + Charset::Utf8, + &custom_last_options, + &mut custom_last, + &mut custom_last_tokens, + &mut custom_last_structure, + ) + .unwrap(); + process_scanned_part_custom( + ScannedPart::new("name=two"), + Charset::Utf8, + &custom_last_options, + &mut custom_last, + &mut custom_last_tokens, + &mut custom_last_structure, + ) + .unwrap(); + let FlatValues::Parsed(custom_last_entries) = custom_last else { + panic!("expected parsed custom values") + }; + assert_eq!( + custom_last_entries.get("name").unwrap().clone().into_node(), + Node::scalar(scalar("TWO")) + ); +} + +#[test] +fn hard_limit_duplicate_combine_errors_propagate_from_direct_combine_helpers() { + let options = DecodeOptions::new() + .with_duplicates(Duplicates::Combine) + .with_list_limit(1) + .with_throw_on_limit_exceeded(true); + let mut values = DefaultAccumulator::direct(); + let mut token_count = 0usize; + + process_plain_part_default("a=1", Some(1), &options, &mut values, &mut token_count).unwrap(); + let error = process_plain_part_default("a=2", Some(1), &options, &mut values, &mut token_count) + .unwrap_err(); + assert!(error.is_list_limit_exceeded()); + assert_eq!(error.list_limit(), Some(1)); +} diff --git a/src/decode/scan.rs b/src/decode/scan.rs index d1782a3..ce871ce 100644 --- a/src/decode/scan.rs +++ b/src/decode/scan.rs @@ -9,3 +9,5 @@ pub(super) use self::metadata::{ contains_ascii_case_insensitive_bytes, hex_value, }; pub(super) use self::parse::parse_query_string_values; +#[cfg(test)] +pub(in crate::decode) use self::parts::{scan_default_parts_by_byte_delimiter, scan_string_parts}; diff --git a/src/decode/scan/parts.rs b/src/decode/scan/parts.rs index 82f4f57..be8618c 100644 --- a/src/decode/scan/parts.rs +++ b/src/decode/scan/parts.rs @@ -9,7 +9,7 @@ use super::super::accumulate::{ use super::super::flat::DefaultAccumulator; use super::metadata::ScannedPart; -pub(super) fn scan_string_parts( +pub(in crate::decode) fn scan_string_parts( input: &str, delimiter: &str, mut visit: F, @@ -66,7 +66,7 @@ where Ok(()) } -pub(super) fn scan_default_parts_by_byte_delimiter( +pub(in crate::decode) fn scan_default_parts_by_byte_delimiter( input: &str, delimiter: u8, effective_charset: Charset, diff --git a/src/decode/tests/flat.rs b/src/decode/tests/flat.rs index 787eaa5..2f11e99 100644 --- a/src/decode/tests/flat.rs +++ b/src/decode/tests/flat.rs @@ -2,8 +2,9 @@ use std::sync::{Arc, Mutex}; use super::{ DecodeDecoder, DecodeOptions, Delimiter, FlatValues, IndexMap, Node, ParsedFlatValue, Regex, - Value, collect_pair_values, decode, decode_from_pairs_map, finalize_flat, + Value, collect_pair_values, decode, decode_from_pairs_map, decode_pairs, finalize_flat, parse_query_string_values, scan_structured_keys, stores_concrete_value, stores_parsed_value, + value_list_length_for_combine, }; use crate::options::DecodeKind; @@ -199,6 +200,12 @@ fn regex_custom_and_decode_pairs_keep_the_parsed_path() { assert!(stores_parsed_value(&pair_parsed.values, "a")); } +#[test] +fn decode_pairs_returns_empty_for_empty_input() { + let decoded = decode_pairs(Vec::<(String, Value)>::new(), &DecodeOptions::new()).unwrap(); + assert!(decoded.is_empty()); +} + #[test] fn public_decode_applies_custom_decoder_to_plain_unescaped_values() { let seen = Arc::new(Mutex::new(Vec::new())); @@ -254,3 +261,130 @@ fn public_decode_applies_custom_decoder_to_each_comma_split_value() { ); assert_eq!(*seen.lock().unwrap(), vec!["a".to_owned(), "b".to_owned()]); } + +#[test] +fn flat_value_helpers_cover_limits_lengths_and_undefined_outputs() { + let soft_limited = collect_pair_values( + [ + ("".to_owned(), Value::String("skip".to_owned())), + ("a".to_owned(), Value::String("1".to_owned())), + ("b".to_owned(), Value::String("2".to_owned())), + ], + &DecodeOptions::new().with_parameter_limit(1), + ) + .unwrap(); + assert!(stores_parsed_value(&soft_limited.values, "a")); + assert!(!stores_parsed_value(&soft_limited.values, "b")); + + let error = collect_pair_values( + [ + ("a".to_owned(), Value::String("1".to_owned())), + ("b".to_owned(), Value::String("2".to_owned())), + ], + &DecodeOptions::new() + .with_parameter_limit(1) + .with_throw_on_limit_exceeded(true), + ) + .unwrap_err(); + assert!(error.is_parameter_limit_exceeded()); + assert_eq!(error.parameter_limit(), Some(1)); + + assert!(matches!( + ParsedFlatValue::concrete(Value::String("x".to_owned())).force_parsed(), + ParsedFlatValue::Parsed { .. } + )); + assert!(matches!( + ParsedFlatValue::parsed(Node::Undefined, true).force_parsed(), + ParsedFlatValue::Parsed { + node: Node::Undefined, + needs_compaction: true, + } + )); + + assert_eq!( + ParsedFlatValue::concrete(Value::Array(vec![Value::String("x".to_owned())])) + .list_length_for_combine(), + 1 + ); + assert_eq!( + ParsedFlatValue::concrete(Value::String("x".to_owned())).list_length_for_combine(), + 1 + ); + assert_eq!( + ParsedFlatValue::concrete(Value::String(String::new())).list_length_for_combine(), + 0 + ); + assert_eq!( + ParsedFlatValue::parsed( + Node::OverflowObject { + entries: [("1".to_owned(), Node::scalar(Value::String("x".to_owned())))].into(), + max_index: 1, + }, + true, + ) + .list_length_for_combine(), + 2 + ); + + assert_eq!( + value_list_length_for_combine(&Value::Object( + [("inner".to_owned(), Value::String("x".to_owned()))].into() + )), + 1 + ); + + let finalized = finalize_flat( + FlatValues::Parsed( + [ + ( + "skip".to_owned(), + ParsedFlatValue::parsed(Node::Undefined, false), + ), + ( + "object".to_owned(), + ParsedFlatValue::concrete(Value::Object( + [("inner".to_owned(), Value::String("x".to_owned()))].into(), + )), + ), + ] + .into(), + ), + &DecodeOptions::new(), + ) + .unwrap(); + assert!(!finalized.contains_key("skip")); + assert_eq!( + finalized.get("object"), + Some(&Value::Object( + [("inner".to_owned(), Value::String("x".to_owned()))].into() + )) + ); +} + +#[test] +fn structured_decode_from_pairs_map_merges_existing_roots_with_flat_values() { + let values = FlatValues::Parsed( + [ + ( + "plain[child]".to_owned(), + ParsedFlatValue::parsed(Node::scalar(Value::String("nested".to_owned())), true), + ), + ( + "plain".to_owned(), + ParsedFlatValue::parsed(Node::scalar(Value::String("root".to_owned())), true), + ), + ] + .into(), + ); + let options = DecodeOptions::new(); + let scan = scan_structured_keys(["plain[child]", "plain"], &options).unwrap(); + + let decoded = decode_from_pairs_map(values, &options, &scan).unwrap(); + assert_eq!( + decoded.get("plain"), + Some(&Value::Array(vec![ + Value::Object([("child".to_owned(), Value::String("nested".to_owned()))].into()), + Value::String("root".to_owned()), + ])) + ); +} diff --git a/src/decode/tests/keys.rs b/src/decode/tests/keys.rs new file mode 100644 index 0000000..014c9fd --- /dev/null +++ b/src/decode/tests/keys.rs @@ -0,0 +1,62 @@ +use super::{ + DecodeOptions, Node, Value, find_recoverable_balanced_open, parse_keys, split_key_into_segments, +}; + +fn scalar(value: &str) -> Node { + Node::scalar(Value::String(value.to_owned())) +} + +#[test] +fn structured_key_helpers_cover_recovered_roots_and_trailing_segments() { + assert_eq!( + split_key_into_segments("[a[b]", false, 5, false).unwrap(), + vec!["[a".to_owned(), "[b]".to_owned()] + ); + assert_eq!(find_recoverable_balanced_open("[a[b]", 1), Some(2)); + + assert_eq!( + split_key_into_segments("a[b]tail", false, 5, false).unwrap(), + vec!["a".to_owned(), "[b]".to_owned(), "[tail]".to_owned()] + ); + assert!(split_key_into_segments("a[b]tail", false, 5, true).is_err()); +} + +#[test] +fn parse_keys_covers_empty_inputs_decoded_dots_and_list_limit_errors() { + assert!( + parse_keys("", scalar("x"), &DecodeOptions::new()) + .unwrap() + .is_none() + ); + + let decoded = parse_keys( + "user[%2Ehidden]", + scalar("x"), + &DecodeOptions::new() + .with_allow_dots(true) + .with_decode_dot_in_keys(true), + ) + .unwrap() + .unwrap(); + assert_eq!( + decoded, + Node::Object( + [( + "user".to_owned(), + Node::Object([(".hidden".to_owned(), scalar("x"))].into()), + )] + .into() + ) + ); + + let error = parse_keys( + "list[2]", + scalar("x"), + &DecodeOptions::new() + .with_list_limit(2) + .with_throw_on_limit_exceeded(true), + ) + .unwrap_err(); + assert!(error.is_list_limit_exceeded()); + assert_eq!(error.list_limit(), Some(2)); +} diff --git a/src/decode/tests/mod.rs b/src/decode/tests/mod.rs index fa1fc15..2734e2d 100644 --- a/src/decode/tests/mod.rs +++ b/src/decode/tests/mod.rs @@ -1,11 +1,17 @@ +pub(super) use super::scan::{ + ScannedPart, scan_default_parts_by_byte_delimiter, scan_string_parts, +}; pub(super) use super::{ - FlatValues, ParsedFlatValue, ScannedPart, collect_pair_values, combine_with_limit, decode, - decode_from_pairs_map, decode_scalar, dot_to_bracket_top_level, finalize_flat, - find_recoverable_balanced_open, interpret_numeric_entities, parse_query_string_values, - split_key_into_segments, + DefaultAccumulator, FlatValues, ParsedFlatValue, collect_pair_values, combine_with_limit, + decode, decode_component, decode_from_pairs_map, decode_pairs, decode_scalar, + dot_to_bracket_top_level, finalize_flat, find_recoverable_balanced_open, + interpret_numeric_entities, interpret_numeric_entities_in_node, parse_keys, + parse_query_string_values, split_key_into_segments, value_list_length_for_combine, }; pub(super) use crate::internal::node::Node; -pub(super) use crate::options::{Charset, DecodeDecoder, DecodeOptions, Delimiter, Duplicates}; +pub(super) use crate::options::{ + Charset, DecodeDecoder, DecodeKind, DecodeOptions, Delimiter, Duplicates, +}; pub(super) use crate::structured_scan::scan_structured_keys; pub(super) use crate::value::Value; pub(super) use indexmap::IndexMap; @@ -36,4 +42,7 @@ pub(super) fn stores_parsed_value_with_compaction(values: &FlatValues, key: &str mod charset; mod duplicates; mod flat; +mod keys; +mod parts; +mod scalar_helpers; mod scanner; diff --git a/src/decode/tests/parts.rs b/src/decode/tests/parts.rs new file mode 100644 index 0000000..5378f84 --- /dev/null +++ b/src/decode/tests/parts.rs @@ -0,0 +1,53 @@ +use super::{ + Charset, DecodeOptions, DefaultAccumulator, Duplicates, ScannedPart, Value, finalize_flat, + scan_default_parts_by_byte_delimiter, scan_string_parts, +}; + +#[test] +fn string_part_scanners_skip_empty_segments_for_byte_and_multi_byte_delimiters() { + let mut byte_parts = Vec::new(); + scan_string_parts("a=1&&b=2&", "&", |part: ScannedPart<'_>| { + byte_parts.push(part.raw_parts().0.to_owned()); + Ok(()) + }) + .unwrap(); + assert_eq!(byte_parts, vec!["a".to_owned(), "b".to_owned()]); + + let mut multi_parts = Vec::new(); + scan_string_parts("a=1&&b=2&&", "&&", |part: ScannedPart<'_>| { + multi_parts.push(part.raw_parts().0.to_owned()); + Ok(()) + }) + .unwrap(); + assert_eq!(multi_parts, vec!["a".to_owned(), "b".to_owned()]); +} + +#[test] +fn default_byte_scanner_routes_plain_and_featureful_parts() { + let options = DecodeOptions::new() + .with_charset(Charset::Iso88591) + .with_interpret_numeric_entities(true) + .with_duplicates(Duplicates::Last); + let mut values = DefaultAccumulator::direct(); + let mut token_count = 0usize; + let mut has_any_structured_syntax = false; + + scan_default_parts_by_byte_delimiter( + "plain=1|latin=A|encoded%20key=value", + b'|', + Charset::Iso88591, + &options, + &mut values, + &mut token_count, + &mut has_any_structured_syntax, + ) + .unwrap(); + + let decoded = finalize_flat(values.into_flat_values(), &options).unwrap(); + assert_eq!(decoded.get("plain"), Some(&Value::String("1".to_owned()))); + assert_eq!(decoded.get("latin"), Some(&Value::String("A".to_owned()))); + assert_eq!( + decoded.get("encoded key"), + Some(&Value::String("value".to_owned())) + ); +} diff --git a/src/decode/tests/scalar_helpers.rs b/src/decode/tests/scalar_helpers.rs new file mode 100644 index 0000000..42ce81b --- /dev/null +++ b/src/decode/tests/scalar_helpers.rs @@ -0,0 +1,37 @@ +use super::{ + Charset, DecodeDecoder, DecodeKind, DecodeOptions, Node, Value, decode_component, + interpret_numeric_entities, interpret_numeric_entities_in_node, +}; + +#[test] +fn scalar_helpers_cover_custom_decoders_and_recursive_numeric_entity_nodes() { + let options = DecodeOptions::new().with_decoder(Some(DecodeDecoder::new( + |input, _charset, kind| match kind { + DecodeKind::Value => input.to_ascii_uppercase(), + DecodeKind::Key => input.to_owned(), + }, + ))); + assert_eq!( + decode_component("plain", Charset::Utf8, DecodeKind::Value, &options), + "PLAIN" + ); + assert_eq!(interpret_numeric_entities("plain"), "plain"); + + let interpreted = interpret_numeric_entities_in_node(Node::Array(vec![ + Node::scalar(Value::String("A".to_owned())), + Node::scalar(Value::String("plain".to_owned())), + ])); + assert_eq!( + interpreted, + Node::Array(vec![ + Node::scalar(Value::String("A".to_owned())), + Node::scalar(Value::String("plain".to_owned())), + ]) + ); + + let untouched_object = Node::Object([("field".to_owned(), Node::scalar(Value::Null))].into()); + assert_eq!( + interpret_numeric_entities_in_node(untouched_object.clone()), + untouched_object + ); +} diff --git a/src/decode/tests/scanner.rs b/src/decode/tests/scanner.rs index 0c4ddaa..3ca14ad 100644 --- a/src/decode/tests/scanner.rs +++ b/src/decode/tests/scanner.rs @@ -1,7 +1,9 @@ use super::{ - Charset, DecodeOptions, Delimiter, Regex, ScannedPart, Value, decode, dot_to_bracket_top_level, - find_recoverable_balanced_open, parse_query_string_values, split_key_into_segments, + Charset, DecodeDecoder, DecodeOptions, Delimiter, Regex, ScannedPart, Value, decode, + dot_to_bracket_top_level, find_recoverable_balanced_open, parse_query_string_values, + split_key_into_segments, }; +use crate::options::DecodeKind; #[test] fn split_key_into_segments_handles_dots_and_unterminated_groups() { @@ -111,6 +113,12 @@ fn scanned_part_metadata_tracks_default_fast_path_flags() { let numeric_entity = ScannedPart::new("a=%26%239786%3B"); assert!(numeric_entity.value_has_escape_or_plus); assert!(numeric_entity.value_has_numeric_entity_candidate); + + let repartitioned = ScannedPart::new("a+%5D=x"); + assert!(repartitioned.key_has_escape_or_plus); + + let hash_numeric_entity = ScannedPart::new("a=#123"); + assert!(hash_numeric_entity.value_has_numeric_entity_candidate); } #[test] @@ -139,6 +147,63 @@ fn parse_query_string_values_skip_adjacent_empty_segments_for_string_and_regex_d ); } +#[test] +fn charset_detection_and_regex_custom_paths_cover_remaining_scanner_edges() { + let custom_regex = DecodeOptions::new() + .with_delimiter(Delimiter::Regex(Regex::new("[&;]").unwrap())) + .with_decoder(Some(DecodeDecoder::new( + |input, _charset, kind| match kind { + DecodeKind::Key => input.to_owned(), + DecodeKind::Value => input.to_ascii_uppercase(), + }, + ))); + let parsed = parse_query_string_values("name=one;other=two", &custom_regex).unwrap(); + assert_eq!( + super::finalize_flat(parsed.values, &custom_regex).unwrap(), + [ + ("name".to_owned(), Value::String("ONE".to_owned())), + ("other".to_owned(), Value::String("TWO".to_owned())), + ] + .into() + ); + + let empty_delimiter = parse_query_string_values( + "utf8=%E2%9C%93", + &DecodeOptions::new() + .with_charset_sentinel(true) + .with_delimiter(Delimiter::String(String::new())), + ) + .unwrap_err(); + assert!(matches!( + empty_delimiter, + crate::error::DecodeError::EmptyDelimiter + )); + + let trailing_single = decode( + "utf8=%E2%9C%93&a=1&", + &DecodeOptions::new() + .with_charset(Charset::Iso88591) + .with_charset_sentinel(true), + ) + .unwrap(); + assert_eq!( + trailing_single.get("a"), + Some(&Value::String("1".to_owned())) + ); + + let trailing_multi = decode( + "a=1&&", + &DecodeOptions::new() + .with_charset_sentinel(true) + .with_delimiter(Delimiter::String("&&".to_owned())), + ) + .unwrap(); + assert_eq!( + trailing_multi.get("a"), + Some(&Value::String("1".to_owned())) + ); +} + #[test] fn raw_parse_marks_only_potentially_structured_inputs_for_follow_up_scan() { let flat = parse_query_string_values("a=1&b=2", &DecodeOptions::new()).unwrap(); @@ -249,3 +314,38 @@ fn single_byte_plain_fast_path_preserves_featureful_fallback_behavior() { [("a".to_owned(), Value::String("1".to_owned()))].into() ); } + +#[test] +fn regex_custom_parser_and_multi_byte_sentinel_scan_cover_remaining_paths() { + let regex_options = DecodeOptions::new() + .with_delimiter(Delimiter::Regex(Regex::new("[;|]").unwrap())) + .with_decoder(Some(super::DecodeDecoder::new( + |input, _charset, kind| match kind { + DecodeKind::Value => input.to_ascii_uppercase(), + DecodeKind::Key => input.to_owned(), + }, + ))); + let parsed = parse_query_string_values("a=one;b=two", ®ex_options).unwrap(); + assert_eq!( + super::finalize_flat(parsed.values, ®ex_options).unwrap(), + [ + ("a".to_owned(), Value::String("ONE".to_owned())), + ("b".to_owned(), Value::String("TWO".to_owned())), + ] + .into() + ); + + let multi_byte_options = DecodeOptions::new() + .with_delimiter(Delimiter::String("&&".to_owned())) + .with_charset(Charset::Iso88591) + .with_charset_sentinel(true); + let parsed = parse_query_string_values("a=1&&b=2", &multi_byte_options).unwrap(); + assert_eq!( + super::finalize_flat(parsed.values, &multi_byte_options).unwrap(), + [ + ("a".to_owned(), Value::String("1".to_owned())), + ("b".to_owned(), Value::String("2".to_owned())), + ] + .into() + ); +} diff --git a/src/key_path/tests.rs b/src/key_path/tests.rs index c5c47d8..69b5c8d 100644 --- a/src/key_path/tests.rs +++ b/src/key_path/tests.rs @@ -30,3 +30,16 @@ fn dot_encoded_cache_reuses_nodes_when_segments_have_no_dots() { assert_eq!(encoded_once.materialize(), "user%2Ename[first%2Elast][0]"); assert!(Rc::ptr_eq(&encoded_once.0, &encoded_twice.0)); } + +#[test] +fn append_and_dot_encoding_helpers_reuse_nodes_when_nothing_changes() { + let root = KeyPathNode::from_raw("user"); + let same = root.append_dot_component(""); + assert!(Rc::ptr_eq(&root.0, &same.0)); + + let nested = root.append_bracketed_component("name"); + assert_eq!(nested.materialize(), "user[name]"); + + let encoded = nested.as_dot_encoded("%2E"); + assert!(Rc::ptr_eq(&nested.0, &encoded.0)); +} diff --git a/src/structured_scan/tests.rs b/src/structured_scan/tests.rs index f5e1ca9..f81b8c5 100644 --- a/src/structured_scan/tests.rs +++ b/src/structured_scan/tests.rs @@ -38,3 +38,12 @@ fn leading_structured_root_preserves_noncanonical_numeric_keys() { "01" ); } + +#[test] +fn structured_scan_helpers_cover_plain_percent_inputs_and_empty_segments() { + assert_eq!(first_structured_split_index("plain%20text", true), None); + assert_eq!( + leading_structured_root("", &DecodeOptions::default()).unwrap(), + "" + ); +} From 822033e93ba955a4441b120330fb3ef4c60f8593 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 5 Apr 2026 12:11:17 +0100 Subject: [PATCH 2/6] test(encode): expand encode and temporal coverage --- src/encode.rs | 5 +- src/encode/filter.rs | 3 + src/encode/filter/tests.rs | 157 ++++++++++++++++++++++++++++++++++++ src/encode/scalar.rs | 2 +- src/encode/tests/filters.rs | 24 ++++++ src/encode/tests/mod.rs | 6 +- src/encode/tests/scalar.rs | 62 ++++++++++++++ src/options/encode.rs | 3 + src/options/encode/tests.rs | 136 +++++++++++++++++++++++++++++++ src/temporal/tests.rs | 101 ++++++++++++++++++++++- 10 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 src/encode/filter/tests.rs create mode 100644 src/encode/tests/scalar.rs create mode 100644 src/options/encode/tests.rs diff --git a/src/encode.rs b/src/encode.rs index d167c6e..babdda4 100644 --- a/src/encode.rs +++ b/src/encode.rs @@ -19,7 +19,10 @@ use self::scalar::encoded_dot_escape; #[cfg(test)] use self::comma::encode_comma_array; #[cfg(test)] -use self::scalar::{encode_key_only_fragment, percent_encode_bytes, percent_encode_latin1}; +use self::scalar::{ + encode_key_only_fragment, encoded_scalar_text, percent_encode_bytes, percent_encode_latin1, + plain_scalar_text, plain_string_for_comma, scalar_is_null_like, +}; /// Encodes a [`Value`] tree into a query string. /// diff --git a/src/encode/filter.rs b/src/encode/filter.rs index 41ac298..ca91df5 100644 --- a/src/encode/filter.rs +++ b/src/encode/filter.rs @@ -271,3 +271,6 @@ impl<'a> EncodeFrame<'a> { } } } + +#[cfg(test)] +mod tests; diff --git a/src/encode/filter/tests.rs b/src/encode/filter/tests.rs new file mode 100644 index 0000000..eaa77d5 --- /dev/null +++ b/src/encode/filter/tests.rs @@ -0,0 +1,157 @@ +use super::{ + EncodeInput, apply_filter_result, encode_node_filtered, filter_root_value, has_filter_control, + has_function_filter, +}; +use crate::key_path::KeyPathNode; +use crate::options::{ + EncodeFilter, EncodeOptions, FilterResult, FunctionFilter, ListFormat, WhitelistSelector, +}; +use crate::value::Value; + +#[test] +fn filtered_encode_fast_path_traverses_linear_objects() { + let fast = encode_node_filtered( + EncodeInput::Borrowed(&Value::Object( + [( + "b".to_owned(), + Value::Object([("c".to_owned(), Value::String("x".to_owned()))].into()), + )] + .into(), + )), + KeyPathNode::from_raw("a"), + &EncodeOptions::new().with_encode(false), + 0, + ) + .unwrap(); + assert_eq!(fast, vec!["a[b][c]=x".to_owned()]); +} + +#[test] +fn filtered_encode_returns_empty_output_for_omitted_input() { + let omitted = encode_node_filtered( + EncodeInput::Omitted, + KeyPathNode::from_raw("a"), + &EncodeOptions::new().with_encode(false), + 0, + ) + .unwrap(); + assert!(omitted.is_empty()); +} + +#[test] +fn filtered_encode_reports_max_depth_errors() { + let error = encode_node_filtered( + EncodeInput::Borrowed(&Value::Object( + [ + ("a".to_owned(), Value::String("x".to_owned())), + ("b".to_owned(), Value::String("y".to_owned())), + ] + .into(), + )), + KeyPathNode::from_raw("root"), + &EncodeOptions::new() + .with_encode(false) + .with_max_depth(Some(0)), + 1, + ) + .unwrap_err(); + assert!(error.is_depth_exceeded()); +} + +#[test] +fn filtered_encode_emits_empty_list_suffixes_when_allowed() { + let empty_list = encode_node_filtered( + EncodeInput::Borrowed(&Value::Array(Vec::new())), + KeyPathNode::from_raw("items"), + &EncodeOptions::new() + .with_encode(false) + .with_allow_empty_lists(true) + .with_list_format(ListFormat::Indices), + 0, + ) + .unwrap(); + assert_eq!(empty_list, vec!["items[]".to_owned()]); +} + +#[test] +fn filtered_encode_uses_dot_encoded_keys_and_skips_null_object_children() { + let dotted = encode_node_filtered( + EncodeInput::Borrowed(&Value::Object( + [ + ("dot.key".to_owned(), Value::String("x".to_owned())), + ("skip".to_owned(), Value::Null), + ] + .into(), + )), + KeyPathNode::from_raw("root"), + &EncodeOptions::new() + .with_encode(false) + .with_allow_dots(true) + .with_encode_dot_in_keys(true) + .with_skip_nulls(true), + 0, + ) + .unwrap(); + assert_eq!(dotted, vec!["root.dot%2Ekey=x".to_owned()]); +} + +#[test] +fn filtered_encode_skips_null_array_items() { + let skipped_array_null = encode_node_filtered( + EncodeInput::Borrowed(&Value::Array(vec![ + Value::Null, + Value::String("y".to_owned()), + ])), + KeyPathNode::from_raw("list"), + &EncodeOptions::new() + .with_encode(false) + .with_skip_nulls(true), + 0, + ) + .unwrap(); + assert_eq!(skipped_array_null, vec!["list[1]=y".to_owned()]); +} + +#[test] +fn filter_helpers_cover_root_replacements_and_omitted_inputs() { + let original = Value::String("root".to_owned()); + let replacement_options = EncodeOptions::new().with_filter(Some(EncodeFilter::Function( + FunctionFilter::new(|prefix, _| { + if prefix.is_empty() { + FilterResult::Replace(Value::Object( + [("answer".to_owned(), Value::String("42".to_owned()))].into(), + )) + } else { + FilterResult::Keep + } + }), + ))); + let replaced_root = filter_root_value(&original, &replacement_options); + assert!(matches!( + replaced_root, + EncodeInput::Owned(Value::Object(entries)) + if matches!(entries.get("answer"), Some(Value::String(text)) if text == "42") + )); + + let non_container_options = EncodeOptions::new().with_filter(Some(EncodeFilter::Function( + FunctionFilter::new(|_, _| FilterResult::Replace(Value::String("ignored".to_owned()))), + ))); + assert!(matches!( + filter_root_value(&original, &non_container_options), + EncodeInput::Borrowed(Value::String(text)) if text == "root" + )); + + let omitted = apply_filter_result(EncodeInput::Omitted, "ignored", &replacement_options); + assert!(matches!(omitted, EncodeInput::Omitted)); + + let function_options = EncodeOptions::new().with_filter(Some(EncodeFilter::Function( + FunctionFilter::new(|_, _| FilterResult::Keep), + ))); + assert!(has_function_filter(&function_options)); + assert!(has_filter_control(&function_options)); + + let whitelist_options = EncodeOptions::new() + .with_whitelist(Some(vec![WhitelistSelector::Key("answer".to_owned())])); + assert!(!has_function_filter(&whitelist_options)); + assert!(has_filter_control(&whitelist_options)); +} diff --git a/src/encode/scalar.rs b/src/encode/scalar.rs index 6f9a2d4..ef23956 100644 --- a/src/encode/scalar.rs +++ b/src/encode/scalar.rs @@ -191,7 +191,7 @@ fn key_token_text(key: &str, options: &EncodeOptions) -> String { key.to_owned() } -fn plain_scalar_text(value: &Value, options: &EncodeOptions) -> Option { +pub(super) fn plain_scalar_text(value: &Value, options: &EncodeOptions) -> Option { match value { Value::Null => None, Value::Bool(boolean) => Some(boolean.to_string()), diff --git a/src/encode/tests/filters.rs b/src/encode/tests/filters.rs index 819af89..5f629d4 100644 --- a/src/encode/tests/filters.rs +++ b/src/encode/tests/filters.rs @@ -213,3 +213,27 @@ fn root_function_filter_non_container_reuses_original_root() { assert_eq!(encoded, "a=x"); } + +#[test] +fn root_function_filter_can_replace_the_root_container() { + let value = Value::Object([("a".to_owned(), Value::String("x".to_owned()))].into()); + let encoded = encode( + &value, + &EncodeOptions::new() + .with_encode(false) + .with_filter(Some(EncodeFilter::Function(FunctionFilter::new( + |prefix, _| { + if prefix.is_empty() { + FilterResult::Replace(Value::Object( + [("b".to_owned(), Value::String("y".to_owned()))].into(), + )) + } else { + FilterResult::Keep + } + }, + )))), + ) + .unwrap(); + + assert_eq!(encoded, "b=y"); +} diff --git a/src/encode/tests/mod.rs b/src/encode/tests/mod.rs index 761aaa5..655603d 100644 --- a/src/encode/tests/mod.rs +++ b/src/encode/tests/mod.rs @@ -1,7 +1,8 @@ pub(super) use super::comma::encode_comma_array_controlled; pub(super) use super::{ - encode, encode_comma_array, encode_key_only_fragment, encoded_dot_escape, percent_encode_bytes, - percent_encode_latin1, try_encode_linear_map_chain, + encode, encode_comma_array, encode_key_only_fragment, encoded_dot_escape, encoded_scalar_text, + percent_encode_bytes, percent_encode_latin1, plain_scalar_text, plain_string_for_comma, + scalar_is_null_like, try_encode_linear_map_chain, }; pub(super) use crate::key_path::KeyPathNode; pub(super) use crate::options::{ @@ -20,4 +21,5 @@ mod fast_path; mod filters; mod helpers; mod iterative; +mod scalar; mod temporal; diff --git a/src/encode/tests/scalar.rs b/src/encode/tests/scalar.rs new file mode 100644 index 0000000..98c4024 --- /dev/null +++ b/src/encode/tests/scalar.rs @@ -0,0 +1,62 @@ +use super::{ + Charset, EncodeOptions, TemporalSerializer, TemporalValue, Value, encoded_scalar_text, + plain_scalar_text, plain_string_for_comma, scalar_is_null_like, +}; + +#[test] +fn scalar_text_helpers_cover_nested_arrays_objects_bytes_and_temporals() { + let options = EncodeOptions::new().with_encode(false); + let nested = Value::Array(vec![ + Value::String("a".to_owned()), + Value::Object([("field".to_owned(), Value::String("x".to_owned()))].into()), + ]); + assert_eq!( + plain_string_for_comma(&nested, &options), + "a,[object Object]" + ); + assert_eq!( + encoded_scalar_text(&nested, &options), + Some("a,[object Object]".to_owned()) + ); + assert_eq!( + encoded_scalar_text( + &Value::Object([("field".to_owned(), Value::String("x".to_owned()))].into()), + &options, + ), + Some("[object Object]".to_owned()) + ); + assert_eq!( + plain_scalar_text(&nested, &options), + Some("a,[object Object]".to_owned()) + ); + assert_eq!( + plain_scalar_text( + &Value::Object([("field".to_owned(), Value::String("x".to_owned()))].into()), + &options, + ), + Some("[object Object]".to_owned()) + ); + + let utf8_bytes = Value::Bytes(b"ok".to_vec()); + let latin1_bytes = Value::Bytes(vec![0xE9]); + assert_eq!(plain_string_for_comma(&utf8_bytes, &options), "ok"); + assert_eq!( + plain_string_for_comma( + &latin1_bytes, + &EncodeOptions::new() + .with_encode(false) + .with_charset(Charset::Iso88591), + ), + "é" + ); + + assert_eq!(encoded_scalar_text(&Value::Null, &options), None); + + let temporal = TemporalValue::datetime(2024, 1, 2, 3, 4, 5, 0, None).unwrap(); + let temporal_options = + EncodeOptions::new().with_temporal_serializer(Some(TemporalSerializer::new(|_| None))); + assert!(scalar_is_null_like( + &Value::Temporal(temporal), + &temporal_options, + )); +} diff --git a/src/options/encode.rs b/src/options/encode.rs index 8a1a22b..5ca39e2 100644 --- a/src/options/encode.rs +++ b/src/options/encode.rs @@ -348,3 +348,6 @@ impl EncodeOptions { self.temporal_serializer.is_some() } } + +#[cfg(test)] +mod tests; diff --git a/src/options/encode/tests.rs b/src/options/encode/tests.rs new file mode 100644 index 0000000..aed9bce --- /dev/null +++ b/src/options/encode/tests.rs @@ -0,0 +1,136 @@ +use std::cmp::Ordering; + +use super::EncodeOptions; +use crate::error::EncodeError; +use crate::temporal::TemporalValue; +use crate::value::Value; +use crate::{ + Charset, EncodeFilter, EncodeToken, EncodeTokenEncoder, FilterResult, Format, FunctionFilter, + SortMode, Sorter, TemporalSerializer, WhitelistSelector, +}; + +#[test] +fn getters_and_builders_cover_encode_specific_configuration() { + let options = EncodeOptions::new() + .with_skip_nulls(true) + .with_comma_round_trip(true) + .with_comma_compact_nulls(true) + .with_encode_values_only(true) + .with_add_query_prefix(true) + .with_encode_dot_in_keys(true) + .with_filter(Some(EncodeFilter::Function(FunctionFilter::new(|_, _| { + FilterResult::Keep + })))) + .with_sort(SortMode::LexicographicAsc) + .with_sorter(Some(Sorter::new(|left, right| { + left.len().cmp(&right.len()) + }))) + .with_encoder(Some(EncodeTokenEncoder::new(|token, _, _| match token { + EncodeToken::Key(text) | EncodeToken::TextValue(text) => format!("enc:{text}"), + EncodeToken::Value(Value::String(text)) => format!("enc:{text}"), + EncodeToken::Value(_) => "enc:other".to_owned(), + }))) + .with_temporal_serializer(Some(TemporalSerializer::new(|_| { + Some("temporal".to_owned()) + }))) + .with_max_depth(Some(3)); + + assert!(options.skip_nulls()); + assert!(options.comma_round_trip()); + assert!(options.comma_compact_nulls()); + assert!(options.encode_values_only()); + assert!(options.add_query_prefix()); + assert!(options.allow_dots()); + assert!(options.encode_dot_in_keys()); + assert!(matches!(options.filter(), Some(EncodeFilter::Function(_)))); + assert_eq!(options.sort(), SortMode::LexicographicAsc); + assert!(options.sorter().is_some()); + assert!(options.encoder().is_some()); + assert!(options.temporal_serializer().is_some()); + assert_eq!(options.max_depth(), Some(3)); + assert!(options.has_temporal_serializer()); + + let cleared = options.clone().with_allow_dots(false); + assert!(!cleared.allow_dots()); + assert!(!cleared.encode_dot_in_keys()); + + let whitelist = EncodeOptions::new().with_whitelist(Some(vec![ + WhitelistSelector::Key("a".to_owned()), + WhitelistSelector::Index(1), + ])); + assert!(matches!( + whitelist.filter(), + Some(EncodeFilter::Whitelist(entries)) if entries.len() == 2 + )); + assert_eq!( + whitelist.whitelist(), + Some( + &[ + WhitelistSelector::Key("a".to_owned()), + WhitelistSelector::Index(1), + ][..] + ) + ); + + let no_whitelist = EncodeOptions::new().with_filter(Some(EncodeFilter::Function( + FunctionFilter::new(|_, _| FilterResult::Keep), + ))); + assert!(no_whitelist.whitelist().is_none()); + + let sorter = options.sorter().unwrap(); + assert_eq!(sorter.compare("aa", "b"), Ordering::Greater); + let filter = match options.filter().unwrap() { + EncodeFilter::Function(filter) => filter, + other => panic!("expected function filter, got {other:?}"), + }; + assert_eq!(filter.apply("root", &Value::Null), FilterResult::Keep); + + let encoder = options.encoder().unwrap(); + assert_eq!( + encoder.encode(EncodeToken::Key("dot"), Charset::Utf8, Format::Rfc3986), + "enc:dot" + ); + assert_eq!( + encoder.encode( + EncodeToken::TextValue("joined"), + Charset::Utf8, + Format::Rfc3986, + ), + "enc:joined" + ); + assert_eq!( + encoder.encode( + EncodeToken::Value(&Value::String("leaf".to_owned())), + Charset::Utf8, + Format::Rfc3986, + ), + "enc:leaf" + ); + assert_eq!( + encoder.encode( + EncodeToken::Value(&Value::Bool(true)), + Charset::Utf8, + Format::Rfc3986, + ), + "enc:other" + ); + let temporal = TemporalValue::datetime(2024, 1, 2, 3, 4, 5, 0, None).unwrap(); + assert_eq!( + options.temporal_serializer().unwrap().serialize(&temporal), + Some("temporal".to_owned()) + ); +} + +#[test] +fn validate_rejects_impossible_dot_configuration() { + let options = EncodeOptions { + allow_dots: false, + encode_dot_in_keys: true, + ..EncodeOptions::new() + }; + + assert!(matches!( + options.validate(), + Err(EncodeError::EncodeDotInKeysRequiresAllowDots) + )); +} diff --git a/src/temporal/tests.rs b/src/temporal/tests.rs index 7c85b82..df7ca9c 100644 --- a/src/temporal/tests.rs +++ b/src/temporal/tests.rs @@ -1,4 +1,9 @@ -use super::{DateTimeValue, TemporalValue, TemporalValueError}; +use std::str::FromStr; + +use super::{ + DateTimeValue, TemporalValue, TemporalValueError, days_in_month, expect_byte, format_year, + parse_date, parse_offset, parse_time, parse_u8_exact, +}; #[test] fn datetime_rejects_invalid_components() { @@ -72,3 +77,97 @@ fn datetime_round_trips_second_precision_offsets_and_variable_fraction_precision aware ); } + +#[test] +fn public_temporal_surface_exposes_constructors_accessors_and_from_str() { + let temporal = TemporalValue::datetime(2024, 1, 2, 3, 4, 5, 600_000_000, Some(-3_661)).unwrap(); + let datetime = temporal.as_datetime().unwrap().clone(); + + assert_eq!(datetime.year(), 2024); + assert_eq!(datetime.month(), 1); + assert_eq!(datetime.day(), 2); + assert_eq!(datetime.hour(), 3); + assert_eq!(datetime.minute(), 4); + assert_eq!(datetime.second(), 5); + assert_eq!(datetime.nanosecond(), 600_000_000); + assert_eq!(datetime.offset_seconds(), Some(-3_661)); + assert_eq!(datetime.to_string(), "2024-01-02T03:04:05.6-01:01:01"); + + assert_eq!( + TemporalValue::from_str("2024-01-02T03:04:05.6-01:01:01").unwrap(), + temporal + ); + assert_eq!( + DateTimeValue::from_str("2024-01-02T03:04:05.6-01:01:01").unwrap(), + datetime + ); +} + +#[test] +fn internal_temporal_helpers_cover_remaining_parse_and_format_edges() { + assert_eq!(format_year(-12), "-0012"); + assert_eq!(format_year(12_345), "+12345"); + assert_eq!(days_in_month(2024, 2), 29); + assert_eq!(days_in_month(2023, 2), 28); + assert_eq!(days_in_month(2024, 13), 0); + + assert_eq!( + DateTimeValue::new(2024, 1, 1, 0, 60, 0, 0, None), + Err(TemporalValueError::InvalidMinute(60)) + ); + assert_eq!( + DateTimeValue::new(2024, 1, 1, 0, 0, 60, 0, None), + Err(TemporalValueError::InvalidSecond(60)) + ); + assert_eq!( + DateTimeValue::new(2024, 1, 1, 0, 0, 0, 1_000_000_000, None), + Err(TemporalValueError::InvalidNanosecond(1_000_000_000)) + ); + + assert_eq!(parse_date(""), Err(TemporalValueError::InvalidFormat)); + assert_eq!( + parse_date("123-01-02"), + Err(TemporalValueError::InvalidFormat) + ); + assert_eq!( + parse_date("2024-01-02x"), + Err(TemporalValueError::InvalidFormat) + ); + + assert_eq!( + parse_time("03:04:0"), + Err(TemporalValueError::InvalidFormat) + ); + assert_eq!( + parse_time("03:04:0x"), + Err(TemporalValueError::InvalidFormat) + ); + assert_eq!( + parse_time("03:04:05Q"), + Err(TemporalValueError::InvalidFormat) + ); + + let mut index = 0usize; + assert_eq!( + parse_offset(b"x", &mut index), + Err(TemporalValueError::InvalidFormat) + ); + + let mut index = 0usize; + assert_eq!( + parse_u8_exact(b"7", &mut index, 2), + Err(TemporalValueError::InvalidFormat) + ); + + let mut index = 0usize; + assert_eq!( + parse_u8_exact(b"7x", &mut index, 2), + Err(TemporalValueError::InvalidFormat) + ); + + let mut index = 0usize; + assert_eq!( + expect_byte(b"x", &mut index, b'y'), + Err(TemporalValueError::InvalidFormat) + ); +} From 33e50d252c3f90810d811aab3e16f28b078233cc Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 5 Apr 2026 12:11:30 +0100 Subject: [PATCH 3/6] ci: derive MSRV, pin toolchain, and configure Codacy --- .codacy.yml | 7 ++++ .github/workflows/test.yml | 84 +++++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 .codacy.yml diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..43f5da3 --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,7 @@ +--- +# Exclude test sources from Codacy code-pattern analysis. +# Coverage exclusions must be handled in the coverage tool/report itself. +exclude_paths: + - "tests/**" + - "src/**/tests.rs" + - "src/**/tests/**" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7b5ec3b..f4ded22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,10 +9,40 @@ on: - main workflow_call: +defaults: + run: + shell: bash + permissions: contents: read jobs: + msrv-meta: + name: Read MSRV + runs-on: ubuntu-latest + outputs: + msrv: ${{ steps.read-msrv.outputs.msrv }} + + steps: + - uses: actions/checkout@v6 + + - id: read-msrv + name: Read rust-version from Cargo.toml + run: | + set -euo pipefail + msrv="$( + awk -F'"' ' + /^\[package\]$/ { in_package = 1; next } + /^\[/ { in_package = 0 } + in_package && /^rust-version[[:space:]]*=/ { print $2; exit } + ' Cargo.toml + )" + if [[ -z "$msrv" ]]; then + echo "failed to read package.rust-version from Cargo.toml" >&2 + exit 1 + fi + echo "msrv=${msrv}" >> "$GITHUB_OUTPUT" + stable: name: Stable test (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -144,13 +174,16 @@ jobs: run: cargo test --locked --test comparison --test parity_decode --test parity_encode msrv: - name: MSRV (1.88) + name: MSRV (${{ needs.msrv-meta.outputs.msrv }}) + needs: msrv-meta runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.88.0 + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + with: + toolchain: ${{ needs.msrv-meta.outputs.msrv }} - uses: Swatinem/rust-cache@v2 @@ -170,8 +203,55 @@ jobs: - name: Package contents run: | + set -euo pipefail cargo package --locked --list > /tmp/package-list.txt ! grep -E '^(fuzz/|tests/|scripts/|perf/|\.github/|\.vscode/|\.gitignore$|AGENTS\.md|pyproject\.toml$|src/bin/|src/.*/tests(/|\.rs$))' /tmp/package-list.txt - name: Package check run: cargo package --locked + + ci-required: + name: CI Required + if: ${{ always() }} + needs: + - stable + - feature-matrix + - quality + - parity + - msrv + - package + runs-on: ubuntu-latest + env: + STABLE_RESULT: ${{ needs.stable.result }} + FEATURE_MATRIX_RESULT: ${{ needs.feature-matrix.result }} + QUALITY_RESULT: ${{ needs.quality.result }} + PARITY_RESULT: ${{ needs.parity.result }} + MSRV_RESULT: ${{ needs.msrv.result }} + PACKAGE_RESULT: ${{ needs.package.result }} + + steps: + - name: Verify required job results + run: | + set -euo pipefail + + results=( + "stable:${STABLE_RESULT}" + "feature-matrix:${FEATURE_MATRIX_RESULT}" + "quality:${QUALITY_RESULT}" + "parity:${PARITY_RESULT}" + "msrv:${MSRV_RESULT}" + "package:${PACKAGE_RESULT}" + ) + + failed=0 + for entry in "${results[@]}"; do + job="${entry%%:*}" + result="${entry#*:}" + echo "${job}: ${result}" + if [[ "${result}" != "success" ]]; then + echo "::error::${job} finished with result '${result}'" + failed=1 + fi + done + + exit "${failed}" From 6d364c62406a95073cd64e2e8848f299d1142d90 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 5 Apr 2026 12:23:50 +0100 Subject: [PATCH 4/6] test(encode): add comment to test for invalid dot configuration in EncodeOptions --- src/options/encode/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/options/encode/tests.rs b/src/options/encode/tests.rs index aed9bce..5769bdc 100644 --- a/src/options/encode/tests.rs +++ b/src/options/encode/tests.rs @@ -123,6 +123,8 @@ fn getters_and_builders_cover_encode_specific_configuration() { #[test] fn validate_rejects_impossible_dot_configuration() { + // The public builders keep these flags in sync, so construct the invalid + // combination directly to exercise the defensive validate() branch. let options = EncodeOptions { allow_dots: false, encode_dot_in_keys: true, From 78c06a58e7f0a40c6b1ab1c807b4e3658a1a020b Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 5 Apr 2026 12:26:53 +0100 Subject: [PATCH 5/6] fix(ci): improve regex for rust-version extraction and package list validation --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f4ded22..c82f567 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,9 +32,9 @@ jobs: set -euo pipefail msrv="$( awk -F'"' ' - /^\[package\]$/ { in_package = 1; next } - /^\[/ { in_package = 0 } - in_package && /^rust-version[[:space:]]*=/ { print $2; exit } + /^[[:space:]]*\[package\][[:space:]]*$/ { in_package = 1; next } + /^[[:space:]]*\[/ { in_package = 0 } + in_package && /^[[:space:]]*rust-version[[:space:]]*=/ { print $2; exit } ' Cargo.toml )" if [[ -z "$msrv" ]]; then @@ -205,7 +205,7 @@ jobs: run: | set -euo pipefail cargo package --locked --list > /tmp/package-list.txt - ! grep -E '^(fuzz/|tests/|scripts/|perf/|\.github/|\.vscode/|\.gitignore$|AGENTS\.md|pyproject\.toml$|src/bin/|src/.*/tests(/|\.rs$))' /tmp/package-list.txt + ! grep -E '^(fuzz/|tests/|scripts/|perf/|\.github/|\.vscode/|\.gitignore$|AGENTS\.md|pyproject\.toml$|src/bin/|src(/.*)?/tests(/|\.rs$))' /tmp/package-list.txt - name: Package check run: cargo package --locked From fe3b83229ec1d48d262fe401fee3eecf49b69a1a Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sun, 5 Apr 2026 12:27:28 +0100 Subject: [PATCH 6/6] fix(make): update package-check regex to correctly match test files --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d48060c..962dd9c 100644 --- a/Makefile +++ b/Makefile @@ -80,7 +80,7 @@ package-list: ## List files included in the published crate package package-check: ## Run the packaging checks used by CI $(CARGO) package --locked --list --allow-dirty > $(PACKAGE_LIST) - ! grep -E '^(fuzz/|tests/|scripts/|perf/|\.github/|\.vscode/|\.gitignore$$|AGENTS\.md|pyproject\.toml$$|src/bin/|src/.*/tests(/|\.rs$$))' $(PACKAGE_LIST) + ! grep -E '^(fuzz/|tests/|scripts/|perf/|\.github/|\.vscode/|\.gitignore$$|AGENTS\.md|pyproject\.toml$$|src/bin/|src(/.*)?/tests(/|\.rs$$))' $(PACKAGE_LIST) $(CARGO) package --locked docs: ## Build library docs with docs.rs warning settings