nula_core/nips/nip50.rs
1//! [NIP-50] Search Capability.
2//!
3//! NIP-50 specifies a `search` field on `REQ` filters, plus a tiny
4//! query-string DSL for constrained extensions:
5//!
6//! ```text
7//! best nostr clients language:en nsfw:false sentiment:positive
8//! ```
9//!
10//! The free-text portion is whatever the relay's search backend
11//! understands; the extensions are well-defined `key:value` pairs.
12//!
13//! # Why a typed wrapper
14//!
15//! [`crate::Filter::search`] already accepts an arbitrary string —
16//! that satisfies the wire format. NIP-50's value lies in:
17//!
18//! 1. *Building* such queries safely so a typo in
19//! `sentiment:positiv` does not silently mean "match everything";
20//! 2. *Parsing* relay-supplied queries to discover which extensions
21//! are in use without rolling per-call regex;
22//! 3. *Forward compatibility* — relays MAY add their own extensions
23//! (NIP-50 §"Extensions" line about ignoring unknown keys), so
24//! [`SearchExtension::Other`] keeps unknown pairs round-tripping.
25//!
26//! # Wire shape
27//!
28//! Free-text and extensions live in the same string. Extensions are
29//! `key:value` tokens (no whitespace inside the value). Both halves
30//! are joined with single spaces. The parser tolerates extra
31//! whitespace and is order-insensitive: a relay can quote any
32//! extension before, after, or amongst the free text.
33//!
34//! # Usage
35//!
36//! ```
37//! use nula_core::nips::nip50::{SearchExtension, SearchQuery, Sentiment};
38//!
39//! let q = SearchQuery::new("best nostr apps")
40//! .with_extension(SearchExtension::Language("en".to_owned()))
41//! .with_extension(SearchExtension::Nsfw(false))
42//! .with_extension(SearchExtension::Sentiment(Sentiment::Positive));
43//! let rendered = q.render();
44//! let parsed = SearchQuery::parse(&rendered);
45//! assert_eq!(parsed.free_text, "best nostr apps");
46//! ```
47//!
48//! [NIP-50]: https://github.com/nostr-protocol/nips/blob/master/50.md
49
50use thiserror::Error;
51
52use crate::filter::Filter;
53
54/// One spec-named NIP-50 extension or a forward-compatible
55/// passthrough for unknown ones.
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57#[non_exhaustive]
58pub enum SearchExtension {
59 /// `include:spam` — disable the relay's spam filter for this query.
60 IncludeSpam,
61 /// `domain:<domain>` — restrict to authors whose NIP-05 domain matches.
62 Domain(String),
63 /// `language:<iso-639-1>` — restrict to events of the given language
64 /// (lowercase two-letter code).
65 Language(String),
66 /// `sentiment:<negative|neutral|positive>` — filter by sentiment.
67 Sentiment(Sentiment),
68 /// `nsfw:<true|false>` — include or exclude NSFW.
69 Nsfw(bool),
70 /// Any other `key:value` extension. The `key` is normalised to
71 /// lowercase by the parser; case-sensitive values are
72 /// preserved as-is.
73 Other {
74 /// Extension key (lowercase).
75 key: String,
76 /// Extension value (verbatim).
77 value: String,
78 },
79}
80
81/// Sentiment classification spec'd by NIP-50.
82#[derive(Debug, Clone, PartialEq, Eq, Hash)]
83#[non_exhaustive]
84pub enum Sentiment {
85 /// `negative`.
86 Negative,
87 /// `neutral`.
88 Neutral,
89 /// `positive`.
90 Positive,
91 /// Unknown sentiment string. Forward-compatible.
92 Other(String),
93}
94
95impl Sentiment {
96 /// Render to wire form.
97 ///
98 /// Returns the spec-defined lowercase token or, for [`Self::Other`],
99 /// the borrowed inner string — which precludes a `const fn`.
100 #[must_use]
101 #[expect(
102 clippy::missing_const_for_fn,
103 reason = "`Self::Other` borrows from a heap `String`"
104 )]
105 pub fn as_str(&self) -> &str {
106 match self {
107 Self::Negative => "negative",
108 Self::Neutral => "neutral",
109 Self::Positive => "positive",
110 Self::Other(s) => s.as_str(),
111 }
112 }
113
114 /// Parse a wire token. Always succeeds: unknown values become
115 /// [`Self::Other`] for forward compatibility.
116 #[must_use]
117 pub fn parse(s: &str) -> Self {
118 match s {
119 "negative" => Self::Negative,
120 "neutral" => Self::Neutral,
121 "positive" => Self::Positive,
122 other => Self::Other(other.to_owned()),
123 }
124 }
125}
126
127impl SearchExtension {
128 /// Render the extension as one wire token (`key:value`).
129 #[must_use]
130 pub fn render(&self) -> String {
131 match self {
132 Self::IncludeSpam => "include:spam".to_owned(),
133 Self::Domain(d) => format!("domain:{d}"),
134 Self::Language(l) => format!("language:{l}"),
135 Self::Sentiment(s) => format!("sentiment:{}", s.as_str()),
136 Self::Nsfw(b) => format!("nsfw:{b}"),
137 Self::Other { key, value } => format!("{key}:{value}"),
138 }
139 }
140
141 /// Parse a single `key:value` token.
142 ///
143 /// Returns `None` if the token has no colon (i.e. it's free
144 /// text, not an extension). Returns `Some(Other { ... })` for
145 /// any unknown key. Returns
146 /// `Some(InvalidExtensionValue)` only when a *known* key
147 /// receives a value the spec forbids (`nsfw:bogus`).
148 ///
149 /// # Errors
150 ///
151 /// - [`SearchExtensionError::EmptyKey`] when the token starts
152 /// with `:`.
153 /// - [`SearchExtensionError::InvalidNsfwValue`] when `nsfw:` is
154 /// followed by anything other than `true`/`false`.
155 pub fn parse_token(token: &str) -> Result<Option<Self>, SearchExtensionError> {
156 let Some((key, value)) = token.split_once(':') else {
157 return Ok(None);
158 };
159 if key.is_empty() {
160 return Err(SearchExtensionError::EmptyKey);
161 }
162 let key_lc = key.to_ascii_lowercase();
163 let parsed = match key_lc.as_str() {
164 "include" if value == "spam" => Self::IncludeSpam,
165 "domain" => Self::Domain(value.to_owned()),
166 "language" => Self::Language(value.to_owned()),
167 "sentiment" => Self::Sentiment(Sentiment::parse(value)),
168 "nsfw" => Self::Nsfw(parse_bool(value)?),
169 _ => Self::Other {
170 key: key_lc,
171 value: value.to_owned(),
172 },
173 };
174 Ok(Some(parsed))
175 }
176}
177
178/// Errors raised when parsing a single NIP-50 extension token.
179#[derive(Debug, Error, PartialEq, Eq)]
180#[non_exhaustive]
181pub enum SearchExtensionError {
182 /// The token started with `:`, leaving the key empty.
183 #[error("extension token has empty key")]
184 EmptyKey,
185 /// `nsfw:` saw a value that wasn't `true` or `false`.
186 #[error("`nsfw:` value must be `true` or `false`, got `{0}`")]
187 InvalidNsfwValue(String),
188}
189
190fn parse_bool(s: &str) -> Result<bool, SearchExtensionError> {
191 match s {
192 "true" => Ok(true),
193 "false" => Ok(false),
194 other => Err(SearchExtensionError::InvalidNsfwValue(other.to_owned())),
195 }
196}
197
198/// Typed NIP-50 query: free text + zero or more extension tokens.
199#[derive(Debug, Clone, PartialEq, Eq, Default)]
200pub struct SearchQuery {
201 /// Whatever the relay's search backend interprets as a natural
202 /// language query. Whitespace is preserved as-is (the wire
203 /// format does the same).
204 pub free_text: String,
205 /// Spec-named or forward-compatible extension tokens, in the
206 /// order they were inserted / parsed.
207 pub extensions: Vec<SearchExtension>,
208}
209
210impl SearchQuery {
211 /// Build a query with only free text.
212 #[must_use]
213 pub fn new(free_text: impl Into<String>) -> Self {
214 Self {
215 free_text: free_text.into(),
216 extensions: Vec::new(),
217 }
218 }
219
220 /// Append one extension. Order is preserved on render.
221 #[must_use]
222 pub fn with_extension(mut self, ext: SearchExtension) -> Self {
223 self.extensions.push(ext);
224 self
225 }
226
227 /// Render to the NIP-50 wire form. Extensions follow the free
228 /// text, single-spaced.
229 #[must_use]
230 pub fn render(&self) -> String {
231 if self.extensions.is_empty() {
232 return self.free_text.clone();
233 }
234 let mut out = self.free_text.trim().to_owned();
235 for ext in &self.extensions {
236 if !out.is_empty() {
237 out.push(' ');
238 }
239 out.push_str(&ext.render());
240 }
241 out
242 }
243
244 /// Parse a NIP-50 wire string into a typed query.
245 ///
246 /// The parser is **lenient**: tokens that do not look like
247 /// extensions flow into `free_text`, and unknown extension keys
248 /// surface as [`SearchExtension::Other`] so a relay's bespoke
249 /// extension never lands in the free-text bucket by accident.
250 /// The only token-level error is a malformed *known* extension
251 /// (currently only `nsfw:`), which is silently dropped after
252 /// emitting a `tracing` event so callers cannot lose the
253 /// surrounding query.
254 #[must_use]
255 pub fn parse(query: &str) -> Self {
256 let mut extensions: Vec<SearchExtension> = Vec::new();
257 let mut free_parts: Vec<&str> = Vec::new();
258 for token in query.split_whitespace() {
259 match SearchExtension::parse_token(token) {
260 Ok(Some(ext)) => extensions.push(ext),
261 Ok(None) => free_parts.push(token),
262 Err(_) => {
263 // Malformed known extension — keep it as free
264 // text rather than dropping data. The relay
265 // will reject at parse time anyway.
266 free_parts.push(token);
267 }
268 }
269 }
270 Self {
271 free_text: free_parts.join(" "),
272 extensions,
273 }
274 }
275}
276
277impl Filter {
278 /// Apply a typed [`SearchQuery`] to this filter.
279 ///
280 /// Equivalent to `filter.search(query.render())` but spelled out
281 /// so call sites stay self-documenting.
282 #[must_use]
283 pub fn search_query(self, query: &SearchQuery) -> Self {
284 self.search(query.render())
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn parse_extracts_known_extensions_in_order() {
294 let q = SearchQuery::parse("best apps language:en sentiment:positive nsfw:false");
295 assert_eq!(q.free_text, "best apps");
296 assert_eq!(
297 q.extensions,
298 vec![
299 SearchExtension::Language("en".to_owned()),
300 SearchExtension::Sentiment(Sentiment::Positive),
301 SearchExtension::Nsfw(false),
302 ]
303 );
304 }
305
306 #[test]
307 fn parse_handles_include_spam() {
308 let q = SearchQuery::parse("include:spam orange");
309 assert_eq!(q.free_text, "orange");
310 assert_eq!(q.extensions, vec![SearchExtension::IncludeSpam]);
311 }
312
313 #[test]
314 fn parse_preserves_unknown_extensions_as_other() {
315 let q = SearchQuery::parse("foo BAR:baz another:thing");
316 assert_eq!(q.free_text, "foo");
317 assert_eq!(
318 q.extensions,
319 vec![
320 SearchExtension::Other {
321 key: "bar".to_owned(),
322 value: "baz".to_owned(),
323 },
324 SearchExtension::Other {
325 key: "another".to_owned(),
326 value: "thing".to_owned(),
327 },
328 ]
329 );
330 }
331
332 #[test]
333 fn parse_keeps_malformed_known_ext_in_free_text() {
334 let q = SearchQuery::parse("orange nsfw:bogus");
335 assert_eq!(q.free_text, "orange nsfw:bogus");
336 assert!(q.extensions.is_empty());
337 }
338
339 #[test]
340 fn render_round_trips() {
341 let original = SearchQuery::new("rust nostr")
342 .with_extension(SearchExtension::Domain("nostr.example".to_owned()))
343 .with_extension(SearchExtension::Nsfw(true));
344 let rendered = original.render();
345 assert_eq!(rendered, "rust nostr domain:nostr.example nsfw:true");
346 assert_eq!(SearchQuery::parse(&rendered), original);
347 }
348
349 #[test]
350 fn empty_query_renders_empty() {
351 assert_eq!(SearchQuery::default().render(), "");
352 assert_eq!(SearchQuery::new("").render(), "");
353 }
354
355 #[test]
356 fn extensions_only_drops_leading_whitespace() {
357 let q = SearchQuery::default().with_extension(SearchExtension::IncludeSpam);
358 assert_eq!(q.render(), "include:spam");
359 }
360
361 #[test]
362 fn sentiment_round_trips_unknown() {
363 let s = Sentiment::parse("euphoric");
364 assert_eq!(s, Sentiment::Other("euphoric".to_owned()));
365 assert_eq!(s.as_str(), "euphoric");
366 }
367
368 #[test]
369 fn empty_key_token_errors() {
370 let err = SearchExtension::parse_token(":value").unwrap_err();
371 assert_eq!(err, SearchExtensionError::EmptyKey);
372 }
373
374 #[test]
375 fn token_without_colon_is_free_text() {
376 assert!(SearchExtension::parse_token("plain").unwrap().is_none());
377 }
378
379 #[test]
380 fn filter_search_query_helper_round_trips() {
381 let q = SearchQuery::new("rust").with_extension(SearchExtension::Language("en".to_owned()));
382 let filter = Filter::new().search_query(&q);
383 assert_eq!(filter.search.as_deref(), Some("rust language:en"));
384 }
385}