1#![allow(
51 clippy::excessive_nesting,
52 reason = "the per-tag match-on-name dispatch pattern in `from_event` keeps the wire-format-to-field mapping at the surface; flattening obscures it"
53)]
54#![allow(
55 clippy::too_many_lines,
56 reason = "NIP-34 fields fan out across 8 typed bundles; splitting renders / parsers further would create indirection without clarity"
57)]
58
59use thiserror::Error;
60
61use crate::event::{
62 Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
63 SingleLetterTag, Tag, TagError, TagKind,
64};
65use crate::key::{PublicKey, PublicKeyError};
66use crate::types::{RelayUrl, RelayUrlError};
67
68pub const KIND_REPO: Kind = Kind::GIT_REPOSITORY;
70pub const KIND_REPO_STATE: Kind = Kind::GIT_REPOSITORY_STATE;
72pub const KIND_PATCH: Kind = Kind::GIT_PATCH;
74pub const KIND_PULL_REQUEST: Kind = Kind::GIT_PULL_REQUEST;
76pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::GIT_PULL_REQUEST_UPDATE;
78pub const KIND_ISSUE: Kind = Kind::GIT_ISSUE;
80pub const KIND_STATUS_OPEN: Kind = Kind::GIT_STATUS_OPEN;
82pub const KIND_STATUS_APPLIED: Kind = Kind::GIT_STATUS_APPLIED;
84pub const KIND_STATUS_CLOSED: Kind = Kind::GIT_STATUS_CLOSED;
86pub const KIND_STATUS_DRAFT: Kind = Kind::GIT_STATUS_DRAFT;
88pub const KIND_GRASP_LIST: Kind = Kind::GIT_GRASP_LIST;
90
91pub const PERSONAL_FORK_HASHTAG: &str = "personal-fork";
93pub const EUC_MARKER: &str = "euc";
95
96mod tag_names {
97 pub(super) const D: &str = "d";
98 pub(super) const NAME: &str = "name";
99 pub(super) const DESCRIPTION: &str = "description";
100 pub(super) const WEB: &str = "web";
101 pub(super) const CLONE: &str = "clone";
102 pub(super) const RELAYS: &str = "relays";
103 pub(super) const MAINTAINERS: &str = "maintainers";
104 pub(super) const COMMIT: &str = "commit";
105 pub(super) const PARENT_COMMIT: &str = "parent-commit";
106 pub(super) const COMMIT_PGP_SIG: &str = "commit-pgp-sig";
107 pub(super) const COMMITTER: &str = "committer";
108 pub(super) const SUBJECT: &str = "subject";
109 pub(super) const BRANCH_NAME: &str = "branch-name";
110 pub(super) const MERGE_BASE: &str = "merge-base";
111 pub(super) const MERGE_COMMIT: &str = "merge-commit";
112 pub(super) const APPLIED_AS_COMMITS: &str = "applied-as-commits";
113 pub(super) const HEAD: &str = "HEAD";
114 pub(super) const REFS_PREFIX: &str = "refs/";
115}
116
117#[derive(Debug, Error)]
119#[non_exhaustive]
120pub enum Nip34Error {
121 #[error("expected kind {expected}, got {got}")]
123 WrongKind {
124 expected: Kind,
126 got: Kind,
128 },
129 #[error("expected a status kind (1630..=1633), got {0}")]
131 InvalidStatusKind(Kind),
132 #[error("NIP-34 {kind} event missing required `d` tag")]
134 MissingIdentifier {
135 kind: Kind,
137 },
138 #[error("NIP-34 {kind} event missing required `a` repository tag")]
141 MissingRepository {
142 kind: Kind,
144 },
145 #[error(transparent)]
147 Coordinate(#[from] CoordinateError),
148 #[error(transparent)]
150 EventId(#[from] EventIdError),
151 #[error(transparent)]
153 RelayUrl(#[from] RelayUrlError),
154 #[error(transparent)]
156 PublicKey(#[from] PublicKeyError),
157 #[error(transparent)]
159 Tag(#[from] TagError),
160}
161
162fn second(values: &[String]) -> Option<&str> {
163 values.get(1).map(String::as_str)
164}
165
166fn args(values: &[String]) -> &[String] {
167 values.get(1..).unwrap_or(&[])
168}
169
170fn p_tag(pubkey: PublicKey) -> Tag {
171 Tag::with(
172 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
173 [pubkey.to_hex()],
174 )
175}
176
177fn e_tag(event_id: EventId, marker: Option<&str>) -> Tag {
178 let mut row = vec![event_id.to_hex()];
179 row.push(String::new());
180 if let Some(marker) = marker {
181 row.push(marker.to_owned());
182 }
183 Tag::with(
184 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E)),
185 row,
186 )
187}
188
189fn r_tag(value: impl Into<String>) -> Tag {
190 Tag::with(
191 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R)),
192 [value.into()],
193 )
194}
195
196fn a_tag(coordinate: &Coordinate) -> Tag {
197 Tag::with(
198 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A)),
199 [coordinate.to_wire()],
200 )
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct Repository {
206 pub identifier: String,
208 pub name: Option<String>,
210 pub description: Option<String>,
212 pub web: Vec<String>,
214 pub clone: Vec<String>,
216 pub relays: Vec<RelayUrl>,
218 pub earliest_unique_commit: Option<String>,
220 pub maintainers: Vec<PublicKey>,
222 pub hashtags: Vec<String>,
224 pub personal_fork: bool,
226}
227
228impl Repository {
229 #[must_use]
232 pub fn new(identifier: impl Into<String>) -> Self {
233 Self {
234 identifier: identifier.into(),
235 name: None,
236 description: None,
237 web: Vec::new(),
238 clone: Vec::new(),
239 relays: Vec::new(),
240 earliest_unique_commit: None,
241 maintainers: Vec::new(),
242 hashtags: Vec::new(),
243 personal_fork: false,
244 }
245 }
246
247 #[must_use]
250 pub fn coordinate(&self, author: PublicKey) -> Coordinate {
251 Coordinate::new(KIND_REPO, author, self.identifier.clone())
252 }
253
254 #[must_use]
256 pub fn to_tags(&self) -> Vec<Tag> {
257 let mut tags: Vec<Tag> = Vec::new();
258 tags.push(Tag::with(
259 &TagKind::custom(tag_names::D),
260 [self.identifier.clone()],
261 ));
262 if let Some(name) = &self.name {
263 tags.push(Tag::with(&TagKind::custom(tag_names::NAME), [name.clone()]));
264 }
265 if let Some(description) = &self.description {
266 tags.push(Tag::with(
267 &TagKind::custom(tag_names::DESCRIPTION),
268 [description.clone()],
269 ));
270 }
271 if !self.web.is_empty() {
272 tags.push(Tag::with(
273 &TagKind::custom(tag_names::WEB),
274 self.web.clone(),
275 ));
276 }
277 if !self.clone.is_empty() {
278 tags.push(Tag::with(
279 &TagKind::custom(tag_names::CLONE),
280 self.clone.clone(),
281 ));
282 }
283 if !self.relays.is_empty() {
284 let row: Vec<String> = self.relays.iter().map(|r| r.as_str().to_owned()).collect();
285 tags.push(Tag::with(&TagKind::custom(tag_names::RELAYS), row));
286 }
287 if let Some(euc) = &self.earliest_unique_commit {
288 tags.push(Tag::with(
289 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R)),
290 [euc.clone(), EUC_MARKER.to_owned()],
291 ));
292 }
293 if !self.maintainers.is_empty() {
294 let row: Vec<String> = self.maintainers.iter().map(|pk| pk.to_hex()).collect();
295 tags.push(Tag::with(&TagKind::custom(tag_names::MAINTAINERS), row));
296 }
297 if self.personal_fork {
298 tags.push(Tag::with(
299 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
300 [PERSONAL_FORK_HASHTAG.to_owned()],
301 ));
302 }
303 for hashtag in &self.hashtags {
304 tags.push(Tag::with(
305 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
306 [hashtag.clone()],
307 ));
308 }
309 tags
310 }
311
312 pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
319 if event.kind != KIND_REPO {
320 return Err(Nip34Error::WrongKind {
321 expected: KIND_REPO,
322 got: event.kind,
323 });
324 }
325 let mut identifier: Option<String> = None;
326 let mut repo = Self::new(String::new());
327 for tag in &event.tags {
328 let values = tag.values();
329 let args = args(values);
330 match tag.name() {
331 tag_names::D => identifier = second(values).map(String::from),
332 tag_names::NAME => repo.name = second(values).map(String::from),
333 tag_names::DESCRIPTION => repo.description = second(values).map(String::from),
334 tag_names::WEB => {
335 for v in args {
336 repo.web.push(v.clone());
337 }
338 }
339 tag_names::CLONE => {
340 for v in args {
341 repo.clone.push(v.clone());
342 }
343 }
344 tag_names::RELAYS => {
345 for v in args {
346 repo.relays.push(RelayUrl::parse(v)?);
347 }
348 }
349 tag_names::MAINTAINERS => {
350 for v in args {
351 repo.maintainers.push(PublicKey::parse(v)?);
352 }
353 }
354 "r" => {
355 let marker = args.get(1).map(String::as_str);
356 if marker == Some(EUC_MARKER) {
357 repo.earliest_unique_commit = args.first().cloned();
358 }
359 }
360 "t" => {
361 if let Some(value) = args.first() {
362 if value == PERSONAL_FORK_HASHTAG {
363 repo.personal_fork = true;
364 } else {
365 repo.hashtags.push(value.clone());
366 }
367 }
368 }
369 _ => {}
370 }
371 }
372 repo.identifier = identifier.ok_or(Nip34Error::MissingIdentifier { kind: KIND_REPO })?;
373 Ok(repo)
374 }
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct GitRef {
380 pub ref_path: String,
382 pub oid: String,
384}
385
386impl GitRef {
387 #[must_use]
389 pub fn new(ref_path: impl Into<String>, oid: impl Into<String>) -> Self {
390 Self {
391 ref_path: ref_path.into(),
392 oid: oid.into(),
393 }
394 }
395}
396
397#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct RepositoryState {
400 pub identifier: String,
402 pub refs: Vec<GitRef>,
404 pub head: Option<String>,
406}
407
408impl RepositoryState {
409 #[must_use]
412 pub fn new(identifier: impl Into<String>) -> Self {
413 Self {
414 identifier: identifier.into(),
415 refs: Vec::new(),
416 head: None,
417 }
418 }
419
420 #[must_use]
422 pub fn to_tags(&self) -> Vec<Tag> {
423 let mut tags: Vec<Tag> = Vec::new();
424 tags.push(Tag::with(
425 &TagKind::custom(tag_names::D),
426 [self.identifier.clone()],
427 ));
428 for git_ref in &self.refs {
429 tags.push(Tag::with(
430 &TagKind::custom(&git_ref.ref_path),
431 [git_ref.oid.clone()],
432 ));
433 }
434 if let Some(head) = &self.head {
435 tags.push(Tag::with(&TagKind::custom(tag_names::HEAD), [head.clone()]));
436 }
437 tags
438 }
439
440 pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
447 if event.kind != KIND_REPO_STATE {
448 return Err(Nip34Error::WrongKind {
449 expected: KIND_REPO_STATE,
450 got: event.kind,
451 });
452 }
453 let mut identifier: Option<String> = None;
454 let mut state = Self::new(String::new());
455 for tag in &event.tags {
456 let values = tag.values();
457 let name = tag.name();
458 if name == tag_names::D {
459 identifier = second(values).map(String::from);
460 continue;
461 }
462 if name == tag_names::HEAD {
463 state.head = second(values).map(String::from);
464 continue;
465 }
466 if name.starts_with(tag_names::REFS_PREFIX)
467 && let Some(oid) = second(values)
468 {
469 state.refs.push(GitRef::new(name, oid));
470 }
471 }
472 state.identifier = identifier.ok_or(Nip34Error::MissingIdentifier {
473 kind: KIND_REPO_STATE,
474 })?;
475 Ok(state)
476 }
477}
478
479#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct Patch {
482 pub content: String,
484 pub repo: Coordinate,
486 pub repo_euc: Option<String>,
489 pub mentions: Vec<PublicKey>,
491 pub root: bool,
493 pub root_revision: bool,
495 pub commit: Option<String>,
497 pub parent_commit: Option<String>,
499 pub commit_pgp_sig: Option<String>,
501 pub committer: Option<Committer>,
503}
504
505#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct Committer {
508 pub name: String,
510 pub email: String,
512 pub timestamp: String,
514 pub offset: String,
516}
517
518impl Patch {
519 #[must_use]
521 pub fn new(repo: Coordinate, content: impl Into<String>) -> Self {
522 Self {
523 content: content.into(),
524 repo,
525 repo_euc: None,
526 mentions: Vec::new(),
527 root: false,
528 root_revision: false,
529 commit: None,
530 parent_commit: None,
531 commit_pgp_sig: None,
532 committer: None,
533 }
534 }
535
536 #[must_use]
538 pub fn to_tags(&self) -> Vec<Tag> {
539 let mut tags: Vec<Tag> = Vec::new();
540 tags.push(a_tag(&self.repo));
541 if let Some(euc) = &self.repo_euc {
542 tags.push(r_tag(euc.clone()));
543 }
544 for mention in &self.mentions {
545 tags.push(p_tag(*mention));
546 }
547 if self.root {
548 tags.push(Tag::with(
549 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
550 ["root".to_owned()],
551 ));
552 }
553 if self.root_revision {
554 tags.push(Tag::with(
555 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
556 ["root-revision".to_owned()],
557 ));
558 }
559 if let Some(commit) = &self.commit {
560 tags.push(Tag::with(
561 &TagKind::custom(tag_names::COMMIT),
562 [commit.clone()],
563 ));
564 tags.push(r_tag(commit.clone()));
565 }
566 if let Some(parent_commit) = &self.parent_commit {
567 tags.push(Tag::with(
568 &TagKind::custom(tag_names::PARENT_COMMIT),
569 [parent_commit.clone()],
570 ));
571 }
572 if let Some(sig) = &self.commit_pgp_sig {
573 tags.push(Tag::with(
574 &TagKind::custom(tag_names::COMMIT_PGP_SIG),
575 [sig.clone()],
576 ));
577 }
578 if let Some(committer) = &self.committer {
579 tags.push(Tag::with(
580 &TagKind::custom(tag_names::COMMITTER),
581 [
582 committer.name.clone(),
583 committer.email.clone(),
584 committer.timestamp.clone(),
585 committer.offset.clone(),
586 ],
587 ));
588 }
589 tags
590 }
591
592 pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
600 if event.kind != KIND_PATCH {
601 return Err(Nip34Error::WrongKind {
602 expected: KIND_PATCH,
603 got: event.kind,
604 });
605 }
606 let mut repo: Option<Coordinate> = None;
607 let mut mentions: Vec<PublicKey> = Vec::new();
608 let mut root = false;
609 let mut root_revision = false;
610 let mut commit: Option<String> = None;
611 let mut parent_commit: Option<String> = None;
612 let mut commit_pgp_sig: Option<String> = None;
613 let mut committer: Option<Committer> = None;
614 let mut repo_euc: Option<String> = None;
615 for tag in &event.tags {
616 let values = tag.values();
617 let args = args(values);
618 match tag.name() {
619 "a" => {
620 if let Some(value) = args.first() {
621 repo = Some(Coordinate::parse(value)?);
622 }
623 }
624 "r" => {
625 if let Some(value) = args.first() {
626 repo_euc.get_or_insert_with(|| value.clone());
627 }
628 }
629 "p" => {
630 if let Some(value) = args.first() {
631 mentions.push(PublicKey::parse(value)?);
632 }
633 }
634 "t" => match args.first().map(String::as_str) {
635 Some("root") => root = true,
636 Some("root-revision") => root_revision = true,
637 _ => {}
638 },
639 tag_names::COMMIT => commit = args.first().cloned(),
640 tag_names::PARENT_COMMIT => parent_commit = args.first().cloned(),
641 tag_names::COMMIT_PGP_SIG => commit_pgp_sig = args.first().cloned(),
642 tag_names::COMMITTER => {
643 if let [name, email, timestamp, offset, ..] = args {
644 committer = Some(Committer {
645 name: name.clone(),
646 email: email.clone(),
647 timestamp: timestamp.clone(),
648 offset: offset.clone(),
649 });
650 }
651 }
652 _ => {}
653 }
654 }
655 let repo = repo.ok_or(Nip34Error::MissingRepository { kind: KIND_PATCH })?;
656 Ok(Self {
657 content: event.content.clone(),
658 repo,
659 repo_euc,
660 mentions,
661 root,
662 root_revision,
663 commit,
664 parent_commit,
665 commit_pgp_sig,
666 committer,
667 })
668 }
669}
670
671#[derive(Debug, Clone, PartialEq, Eq)]
673pub struct PullRequest {
674 pub content: String,
676 pub repo: Coordinate,
678 pub repo_euc: Option<String>,
680 pub mentions: Vec<PublicKey>,
682 pub subject: Option<String>,
684 pub hashtags: Vec<String>,
686 pub tip_commit: Option<String>,
688 pub clone: Vec<String>,
690 pub branch_name: Option<String>,
692 pub revises_event: Option<EventId>,
694 pub merge_base: Option<String>,
697}
698
699impl PullRequest {
700 #[must_use]
702 pub fn new(repo: Coordinate, content: impl Into<String>) -> Self {
703 Self {
704 content: content.into(),
705 repo,
706 repo_euc: None,
707 mentions: Vec::new(),
708 subject: None,
709 hashtags: Vec::new(),
710 tip_commit: None,
711 clone: Vec::new(),
712 branch_name: None,
713 revises_event: None,
714 merge_base: None,
715 }
716 }
717
718 #[must_use]
720 pub fn to_tags(&self) -> Vec<Tag> {
721 let mut tags: Vec<Tag> = Vec::new();
722 tags.push(a_tag(&self.repo));
723 if let Some(euc) = &self.repo_euc {
724 tags.push(r_tag(euc.clone()));
725 }
726 for mention in &self.mentions {
727 tags.push(p_tag(*mention));
728 }
729 if let Some(subject) = &self.subject {
730 tags.push(Tag::with(
731 &TagKind::custom(tag_names::SUBJECT),
732 [subject.clone()],
733 ));
734 }
735 for hashtag in &self.hashtags {
736 tags.push(Tag::with(
737 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
738 [hashtag.clone()],
739 ));
740 }
741 if let Some(c) = &self.tip_commit {
742 tags.push(Tag::with(
743 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::C)),
744 [c.clone()],
745 ));
746 }
747 if !self.clone.is_empty() {
748 tags.push(Tag::with(
749 &TagKind::custom(tag_names::CLONE),
750 self.clone.clone(),
751 ));
752 }
753 if let Some(branch) = &self.branch_name {
754 tags.push(Tag::with(
755 &TagKind::custom(tag_names::BRANCH_NAME),
756 [branch.clone()],
757 ));
758 }
759 if let Some(event_id) = self.revises_event {
760 tags.push(e_tag(event_id, None));
761 }
762 if let Some(merge_base) = &self.merge_base {
763 tags.push(Tag::with(
764 &TagKind::custom(tag_names::MERGE_BASE),
765 [merge_base.clone()],
766 ));
767 }
768 tags
769 }
770
771 pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
779 if event.kind != KIND_PULL_REQUEST {
780 return Err(Nip34Error::WrongKind {
781 expected: KIND_PULL_REQUEST,
782 got: event.kind,
783 });
784 }
785 let mut repo: Option<Coordinate> = None;
786 let mut repo_euc: Option<String> = None;
787 let mut mentions: Vec<PublicKey> = Vec::new();
788 let mut subject: Option<String> = None;
789 let mut hashtags: Vec<String> = Vec::new();
790 let mut tip_commit: Option<String> = None;
791 let mut clone: Vec<String> = Vec::new();
792 let mut branch_name: Option<String> = None;
793 let mut revises_event: Option<EventId> = None;
794 let mut merge_base: Option<String> = None;
795 for tag in &event.tags {
796 let values = tag.values();
797 let args = args(values);
798 match tag.name() {
799 "a" => {
800 if let Some(value) = args.first() {
801 repo = Some(Coordinate::parse(value)?);
802 }
803 }
804 "r" => {
805 if let Some(value) = args.first() {
806 repo_euc.get_or_insert_with(|| value.clone());
807 }
808 }
809 "p" => {
810 if let Some(value) = args.first() {
811 mentions.push(PublicKey::parse(value)?);
812 }
813 }
814 tag_names::SUBJECT => subject = args.first().cloned(),
815 "t" => {
816 if let Some(value) = args.first() {
817 hashtags.push(value.clone());
818 }
819 }
820 "c" => tip_commit = args.first().cloned(),
821 tag_names::CLONE => {
822 for v in args {
823 clone.push(v.clone());
824 }
825 }
826 tag_names::BRANCH_NAME => branch_name = args.first().cloned(),
827 "e" => {
828 if let Some(value) = args.first() {
829 revises_event = Some(EventId::parse(value)?);
830 }
831 }
832 tag_names::MERGE_BASE => merge_base = args.first().cloned(),
833 _ => {}
834 }
835 }
836 let repo = repo.ok_or(Nip34Error::MissingRepository {
837 kind: KIND_PULL_REQUEST,
838 })?;
839 Ok(Self {
840 content: event.content.clone(),
841 repo,
842 repo_euc,
843 mentions,
844 subject,
845 hashtags,
846 tip_commit,
847 clone,
848 branch_name,
849 revises_event,
850 merge_base,
851 })
852 }
853}
854
855#[derive(Debug, Clone, PartialEq, Eq)]
857pub struct PullRequestUpdate {
858 pub content: String,
860 pub repo: Coordinate,
862 pub repo_euc: Option<String>,
864 pub mentions: Vec<PublicKey>,
866 pub root_event: EventId,
868 pub root_pubkey: PublicKey,
870 pub tip_commit: Option<String>,
872 pub clone: Vec<String>,
874 pub merge_base: Option<String>,
876}
877
878impl PullRequestUpdate {
879 #[must_use]
881 pub fn new(
882 repo: Coordinate,
883 root_event: EventId,
884 root_pubkey: PublicKey,
885 content: impl Into<String>,
886 ) -> Self {
887 Self {
888 content: content.into(),
889 repo,
890 repo_euc: None,
891 mentions: Vec::new(),
892 root_event,
893 root_pubkey,
894 tip_commit: None,
895 clone: Vec::new(),
896 merge_base: None,
897 }
898 }
899
900 #[must_use]
902 pub fn to_tags(&self) -> Vec<Tag> {
903 let mut tags: Vec<Tag> = Vec::new();
904 tags.push(a_tag(&self.repo));
905 if let Some(euc) = &self.repo_euc {
906 tags.push(r_tag(euc.clone()));
907 }
908 for mention in &self.mentions {
909 tags.push(p_tag(*mention));
910 }
911 tags.push(Tag::with(
912 &TagKind::single_letter(SingleLetterTag::uppercase(Alphabet::E)),
913 [self.root_event.to_hex()],
914 ));
915 tags.push(Tag::with(
916 &TagKind::single_letter(SingleLetterTag::uppercase(Alphabet::P)),
917 [self.root_pubkey.to_hex()],
918 ));
919 if let Some(c) = &self.tip_commit {
920 tags.push(Tag::with(
921 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::C)),
922 [c.clone()],
923 ));
924 }
925 if !self.clone.is_empty() {
926 tags.push(Tag::with(
927 &TagKind::custom(tag_names::CLONE),
928 self.clone.clone(),
929 ));
930 }
931 if let Some(merge_base) = &self.merge_base {
932 tags.push(Tag::with(
933 &TagKind::custom(tag_names::MERGE_BASE),
934 [merge_base.clone()],
935 ));
936 }
937 tags
938 }
939
940 pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
949 if event.kind != KIND_PULL_REQUEST_UPDATE {
950 return Err(Nip34Error::WrongKind {
951 expected: KIND_PULL_REQUEST_UPDATE,
952 got: event.kind,
953 });
954 }
955 let mut repo: Option<Coordinate> = None;
956 let mut repo_euc: Option<String> = None;
957 let mut mentions: Vec<PublicKey> = Vec::new();
958 let mut root_event: Option<EventId> = None;
959 let mut root_pubkey: Option<PublicKey> = None;
960 let mut tip_commit: Option<String> = None;
961 let mut clone: Vec<String> = Vec::new();
962 let mut merge_base: Option<String> = None;
963 for tag in &event.tags {
964 let values = tag.values();
965 let args = args(values);
966 match tag.name() {
967 "a" => {
968 if let Some(value) = args.first() {
969 repo = Some(Coordinate::parse(value)?);
970 }
971 }
972 "r" => {
973 if let Some(value) = args.first() {
974 repo_euc.get_or_insert_with(|| value.clone());
975 }
976 }
977 "p" => {
978 if let Some(value) = args.first() {
979 mentions.push(PublicKey::parse(value)?);
980 }
981 }
982 "E" => {
983 if let Some(value) = args.first() {
984 root_event = Some(EventId::parse(value)?);
985 }
986 }
987 "P" => {
988 if let Some(value) = args.first() {
989 root_pubkey = Some(PublicKey::parse(value)?);
990 }
991 }
992 "c" => tip_commit = args.first().cloned(),
993 tag_names::CLONE => {
994 for v in args {
995 clone.push(v.clone());
996 }
997 }
998 tag_names::MERGE_BASE => merge_base = args.first().cloned(),
999 _ => {}
1000 }
1001 }
1002 let repo = repo.ok_or(Nip34Error::MissingRepository {
1003 kind: KIND_PULL_REQUEST_UPDATE,
1004 })?;
1005 let root_event = root_event.ok_or(Nip34Error::MissingRepository {
1006 kind: KIND_PULL_REQUEST_UPDATE,
1007 })?;
1008 let root_pubkey = root_pubkey.ok_or(Nip34Error::MissingRepository {
1009 kind: KIND_PULL_REQUEST_UPDATE,
1010 })?;
1011 Ok(Self {
1012 content: event.content.clone(),
1013 repo,
1014 repo_euc,
1015 mentions,
1016 root_event,
1017 root_pubkey,
1018 tip_commit,
1019 clone,
1020 merge_base,
1021 })
1022 }
1023}
1024
1025#[derive(Debug, Clone, PartialEq, Eq)]
1027pub struct Issue {
1028 pub content: String,
1030 pub repo: Coordinate,
1032 pub mentions: Vec<PublicKey>,
1034 pub subject: Option<String>,
1036 pub hashtags: Vec<String>,
1038}
1039
1040impl Issue {
1041 #[must_use]
1043 pub fn new(repo: Coordinate, content: impl Into<String>) -> Self {
1044 Self {
1045 content: content.into(),
1046 repo,
1047 mentions: Vec::new(),
1048 subject: None,
1049 hashtags: Vec::new(),
1050 }
1051 }
1052
1053 #[must_use]
1055 pub fn to_tags(&self) -> Vec<Tag> {
1056 let mut tags: Vec<Tag> = Vec::new();
1057 tags.push(a_tag(&self.repo));
1058 for mention in &self.mentions {
1059 tags.push(p_tag(*mention));
1060 }
1061 if let Some(subject) = &self.subject {
1062 tags.push(Tag::with(
1063 &TagKind::custom(tag_names::SUBJECT),
1064 [subject.clone()],
1065 ));
1066 }
1067 for hashtag in &self.hashtags {
1068 tags.push(Tag::with(
1069 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
1070 [hashtag.clone()],
1071 ));
1072 }
1073 tags
1074 }
1075
1076 pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
1084 if event.kind != KIND_ISSUE {
1085 return Err(Nip34Error::WrongKind {
1086 expected: KIND_ISSUE,
1087 got: event.kind,
1088 });
1089 }
1090 let mut repo: Option<Coordinate> = None;
1091 let mut mentions: Vec<PublicKey> = Vec::new();
1092 let mut subject: Option<String> = None;
1093 let mut hashtags: Vec<String> = Vec::new();
1094 for tag in &event.tags {
1095 let values = tag.values();
1096 let args = args(values);
1097 match tag.name() {
1098 "a" => {
1099 if let Some(value) = args.first() {
1100 repo = Some(Coordinate::parse(value)?);
1101 }
1102 }
1103 "p" => {
1104 if let Some(value) = args.first() {
1105 mentions.push(PublicKey::parse(value)?);
1106 }
1107 }
1108 tag_names::SUBJECT => subject = args.first().cloned(),
1109 "t" => {
1110 if let Some(value) = args.first() {
1111 hashtags.push(value.clone());
1112 }
1113 }
1114 _ => {}
1115 }
1116 }
1117 let repo = repo.ok_or(Nip34Error::MissingRepository { kind: KIND_ISSUE })?;
1118 Ok(Self {
1119 content: event.content.clone(),
1120 repo,
1121 mentions,
1122 subject,
1123 hashtags,
1124 })
1125 }
1126}
1127
1128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1130#[non_exhaustive]
1131pub enum GitStatus {
1132 Open,
1134 Applied,
1136 Closed,
1138 Draft,
1140}
1141
1142impl GitStatus {
1143 #[must_use]
1145 pub const fn to_kind(self) -> Kind {
1146 match self {
1147 Self::Open => KIND_STATUS_OPEN,
1148 Self::Applied => KIND_STATUS_APPLIED,
1149 Self::Closed => KIND_STATUS_CLOSED,
1150 Self::Draft => KIND_STATUS_DRAFT,
1151 }
1152 }
1153
1154 pub const fn from_kind(kind: Kind) -> Result<Self, Nip34Error> {
1161 match kind.as_u16() {
1162 1_630 => Ok(Self::Open),
1163 1_631 => Ok(Self::Applied),
1164 1_632 => Ok(Self::Closed),
1165 1_633 => Ok(Self::Draft),
1166 _ => Err(Nip34Error::InvalidStatusKind(kind)),
1167 }
1168 }
1169}
1170
1171#[derive(Debug, Clone, PartialEq, Eq)]
1173pub struct StatusReference {
1174 pub event_id: EventId,
1176 pub marker: Option<String>,
1178}
1179
1180#[derive(Debug, Clone, PartialEq, Eq)]
1182pub struct StatusEvent {
1183 pub content: String,
1185 pub status: GitStatus,
1187 pub references: Vec<StatusReference>,
1189 pub mentions: Vec<PublicKey>,
1191 pub repo: Option<Coordinate>,
1193 pub repo_euc: Option<String>,
1195 pub quoted_patches: Vec<EventId>,
1197 pub merge_commit: Option<String>,
1199 pub applied_as_commits: Vec<String>,
1201}
1202
1203impl StatusEvent {
1204 #[must_use]
1206 pub const fn new(status: GitStatus) -> Self {
1207 Self {
1208 content: String::new(),
1209 status,
1210 references: Vec::new(),
1211 mentions: Vec::new(),
1212 repo: None,
1213 repo_euc: None,
1214 quoted_patches: Vec::new(),
1215 merge_commit: None,
1216 applied_as_commits: Vec::new(),
1217 }
1218 }
1219
1220 #[must_use]
1222 pub fn to_tags(&self) -> Vec<Tag> {
1223 let mut tags: Vec<Tag> = Vec::new();
1224 for reference in &self.references {
1225 tags.push(e_tag(reference.event_id, reference.marker.as_deref()));
1226 }
1227 for mention in &self.mentions {
1228 tags.push(p_tag(*mention));
1229 }
1230 if let Some(repo) = &self.repo {
1231 tags.push(a_tag(repo));
1232 }
1233 if let Some(euc) = &self.repo_euc {
1234 tags.push(r_tag(euc.clone()));
1235 }
1236 for q in &self.quoted_patches {
1237 tags.push(Tag::with(
1238 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::Q)),
1239 [q.to_hex(), String::new(), String::new()],
1240 ));
1241 }
1242 if let Some(merge_commit) = &self.merge_commit {
1243 tags.push(Tag::with(
1244 &TagKind::custom(tag_names::MERGE_COMMIT),
1245 [merge_commit.clone()],
1246 ));
1247 tags.push(r_tag(merge_commit.clone()));
1248 }
1249 if !self.applied_as_commits.is_empty() {
1250 tags.push(Tag::with(
1251 &TagKind::custom(tag_names::APPLIED_AS_COMMITS),
1252 self.applied_as_commits.clone(),
1253 ));
1254 for commit in &self.applied_as_commits {
1255 tags.push(r_tag(commit.clone()));
1256 }
1257 }
1258 tags
1259 }
1260
1261 pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
1268 let status = GitStatus::from_kind(event.kind)?;
1269 let mut bundle = Self::new(status);
1270 bundle.content.clone_from(&event.content);
1271 for tag in &event.tags {
1272 let values = tag.values();
1273 let args = args(values);
1274 match tag.name() {
1275 "e" => {
1276 let Some(id_hex) = args.first() else {
1277 continue;
1278 };
1279 let event_id = EventId::parse(id_hex)?;
1280 let marker = args.get(2).cloned().filter(|s| !s.is_empty());
1281 bundle.references.push(StatusReference { event_id, marker });
1282 }
1283 "p" => {
1284 if let Some(value) = args.first() {
1285 bundle.mentions.push(PublicKey::parse(value)?);
1286 }
1287 }
1288 "a" => {
1289 if let Some(value) = args.first() {
1290 bundle.repo = Some(Coordinate::parse(value)?);
1291 }
1292 }
1293 "r" => {
1294 if let Some(value) = args.first() {
1295 bundle.repo_euc.get_or_insert_with(|| value.clone());
1296 }
1297 }
1298 "q" => {
1299 if let Some(value) = args.first() {
1300 bundle.quoted_patches.push(EventId::parse(value)?);
1301 }
1302 }
1303 tag_names::MERGE_COMMIT => bundle.merge_commit = args.first().cloned(),
1304 tag_names::APPLIED_AS_COMMITS => {
1305 for v in args {
1306 bundle.applied_as_commits.push(v.clone());
1307 }
1308 }
1309 _ => {}
1310 }
1311 }
1312 Ok(bundle)
1313 }
1314}
1315
1316#[derive(Debug, Clone, PartialEq, Eq, Default)]
1318pub struct GraspServerList {
1319 pub servers: Vec<RelayUrl>,
1321}
1322
1323impl GraspServerList {
1324 #[must_use]
1326 pub fn new<I>(servers: I) -> Self
1327 where
1328 I: IntoIterator<Item = RelayUrl>,
1329 {
1330 Self {
1331 servers: servers.into_iter().collect(),
1332 }
1333 }
1334
1335 #[must_use]
1337 pub fn to_tags(&self) -> Vec<Tag> {
1338 self.servers
1339 .iter()
1340 .map(|server| {
1341 Tag::with(
1342 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::G)),
1343 [server.as_str().to_owned()],
1344 )
1345 })
1346 .collect()
1347 }
1348
1349 pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
1355 if event.kind != KIND_GRASP_LIST {
1356 return Err(Nip34Error::WrongKind {
1357 expected: KIND_GRASP_LIST,
1358 got: event.kind,
1359 });
1360 }
1361 let mut servers: Vec<RelayUrl> = Vec::new();
1362 for tag in &event.tags {
1363 if tag.name() != "g" {
1364 continue;
1365 }
1366 if let Some(value) = tag.values().get(1) {
1367 servers.push(RelayUrl::parse(value)?);
1368 }
1369 }
1370 Ok(Self { servers })
1371 }
1372}
1373
1374impl EventBuilder {
1375 #[must_use]
1377 pub fn git_repository(repo: &Repository) -> Self {
1378 let mut builder = Self::new(KIND_REPO, "");
1379 for tag in repo.to_tags() {
1380 builder = builder.tag(tag);
1381 }
1382 builder
1383 }
1384
1385 #[must_use]
1387 pub fn git_repository_state(state: &RepositoryState) -> Self {
1388 let mut builder = Self::new(KIND_REPO_STATE, "");
1389 for tag in state.to_tags() {
1390 builder = builder.tag(tag);
1391 }
1392 builder
1393 }
1394
1395 #[must_use]
1397 pub fn git_patch(patch: &Patch) -> Self {
1398 let mut builder = Self::new(KIND_PATCH, patch.content.clone());
1399 for tag in patch.to_tags() {
1400 builder = builder.tag(tag);
1401 }
1402 builder
1403 }
1404
1405 #[must_use]
1407 pub fn git_pull_request(pr: &PullRequest) -> Self {
1408 let mut builder = Self::new(KIND_PULL_REQUEST, pr.content.clone());
1409 for tag in pr.to_tags() {
1410 builder = builder.tag(tag);
1411 }
1412 builder
1413 }
1414
1415 #[must_use]
1417 pub fn git_pull_request_update(update: &PullRequestUpdate) -> Self {
1418 let mut builder = Self::new(KIND_PULL_REQUEST_UPDATE, update.content.clone());
1419 for tag in update.to_tags() {
1420 builder = builder.tag(tag);
1421 }
1422 builder
1423 }
1424
1425 #[must_use]
1427 pub fn git_issue(issue: &Issue) -> Self {
1428 let mut builder = Self::new(KIND_ISSUE, issue.content.clone());
1429 for tag in issue.to_tags() {
1430 builder = builder.tag(tag);
1431 }
1432 builder
1433 }
1434
1435 #[must_use]
1437 pub fn git_status(event: &StatusEvent) -> Self {
1438 let mut builder = Self::new(event.status.to_kind(), event.content.clone());
1439 for tag in event.to_tags() {
1440 builder = builder.tag(tag);
1441 }
1442 builder
1443 }
1444
1445 #[must_use]
1447 pub fn git_grasp_servers(list: &GraspServerList) -> Self {
1448 let mut builder = Self::new(KIND_GRASP_LIST, "");
1449 for tag in list.to_tags() {
1450 builder = builder.tag(tag);
1451 }
1452 builder
1453 }
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458 use super::*;
1459 use crate::Keys;
1460
1461 fn keys() -> Keys {
1462 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
1463 }
1464
1465 fn other_keys() -> Keys {
1466 Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
1467 }
1468
1469 fn repo_coord() -> Coordinate {
1470 Coordinate::new(KIND_REPO, *keys().public_key(), "ngit".to_owned())
1471 }
1472
1473 #[test]
1474 fn repository_round_trips_through_event() {
1475 let repo = Repository {
1476 identifier: "ngit".to_owned(),
1477 name: Some("ngit".to_owned()),
1478 description: Some("Nostr git-helper".to_owned()),
1479 web: vec!["https://ngit.dev".to_owned()],
1480 clone: vec!["https://github.com/x/ngit.git".to_owned()],
1481 relays: vec![RelayUrl::parse("wss://relay.ngit.dev").unwrap()],
1482 earliest_unique_commit: Some("deadbeef".to_owned()),
1483 maintainers: vec![*other_keys().public_key()],
1484 hashtags: vec!["git".to_owned(), "tooling".to_owned()],
1485 personal_fork: false,
1486 };
1487 let event = EventBuilder::git_repository(&repo)
1488 .sign_with_keys(&keys())
1489 .unwrap();
1490 assert_eq!(event.kind, KIND_REPO);
1491 let recovered = Repository::from_event(&event).unwrap();
1492 assert_eq!(recovered, repo);
1493 }
1494
1495 #[test]
1496 fn repository_personal_fork_flag_round_trips() {
1497 let repo = Repository {
1498 personal_fork: true,
1499 ..Repository::new("fork".to_owned())
1500 };
1501 let event = EventBuilder::git_repository(&repo)
1502 .sign_with_keys(&keys())
1503 .unwrap();
1504 let recovered = Repository::from_event(&event).unwrap();
1505 assert!(recovered.personal_fork);
1506 }
1507
1508 #[test]
1509 fn repository_from_event_requires_identifier() {
1510 let event = EventBuilder::new(KIND_REPO, "")
1511 .sign_with_keys(&keys())
1512 .unwrap();
1513 assert!(matches!(
1514 Repository::from_event(&event),
1515 Err(Nip34Error::MissingIdentifier { .. }),
1516 ));
1517 }
1518
1519 #[test]
1520 fn repository_state_round_trips_through_event() {
1521 let state = RepositoryState {
1522 identifier: "ngit".to_owned(),
1523 refs: vec![
1524 GitRef::new("refs/heads/main", "aabb"),
1525 GitRef::new("refs/tags/v1.0.0", "ccdd"),
1526 ],
1527 head: Some("ref: refs/heads/main".to_owned()),
1528 };
1529 let event = EventBuilder::git_repository_state(&state)
1530 .sign_with_keys(&keys())
1531 .unwrap();
1532 assert_eq!(event.kind, KIND_REPO_STATE);
1533 let recovered = RepositoryState::from_event(&event).unwrap();
1534 assert_eq!(recovered, state);
1535 }
1536
1537 #[test]
1538 fn patch_round_trips_through_event() {
1539 let patch = Patch {
1540 content: "From <git format-patch output>\n".to_owned(),
1541 repo: repo_coord(),
1542 repo_euc: Some("deadbeef".to_owned()),
1543 mentions: vec![*other_keys().public_key()],
1544 root: true,
1545 root_revision: false,
1546 commit: Some("abc123".to_owned()),
1547 parent_commit: Some("def456".to_owned()),
1548 commit_pgp_sig: Some("-----BEGIN PGP SIGNATURE-----\n...".to_owned()),
1549 committer: Some(Committer {
1550 name: "Satoshi".to_owned(),
1551 email: "satoshi@example".to_owned(),
1552 timestamp: "1700000000".to_owned(),
1553 offset: "+0000".to_owned(),
1554 }),
1555 };
1556 let event = EventBuilder::git_patch(&patch)
1557 .sign_with_keys(&keys())
1558 .unwrap();
1559 assert_eq!(event.kind, KIND_PATCH);
1560 let recovered = Patch::from_event(&event).unwrap();
1561 assert_eq!(recovered, patch);
1562 }
1563
1564 #[test]
1565 fn patch_from_event_requires_repository() {
1566 let event = EventBuilder::new(KIND_PATCH, "")
1567 .sign_with_keys(&keys())
1568 .unwrap();
1569 assert!(matches!(
1570 Patch::from_event(&event),
1571 Err(Nip34Error::MissingRepository { .. }),
1572 ));
1573 }
1574
1575 #[test]
1576 fn pull_request_round_trips_through_event() {
1577 let pr = PullRequest {
1578 content: "Adds awesome feature".to_owned(),
1579 repo: repo_coord(),
1580 repo_euc: Some("deadbeef".to_owned()),
1581 mentions: vec![*other_keys().public_key()],
1582 subject: Some("feat: awesome".to_owned()),
1583 hashtags: vec!["feature".to_owned()],
1584 tip_commit: Some("abc123".to_owned()),
1585 clone: vec!["https://github.com/x/ngit.git".to_owned()],
1586 branch_name: Some("feat/awesome".to_owned()),
1587 revises_event: Some(EventId::from_byte_array([0xaa; 32])),
1588 merge_base: Some("base999".to_owned()),
1589 };
1590 let event = EventBuilder::git_pull_request(&pr)
1591 .sign_with_keys(&keys())
1592 .unwrap();
1593 assert_eq!(event.kind, KIND_PULL_REQUEST);
1594 let recovered = PullRequest::from_event(&event).unwrap();
1595 assert_eq!(recovered, pr);
1596 }
1597
1598 #[test]
1599 fn pull_request_update_round_trips_through_event() {
1600 let update = PullRequestUpdate {
1601 content: "Updated tip".to_owned(),
1602 repo: repo_coord(),
1603 repo_euc: None,
1604 mentions: vec![],
1605 root_event: EventId::from_byte_array([0xbb; 32]),
1606 root_pubkey: *other_keys().public_key(),
1607 tip_commit: Some("def456".to_owned()),
1608 clone: vec!["https://github.com/x/ngit.git".to_owned()],
1609 merge_base: Some("base000".to_owned()),
1610 };
1611 let event = EventBuilder::git_pull_request_update(&update)
1612 .sign_with_keys(&keys())
1613 .unwrap();
1614 assert_eq!(event.kind, KIND_PULL_REQUEST_UPDATE);
1615 let recovered = PullRequestUpdate::from_event(&event).unwrap();
1616 assert_eq!(recovered, update);
1617 }
1618
1619 #[test]
1620 fn issue_round_trips_through_event() {
1621 let issue = Issue {
1622 content: "Bug body in Markdown".to_owned(),
1623 repo: repo_coord(),
1624 mentions: vec![*other_keys().public_key()],
1625 subject: Some("Crash on startup".to_owned()),
1626 hashtags: vec!["bug".to_owned(), "priority-high".to_owned()],
1627 };
1628 let event = EventBuilder::git_issue(&issue)
1629 .sign_with_keys(&keys())
1630 .unwrap();
1631 assert_eq!(event.kind, KIND_ISSUE);
1632 let recovered = Issue::from_event(&event).unwrap();
1633 assert_eq!(recovered, issue);
1634 }
1635
1636 #[test]
1637 fn status_round_trips_for_each_kind() {
1638 for status in [
1639 GitStatus::Open,
1640 GitStatus::Applied,
1641 GitStatus::Closed,
1642 GitStatus::Draft,
1643 ] {
1644 let event = StatusEvent {
1645 content: format!("status: {status:?}"),
1646 status,
1647 references: vec![StatusReference {
1648 event_id: EventId::from_byte_array([0xcc; 32]),
1649 marker: Some("root".to_owned()),
1650 }],
1651 mentions: vec![*other_keys().public_key()],
1652 repo: Some(repo_coord()),
1653 repo_euc: Some("deadbeef".to_owned()),
1654 quoted_patches: vec![],
1655 merge_commit: None,
1656 applied_as_commits: vec![],
1657 };
1658 let signed = EventBuilder::git_status(&event)
1659 .sign_with_keys(&keys())
1660 .unwrap();
1661 assert_eq!(signed.kind, status.to_kind());
1662 let recovered = StatusEvent::from_event(&signed).unwrap();
1663 assert_eq!(recovered, event);
1664 }
1665 }
1666
1667 #[test]
1668 fn status_applied_carries_merge_metadata() {
1669 let event = StatusEvent {
1670 content: String::new(),
1671 status: GitStatus::Applied,
1672 references: vec![],
1673 mentions: vec![],
1674 repo: None,
1675 repo_euc: None,
1676 quoted_patches: vec![EventId::from_byte_array([0xdd; 32])],
1677 merge_commit: Some("mergecommithex".to_owned()),
1678 applied_as_commits: vec!["a1".to_owned(), "a2".to_owned()],
1679 };
1680 let signed = EventBuilder::git_status(&event)
1681 .sign_with_keys(&keys())
1682 .unwrap();
1683 let recovered = StatusEvent::from_event(&signed).unwrap();
1684 assert_eq!(recovered.merge_commit.as_deref(), Some("mergecommithex"));
1685 assert_eq!(recovered.applied_as_commits, event.applied_as_commits);
1686 assert_eq!(recovered.quoted_patches, event.quoted_patches);
1687 }
1688
1689 #[test]
1690 fn status_from_event_rejects_kind_outside_range() {
1691 let event = EventBuilder::text_note("not a status")
1692 .sign_with_keys(&keys())
1693 .unwrap();
1694 assert!(matches!(
1695 StatusEvent::from_event(&event),
1696 Err(Nip34Error::InvalidStatusKind(_)),
1697 ));
1698 }
1699
1700 #[test]
1701 fn git_status_kind_helpers_round_trip() {
1702 for status in [
1703 GitStatus::Open,
1704 GitStatus::Applied,
1705 GitStatus::Closed,
1706 GitStatus::Draft,
1707 ] {
1708 assert_eq!(GitStatus::from_kind(status.to_kind()).unwrap(), status);
1709 }
1710 assert!(matches!(
1711 GitStatus::from_kind(Kind::TEXT_NOTE),
1712 Err(Nip34Error::InvalidStatusKind(_)),
1713 ));
1714 }
1715
1716 #[test]
1717 fn grasp_server_list_round_trips_through_event() {
1718 let list = GraspServerList::new([
1719 RelayUrl::parse("wss://grasp.one.example").unwrap(),
1720 RelayUrl::parse("wss://grasp.two.example").unwrap(),
1721 ]);
1722 let event = EventBuilder::git_grasp_servers(&list)
1723 .sign_with_keys(&keys())
1724 .unwrap();
1725 assert_eq!(event.kind, KIND_GRASP_LIST);
1726 let recovered = GraspServerList::from_event(&event).unwrap();
1727 assert_eq!(recovered, list);
1728 }
1729
1730 #[test]
1731 fn grasp_server_list_from_event_rejects_wrong_kind() {
1732 let event = EventBuilder::text_note("nope")
1733 .sign_with_keys(&keys())
1734 .unwrap();
1735 assert!(matches!(
1736 GraspServerList::from_event(&event),
1737 Err(Nip34Error::WrongKind { .. }),
1738 ));
1739 }
1740}