Skip to main content

nula_core/nips/
nipc0.rs

1//! [NIP-C0] Code Snippets.
2//!
3//! `kind: 1337` carries a code snippet body in `.content` plus an
4//! extensible metadata surface (language, file extension, runtime,
5//! license, dependencies, repository pointer). The repository tag may
6//! reference a NIP-34 git repository announcement coordinate or any
7//! `https://`-style URL.
8//!
9//! [NIP-C0]: https://github.com/nostr-protocol/nips/blob/master/C0.md
10
11use thiserror::Error;
12
13use crate::event::{Coordinate, CoordinateError, Event, EventBuilder, Kind, Tag, TagKind};
14use crate::types::{RelayUrl, RelayUrlError, Url, UrlError};
15
16/// `kind: 1337` — code snippet.
17pub 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/// `repo` reference flavours: a plain URL or a NIP-34 repository
29/// addressable coordinate (with optional relay hint).
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum CodeRepo {
32    /// Plain HTTP URL pointing at a public repo browser page.
33    Url(Url),
34    /// NIP-34 `kind: 30617` repository announcement coordinate.
35    Repository {
36        /// Repo coordinate.
37        coordinate: Coordinate,
38        /// Optional relay hint.
39        relay_hint: Option<RelayUrl>,
40    },
41}
42
43/// Typed bundle for a `kind: 1337` code-snippet event.
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct CodeSnippet {
46    /// The actual code body.
47    pub content: String,
48    /// `l` programming language token (lower-case per spec).
49    pub language: Option<String>,
50    /// `name` (commonly a filename).
51    pub name: Option<String>,
52    /// `extension` (without the leading dot).
53    pub extension: Option<String>,
54    /// `description` line.
55    pub description: Option<String>,
56    /// `runtime` specifier.
57    pub runtime: Option<String>,
58    /// `license` SPDX identifiers (multi-licensing supported).
59    pub licenses: Vec<String>,
60    /// `dep` dependency lines (repeatable).
61    pub dependencies: Vec<String>,
62    /// `repo` references (URL or NIP-34 coordinate).
63    pub repos: Vec<CodeRepo>,
64    /// Forward-compatible passthrough for unknown tags.
65    pub extra_tags: Vec<Tag>,
66}
67
68/// Errors raised while parsing a NIP-C0 event.
69#[derive(Debug, Error)]
70#[non_exhaustive]
71pub enum CodeSnippetError {
72    /// Event kind is not `1337`.
73    #[error("unexpected kind for NIP-C0 code snippet: {}", .0.as_u16())]
74    WrongKind(Kind),
75    /// `repo` tag has no value column.
76    #[error("`repo` tag missing value")]
77    MalformedRepo,
78    /// Wrapped URL parser error.
79    #[error(transparent)]
80    InvalidUrl(#[from] UrlError),
81    /// Wrapped coordinate parser error.
82    #[error(transparent)]
83    InvalidCoordinate(#[from] CoordinateError),
84    /// Wrapped relay-URL parser error.
85    #[error(transparent)]
86    InvalidRelayUrl(#[from] RelayUrlError),
87}
88
89impl CodeSnippet {
90    /// Construct a code snippet with the body seeded.
91    #[must_use]
92    pub fn new(content: impl Into<String>) -> Self {
93        Self {
94            content: content.into(),
95            ..Self::default()
96        }
97    }
98
99    /// Parse a `kind: 1337` code-snippet event.
100    ///
101    /// # Errors
102    ///
103    /// See [`CodeSnippetError`] for the failure modes.
104    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    /// Author a NIP-C0 `kind: 1337` code-snippet event.
172    #[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        // Smallest valid snippet: body + language label only.
263        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        // A snippet may attach a plain URL repo \u2014 the parser preserves
279        // the `CodeRepo::Url` shape rather than upgrading to `Repository`.
280        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        // A `repo` tag with only the head column is malformed.
298        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}