1use thiserror::Error;
24
25use crate::event::{Tag, TagKind, Tags};
26
27pub const CONTENT_WARNING_TAG: &str = "content-warning";
29
30pub const CONTENT_WARNING_NAMESPACE: &str = "content-warning";
33
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct ContentWarning {
40 pub reason: Option<String>,
42}
43
44impl ContentWarning {
45 #[must_use]
48 pub const fn unspecified() -> Self {
49 Self { reason: None }
50 }
51
52 #[must_use]
54 pub fn with_reason(reason: impl Into<String>) -> Self {
55 Self {
56 reason: Some(reason.into()),
57 }
58 }
59
60 #[must_use]
62 pub fn to_tag(&self) -> Tag {
63 let head = TagKind::from_wire(CONTENT_WARNING_TAG);
64 self.reason.as_ref().map_or_else(
65 || Tag::with(&head, std::iter::empty::<String>()),
66 |reason| Tag::with(&head, [reason.clone()]),
67 )
68 }
69
70 pub fn from_tag(tag: &Tag) -> Result<Self, ContentWarningError> {
79 if tag.name() != CONTENT_WARNING_TAG {
80 return Err(ContentWarningError::WrongTag);
81 }
82 let reason = tag.get(1).filter(|s| !s.is_empty()).map(str::to_owned);
83 Ok(Self { reason })
84 }
85}
86
87#[must_use]
92pub fn content_warning_from_tags(tags: &Tags) -> Option<ContentWarning> {
93 let head = TagKind::from_wire(CONTENT_WARNING_TAG);
94 tags.find_first(&head)
95 .and_then(|tag| ContentWarning::from_tag(tag).ok())
96}
97
98#[derive(Debug, Error)]
100#[non_exhaustive]
101pub enum ContentWarningError {
102 #[error("expected `content-warning` tag")]
104 WrongTag,
105}
106
107impl Tag {
108 #[must_use]
113 pub fn content_warning(reason: Option<impl Into<String>>) -> Self {
114 let head = TagKind::from_wire(CONTENT_WARNING_TAG);
115 reason.map_or_else(
116 || Self::with(&head, std::iter::empty::<String>()),
117 |r| Self::with(&head, [r.into()]),
118 )
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use crate::EventBuilder;
126 use crate::Keys;
127
128 fn keys() -> Keys {
129 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
130 }
131
132 #[test]
133 fn round_trip_with_reason() {
134 let warning = ContentWarning::with_reason("violent imagery");
135 let tag = warning.to_tag();
136 assert_eq!(tag.name(), CONTENT_WARNING_TAG);
137 assert_eq!(tag.get(1), Some("violent imagery"));
138 let parsed = ContentWarning::from_tag(&tag).unwrap();
139 assert_eq!(parsed, warning);
140 }
141
142 #[test]
143 fn round_trip_without_reason() {
144 let warning = ContentWarning::unspecified();
145 let tag = warning.to_tag();
146 assert_eq!(tag.name(), CONTENT_WARNING_TAG);
147 assert_eq!(tag.get(1), None);
148 let parsed = ContentWarning::from_tag(&tag).unwrap();
149 assert_eq!(parsed, warning);
150 }
151
152 #[test]
153 fn empty_reason_string_is_treated_as_none() {
154 let tag = Tag::with(&TagKind::from_wire(CONTENT_WARNING_TAG), [""]);
155 let parsed = ContentWarning::from_tag(&tag).unwrap();
156 assert_eq!(parsed.reason, None);
157 }
158
159 #[test]
160 fn wrong_tag_is_rejected() {
161 let tag = Tag::title("not a warning");
162 assert!(matches!(
163 ContentWarning::from_tag(&tag),
164 Err(ContentWarningError::WrongTag),
165 ));
166 }
167
168 #[test]
169 fn tag_builder_round_trips_through_event() {
170 let event = EventBuilder::text_note("sensitive")
171 .tag(Tag::content_warning(Some("nsfw")))
172 .sign_with_keys(&keys())
173 .unwrap();
174 let warning = content_warning_from_tags(&event.tags).unwrap();
175 assert_eq!(warning.reason.as_deref(), Some("nsfw"));
176 }
177
178 #[test]
179 fn content_warning_from_tags_returns_none_when_absent() {
180 let event = EventBuilder::text_note("clean")
181 .sign_with_keys(&keys())
182 .unwrap();
183 assert!(content_warning_from_tags(&event.tags).is_none());
184 }
185
186 #[test]
187 fn bare_tag_builder() {
188 let tag = Tag::content_warning(None::<String>);
189 assert_eq!(tag.name(), CONTENT_WARNING_TAG);
190 assert_eq!(tag.values().len(), 1, "only the tag head, no reason");
191 }
192}