Skip to main content

nula_core/event/
builder.rs

1//! Fluent builder for [`Event`]s and [`UnsignedEvent`]s.
2//!
3//! [`EventBuilder`] decouples the *intent* (kind, content, tags, …) from the
4//! *signer* (local [`Keys`], NIP-46 remote signer, hardware bunker, …). Every
5//! builder method returns `Self`, so the typical call site reads top-down:
6//!
7//! ```
8//! use nula_core::{EventBuilder, Keys, Kind, Tag};
9//!
10//! let keys = Keys::generate().unwrap();
11//! let event = EventBuilder::new(Kind::TEXT_NOTE, "hello, nostr")
12//!     .tag(Tag::new(["alt", "greeting"]).unwrap())
13//!     .sign_with_keys(&keys)
14//!     .unwrap();
15//! event.verify().unwrap();
16//! ```
17
18use super::event::Event;
19use super::kind::Kind;
20use super::tag::{Tag, Tags};
21use super::unsigned::{UnsignedEvent, UnsignedEventError};
22use crate::key::{Keys, PublicKey};
23use crate::types::{Timestamp, TimestampError};
24
25/// Errors raised by [`EventBuilder`] terminal methods.
26#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum EventBuilderError {
29    /// The system clock could not be read while choosing `created_at`.
30    #[error("could not read the system clock: {0}")]
31    Clock(#[from] TimestampError),
32    /// The signer's public key did not match the supplied `pubkey`.
33    #[error(transparent)]
34    Signer(#[from] UnsignedEventError),
35}
36
37/// Fluent builder for [`UnsignedEvent`] / [`Event`].
38///
39/// The four constituents — `kind`, `content`, `tags`, `created_at` — are
40/// **private** by design: callers must go through the builder methods to
41/// mutate them. The reason is twofold:
42///
43/// 1. NIP-46 / NIP-59 (gift wrap) compose builders that produce
44///    *encrypted* `content` derived from a particular `(kind, tags)`
45///    snapshot. Letting outer code reach in and rewrite, say, `kind`
46///    after the inner ciphertext was sealed would silently corrupt the
47///    event without the type system noticing.
48/// 2. Keeping the surface fluent (`builder.kind(K).tag(t).content(c)`)
49///    means downstream code never has to know whether a field is `String`
50///    vs `Cow<'_, str>` vs `&str` — we can change the storage type freely
51///    without breaking callers.
52///
53/// The struct is `#[non_exhaustive]` so future versions may add
54/// configuration fields (`PoW` search budget, dedup toggles, mining-aux
55/// nonce cache, …) without breaking downstream pattern matches.
56#[derive(Debug, Clone)]
57#[non_exhaustive]
58pub struct EventBuilder {
59    kind: Kind,
60    content: String,
61    tags: Tags,
62    created_at: Option<Timestamp>,
63}
64
65impl EventBuilder {
66    /// Construct a builder with the given kind and content.
67    #[must_use]
68    pub fn new<S>(kind: Kind, content: S) -> Self
69    where
70        S: Into<String>,
71    {
72        Self {
73            kind,
74            content: content.into(),
75            tags: Tags::new(),
76            created_at: None,
77        }
78    }
79
80    /// Build a [`Kind::TEXT_NOTE`] event (NIP-01) with the supplied content.
81    #[must_use]
82    pub fn text_note<S>(content: S) -> Self
83    where
84        S: Into<String>,
85    {
86        Self::new(Kind::TEXT_NOTE, content)
87    }
88
89    /// Read-only accessor for the event kind.
90    #[must_use]
91    pub const fn current_kind(&self) -> Kind {
92        self.kind
93    }
94
95    /// Read-only accessor for the event content.
96    #[must_use]
97    pub fn current_content(&self) -> &str {
98        &self.content
99    }
100
101    /// Read-only accessor for the event tags.
102    #[must_use]
103    pub const fn current_tags(&self) -> &Tags {
104        &self.tags
105    }
106
107    /// Read-only accessor for the explicitly pinned `created_at`.
108    ///
109    /// `None` means "use the wall clock at sign time".
110    #[must_use]
111    pub const fn current_created_at(&self) -> Option<Timestamp> {
112        self.created_at
113    }
114
115    /// Mutable accessor for the in-progress tag list.
116    ///
117    /// Use this when a tag insertion needs custom logic (deduplication,
118    /// uniqueness, replace-or-push) that the fluent [`Self::tag`] /
119    /// [`Self::tags`] helpers do not cover. The fluent methods remain the
120    /// preferred surface for plain appends.
121    #[must_use]
122    pub const fn tags_mut(&mut self) -> &mut Tags {
123        &mut self.tags
124    }
125
126    /// Set the event kind (overrides any previously set value).
127    #[must_use]
128    pub const fn kind(mut self, kind: Kind) -> Self {
129        self.kind = kind;
130        self
131    }
132
133    /// Replace the event content.
134    #[must_use]
135    pub fn content<S>(mut self, content: S) -> Self
136    where
137        S: Into<String>,
138    {
139        self.content = content.into();
140        self
141    }
142
143    /// Append a single tag.
144    #[must_use]
145    pub fn tag(mut self, tag: Tag) -> Self {
146        self.tags.push(tag);
147        self
148    }
149
150    /// Append several tags from any iterator.
151    #[must_use]
152    pub fn tags<I>(mut self, tags: I) -> Self
153    where
154        I: IntoIterator<Item = Tag>,
155    {
156        self.tags.extend(tags);
157        self
158    }
159
160    /// Pin a custom `created_at`.
161    ///
162    /// Useful when re-signing historical events or backfilling fixtures.
163    #[must_use]
164    pub const fn created_at(mut self, ts: Timestamp) -> Self {
165        self.created_at = Some(ts);
166        self
167    }
168
169    /// Build the [`UnsignedEvent`] but do not sign it.
170    ///
171    /// `pubkey` becomes the `pubkey` field. The `created_at` field is taken
172    /// from the builder if set, otherwise from the system clock.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`EventBuilderError::Clock`] if the wall clock could not be
177    /// read.
178    pub fn build_unsigned(self, pubkey: PublicKey) -> Result<UnsignedEvent, EventBuilderError> {
179        let created_at = match self.created_at {
180            Some(ts) => ts,
181            None => Timestamp::now()?,
182        };
183        Ok(UnsignedEvent::new(
184            pubkey,
185            created_at,
186            self.kind,
187            self.tags,
188            self.content,
189        ))
190    }
191
192    /// Build and sign with `keys` in one shot.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`EventBuilderError::Clock`] if the wall clock could not be
197    /// read or [`EventBuilderError::Signer`] if signing fails (it cannot, in
198    /// the local-keys case).
199    pub fn sign_with_keys(self, keys: &Keys) -> Result<Event, EventBuilderError> {
200        let unsigned = self.build_unsigned(*keys.public_key())?;
201        let event = unsigned.sign_with_keys(keys)?;
202        Ok(event)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn fixture_keys() -> Keys {
211        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
212    }
213
214    #[test]
215    fn new_text_note_signs_and_verifies() {
216        let keys = fixture_keys();
217        let event = EventBuilder::text_note("hello")
218            .sign_with_keys(&keys)
219            .unwrap();
220        assert_eq!(event.kind, Kind::TEXT_NOTE);
221        assert_eq!(event.content, "hello");
222        event.verify().unwrap();
223    }
224
225    #[test]
226    fn pin_created_at_round_trip() {
227        let keys = fixture_keys();
228        let ts = Timestamp::from_secs(1_700_000_000);
229        let event = EventBuilder::text_note("pinned")
230            .created_at(ts)
231            .sign_with_keys(&keys)
232            .unwrap();
233        assert_eq!(event.created_at, ts);
234        event.verify().unwrap();
235    }
236
237    #[test]
238    fn tags_and_kind_are_applied() {
239        let keys = fixture_keys();
240        let event = EventBuilder::new(Kind::REACTION, "+")
241            .tag(Tag::new(["e", "abc"]).unwrap())
242            .tag(Tag::new(["p", "def"]).unwrap())
243            .sign_with_keys(&keys)
244            .unwrap();
245        assert_eq!(event.kind, Kind::REACTION);
246        assert_eq!(event.tags.len(), 2);
247        event.verify().unwrap();
248    }
249
250    #[test]
251    fn build_unsigned_does_not_require_keys() {
252        let keys = fixture_keys();
253        let unsigned = EventBuilder::text_note("draft")
254            .build_unsigned(*keys.public_key())
255            .unwrap();
256        assert_eq!(unsigned.pubkey, *keys.public_key());
257        // Signing with the matching keys works.
258        let event = unsigned.sign_with_keys(&keys).unwrap();
259        event.verify().unwrap();
260    }
261
262    #[test]
263    fn extend_tags_in_one_call() {
264        let keys = fixture_keys();
265        let event = EventBuilder::text_note("multi")
266            .tags([
267                Tag::new(["e", "id-1"]).unwrap(),
268                Tag::new(["p", "pk-1"]).unwrap(),
269            ])
270            .sign_with_keys(&keys)
271            .unwrap();
272        assert_eq!(event.tags.len(), 2);
273        event.verify().unwrap();
274    }
275}