1use thiserror::Error;
23
24use crate::event::{Event, EventBuilder, Tag, TagKind};
25use crate::types::{Timestamp, TimestampError};
26
27pub const EXPIRATION_TAG: &str = "expiration";
29
30#[derive(Debug, Clone, Error)]
32#[non_exhaustive]
33pub enum ExpirationError {
34 #[error("`expiration` tag is missing the timestamp value")]
36 MissingValue,
37 #[error("`expiration` tag value `{0}` is not a valid unix timestamp")]
39 InvalidTimestamp(String),
40}
41
42pub fn parse_expiration(event: &Event) -> Result<Option<Timestamp>, ExpirationError> {
51 let kind = TagKind::from_wire(EXPIRATION_TAG);
52 let Some(tag) = event.tags.find_first(&kind) else {
53 return Ok(None);
54 };
55 let Some(value) = tag.values().get(1) else {
56 return Err(ExpirationError::MissingValue);
57 };
58 let secs: u64 = value
59 .parse()
60 .map_err(|_| ExpirationError::InvalidTimestamp(value.clone()))?;
61 Ok(Some(Timestamp::from_secs(secs)))
62}
63
64pub fn is_expired(event: &Event, now: Timestamp) -> Result<bool, ExpirationError> {
73 Ok(parse_expiration(event)?.is_some_and(|deadline| now >= deadline))
74}
75
76pub fn is_expired_now(event: &Event) -> Result<bool, IsExpiredError> {
83 let now = Timestamp::now()?;
84 Ok(is_expired(event, now)?)
85}
86
87#[derive(Debug, Error)]
89#[non_exhaustive]
90pub enum IsExpiredError {
91 #[error(transparent)]
93 Expiration(#[from] ExpirationError),
94 #[error(transparent)]
96 Clock(#[from] TimestampError),
97}
98
99impl EventBuilder {
100 #[must_use]
105 pub fn expiration(mut self, ts: Timestamp) -> Self {
106 let kind = TagKind::from_wire(EXPIRATION_TAG);
107 let tag = Tag::with(&kind, [ts.as_secs().to_string()]);
108 self.tags_mut().replace_or_push(&kind, tag);
109 self
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use crate::Keys;
117
118 fn keys() -> Keys {
119 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
120 }
121
122 #[test]
123 fn missing_tag_returns_none() {
124 let event = EventBuilder::text_note("no-deadline")
125 .created_at(Timestamp::from_secs(1))
126 .sign_with_keys(&keys())
127 .unwrap();
128 assert_eq!(parse_expiration(&event).unwrap(), None);
129 assert!(!is_expired(&event, Timestamp::from_secs(u64::MAX)).unwrap());
130 }
131
132 #[test]
133 fn builder_attaches_expiration_tag() {
134 let deadline = Timestamp::from_secs(1_700_000_000);
135 let event = EventBuilder::text_note("deadline")
136 .created_at(Timestamp::from_secs(1))
137 .expiration(deadline)
138 .sign_with_keys(&keys())
139 .unwrap();
140 assert_eq!(parse_expiration(&event).unwrap(), Some(deadline));
141 }
142
143 #[test]
144 fn expiration_replaces_previous() {
145 let earlier = Timestamp::from_secs(100);
146 let later = Timestamp::from_secs(200);
147 let event = EventBuilder::text_note("replace")
148 .created_at(Timestamp::from_secs(1))
149 .expiration(earlier)
150 .expiration(later)
151 .sign_with_keys(&keys())
152 .unwrap();
153 assert_eq!(parse_expiration(&event).unwrap(), Some(later));
154 let count = event
156 .tags
157 .iter()
158 .filter(|t| t.kind() == TagKind::from_wire(EXPIRATION_TAG))
159 .count();
160 assert_eq!(count, 1);
161 }
162
163 #[test]
164 fn before_deadline_not_expired() {
165 let event = EventBuilder::text_note("future")
166 .created_at(Timestamp::from_secs(1))
167 .expiration(Timestamp::from_secs(2_000))
168 .sign_with_keys(&keys())
169 .unwrap();
170 assert!(!is_expired(&event, Timestamp::from_secs(1_999)).unwrap());
171 }
172
173 #[test]
174 fn at_or_after_deadline_is_expired() {
175 let event = EventBuilder::text_note("late")
176 .created_at(Timestamp::from_secs(1))
177 .expiration(Timestamp::from_secs(2_000))
178 .sign_with_keys(&keys())
179 .unwrap();
180 assert!(is_expired(&event, Timestamp::from_secs(2_000)).unwrap());
181 assert!(is_expired(&event, Timestamp::from_secs(2_001)).unwrap());
182 }
183
184 #[test]
185 fn malformed_value_is_reported() {
186 let event = EventBuilder::text_note("oops")
187 .created_at(Timestamp::from_secs(1))
188 .tag(Tag::new(["expiration", "soon"]).unwrap())
189 .sign_with_keys(&keys())
190 .unwrap();
191 let err = parse_expiration(&event).unwrap_err();
192 assert!(matches!(err, ExpirationError::InvalidTimestamp(_)));
193 }
194
195 #[test]
196 fn missing_value_is_reported() {
197 let event = EventBuilder::text_note("oops")
198 .created_at(Timestamp::from_secs(1))
199 .tag(Tag::new(["expiration"]).unwrap())
200 .sign_with_keys(&keys())
201 .unwrap();
202 let err = parse_expiration(&event).unwrap_err();
203 assert!(matches!(err, ExpirationError::MissingValue));
204 }
205}