Skip to main content

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}