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 checks regardless of the key type, and deterministic iteration order for
serialization and hashing. serialization and hashing.
The `tags` field is a `BTreeMap` from tag name to a set of acceptable The `tags` field is a `BTreeMap` from a tag key to a set of values. The
values. Tag names are arbitrary strings — not restricted to single key is the tag name, prefixed either by `#` (any value matches, defined
letters. Single-letter indexing is a relay optimization, not a protocol in NIP-01) or `&` (all values match, defined in NIP-91).
constraint, and the filter type should not encode relay policy.
```rust {file=coracle-lib/src/filters.rs} ```rust {file=coracle-lib/src/filters.rs}
/// A predicate over nostr events. /// A predicate over nostr events.
@@ -73,8 +72,7 @@ pub struct Filter {
pub authors: Option<BTreeSet<PublicKey>>, pub authors: Option<BTreeSet<PublicKey>>,
/// Event kinds to match. /// Event kinds to match.
pub kinds: Option<BTreeSet<u16>>, pub kinds: Option<BTreeSet<u16>>,
/// Tag filters: for each entry, the event must have at least one tag /// Tag filters, keyed by prefixed tag name.
/// with that name whose value appears in the set.
pub tags: BTreeMap<String, BTreeSet<String>>, pub tags: BTreeMap<String, BTreeSet<String>>,
/// Lower bound on `created_at` (inclusive). /// Lower bound on `created_at` (inclusive).
pub since: Option<u64>, 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 are a simple set-membership test and reject most non-matching events
immediately, before the more involved tag iteration. immediately, before the more involved tag iteration.
Tag matching checks every entry in the filter's `tags` map. For each Tag matching checks every entry in the filter's `tags` map, and the key's
tag name, the event must contain at least one tag with that name whose leading character determines the semantics.
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.
```rust {file=coracle-lib/src/filters.rs} ```rust {file=coracle-lib/src/filters.rs}
impl Filter { impl Filter {
@@ -137,11 +132,22 @@ impl Filter {
} }
} }
for (name, values) in &self.tags { for (key, values) in &self.tags {
let has_match = event let name = &key[1..];
.tags let has_match = match key.chars().next() {
.find_all(name) Some('#') => event
.any(|tag| values.contains(tag.value())); .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 { if !has_match {
return false; 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` — take values back out, and `clear_*` methods reset a field to `None` —
removing the constraint entirely. 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} ```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 { impl Filter {
/// Create an empty filter that matches every event. /// Create an empty filter that matches every event.
pub fn new() -> Self { pub fn new() -> Self {
@@ -286,11 +321,10 @@ impl Filter {
self self
} }
/// Add a value to a tag filter: the event must have at least one tag /// Add a value to a tag filter.
/// with this name whose value appears in the set. pub fn add_tag(mut self, mode: TagMatch, name: &str, value: impl Into<String>) -> Self {
pub fn add_tag(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.tags self.tags
.entry(name.into()) .entry(mode.key(name))
.or_default() .or_default()
.insert(value.into()); .insert(value.into());
self self
@@ -299,31 +333,32 @@ impl Filter {
/// Add multiple values to a tag filter. /// Add multiple values to a tag filter.
pub fn add_tags( pub fn add_tags(
mut self, mut self,
name: impl Into<String>, mode: TagMatch,
name: &str,
values: impl IntoIterator<Item = impl Into<String>>, values: impl IntoIterator<Item = impl Into<String>>,
) -> Self { ) -> Self {
self.tags self.tags
.entry(name.into()) .entry(mode.key(name))
.or_default() .or_default()
.extend(values.into_iter().map(Into::into)); .extend(values.into_iter().map(Into::into));
self self
} }
/// Remove a value from a tag filter. If the value set becomes empty, /// Remove a value from a tag filter.
/// the tag entry is removed from the map. pub fn remove_tag(mut self, mode: TagMatch, name: &str, value: &str) -> Self {
pub fn remove_tag(mut self, name: &str, value: &str) -> Self { let key = mode.key(name);
if let Some(values) = self.tags.get_mut(name) { if let Some(values) = self.tags.get_mut(&key) {
values.remove(value); values.remove(value);
if values.is_empty() { if values.is_empty() {
self.tags.remove(name); self.tags.remove(&key);
} }
} }
self self
} }
/// Remove an entire tag filter by name. /// Remove an entire tag filter by its mode and name.
pub fn clear_tag(mut self, name: &str) -> Self { pub fn clear_tag(mut self, mode: TagMatch, name: &str) -> Self {
self.tags.remove(name); self.tags.remove(&mode.key(name));
self self
} }
@@ -382,14 +417,14 @@ translates these into the corresponding filter fields.
impl Filter { impl Filter {
/// Add constraints that match events at the given address. /// 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), /// 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. /// with an empty value.
pub fn add_address(self, addr: &Address) -> Self { pub fn add_address(self, addr: &Address) -> Self {
self.add_kind(addr.kind) self.add_kind(addr.kind)
.add_author(addr.pubkey) .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 addr: Address = "30023:ab12...cd34:my-article".parse().unwrap();
let filter = Filter::new().add_address(&addr); let filter = Filter::new().add_address(&addr);
// Equivalent to: // 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 ## Serialization
The NIP-01 wire format for a filter is a flat JSON object where tag 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 ```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 The `tags` map already holds prefixed keys, so flattening is direct: each
object during serialization and reconstituted during deserialization. key becomes a top-level field verbatim, and reconstituting it on the way
This rules out `#[derive(Serialize, Deserialize)]` — we need a hand- back is just as simple. Still, interleaving tag keys with the fixed
fields rules out `#[derive(Serialize, Deserialize)]` — we need a hand-
written implementation. written implementation.
```rust {file=coracle-lib/src/filters.rs} ```rust {file=coracle-lib/src/filters.rs}
@@ -474,10 +510,9 @@ impl Serialize for Filter {
map.serialize_entry("kinds", &kinds_vec)?; map.serialize_entry("kinds", &kinds_vec)?;
} }
for (name, values) in &self.tags { for (key, values) in &self.tags {
let key = format!("#{name}");
let vals: Vec<&str> = values.iter().map(String::as_str).collect(); 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 { 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 Deserialization collects known keys into their fields and routes any key
starting with `#` into the `tags` map. Unknown keys are silently starting with `#` or `&` into the `tags` map, prefix and all. Unknown
ignored for forward compatibility. keys are silently ignored for forward compatibility.
```rust {file=coracle-lib/src/filters.rs} ```rust {file=coracle-lib/src/filters.rs}
impl<'de> Deserialize<'de> for Filter { impl<'de> Deserialize<'de> for Filter {
@@ -561,10 +596,9 @@ impl<'de> Visitor<'de> for FilterVisitor {
"until" => until = Some(map.next_value()?), "until" => until = Some(map.next_value()?),
"limit" => limit = Some(map.next_value()?), "limit" => limit = Some(map.next_value()?),
"search" => search = Some(map.next_value()?), "search" => search = Some(map.next_value()?),
other if other.starts_with('#') => { other if other.starts_with('#') || other.starts_with('&') => {
let tag_name = other[1..].to_string();
let values: Vec<String> = map.next_value()?; 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()?; let _: de::IgnoredAny = map.next_value()?;
@@ -589,11 +623,12 @@ impl<'de> Visitor<'de> for FilterVisitor {
A round-trip through JSON preserves all fields: A round-trip through JSON preserves all fields:
```rust ```rust
use coracle_lib::filters::Filter; use coracle_lib::filters::{Filter, TagMatch};
let filter = Filter::new() let filter = Filter::new()
.add_kind(1) .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_since(1_700_000_000)
.add_limit(10); .add_limit(10);
let json = serde_json::to_string(&filter).unwrap(); let json = serde_json::to_string(&filter).unwrap();
@@ -648,8 +683,10 @@ impl Filter {
/// fields (`ids`, `authors`, `kinds`, tag values). The group key /// fields (`ids`, `authors`, `kinds`, tag values). The group key
/// captures: /// captures:
/// ///
/// - Which set fields are present and which tag names appear /// - Which set fields are present and which tag keys appear
/// (structural shape) /// (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 /// - The exact `since` and `until` values (different time windows
/// cannot be combined) /// cannot be combined)
/// - The exact `search` query (different searches 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.ids.is_some().hash(&mut hasher);
self.authors.is_some().hash(&mut hasher); self.authors.is_some().hash(&mut hasher);
self.kinds.is_some().hash(&mut hasher); self.kinds.is_some().hash(&mut hasher);
for name in self.tags.keys() { for (key, values) in &self.tags {
name.hash(&mut hasher); key.hash(&mut hasher);
if key.starts_with('&') {
for value in values {
value.hash(&mut hasher);
}
}
} }
self.since.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 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` over-fetch or under-fetch relative to what was requested. The `search`
query is treated the same way: two filters with different searches can 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 ## 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.ids, &filter.ids);
merge_sets(&mut existing.authors, &filter.authors); merge_sets(&mut existing.authors, &filter.authors);
merge_sets(&mut existing.kinds, &filter.kinds); merge_sets(&mut existing.kinds, &filter.kinds);
for (name, values) in &filter.tags { for (key, values) in &filter.tags {
existing existing
.tags .tags
.entry(name.clone()) .entry(key.clone())
.or_default() .or_default()
.extend(values.iter().cloned()); .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.authors = union_option_sets(&a.authors, &b.authors);
f.kinds = union_option_sets(&a.kinds, &b.kinds); 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 f.tags
.entry(name.clone()) .entry(key.clone())
.or_default() .or_default()
.extend(values.iter().cloned()); .extend(values.iter().cloned());
} }
+145 -40
View File
@@ -1,7 +1,7 @@
use std::collections::BTreeSet; use std::collections::BTreeSet;
use coracle_lib::events::{Event, EventContent}; 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::keys::SecretKey;
use coracle_lib::tags::Tag; use coracle_lib::tags::Tag;
@@ -76,15 +76,15 @@ fn filter_matches_by_tag() {
1_700_000_000, 1_700_000_000,
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc123"])], vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc123"])],
); );
assert!(Filter::new().add_tag("t", "nostr").matches(&event)); assert!(Filter::new().add_tag(TagMatch::Any, "t", "nostr").matches(&event));
assert!(!Filter::new().add_tag("e", "something").matches(&event)); assert!(!Filter::new().add_tag(TagMatch::Any, "e", "something").matches(&event));
assert!(!Filter::new().add_tag("t", "bitcoin").matches(&event)); assert!(!Filter::new().add_tag(TagMatch::Any, "t", "bitcoin").matches(&event));
} }
#[test] #[test]
fn filter_tag_or_within_same_name() { fn filter_tag_or_within_same_name() {
let event = make_event(1, 1_700_000_000, vec![Tag::new("t", ["nostr"])]); 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)); assert!(filter.matches(&event));
} }
@@ -95,13 +95,55 @@ fn filter_tag_and_across_names() {
1_700_000_000, 1_700_000_000,
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc"])], 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)); 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)); 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] #[test]
fn filter_since_inclusive() { fn filter_since_inclusive() {
let event = make_event(1, 1000, vec![]); let event = make_event(1, 1000, vec![]);
@@ -178,7 +220,7 @@ fn builder_chaining() {
.add_kind(1) .add_kind(1)
.add_kinds([2, 3]) .add_kinds([2, 3])
.add_author(pk) .add_author(pk)
.add_tag("t", "nostr") .add_tag(TagMatch::Any, "t", "nostr")
.add_since(1000) .add_since(1000)
.add_until(2000) .add_until(2000)
.add_limit(10); .add_limit(10);
@@ -200,7 +242,7 @@ fn address_convenience() {
assert!(filter.kinds.as_ref().unwrap().contains(&30023)); assert!(filter.kinds.as_ref().unwrap().contains(&30023));
assert!(filter.authors.as_ref().unwrap().contains(&pk)); 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 --- // --- Construction: remove ---
@@ -235,9 +277,9 @@ fn remove_kind() {
#[test] #[test]
fn remove_tag_value() { fn remove_tag_value() {
let filter = Filter::new() let filter = Filter::new()
.add_tags("t", ["nostr", "bitcoin"]) .add_tags(TagMatch::Any, "t", ["nostr", "bitcoin"])
.remove_tag("t", "bitcoin"); .remove_tag(TagMatch::Any, "t", "bitcoin");
let values = filter.tags.get("t").unwrap(); let values = filter.tags.get("#t").unwrap();
assert!(values.contains("nostr")); assert!(values.contains("nostr"));
assert!(!values.contains("bitcoin")); assert!(!values.contains("bitcoin"));
} }
@@ -245,9 +287,9 @@ fn remove_tag_value() {
#[test] #[test]
fn remove_tag_last_value_removes_entry() { fn remove_tag_last_value_removes_entry() {
let filter = Filter::new() let filter = Filter::new()
.add_tag("t", "nostr") .add_tag(TagMatch::Any, "t", "nostr")
.remove_tag("t", "nostr"); .remove_tag(TagMatch::Any, "t", "nostr");
assert!(!filter.tags.contains_key("t")); assert!(!filter.tags.contains_key("#t"));
} }
#[test] #[test]
@@ -257,7 +299,7 @@ fn remove_on_none_is_noop() {
.remove_id(&[0u8; 32]) .remove_id(&[0u8; 32])
.remove_kind(&1) .remove_kind(&1)
.remove_author(&fixed_secret().public_key()) .remove_author(&fixed_secret().public_key())
.remove_tag("t", "nostr"); .remove_tag(TagMatch::Any, "t", "nostr");
assert!(filter.ids.is_none()); assert!(filter.ids.is_none());
assert!(filter.kinds.is_none()); assert!(filter.kinds.is_none());
assert!(filter.authors.is_none()); assert!(filter.authors.is_none());
@@ -288,18 +330,18 @@ fn clear_kinds() {
#[test] #[test]
fn clear_tag() { fn clear_tag() {
let filter = Filter::new() let filter = Filter::new()
.add_tag("t", "nostr") .add_tag(TagMatch::Any, "t", "nostr")
.add_tag("e", "abc") .add_tag(TagMatch::Any, "e", "abc")
.clear_tag("t"); .clear_tag(TagMatch::Any, "t");
assert!(!filter.tags.contains_key("t")); assert!(!filter.tags.contains_key("#t"));
assert!(filter.tags.contains_key("e")); assert!(filter.tags.contains_key("#e"));
} }
#[test] #[test]
fn clear_tags() { fn clear_tags() {
let filter = Filter::new() let filter = Filter::new()
.add_tag("t", "nostr") .add_tag(TagMatch::Any, "t", "nostr")
.add_tag("e", "abc") .add_tag(TagMatch::Any, "e", "abc")
.clear_tags(); .clear_tags();
assert!(filter.tags.is_empty()); assert!(filter.tags.is_empty());
} }
@@ -330,8 +372,8 @@ fn json_round_trip() {
let filter = Filter::new() let filter = Filter::new()
.add_kind(1) .add_kind(1)
.add_author(pk) .add_author(pk)
.add_tag("t", "nostr") .add_tag(TagMatch::Any, "t", "nostr")
.add_tag("e", "abc123") .add_tag(TagMatch::Any, "e", "abc123")
.add_since(1000) .add_since(1000)
.add_until(2000) .add_until(2000)
.add_limit(10); .add_limit(10);
@@ -343,12 +385,25 @@ fn json_round_trip() {
#[test] #[test]
fn json_tag_flattening() { 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(); let json = serde_json::to_string(&filter).unwrap();
assert!(json.contains("\"#t\"")); assert!(json.contains("\"#t\""));
assert!(json.contains("\"nostr\"")); 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] #[test]
fn json_empty_filter() { fn json_empty_filter() {
let filter = Filter::new(); let filter = Filter::new();
@@ -385,8 +440,8 @@ fn json_ignores_unknown_keys() {
#[test] #[test]
fn filter_id_deterministic() { fn filter_id_deterministic() {
let f1 = 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("t", "nostr"); let f2 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr");
assert_eq!(f1.id(), f2.id()); assert_eq!(f1.id(), f2.id());
} }
@@ -399,15 +454,15 @@ fn filter_id_differs_for_different_filters() {
#[test] #[test]
fn filter_group_same_shape() { fn filter_group_same_shape() {
let f1 = 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(2).add_tag("t", "bitcoin"); let f2 = Filter::new().add_kind(2).add_tag(TagMatch::Any, "t", "bitcoin");
assert_eq!(f1.group(), f2.group()); assert_eq!(f1.group(), f2.group());
} }
#[test] #[test]
fn filter_group_different_shape() { fn filter_group_different_shape() {
let f1 = 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("e", "abc"); let f2 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "e", "abc");
assert_ne!(f1.group(), f2.group()); assert_ne!(f1.group(), f2.group());
} }
@@ -440,6 +495,22 @@ fn group_key_no_limit_is_mergeable() {
assert_eq!(f1.group(), f2.group()); 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 --- // --- union_filters ---
#[test] #[test]
@@ -462,7 +533,7 @@ fn union_merges_same_shape() {
fn union_keeps_different_shapes_separate() { fn union_keeps_different_shapes_separate() {
let filters = vec![ let filters = vec![
Filter::new().add_kind(1), 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); let result = union_filters(&filters);
assert_eq!(result.len(), 2); assert_eq!(result.len(), 2);
@@ -497,30 +568,40 @@ fn union_empty_input() {
#[test] #[test]
fn union_merges_tag_values() { fn union_merges_tag_values() {
let filters = vec![ let filters = vec![
Filter::new().add_tag("t", "nostr"), Filter::new().add_tag(TagMatch::Any, "t", "nostr"),
Filter::new().add_tag("t", "bitcoin"), Filter::new().add_tag(TagMatch::Any, "t", "bitcoin"),
]; ];
let result = union_filters(&filters); let result = union_filters(&filters);
assert_eq!(result.len(), 1); 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("nostr"));
assert!(values.contains("bitcoin")); 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 --- // --- intersect_filters ---
#[test] #[test]
fn intersect_cartesian_product() { fn intersect_cartesian_product() {
let groups = vec![ let groups = vec![
vec![Filter::new().add_kind(1), Filter::new().add_kind(2)], 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); let result = intersect_filters(&groups);
// Two combinations merged by union into one: kinds(1,2)+tag(t) // Two combinations merged by union into one: kinds(1,2)+tag(t)
assert_eq!(result.len(), 1); assert_eq!(result.len(), 1);
assert!(result[0].kinds.as_ref().unwrap().contains(&1)); assert!(result[0].kinds.as_ref().unwrap().contains(&1));
assert!(result[0].kinds.as_ref().unwrap().contains(&2)); assert!(result[0].kinds.as_ref().unwrap().contains(&2));
assert!(result[0].tags.contains_key("t")); assert!(result[0].tags.contains_key("#t"));
} }
#[test] #[test]
@@ -528,8 +609,8 @@ fn intersect_incompatible_shapes_stay_separate() {
let groups = vec![ let groups = vec![
vec![Filter::new().add_kind(1)], vec![Filter::new().add_kind(1)],
vec![ vec![
Filter::new().add_tag("t", "nostr"), Filter::new().add_tag(TagMatch::Any, "t", "nostr"),
Filter::new().add_tag("e", "abc"), Filter::new().add_tag(TagMatch::Any, "e", "abc"),
], ],
]; ];
let result = intersect_filters(&groups); let result = intersect_filters(&groups);
@@ -549,6 +630,30 @@ fn intersect_tightens_time_window() {
assert_eq!(result[0].until, Some(3000)); // min 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] #[test]
fn intersect_empty_groups() { fn intersect_empty_groups() {
let result = intersect_filters(&[]); let result = intersect_filters(&[]);