1use thiserror::Error;
12
13use crate::event::{Coordinate, CoordinateError, Event, EventBuilder, Kind, Tag, TagKind};
14use crate::types::{RelayUrl, RelayUrlError, Url, UrlError};
15
16pub const KIND_CODE_SNIPPET: Kind = Kind::CODE_SNIPPET;
18
19const LANGUAGE_TAG: &str = "l";
20const NAME_TAG: &str = "name";
21const EXTENSION_TAG: &str = "extension";
22const DESCRIPTION_TAG: &str = "description";
23const RUNTIME_TAG: &str = "runtime";
24const LICENSE_TAG: &str = "license";
25const DEPENDENCY_TAG: &str = "dep";
26const REPO_TAG: &str = "repo";
27
28#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum CodeRepo {
32 Url(Url),
34 Repository {
36 coordinate: Coordinate,
38 relay_hint: Option<RelayUrl>,
40 },
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct CodeSnippet {
46 pub content: String,
48 pub language: Option<String>,
50 pub name: Option<String>,
52 pub extension: Option<String>,
54 pub description: Option<String>,
56 pub runtime: Option<String>,
58 pub licenses: Vec<String>,
60 pub dependencies: Vec<String>,
62 pub repos: Vec<CodeRepo>,
64 pub extra_tags: Vec<Tag>,
66}
67
68#[derive(Debug, Error)]
70#[non_exhaustive]
71pub enum CodeSnippetError {
72 #[error("unexpected kind for NIP-C0 code snippet: {}", .0.as_u16())]
74 WrongKind(Kind),
75 #[error("`repo` tag missing value")]
77 MalformedRepo,
78 #[error(transparent)]
80 InvalidUrl(#[from] UrlError),
81 #[error(transparent)]
83 InvalidCoordinate(#[from] CoordinateError),
84 #[error(transparent)]
86 InvalidRelayUrl(#[from] RelayUrlError),
87}
88
89impl CodeSnippet {
90 #[must_use]
92 pub fn new(content: impl Into<String>) -> Self {
93 Self {
94 content: content.into(),
95 ..Self::default()
96 }
97 }
98
99 pub fn from_event(event: &Event) -> Result<Self, CodeSnippetError> {
105 if event.kind != KIND_CODE_SNIPPET {
106 return Err(CodeSnippetError::WrongKind(event.kind));
107 }
108 let mut out = Self::new(event.content.clone());
109 for tag in &event.tags {
110 absorb_tag(tag, &mut out)?;
111 }
112 Ok(out)
113 }
114}
115
116fn absorb_tag(tag: &Tag, out: &mut CodeSnippet) -> Result<(), CodeSnippetError> {
117 let col1 = tag.get(1);
118 match tag.name() {
119 LANGUAGE_TAG => out.language = col1.map(str::to_owned),
120 NAME_TAG => out.name = col1.map(str::to_owned),
121 EXTENSION_TAG => out.extension = col1.map(str::to_owned),
122 DESCRIPTION_TAG => out.description = col1.map(str::to_owned),
123 RUNTIME_TAG => out.runtime = col1.map(str::to_owned),
124 LICENSE_TAG => {
125 if let Some(raw) = col1 {
126 out.licenses.push(raw.to_owned());
127 }
128 }
129 DEPENDENCY_TAG => {
130 if let Some(raw) = col1 {
131 out.dependencies.push(raw.to_owned());
132 }
133 }
134 REPO_TAG => out.repos.push(parse_repo(tag)?),
135 _ => out.extra_tags.push(tag.clone()),
136 }
137 Ok(())
138}
139
140fn parse_repo(tag: &Tag) -> Result<CodeRepo, CodeSnippetError> {
141 let raw = tag.get(1).ok_or(CodeSnippetError::MalformedRepo)?;
142 if let Ok(coord) = Coordinate::parse(raw) {
143 let relay_hint = match tag.get(2) {
144 Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
145 _ => None,
146 };
147 Ok(CodeRepo::Repository {
148 coordinate: coord,
149 relay_hint,
150 })
151 } else {
152 Ok(CodeRepo::Url(Url::parse(raw)?))
153 }
154}
155
156fn repo_tag(repo: &CodeRepo) -> Tag {
157 let head = TagKind::from_wire(REPO_TAG);
158 match repo {
159 CodeRepo::Url(url) => Tag::with(&head, [url.as_str().to_owned()]),
160 CodeRepo::Repository {
161 coordinate,
162 relay_hint,
163 } => relay_hint.as_ref().map_or_else(
164 || Tag::with(&head, [coordinate.to_wire()]),
165 |relay| Tag::with(&head, [coordinate.to_wire(), relay.as_str().to_owned()]),
166 ),
167 }
168}
169
170impl EventBuilder {
171 #[must_use]
173 pub fn code_snippet(snippet: &CodeSnippet) -> Self {
174 let mut builder = Self::new(KIND_CODE_SNIPPET, snippet.content.clone());
175 let single_value = [
176 (LANGUAGE_TAG, snippet.language.as_deref()),
177 (NAME_TAG, snippet.name.as_deref()),
178 (EXTENSION_TAG, snippet.extension.as_deref()),
179 (DESCRIPTION_TAG, snippet.description.as_deref()),
180 (RUNTIME_TAG, snippet.runtime.as_deref()),
181 ];
182 for (name, value) in single_value {
183 if let Some(v) = value {
184 builder = builder.tag(Tag::with(&TagKind::from_wire(name), [v.to_owned()]));
185 }
186 }
187 for license in &snippet.licenses {
188 builder = builder.tag(Tag::with(
189 &TagKind::from_wire(LICENSE_TAG),
190 [license.clone()],
191 ));
192 }
193 for dep in &snippet.dependencies {
194 builder = builder.tag(Tag::with(
195 &TagKind::from_wire(DEPENDENCY_TAG),
196 [dep.clone()],
197 ));
198 }
199 for repo in &snippet.repos {
200 builder = builder.tag(repo_tag(repo));
201 }
202 for tag in &snippet.extra_tags {
203 builder = builder.tag(tag.clone());
204 }
205 builder
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::Keys;
213
214 fn keys() -> Keys {
215 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
216 }
217
218 #[test]
219 fn code_snippet_round_trip() {
220 let snippet = CodeSnippet {
221 content: "fn hello() { println!(\"hi\"); }".into(),
222 language: Some("rust".into()),
223 name: Some("hello.rs".into()),
224 extension: Some("rs".into()),
225 description: Some("Demo".into()),
226 runtime: Some("rustc 1.79".into()),
227 licenses: vec!["MIT".into(), "Apache-2.0".into()],
228 dependencies: vec!["serde = \"1\"".into()],
229 repos: vec![
230 CodeRepo::Url(Url::parse("https://github.com/example/hello").unwrap()),
231 CodeRepo::Repository {
232 coordinate: Coordinate::new(
233 Kind::GIT_REPOSITORY,
234 *keys().public_key(),
235 "hello".to_owned(),
236 ),
237 relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
238 },
239 ],
240 extra_tags: Vec::new(),
241 };
242 let event = EventBuilder::code_snippet(&snippet)
243 .sign_with_keys(&keys())
244 .unwrap();
245 let parsed = CodeSnippet::from_event(&event).unwrap();
246 assert_eq!(parsed, snippet);
247 }
248
249 #[test]
250 fn wrong_kind_is_rejected() {
251 let event = EventBuilder::text_note("nope")
252 .sign_with_keys(&keys())
253 .unwrap();
254 assert!(matches!(
255 CodeSnippet::from_event(&event),
256 Err(CodeSnippetError::WrongKind(_))
257 ));
258 }
259
260 #[test]
261 fn minimal_snippet_with_only_language_round_trips() {
262 let snippet = CodeSnippet::new("print('hi')");
264 let mut snippet = snippet;
265 snippet.language = Some("python".into());
266 let event = EventBuilder::code_snippet(&snippet)
267 .sign_with_keys(&keys())
268 .unwrap();
269 let parsed = CodeSnippet::from_event(&event).unwrap();
270 assert_eq!(parsed.language.as_deref(), Some("python"));
271 assert_eq!(parsed.content, "print('hi')");
272 assert!(parsed.licenses.is_empty());
273 assert!(parsed.repos.is_empty());
274 }
275
276 #[test]
277 fn url_only_repo_reference_round_trips() {
278 let mut snippet = CodeSnippet::new("body");
281 snippet.repos.push(CodeRepo::Url(
282 Url::parse("https://github.com/example/proj").unwrap(),
283 ));
284 let event = EventBuilder::code_snippet(&snippet)
285 .sign_with_keys(&keys())
286 .unwrap();
287 let parsed = CodeSnippet::from_event(&event).unwrap();
288 assert_eq!(parsed.repos.len(), 1);
289 match &parsed.repos[0] {
290 CodeRepo::Url(url) => assert_eq!(url.as_str(), "https://github.com/example/proj"),
291 other @ CodeRepo::Repository { .. } => panic!("expected URL repo, got {other:?}"),
292 }
293 }
294
295 #[test]
296 fn malformed_repo_tag_is_rejected() {
297 let event = EventBuilder::new(KIND_CODE_SNIPPET, "body")
299 .tag(Tag::with(
300 &TagKind::from_wire(REPO_TAG),
301 Vec::<String>::new(),
302 ))
303 .sign_with_keys(&keys())
304 .unwrap();
305 assert!(matches!(
306 CodeSnippet::from_event(&event),
307 Err(CodeSnippetError::MalformedRepo)
308 ));
309 }
310}