Add nip 91 tags

This commit is contained in:
Jon Staab
2026-05-22 15:22:02 -07:00
parent 75381b653e
commit 2d50fa13b4
2 changed files with 247 additions and 96 deletions
+102 -56
View File
@@ -51,10 +51,9 @@ tell "don't care" from "impossible."
checks regardless of the key type, and deterministic iteration order for
serialization and hashing.
The `tags` field is a `BTreeMap` from tag name to a set of acceptable
values. Tag names are arbitrary strings — not restricted to single
letters. Single-letter indexing is a relay optimization, not a protocol
constraint, and the filter type should not encode relay policy.
The `tags` field is a `BTreeMap` from a tag key to a set of values. The
key is the tag name, prefixed either by `#` (any value matches, defined
in NIP-01) or `&` (all values match, defined in NIP-91).
```rust {file=coracle-lib/src/filters.rs}
/// A predicate over nostr events.
@@ -73,8 +72,7 @@ pub struct Filter {
pub authors: Option<BTreeSet<PublicKey>>,
/// Event kinds to match.
pub kinds: Option<BTreeSet<u16>>,
/// Tag filters: for each entry, the event must have at least one tag
/// with that name whose value appears in the set.
/// Tag filters, keyed by prefixed tag name.
pub tags: BTreeMap<String, BTreeSet<String>>,
/// Lower bound on `created_at` (inclusive).
pub since: Option<u64>,
@@ -106,11 +104,8 @@ fails. Scalar checks — ids, kinds, authors — come first because they
are a simple set-membership test and reject most non-matching events
immediately, before the more involved tag iteration.
Tag matching checks every entry in the filter's `tags` map. For each
tag name, the event must contain at least one tag with that name whose
value appears in the filter's set. Within a single tag name the values
are disjunctive (OR): any match suffices. Across tag names the
constraints are conjunctive (AND): all must be satisfied.
Tag matching checks every entry in the filter's `tags` map, and the key's
leading character determines the semantics.
```rust {file=coracle-lib/src/filters.rs}
impl Filter {
@@ -137,11 +132,22 @@ impl Filter {
}
}
for (name, values) in &self.tags {
let has_match = event
.tags
.find_all(name)
.any(|tag| values.contains(tag.value()));
for (key, values) in &self.tags {
let name = &key[1..];
let has_match = match key.chars().next() {
Some('#') => event
.tags
.find_all(name)
.any(|tag| values.contains(tag.value())),
Some('&') => values.iter().all(|value| {
event
.tags
.find_all(name)
.any(|tag| tag.value() == value.as_str())
}),
_ => false,
};
if !has_match {
return false;
}
@@ -186,7 +192,36 @@ returning `self` so calls can be chained. Matching `remove_*` methods
take values back out, and `clear_*` methods reset a field to `None` —
removing the constraint entirely.
Tag filters carry a one-character wire prefix — `#` for NIP-01, `&`
for NIP-91. Rather than ask callers to spell that prefix into the key
string, where a typo (`"t"` instead of `"#t"`) would silently produce a
constraint that never matches, the tag methods take a [`TagMatch`] and a
bare name.
```rust {file=coracle-lib/src/filters.rs}
/// How the values of a tag constraint combine when matching events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TagMatch {
/// NIP-01 (`#`): the event matches if it carries the tag with *any* of
/// the listed values.
Any,
/// NIP-91 (`&`): the event must carry the tag for *every* listed value.
All,
}
impl TagMatch {
/// Build the prefixed map key for a bare tag name. This is the only
/// place the wire prefix is produced, so a key can never be anything
/// other than `#`- or `&`-prefixed.
fn key(self, name: &str) -> String {
let prefix = match self {
TagMatch::Any => '#',
TagMatch::All => '&',
};
format!("{prefix}{name}")
}
}
impl Filter {
/// Create an empty filter that matches every event.
pub fn new() -> Self {
@@ -286,11 +321,10 @@ impl Filter {
self
}
/// Add a value to a tag filter: the event must have at least one tag
/// with this name whose value appears in the set.
pub fn add_tag(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
/// Add a value to a tag filter.
pub fn add_tag(mut self, mode: TagMatch, name: &str, value: impl Into<String>) -> Self {
self.tags
.entry(name.into())
.entry(mode.key(name))
.or_default()
.insert(value.into());
self
@@ -299,31 +333,32 @@ impl Filter {
/// Add multiple values to a tag filter.
pub fn add_tags(
mut self,
name: impl Into<String>,
mode: TagMatch,
name: &str,
values: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.tags
.entry(name.into())
.entry(mode.key(name))
.or_default()
.extend(values.into_iter().map(Into::into));
self
}
/// Remove a value from a tag filter. If the value set becomes empty,
/// the tag entry is removed from the map.
pub fn remove_tag(mut self, name: &str, value: &str) -> Self {
if let Some(values) = self.tags.get_mut(name) {
/// Remove a value from a tag filter.
pub fn remove_tag(mut self, mode: TagMatch, name: &str, value: &str) -> Self {
let key = mode.key(name);
if let Some(values) = self.tags.get_mut(&key) {
values.remove(value);
if values.is_empty() {
self.tags.remove(name);
self.tags.remove(&key);
}
}
self
}
/// Remove an entire tag filter by name.
pub fn clear_tag(mut self, name: &str) -> Self {
self.tags.remove(name);
/// Remove an entire tag filter by its mode and name.
pub fn clear_tag(mut self, mode: TagMatch, name: &str) -> Self {
self.tags.remove(&mode.key(name));
self
}
@@ -382,14 +417,14 @@ translates these into the corresponding filter fields.
impl Filter {
/// Add constraints that match events at the given address.
///
/// Sets the kind, author, and `d` tag filter from the address's
/// Sets the kind, author, and `#d` tag filter from the address's
/// components. If the identifier is empty (plain replaceable events),
/// the `d` tag filter is still set — the event must have a `d` tag
/// the `#d` tag filter is still set — the event must have a `d` tag
/// with an empty value.
pub fn add_address(self, addr: &Address) -> Self {
self.add_kind(addr.kind)
.add_author(addr.pubkey)
.add_tag("d", &addr.identifier)
.add_tag(TagMatch::Any, "d", &addr.identifier)
}
}
@@ -409,13 +444,13 @@ use coracle_lib::addresses::Address;
let addr: Address = "30023:ab12...cd34:my-article".parse().unwrap();
let filter = Filter::new().add_address(&addr);
// Equivalent to:
// Filter::new().add_kind(30023).add_author(pubkey).add_tag("d", "my-article")
// Filter::new().add_kind(30023).add_author(pubkey).add_tag(TagMatch::Any, "d", "my-article")
```
## Serialization
The NIP-01 wire format for a filter is a flat JSON object where tag
filters appear as keys prefixed with `#`:
filters appear as keys prefixed with `#` or `&`:
```json
{
@@ -427,9 +462,10 @@ filters appear as keys prefixed with `#`:
}
```
The `tags` map in our struct needs to be flattened into the top-level
object during serialization and reconstituted during deserialization.
This rules out `#[derive(Serialize, Deserialize)]` — we need a hand-
The `tags` map already holds prefixed keys, so flattening is direct: each
key becomes a top-level field verbatim, and reconstituting it on the way
back is just as simple. Still, interleaving tag keys with the fixed
fields rules out `#[derive(Serialize, Deserialize)]` — we need a hand-
written implementation.
```rust {file=coracle-lib/src/filters.rs}
@@ -474,10 +510,9 @@ impl Serialize for Filter {
map.serialize_entry("kinds", &kinds_vec)?;
}
for (name, values) in &self.tags {
let key = format!("#{name}");
for (key, values) in &self.tags {
let vals: Vec<&str> = values.iter().map(String::as_str).collect();
map.serialize_entry(&key, &vals)?;
map.serialize_entry(key, &vals)?;
}
if let Some(since) = self.since {
@@ -499,8 +534,8 @@ impl Serialize for Filter {
```
Deserialization collects known keys into their fields and routes any key
starting with `#` into the `tags` map. Unknown keys are silently
ignored for forward compatibility.
starting with `#` or `&` into the `tags` map, prefix and all. Unknown
keys are silently ignored for forward compatibility.
```rust {file=coracle-lib/src/filters.rs}
impl<'de> Deserialize<'de> for Filter {
@@ -561,10 +596,9 @@ impl<'de> Visitor<'de> for FilterVisitor {
"until" => until = Some(map.next_value()?),
"limit" => limit = Some(map.next_value()?),
"search" => search = Some(map.next_value()?),
other if other.starts_with('#') => {
let tag_name = other[1..].to_string();
other if other.starts_with('#') || other.starts_with('&') => {
let values: Vec<String> = map.next_value()?;
tags.insert(tag_name, values.into_iter().collect());
tags.insert(other.to_string(), values.into_iter().collect());
}
_ => {
let _: de::IgnoredAny = map.next_value()?;
@@ -589,11 +623,12 @@ impl<'de> Visitor<'de> for FilterVisitor {
A round-trip through JSON preserves all fields:
```rust
use coracle_lib::filters::Filter;
use coracle_lib::filters::{Filter, TagMatch};
let filter = Filter::new()
.add_kind(1)
.add_tag("t", "nostr")
.add_tag(TagMatch::Any, "t", "nostr")
.add_tag(TagMatch::All, "t", "rust")
.add_since(1_700_000_000)
.add_limit(10);
let json = serde_json::to_string(&filter).unwrap();
@@ -648,8 +683,10 @@ impl Filter {
/// fields (`ids`, `authors`, `kinds`, tag values). The group key
/// captures:
///
/// - Which set fields are present and which tag names appear
/// - Which set fields are present and which tag keys appear
/// (structural shape)
/// - For `&` (AND) tag keys, the exact values too — they cannot be
/// unioned, so different ones cannot be combined
/// - The exact `since` and `until` values (different time windows
/// cannot be combined)
/// - The exact `search` query (different searches cannot be combined)
@@ -662,8 +699,13 @@ impl Filter {
self.ids.is_some().hash(&mut hasher);
self.authors.is_some().hash(&mut hasher);
self.kinds.is_some().hash(&mut hasher);
for name in self.tags.keys() {
name.hash(&mut hasher);
for (key, values) in &self.tags {
key.hash(&mut hasher);
if key.starts_with('&') {
for value in values {
value.hash(&mut hasher);
}
}
}
self.since.hash(&mut hasher);
@@ -691,7 +733,11 @@ filters with different `since` or `until` values land in different
groups, because a union of their sets under one time window would either
over-fetch or under-fetch relative to what was requested. The `search`
query is treated the same way: two filters with different searches can
never be merged, so each distinct search forms its own group.
never be merged, so each distinct search forms its own group. AND tag
keys go a step further: because `&t: ["meme"]` and `&t: ["cat"]` can
never be unioned without demanding both at once, a `&` key folds its
values into the group identity too, so each distinct AND constraint
lands in its own group.
## Union and intersection
@@ -720,10 +766,10 @@ pub fn union_filters(filters: &[Filter]) -> Vec<Filter> {
merge_sets(&mut existing.ids, &filter.ids);
merge_sets(&mut existing.authors, &filter.authors);
merge_sets(&mut existing.kinds, &filter.kinds);
for (name, values) in &filter.tags {
for (key, values) in &filter.tags {
existing
.tags
.entry(name.clone())
.entry(key.clone())
.or_default()
.extend(values.iter().cloned());
}
@@ -812,9 +858,9 @@ fn combine_pair(a: &Filter, b: &Filter) -> Option<Filter> {
f.authors = union_option_sets(&a.authors, &b.authors);
f.kinds = union_option_sets(&a.kinds, &b.kinds);
for (name, values) in a.tags.iter().chain(b.tags.iter()) {
for (key, values) in a.tags.iter().chain(b.tags.iter()) {
f.tags
.entry(name.clone())
.entry(key.clone())
.or_default()
.extend(values.iter().cloned());
}
+145 -40
View File
@@ -1,7 +1,7 @@
use std::collections::BTreeSet;
use coracle_lib::events::{Event, EventContent};
use coracle_lib::filters::{intersect_filters, matches_any, union_filters, Filter};
use coracle_lib::filters::{intersect_filters, matches_any, union_filters, Filter, TagMatch};
use coracle_lib::keys::SecretKey;
use coracle_lib::tags::Tag;
@@ -76,15 +76,15 @@ fn filter_matches_by_tag() {
1_700_000_000,
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc123"])],
);
assert!(Filter::new().add_tag("t", "nostr").matches(&event));
assert!(!Filter::new().add_tag("e", "something").matches(&event));
assert!(!Filter::new().add_tag("t", "bitcoin").matches(&event));
assert!(Filter::new().add_tag(TagMatch::Any, "t", "nostr").matches(&event));
assert!(!Filter::new().add_tag(TagMatch::Any, "e", "something").matches(&event));
assert!(!Filter::new().add_tag(TagMatch::Any, "t", "bitcoin").matches(&event));
}
#[test]
fn filter_tag_or_within_same_name() {
let event = make_event(1, 1_700_000_000, vec![Tag::new("t", ["nostr"])]);
let filter = Filter::new().add_tags("t", ["nostr", "bitcoin"]);
let filter = Filter::new().add_tags(TagMatch::Any, "t", ["nostr", "bitcoin"]);
assert!(filter.matches(&event));
}
@@ -95,13 +95,55 @@ fn filter_tag_and_across_names() {
1_700_000_000,
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc"])],
);
let filter = Filter::new().add_tag("t", "nostr").add_tag("p", "abc");
let filter = Filter::new().add_tag(TagMatch::Any, "t", "nostr").add_tag(TagMatch::Any, "p", "abc");
assert!(filter.matches(&event));
let filter = Filter::new().add_tag("t", "nostr").add_tag("e", "missing");
let filter = Filter::new().add_tag(TagMatch::Any, "t", "nostr").add_tag(TagMatch::Any, "e", "missing");
assert!(!filter.matches(&event));
}
#[test]
fn filter_and_tag_requires_all_values() {
let both = make_event(
1,
1_700_000_000,
vec![Tag::new("t", ["meme"]), Tag::new("t", ["cat"])],
);
let one = make_event(1, 1_700_000_000, vec![Tag::new("t", ["meme"])]);
let filter = Filter::new().add_tags(TagMatch::All, "t", ["meme", "cat"]);
assert!(filter.matches(&both));
assert!(!filter.matches(&one)); // missing "cat"
}
#[test]
fn filter_or_and_keys_combine() {
// `#t` (OR) and `&t` (AND) are independent entries; both must hold.
let filter = Filter::new()
.add_tags(TagMatch::All, "t", ["meme", "cat"])
.add_tags(TagMatch::Any, "t", ["black", "white"]);
// Has meme and cat but neither black nor white: the `#t` OR fails.
let no_color = make_event(
1,
1_700_000_000,
vec![Tag::new("t", ["meme"]), Tag::new("t", ["cat"])],
);
assert!(!filter.matches(&no_color));
// meme, cat, black: both the AND and the OR are satisfied.
let ok = make_event(
1,
1_700_000_000,
vec![
Tag::new("t", ["meme"]),
Tag::new("t", ["cat"]),
Tag::new("t", ["black"]),
],
);
assert!(filter.matches(&ok));
}
#[test]
fn filter_since_inclusive() {
let event = make_event(1, 1000, vec![]);
@@ -178,7 +220,7 @@ fn builder_chaining() {
.add_kind(1)
.add_kinds([2, 3])
.add_author(pk)
.add_tag("t", "nostr")
.add_tag(TagMatch::Any, "t", "nostr")
.add_since(1000)
.add_until(2000)
.add_limit(10);
@@ -200,7 +242,7 @@ fn address_convenience() {
assert!(filter.kinds.as_ref().unwrap().contains(&30023));
assert!(filter.authors.as_ref().unwrap().contains(&pk));
assert!(filter.tags.get("d").unwrap().contains("my-article"));
assert!(filter.tags.get("#d").unwrap().contains("my-article"));
}
// --- Construction: remove ---
@@ -235,9 +277,9 @@ fn remove_kind() {
#[test]
fn remove_tag_value() {
let filter = Filter::new()
.add_tags("t", ["nostr", "bitcoin"])
.remove_tag("t", "bitcoin");
let values = filter.tags.get("t").unwrap();
.add_tags(TagMatch::Any, "t", ["nostr", "bitcoin"])
.remove_tag(TagMatch::Any, "t", "bitcoin");
let values = filter.tags.get("#t").unwrap();
assert!(values.contains("nostr"));
assert!(!values.contains("bitcoin"));
}
@@ -245,9 +287,9 @@ fn remove_tag_value() {
#[test]
fn remove_tag_last_value_removes_entry() {
let filter = Filter::new()
.add_tag("t", "nostr")
.remove_tag("t", "nostr");
assert!(!filter.tags.contains_key("t"));
.add_tag(TagMatch::Any, "t", "nostr")
.remove_tag(TagMatch::Any, "t", "nostr");
assert!(!filter.tags.contains_key("#t"));
}
#[test]
@@ -257,7 +299,7 @@ fn remove_on_none_is_noop() {
.remove_id(&[0u8; 32])
.remove_kind(&1)
.remove_author(&fixed_secret().public_key())
.remove_tag("t", "nostr");
.remove_tag(TagMatch::Any, "t", "nostr");
assert!(filter.ids.is_none());
assert!(filter.kinds.is_none());
assert!(filter.authors.is_none());
@@ -288,18 +330,18 @@ fn clear_kinds() {
#[test]
fn clear_tag() {
let filter = Filter::new()
.add_tag("t", "nostr")
.add_tag("e", "abc")
.clear_tag("t");
assert!(!filter.tags.contains_key("t"));
assert!(filter.tags.contains_key("e"));
.add_tag(TagMatch::Any, "t", "nostr")
.add_tag(TagMatch::Any, "e", "abc")
.clear_tag(TagMatch::Any, "t");
assert!(!filter.tags.contains_key("#t"));
assert!(filter.tags.contains_key("#e"));
}
#[test]
fn clear_tags() {
let filter = Filter::new()
.add_tag("t", "nostr")
.add_tag("e", "abc")
.add_tag(TagMatch::Any, "t", "nostr")
.add_tag(TagMatch::Any, "e", "abc")
.clear_tags();
assert!(filter.tags.is_empty());
}
@@ -330,8 +372,8 @@ fn json_round_trip() {
let filter = Filter::new()
.add_kind(1)
.add_author(pk)
.add_tag("t", "nostr")
.add_tag("e", "abc123")
.add_tag(TagMatch::Any, "t", "nostr")
.add_tag(TagMatch::Any, "e", "abc123")
.add_since(1000)
.add_until(2000)
.add_limit(10);
@@ -343,12 +385,25 @@ fn json_round_trip() {
#[test]
fn json_tag_flattening() {
let filter = Filter::new().add_tag("t", "nostr");
let filter = Filter::new().add_tag(TagMatch::Any, "t", "nostr");
let json = serde_json::to_string(&filter).unwrap();
assert!(json.contains("\"#t\""));
assert!(json.contains("\"nostr\""));
}
#[test]
fn json_and_tag_flattening() {
let filter = Filter::new()
.add_tags(TagMatch::All, "t", ["meme", "cat"])
.add_tags(TagMatch::Any, "t", ["black", "white"]);
let json = serde_json::to_string(&filter).unwrap();
assert!(json.contains("\"&t\""), "json was: {json}");
assert!(json.contains("\"#t\""), "json was: {json}");
let parsed: Filter = serde_json::from_str(&json).unwrap();
assert_eq!(filter, parsed);
}
#[test]
fn json_empty_filter() {
let filter = Filter::new();
@@ -385,8 +440,8 @@ fn json_ignores_unknown_keys() {
#[test]
fn filter_id_deterministic() {
let f1 = Filter::new().add_kind(1).add_tag("t", "nostr");
let f2 = Filter::new().add_kind(1).add_tag("t", "nostr");
let f1 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr");
let f2 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr");
assert_eq!(f1.id(), f2.id());
}
@@ -399,15 +454,15 @@ fn filter_id_differs_for_different_filters() {
#[test]
fn filter_group_same_shape() {
let f1 = Filter::new().add_kind(1).add_tag("t", "nostr");
let f2 = Filter::new().add_kind(2).add_tag("t", "bitcoin");
let f1 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr");
let f2 = Filter::new().add_kind(2).add_tag(TagMatch::Any, "t", "bitcoin");
assert_eq!(f1.group(), f2.group());
}
#[test]
fn filter_group_different_shape() {
let f1 = Filter::new().add_kind(1).add_tag("t", "nostr");
let f2 = Filter::new().add_kind(1).add_tag("e", "abc");
let f1 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr");
let f2 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "e", "abc");
assert_ne!(f1.group(), f2.group());
}
@@ -440,6 +495,22 @@ fn group_key_no_limit_is_mergeable() {
assert_eq!(f1.group(), f2.group());
}
#[test]
fn group_distinguishes_or_and_keys() {
// `#t` and `&t` are different keys, so they never share a group.
let or = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "meme");
let and = Filter::new().add_kind(1).add_tag(TagMatch::All, "t", "meme");
assert_ne!(or.group(), and.group());
}
#[test]
fn group_distinct_and_values_not_mergeable() {
// AND values are part of the group identity, so they can't be merged.
let f1 = Filter::new().add_kind(1).add_tag(TagMatch::All, "t", "meme");
let f2 = Filter::new().add_kind(1).add_tag(TagMatch::All, "t", "cat");
assert_ne!(f1.group(), f2.group());
}
// --- union_filters ---
#[test]
@@ -462,7 +533,7 @@ fn union_merges_same_shape() {
fn union_keeps_different_shapes_separate() {
let filters = vec![
Filter::new().add_kind(1),
Filter::new().add_kind(1).add_tag("t", "nostr"),
Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr"),
];
let result = union_filters(&filters);
assert_eq!(result.len(), 2);
@@ -497,30 +568,40 @@ fn union_empty_input() {
#[test]
fn union_merges_tag_values() {
let filters = vec![
Filter::new().add_tag("t", "nostr"),
Filter::new().add_tag("t", "bitcoin"),
Filter::new().add_tag(TagMatch::Any, "t", "nostr"),
Filter::new().add_tag(TagMatch::Any, "t", "bitcoin"),
];
let result = union_filters(&filters);
assert_eq!(result.len(), 1);
let values = result[0].tags.get("t").unwrap();
let values = result[0].tags.get("#t").unwrap();
assert!(values.contains("nostr"));
assert!(values.contains("bitcoin"));
}
#[test]
fn union_keeps_distinct_and_values_separate() {
let filters = vec![
Filter::new().add_kind(1).add_tag(TagMatch::All, "t", "meme"),
Filter::new().add_kind(1).add_tag(TagMatch::All, "t", "cat"),
];
let result = union_filters(&filters);
assert_eq!(result.len(), 2);
}
// --- intersect_filters ---
#[test]
fn intersect_cartesian_product() {
let groups = vec![
vec![Filter::new().add_kind(1), Filter::new().add_kind(2)],
vec![Filter::new().add_tag("t", "nostr")],
vec![Filter::new().add_tag(TagMatch::Any, "t", "nostr")],
];
let result = intersect_filters(&groups);
// Two combinations merged by union into one: kinds(1,2)+tag(t)
assert_eq!(result.len(), 1);
assert!(result[0].kinds.as_ref().unwrap().contains(&1));
assert!(result[0].kinds.as_ref().unwrap().contains(&2));
assert!(result[0].tags.contains_key("t"));
assert!(result[0].tags.contains_key("#t"));
}
#[test]
@@ -528,8 +609,8 @@ fn intersect_incompatible_shapes_stay_separate() {
let groups = vec![
vec![Filter::new().add_kind(1)],
vec![
Filter::new().add_tag("t", "nostr"),
Filter::new().add_tag("e", "abc"),
Filter::new().add_tag(TagMatch::Any, "t", "nostr"),
Filter::new().add_tag(TagMatch::Any, "e", "abc"),
],
];
let result = intersect_filters(&groups);
@@ -549,6 +630,30 @@ fn intersect_tightens_time_window() {
assert_eq!(result[0].until, Some(3000)); // min
}
#[test]
fn intersect_accumulates_and_values() {
// Each group requires a different `&t` value; the intersection must
// require both, so the values accumulate into one filter.
let groups = vec![
vec![Filter::new().add_tag(TagMatch::All, "t", "meme")],
vec![Filter::new().add_tag(TagMatch::All, "t", "cat")],
];
let result = intersect_filters(&groups);
assert_eq!(result.len(), 1);
let required = result[0].tags.get("&t").unwrap();
assert!(required.contains("meme"));
assert!(required.contains("cat"));
let both = make_event(
1,
1_700_000_000,
vec![Tag::new("t", ["meme"]), Tag::new("t", ["cat"])],
);
assert!(result[0].matches(&both));
let one = make_event(1, 1_700_000_000, vec![Tag::new("t", ["meme"])]);
assert!(!result[0].matches(&one));
}
#[test]
fn intersect_empty_groups() {
let result = intersect_filters(&[]);