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}