Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .codacy.yml
Original file line number Diff line number Diff line change
@@ -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/**"
86 changes: 83 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'"' '
/^[[: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
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 }}
Expand Down Expand Up @@ -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

Expand All @@ -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
! 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}"
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion src/compact/tests.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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()
);
}
11 changes: 5 additions & 6 deletions src/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/decode/accumulate/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,6 @@ pub(super) fn build_custom_value(

Ok(ParsedFlatValue::parsed(value, true))
}

#[cfg(test)]
mod tests;
177 changes: 177 additions & 0 deletions src/decode/accumulate/build/tests.rs
Original file line number Diff line number Diff line change
@@ -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")])
);
}
17 changes: 17 additions & 0 deletions src/decode/accumulate/insert/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
}
Loading
Loading