From 31256a4870ea631e981352988524a3056b12ec1f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 7 Oct 2026 16:46:29 +0700 Subject: [PATCH] chore: clean up core crate --- crates/signed_core/src/addr.rs | 1 - .../src/{model.rs => announcement.rs} | 439 +------------- crates/signed_core/src/deletions.rs | 10 +- crates/signed_core/src/filters.rs | 10 +- crates/signed_core/src/git_event.rs | 145 +++++ crates/signed_core/src/inbox.rs | 561 ------------------ crates/signed_core/src/inbox/group.rs | 172 ++++++ crates/signed_core/src/inbox/item.rs | 99 ++++ crates/signed_core/src/inbox/mod.rs | 9 + crates/signed_core/src/inbox/read_state.rs | 109 ++++ crates/signed_core/src/inbox/resolver.rs | 249 ++++++++ crates/signed_core/src/lib.rs | 10 +- crates/signed_core/src/pull_request.rs | 279 +++++++++ crates/signed_core/src/state.rs | 4 +- crates/signed_core/src/status.rs | 1 - crates/signed_core/src/upstream.rs | 33 ++ 16 files changed, 1138 insertions(+), 993 deletions(-) rename crates/signed_core/src/{model.rs => announcement.rs} (54%) create mode 100644 crates/signed_core/src/git_event.rs delete mode 100644 crates/signed_core/src/inbox.rs create mode 100644 crates/signed_core/src/inbox/group.rs create mode 100644 crates/signed_core/src/inbox/item.rs create mode 100644 crates/signed_core/src/inbox/mod.rs create mode 100644 crates/signed_core/src/inbox/read_state.rs create mode 100644 crates/signed_core/src/inbox/resolver.rs create mode 100644 crates/signed_core/src/pull_request.rs create mode 100644 crates/signed_core/src/upstream.rs diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs index a3d0194..3f63a16 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -55,7 +55,6 @@ impl RepoAddr { .identifier(self.identifier()) } - // Statuses may omit the `a` tag per NIP-34; those are not matched here. pub fn activity_filter(&self) -> Filter { Filter::new() .kinds(crate::filters::ACTIVITY_KINDS) diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/announcement.rs similarity index 54% rename from crates/signed_core/src/model.rs rename to crates/signed_core/src/announcement.rs index 8baafb4..5c65db0 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/announcement.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use nostr::prelude::*; use crate::RepoAddr; +use crate::upstream::Upstream; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Announcement { @@ -21,276 +22,8 @@ pub struct Announcement { pub hashtags: Vec, } -// The `u` tag of a fork announcement, per NIP-34. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Upstream { - pub raw: String, - // `None` for the git-URL form. - pub addr: Option, -} - -impl Upstream { - fn parse(raw: &str) -> Self { - let coordinate = raw.split('|').next().unwrap_or(raw); - let addr = coordinate - .parse::() - .ok() - .filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement) - .map(RepoAddr::from); - Self { - raw: raw.to_owned(), - addr, - } - } - - pub fn display(&self) -> String { - match &self.addr { - Some(addr) => addr.to_string(), - None => self.raw.clone(), - } - } -} - -pub trait GitEvent { - fn activity_subject(&self) -> String; - - fn current_commit(&self) -> Option; - - fn merge_base(&self) -> Option; - - fn clone_urls(&self) -> Option>; - - fn branch_name(&self) -> Option; - - fn is_git_activity(&self) -> bool; - - // Matches both NIP-10 lowercase `e` and NIP-22 uppercase `E` root pointers. - fn references_root(&self, root: &EventId) -> bool; -} - -impl GitEvent for Event { - fn activity_subject(&self) -> String { - let subject = self - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Subject(subject)) => Some(subject), - _ => None, - }); - - subject - .or_else(|| { - self.content - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .map(|value| value.to_string()) - }) - .unwrap_or("Untitled".to_string()) - } - - fn current_commit(&self) -> Option { - self.tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), - _ => None, - }) - } - - fn merge_base(&self) -> Option { - self.tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()), - _ => None, - }) - } - - fn clone_urls(&self) -> Option> { - self.tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Clone(urls)) => Some(urls), - _ => None, - }) - } - - fn branch_name(&self) -> Option { - self.tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::BranchName(name)) => Some(name), - _ => None, - }) - } - - fn is_git_activity(&self) -> bool { - match self.kind { - Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true, - Kind::Comment => crate::filters::is_git_comment(self), - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => crate::filters::is_git_status(self), - _ => false, - } - } - - fn references_root(&self, root: &EventId) -> bool { - let root = root.to_hex(); - self.tags - .iter() - .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) - } -} - -impl GitEvent for &T { - fn activity_subject(&self) -> String { - (*self).activity_subject() - } - - fn current_commit(&self) -> Option { - (*self).current_commit() - } - - fn merge_base(&self) -> Option { - (*self).merge_base() - } - - fn clone_urls(&self) -> Option> { - (*self).clone_urls() - } - - fn branch_name(&self) -> Option { - (*self).branch_name() - } - - fn is_git_activity(&self) -> bool { - (*self).is_git_activity() - } - - fn references_root(&self, root: &EventId) -> bool { - (*self).references_root(root) - } -} - -pub struct PullRequest<'a>(pub &'a Event); - -impl<'a> PullRequest<'a> { - pub fn new(event: &'a Event) -> Self { - Self(event) - } - - // The PR references its root patch via an `e` tag. - pub fn patches(&self, patches: impl IntoIterator) -> Vec<&'a Event> { - let pr = self.0; - let patches: Vec<&'a Event> = patches.into_iter().collect(); - - if let Some(root_id) = pr.tags.event_ids().next() - && let Some(root) = patches.iter().find(|patch| patch.id == root_id) - { - return Self::forward_series(root, &patches); - } - - let Some(tip) = pr.current_commit() else { - return Vec::new(); - }; - let Some(last) = patches - .iter() - .filter(|patch| Self::patch_produces_commit(patch, &tip)) - .max_by_key(|patch| patch.created_at) - .copied() - else { - return Vec::new(); - }; - - let mut series = vec![last]; - loop { - let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else { - break; - }; - let Some(prev) = patches - .iter() - .find(|patch| patch.id == prev_id && !series.contains(patch)) - .copied() - else { - break; - }; - series.push(prev); - } - series.reverse(); - series - } - - // Falls back to the root event's content when no patch set is found. - pub fn patch(&self, patches: impl IntoIterator) -> String { - let pr = self.0; - let patches: Vec<&'a Event> = patches.into_iter().collect(); - let series = self.patches(patches.iter().copied()); - if series.is_empty() { - return pr.content.clone(); - } - series - .iter() - .map(|patch| patch.content.as_str()) - .collect::>() - .join("\n") - } - - // A pull request's tip is only mutable by its author per NIP-34, - // updates from anyone else are ignored even if they are newer. - pub fn latest_update( - events: impl Iterator, - root: &Event, - ) -> Option<&'a Event> { - let root_hex = root.id.to_hex(); - events - .filter(|e| e.kind == Kind::GitPullRequestUpdate) - .filter(|e| e.pubkey == root.pubkey) - .filter(|e| { - e.tags - .iter() - .any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str())) - }) - .max_by_key(|e| e.created_at) - } - - fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { - let mut series = vec![root]; - loop { - let next = patches - .iter() - .filter(|patch| !series.contains(patch)) - .filter(|patch| { - patch - .tags - .event_ids() - .any(|id| id == series.last().unwrap().id) - }) - .max_by_key(|patch| patch.created_at); - let Some(next) = next else { - break; - }; - series.push(next); - } - series - } - - // Lets clients find existing patches for a specific commit. - fn patch_produces_commit(patch: &Event, commit: &str) -> bool { - patch - .tags - .iter() - .any(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit, - _ => false, - }) - } -} - impl Announcement { - // The user's own forks are listed first. + /// Lists forks of the base, the user's own first. pub fn forks_in<'a>( announcements: &'a [Announcement], base: &RepoAddr, @@ -311,9 +44,7 @@ impl Announcement { own.into_iter().chain(others).collect() } - /// Parse a kind `30617` event. - /// - /// Returns `None` when the kind is wrong or the `d` tag is missing. + /// Parses a kind 30617 event, rejecting other kinds and missing `d` tags. pub fn from_event(event: &Event) -> Option { if event.kind != Kind::GitRepoAnnouncement { return None; @@ -345,8 +76,6 @@ impl Announcement { _ => {} } - // The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually. - // Only the first `u` tag is used. if upstream.is_none() && tag.kind() == "u" { let values = tag.as_slice(); let raw = values.get(1).map(String::as_str).unwrap_or_default(); @@ -373,15 +102,17 @@ impl Announcement { }) } + /// Returns the owner and identifier as a repository address. pub fn addr(&self) -> RepoAddr { RepoAddr::new(self.owner, self.id.clone()) } + /// Returns the name, defaulting to "Untitled". pub fn name(&self) -> String { self.name.clone().unwrap_or("Untitled".into()) } - // The `u` tag pointing at `base` also covers permanent forks whose EUC diverged. + /// Matches a `u` tag pointing at the base or a shared earliest unique commit. pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool { if self.addr() == *base { return false; @@ -392,14 +123,14 @@ impl Announcement { base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc)) } + /// Returns the description, defaulting to "No description". pub fn description(&self) -> String { self.description .clone() .unwrap_or("No description".to_string()) } - // A `u` tag marking the repository as a subordinate fork excludes the - // announcement author from the maintainers, per NIP-34. + /// Adds the owner to the maintainers unless a `u` tag marks this a subordinate fork. pub fn effective_maintainers(&self) -> Vec { let mut maintainers = self.maintainers.clone(); if self.upstream.is_none() && !maintainers.contains(&self.owner) { @@ -408,6 +139,7 @@ impl Announcement { maintainers } + /// Returns deduplicated `git clone` commands for the advertised URLs. pub fn clone_urls(&self) -> Vec { let mut seen = HashSet::new(); self.clone @@ -424,6 +156,7 @@ mod tests { const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272"; + /// Returns the test signer keys. fn keys() -> Keys { Keys::new( SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") @@ -431,6 +164,7 @@ mod tests { ) } + /// Builds a signed repo announcement event from raw tags. fn announcement_event(tags: &[&[&str]]) -> Event { let tags: Vec = tags .iter() @@ -443,6 +177,7 @@ mod tests { .expect("signed event") } + /// Parses an event carrying every supported tag. #[test] fn parses_full_announcement() { let event = announcement_event(&[ @@ -490,6 +225,7 @@ mod tests { assert_eq!(announcement.hashtags, vec!["rust", "nostr"]); } + /// Skips malformed clone, relay, and maintainer values. #[test] fn drops_malformed_values() { let event = announcement_event(&[ @@ -509,6 +245,7 @@ mod tests { assert!(announcement.maintainers.is_empty()); } + /// Parses the `u` tag into an upstream coordinate. #[test] fn parses_upstream_tag() { let event = announcement_event(&[ @@ -540,6 +277,7 @@ mod tests { ); } + /// Matches a fork through its `u` tag coordinate. #[test] fn is_fork_of_matches_the_u_tag_coordinate() { let base = RepoAddr::new( @@ -552,6 +290,7 @@ mod tests { assert!(fork.is_fork_of(&base, None)); } + /// Matches forks sharing the base's earliest unique commit. #[test] fn is_fork_of_matches_a_shared_euc() { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; @@ -573,6 +312,7 @@ mod tests { assert!(!fork.is_fork_of(&base_addr, None)); } + /// Matches permanent forks whose `u` tag points at the base despite a diverged EUC. #[test] fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() { let base = RepoAddr::new( @@ -590,6 +330,7 @@ mod tests { assert!(fork.is_fork_of(&base, Some(base_euc))); } + /// Includes the owner among maintainers for primary repositories. #[test] fn effective_maintainers_include_owner_for_primary_repos() { let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]); @@ -602,6 +343,7 @@ mod tests { assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"))); } + /// Excludes the owner from maintainers for subordinate forks. #[test] fn effective_maintainers_exclude_owner_for_subordinate_forks() { let event = announcement_event(&[ @@ -620,151 +362,13 @@ mod tests { ); } - fn pr_event(content: &str, tags: Vec) -> Event { - EventBuilder::new(Kind::GitPullRequest, content) - .tags(tags) - .finalize(&keys()) - .expect("signed event") - } - - fn patch_event(content: &str, tags: Vec, created_at: u64) -> Event { - EventBuilder::new(Kind::GitPatch, content) - .tags(tags) - .custom_created_at(Timestamp::from(created_at)) - .finalize(&keys()) - .expect("signed event") - } - - #[test] - fn pull_request_patch_joins_the_whole_patch_set() { - let root = patch_event("patch-one", vec![], 100); - let second = patch_event("patch-two", vec![Tag::event(root.id)], 200); - let pr = pr_event("description", vec![Tag::event(root.id)]); - - assert_eq!( - PullRequest::new(&pr).patch([&root, &second]), - "patch-one\npatch-two" - ); - assert_eq!( - PullRequest::new(&pr).patches([&root, &second]), - vec![&root, &second] - ); - } - - #[test] - fn pull_request_patches_walks_the_reply_chain_in_order() { - let root = patch_event("patch-one", vec![], 100); - let second = patch_event("patch-two", vec![Tag::event(root.id)], 200); - let third = patch_event("patch-three", vec![Tag::event(second.id)], 300); - let pr = pr_event("description", vec![Tag::event(root.id)]); - - let series = PullRequest::new(&pr).patches([&third, &root, &second]); - assert_eq!( - series - .iter() - .map(|p| p.content.as_str()) - .collect::>(), - vec!["patch-one", "patch-two", "patch-three"] - ); - } - - #[test] - fn pull_request_patches_finds_the_set_via_the_tip_commit() { - let root = patch_event("patch-one", vec![], 100); - let tip = "1111111111111111111111111111111111111111"; - let last = patch_event( - "patch-two", - vec![ - Tag::event(root.id), - Tag::parse(["r", tip]).expect("valid tag"), - ], - 200, - ); - let pr = pr_event( - "description", - vec![Tag::parse(["c", tip]).expect("valid tag")], - ); - - let series = PullRequest::new(&pr).patches([&root, &last]); - assert_eq!( - series - .iter() - .map(|p| p.content.as_str()) - .collect::>(), - vec!["patch-one", "patch-two"] - ); - } - - const COMMIT_HEX: &str = "1111111111111111111111111111111111111111"; - const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222"; - - fn signed_at(kind: Kind, tags: Vec, created_at: u64) -> Event { - EventBuilder::new(kind, "") - .tags(tags) - .custom_created_at(Timestamp::from(created_at)) - .finalize(&keys()) - .expect("signed event") - } - - fn pr_root() -> Event { - signed_at( - Kind::GitPullRequest, - vec![ - Tag::parse(["c", COMMIT_HEX]).expect("valid tag"), - Tag::parse(["branch-name", "feature/x"]).expect("valid tag"), - ], - 100, - ) - } - - #[test] - fn latest_update_picks_newest_revision_of_the_root() { - let root = pr_root(); - let root_hex = root.id.to_hex(); - - let revision = |created_at: u64| { - signed_at( - Kind::GitPullRequestUpdate, - vec![Tag::parse(["E", &root_hex]).expect("valid tag")], - created_at, - ) - }; - let unrelated = signed_at( - Kind::GitPullRequestUpdate, - vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")], - 999, - ); - - let events = [unrelated, revision(200), root.clone(), revision(300)]; - let latest = PullRequest::latest_update(events.iter(), &root).expect("an update"); - - assert_eq!(latest.created_at.as_secs(), 300); - assert_eq!(latest.kind, Kind::GitPullRequestUpdate); - } - - #[test] - fn latest_update_ignores_other_authors() { - let root = pr_root(); - let root_hex = root.id.to_hex(); - let other = Keys::new( - SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002") - .expect("valid secret key"), - ); - let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "") - .tags([Tag::parse(["E", &root_hex]).expect("valid tag")]) - .custom_created_at(Timestamp::from(999)) - .finalize(&other) - .expect("signed event"); - - assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none()); - } - const OWNER_KEYS: [&str; 3] = [ "0000000000000000000000000000000000000000000000000000000000000001", "0000000000000000000000000000000000000000000000000000000000000002", "0000000000000000000000000000000000000000000000000000000000000003", ]; + /// Builds an announcement event signed by the given owner key. fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event { let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key")); let tags: Vec = tags @@ -777,6 +381,7 @@ mod tests { .expect("signed event") } + /// Builds one announcement owned by the owner key at the given index. fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec { vec![ Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags)) @@ -784,6 +389,7 @@ mod tests { ] } + /// Places the user's own forks before everyone else's. #[test] fn fork_candidates_orders_own_forks_first() { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; @@ -828,6 +434,7 @@ mod tests { assert_eq!(ids, vec!["my-fork", "their-fork"]); } + /// Excludes the base itself, unrelated repos, and repos without clone URLs. #[test] fn fork_candidates_excludes_base_unrelated_and_unfetchable() { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; diff --git a/crates/signed_core/src/deletions.rs b/crates/signed_core/src/deletions.rs index 7fca784..354077a 100644 --- a/crates/signed_core/src/deletions.rs +++ b/crates/signed_core/src/deletions.rs @@ -4,13 +4,16 @@ use nostr::prelude::*; pub struct Deletions { ids: HashSet<(EventId, PublicKey)>, - // All versions of the addressable event up to `cutoff` are deleted. coords: Vec<(Coordinate, PublicKey, Timestamp)>, vanished: Vec<(PublicKey, Timestamp)>, } impl Deletions { - pub fn from_events(events: impl IntoIterator) -> Self { + /// Builds a [`Deletions`] instance from a collection of events. + pub fn from_events(events: E) -> Self + where + E: IntoIterator, + { let mut ids = HashSet::new(); let mut coords = Vec::new(); let mut vanished = Vec::new(); @@ -38,8 +41,7 @@ impl Deletions { } } - // A request is valid when its author matches the deleted event's author, - // per NIP-09. Addressable events are deleted up to the request's `created_at`. + /// Returns whether the given event is deleted. pub fn is_deleted(&self, event: &Event) -> bool { if self .vanished diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 7e7e9a0..68b175d 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -81,7 +81,7 @@ impl Filters { Filter::new().kind(Kind::Custom(10018)).author(public_key) } - // Replaceable events, so the latest of each kind is all we need. + /// Replaceable events, so the latest of each kind is all we need. pub fn user_metadata(public_key: PublicKey) -> Vec { vec![ Self::grasp_list(public_key).limit(1), @@ -106,12 +106,13 @@ impl Filters { ] } - // Two filters: combining `#E` and `#e` would AND the conditions. pub fn comments_for(roots: impl IntoIterator) -> Vec { let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); + if roots.is_empty() { return Vec::new(); } + vec![ Filter::new() .kind(Kind::Comment) @@ -129,7 +130,6 @@ impl Filters { .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) } - // `Filter::pubkey` matches the git events' lowercase `p` tag. pub fn notifications(me: PublicKey) -> Vec { vec![ Self::notification_comments(me), @@ -137,8 +137,6 @@ impl Filters { ] } - // A comment on an unrelated kind matches too, so results must be filtered - // through `GitEvent::is_git_activity` before display. pub fn authored_activity(me: PublicKey) -> Filter { Filter::new().kinds(ACTIVITY_KINDS).author(me) } @@ -151,13 +149,11 @@ impl Filters { Filter::new().kind(Kind::RepoState) } - // Quantized to whole days so identical filters hash the same. fn deletions_since() -> Timestamp { let now = Timestamp::now().as_secs(); Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK } - // Deletion requests must be known before any other event is shown. pub fn deletions() -> Filter { Filter::new() .kinds([Kind::EventDeletion, Kind::RequestToVanish]) diff --git a/crates/signed_core/src/git_event.rs b/crates/signed_core/src/git_event.rs new file mode 100644 index 0000000..8d5faa3 --- /dev/null +++ b/crates/signed_core/src/git_event.rs @@ -0,0 +1,145 @@ +use nostr::prelude::*; + +pub trait GitEvent { + /// Returns the NIP-34 subject or the first non-empty content line. + fn activity_subject(&self) -> String; + + /// Returns the current commit id. + fn current_commit(&self) -> Option; + + /// Returns the merge base commit id. + fn merge_base(&self) -> Option; + + /// Returns the advertised clone URLs. + fn clone_urls(&self) -> Option>; + + /// Returns the branch name. + fn branch_name(&self) -> Option; + + /// Returns whether the event is git activity. + fn is_git_activity(&self) -> bool; + + /// Returns whether an `e` or `E` tag points at the root event. + fn references_root(&self, root: &EventId) -> bool; +} + +impl GitEvent for Event { + /// Reads the subject tag, then the first content line, then "Untitled". + fn activity_subject(&self) -> String { + let subject = self + .tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Subject(subject)) => Some(subject), + _ => None, + }); + + subject + .or_else(|| { + self.content + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|value| value.to_string()) + }) + .unwrap_or("Untitled".to_string()) + } + + /// Reads the `commit` tag. + fn current_commit(&self) -> Option { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), + _ => None, + }) + } + + /// Reads the `merge-base` tag. + fn merge_base(&self) -> Option { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()), + _ => None, + }) + } + + /// Reads the `clone` tag. + fn clone_urls(&self) -> Option> { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Clone(urls)) => Some(urls), + _ => None, + }) + } + + /// Reads the `branch-name` tag. + fn branch_name(&self) -> Option { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::BranchName(name)) => Some(name), + _ => None, + }) + } + + /// Classifies git issue, patch, pull request, comment, and status kinds. + fn is_git_activity(&self) -> bool { + match self.kind { + Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true, + Kind::Comment => crate::filters::is_git_comment(self), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => crate::filters::is_git_status(self), + _ => false, + } + } + + /// Compares `e` and `E` tag contents against the root id. + fn references_root(&self, root: &EventId) -> bool { + let root = root.to_hex(); + self.tags + .iter() + .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) + } +} + +impl GitEvent for &T { + /// Delegates to the referenced event. + fn activity_subject(&self) -> String { + (*self).activity_subject() + } + + /// Delegates to the referenced event. + fn current_commit(&self) -> Option { + (*self).current_commit() + } + + /// Delegates to the referenced event. + fn merge_base(&self) -> Option { + (*self).merge_base() + } + + /// Delegates to the referenced event. + fn clone_urls(&self) -> Option> { + (*self).clone_urls() + } + + /// Delegates to the referenced event. + fn branch_name(&self) -> Option { + (*self).branch_name() + } + + /// Delegates to the referenced event. + fn is_git_activity(&self) -> bool { + (*self).is_git_activity() + } + + /// Delegates to the referenced event. + fn references_root(&self, root: &EventId) -> bool { + (*self).references_root(root) + } +} diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs deleted file mode 100644 index d3365d7..0000000 --- a/crates/signed_core/src/inbox.rs +++ /dev/null @@ -1,561 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::time::Duration; - -use nostr::prelude::*; -use serde::{Deserialize, Serialize}; - -use crate::{GitEvent, RepoAddr}; - -const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60); -const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60); - -#[derive(Debug, Clone)] -pub struct InboxItem { - pub root: EventId, - pub root_event: Option, - pub address: Option, - pub events: Vec, - pub own_events: Vec, - pub unread_ids: Vec, - pub archived: bool, -} - -impl InboxItem { - pub fn title(&self) -> String { - self.root_event - .as_ref() - .or_else(|| self.own_events.first()) - .or_else(|| self.events.first()) - .map(|event| event.activity_subject()) - .unwrap_or_else(|| "Untitled".to_string()) - } - - pub fn kind(&self) -> Option { - self.root_event - .as_ref() - .or_else(|| self.own_events.first()) - .or_else(|| self.events.first()) - .map(|event| event.kind) - } - - pub fn latest_activity(&self) -> Timestamp { - self.root_event - .as_ref() - .into_iter() - .chain(self.own_events.first()) - .chain(self.events.first()) - .map(|event| event.created_at) - .max() - .unwrap_or_default() - } - - pub fn timeline(&self, limit: usize) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut events: Vec = Vec::new(); - - if let Some(root) = &self.root_event { - seen.insert(root.id); - events.push(root.clone()); - } - - let mut rest: Vec = self - .own_events - .iter() - .chain(self.events.iter()) - .filter(|event| seen.insert(event.id)) - .cloned() - .collect(); - - rest.sort_by(|a, b| { - b.created_at - .cmp(&a.created_at) - .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) - }); - rest.truncate(limit.saturating_sub(events.len())); - events.extend(rest); - - events.sort_by_key(|event| event.created_at); - events - } - - pub fn is_unread(&self) -> bool { - !self.archived && !self.unread_ids.is_empty() - } - - pub fn apply_state(&mut self, state: &InboxReadState) { - self.unread_ids = self - .events - .iter() - .rev() - .filter(|event| !state.is_read(event)) - .map(|event| event.id) - .collect(); - - // A thread without notification events is never archived. - self.archived = - !self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event)); - } -} - -pub struct ThreadResolver<'a, L: ?Sized> { - lookup: &'a L, -} - -impl<'a, L> ThreadResolver<'a, L> -where - L: Fn(EventId) -> Option + ?Sized, -{ - pub fn new(lookup: &'a L) -> Self { - Self { lookup } - } - - // Kind → root mapping: - // - issue (1621) / PR (1618): itself - // - patch (1617): its `e` parent patch, else itself - // - NIP-22 comment (1111): uppercase `E` root pointer - // - PR update (1619): uppercase `E` - // - statuses (1630-1633): NIP-10 root `e` - // Returns `None` when the event is not git-related, or when its root is a - // coordinate rather than an event. - pub fn notification_root(&self, event: &Event) -> Option { - match event.kind { - Kind::GitIssue | Kind::GitPullRequest => Some(event.id), - Kind::GitPatch => Some(match self.first_e_id(event) { - Some(parent) => self.resolve_thread_root(parent), - None => event.id, - }), - Kind::Comment => match nip22::extract_root(event) { - Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)), - _ => None, - }, - Kind::GitPullRequestUpdate => self - .first_uppercase_e_id(event) - .map(|root| self.resolve_thread_root(root)), - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => self - .nip10_root_id(event) - .map(|root| self.resolve_thread_root(root)), - _ => None, - } - } - - // Follow NIP-10/NIP-22 parent pointers until a root item is reached. - pub fn resolve_thread_root(&self, id: EventId) -> EventId { - let mut seen = HashSet::new(); - let mut root = id; - - loop { - if !seen.insert(root) { - return id; - } - - let Some(event) = (self.lookup)(root) else { - return root; - }; - - if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) { - return root; - } - - match self.parent_id(&event) { - Some(parent) => root = parent, - None => return root, - } - } - } - - // Mirrors gitworkshop's `getParentId`. - fn parent_id(&self, event: &Event) -> Option { - for marker in ["reply", "root"] { - if let Some(id) = event - .tags - .iter() - .find_map(|tag| self.e_tag_with_marker(tag, marker)) - { - return Some(id); - } - } - - if let Some(id) = event.tags.iter().find_map(|tag| { - if tag.kind() != "e" { - return None; - } - - let slice = tag.as_slice(); - let is_mention = slice.len() == 4 && slice[3] == "mention"; - - if is_mention { - return None; - } - - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - }) { - return Some(id); - } - - self.first_uppercase_e_id(event) - } - - fn nip10_root_id(&self, event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| self.e_tag_with_marker(tag, "root")) - .or_else(|| self.first_e_id(event)) - } - - fn first_e_id(&self, event: &Event) -> Option { - self.first_tag_id(event, "e") - } - - fn first_uppercase_e_id(&self, event: &Event) -> Option { - self.first_tag_id(event, "E") - } - - fn first_tag_id(&self, event: &Event, name: &str) -> Option { - event.tags.iter().find_map(|tag| { - if tag.kind() != name { - return None; - } - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - }) - } - - fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option { - let slice = tag.as_slice(); - if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker { - return None; - } - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - } -} - -pub fn group( - events: E, - own: O, - me: PublicKey, - state: &InboxReadState, - lookup: &L, -) -> Vec -where - E: IntoIterator, - O: IntoIterator, - L: Fn(EventId) -> Option, -{ - let resolver = ThreadResolver::new(lookup); - let mut groups: HashMap> = HashMap::new(); - for event in events { - if event.pubkey == me { - continue; - } - let Some(root) = resolver.notification_root(&event) else { - continue; - }; - groups.entry(root).or_default().push(event); - } - - let mut own_groups: HashMap> = HashMap::new(); - for event in own { - let root = resolver.notification_root(&event).unwrap_or(event.id); - own_groups.entry(root).or_default().push(event); - } - - let mut roots: Vec = groups.keys().chain(own_groups.keys()).copied().collect(); - roots.sort(); - roots.dedup(); - - let mut items: Vec = roots - .into_iter() - .map(|root| { - let mut events = groups.remove(&root).unwrap_or_default(); - let mut own_events = own_groups.remove(&root).unwrap_or_default(); - utils::sort_newest_first(&mut events); - utils::sort_newest_first(&mut own_events); - - let root_event = lookup(root); - - let mut item = InboxItem { - root, - address: root_event - .as_ref() - .and_then(|event| event.tags.coordinates().next()) - .map(RepoAddr::from), - root_event, - events, - own_events, - unread_ids: Vec::new(), - archived: false, - }; - item.apply_state(state); - item - }) - .collect(); - - items.sort_by(|a, b| { - b.latest_activity() - .cmp(&a.latest_activity()) - .then_with(|| b.root.to_hex().cmp(&a.root.to_hex())) - }); - - items -} - -// High-water-mark model: events at or before the cutoff -// are covered without an entry in the id set. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct InboxReadState { - #[serde(default)] - pub read_before: Timestamp, - #[serde(default)] - pub read_ids: HashSet, - #[serde(default)] - pub archived_before: Timestamp, - #[serde(default)] - pub archived_ids: HashSet, -} - -impl InboxReadState { - pub fn is_read(&self, event: &Event) -> bool { - event.created_at <= self.read_before || self.read_ids.contains(&event.id) - } - - pub fn is_archived(&self, event: &Event) -> bool { - event.created_at <= self.archived_before || self.archived_ids.contains(&event.id) - } - - pub fn mark_read(&mut self, event: &Event) { - if event.created_at > self.read_before { - self.read_ids.insert(event.id); - } - } - - pub fn mark_archived(&mut self, event: &Event) { - if event.created_at > self.archived_before { - self.archived_ids.insert(event.id); - } - } - - pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { - let cutoff = now - MARK_ALL_WINDOW; - self.read_before = cutoff; - self.read_ids = all - .iter() - .filter(|event| event.pubkey != me && event.created_at > cutoff) - .map(|event| event.id) - .collect(); - } - - // Advance the cutoff to the newest point that keeps unread events unread, - // then prune the id set. - pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { - let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event)); - self.read_before = cutoff; - prune_ids(&mut self.read_ids, all, cutoff); - } - - pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { - let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| { - self.is_archived(event) - }); - self.archived_before = cutoff; - prune_ids(&mut self.archived_ids, all, cutoff); - } -} - -fn advance_cutoff( - all: &[Event], - me: PublicKey, - now: Timestamp, - current: Timestamp, - is_marked: M, -) -> Timestamp -where - M: Fn(&Event) -> bool, -{ - let fallback = now - ADVANCE_WINDOW; - - let oldest = all - .iter() - .filter(|event| event.pubkey != me && !is_marked(event)) - .map(|event| event.created_at) - .min(); - - let candidate = match oldest { - Some(at) if at < fallback => at - 1, - _ => fallback, - }; - - candidate.max(current) -} - -fn prune_ids(ids: &mut HashSet, all: &[Event], cutoff: Timestamp) { - let created_at: HashMap = all - .iter() - .map(|event| (event.id, event.created_at)) - .collect(); - ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff)); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn keys(seed: u8) -> Keys { - let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string(); - hex.push_str(&format!("{seed:02x}")); - Keys::new(SecretKey::from_hex(&hex).expect("valid secret key")) - } - - fn signed(author: &Keys, kind: Kind, tags: Vec, created_at: u64) -> Event { - EventBuilder::new(kind, "") - .tags(tags) - .custom_created_at(Timestamp::from_secs(created_at)) - .finalize(author) - .expect("signed event") - } - - fn e_tag(event: &Event) -> Tag { - Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag") - } - - fn marked_e_tag(event: &Event, marker: &str) -> Tag { - Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker]) - .expect("valid e tag") - } - - fn uppercase_e_tag(event: &Event) -> Tag { - Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag") - } - - fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option + '_ { - move |id| events.iter().find(|event| event.id == id).cloned() - } - - fn issue(author: &Keys, at: u64) -> Event { - signed(author, Kind::GitIssue, Vec::new(), at) - } - - fn titled_issue(author: &Keys, title: &str, at: u64) -> Event { - signed( - author, - Kind::GitIssue, - vec![Tag::parse(["subject", title]).expect("valid subject tag")], - at, - ) - } - - #[test] - fn comment_resolves_to_its_uppercase_root() { - let issue = issue(&keys(1), 100); - let comment = signed( - &keys(2), - Kind::Comment, - vec![ - uppercase_e_tag(&issue), - Tag::parse(["K", "1621"]).expect("valid K tag"), - ], - 200, - ); - let events = [issue.clone(), comment.clone()]; - assert_eq!( - ThreadResolver::new(&lookup(&events)).notification_root(&comment), - Some(issue.id) - ); - } - - #[test] - fn child_patch_resolves_to_the_root_patch() { - let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100); - let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200); - let events = [root_patch.clone(), child_patch.clone()]; - assert_eq!( - ThreadResolver::new(&lookup(&events)).notification_root(&child_patch), - Some(root_patch.id) - ); - } - - #[test] - fn status_resolves_via_the_root_marker() { - let issue = issue(&keys(1), 100); - let status = signed( - &keys(2), - Kind::GitStatusClosed, - vec![marked_e_tag(&issue, "root")], - 200, - ); - let events = [issue.clone(), status.clone()]; - assert_eq!( - ThreadResolver::new(&lookup(&events)).notification_root(&status), - Some(issue.id) - ); - } - - #[test] - fn nested_comment_chain_follows_to_the_root() { - let issue = issue(&keys(1), 100); - let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200); - let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300); - let events = [issue.clone(), reply, nested.clone()]; - assert_eq!( - ThreadResolver::new(&lookup(&events)).notification_root(&nested), - Some(issue.id) - ); - } - - #[test] - fn group_merges_own_events_into_the_matching_thread() { - let me = keys(1); - let issue = titled_issue(&me, "Add retry logic", 100); - let mine = signed( - &me, - Kind::Comment, - vec![ - uppercase_e_tag(&issue), - Tag::parse(["K", "1621"]).expect("K tag"), - ], - 150, - ); - let reply = signed( - &keys(2), - Kind::Comment, - vec![ - uppercase_e_tag(&issue), - Tag::parse(["K", "1621"]).expect("K tag"), - ], - 200, - ); - - let context = [issue.clone(), mine.clone(), reply.clone()]; - let items = group( - [reply.clone()], - [issue.clone(), mine.clone()], - me.public_key(), - &InboxReadState::default(), - &lookup(&context), - ); - - assert_eq!(items.len(), 1); - assert_eq!(items[0].root, issue.id); - assert_eq!( - items[0].root_event.as_ref().map(|event| event.id), - Some(issue.id) - ); - assert_eq!(items[0].kind(), Some(Kind::GitIssue)); - assert_eq!(items[0].title(), "Add retry logic"); - assert_eq!(items[0].events, vec![reply.clone()]); - assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]); - assert_eq!( - items[0] - .timeline(5) - .iter() - .map(|event| event.id) - .collect::>(), - vec![issue.id, mine.id, reply.id] - ); - } -} diff --git a/crates/signed_core/src/inbox/group.rs b/crates/signed_core/src/inbox/group.rs new file mode 100644 index 0000000..8a1fb08 --- /dev/null +++ b/crates/signed_core/src/inbox/group.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; + +use nostr::prelude::*; +use utils::sort_newest_first; + +use super::{InboxItem, InboxReadState, ThreadResolver}; +use crate::RepoAddr; + +/// Groups events into inbox threads, merging the user's own events. +pub fn group( + events: E, + own: O, + me: PublicKey, + state: &InboxReadState, + lookup: &L, +) -> Vec +where + E: IntoIterator, + O: IntoIterator, + L: Fn(EventId) -> Option, +{ + let resolver = ThreadResolver::new(lookup); + let mut groups: HashMap> = HashMap::new(); + for event in events { + if event.pubkey == me { + continue; + } + let Some(root) = resolver.notification_root(&event) else { + continue; + }; + groups.entry(root).or_default().push(event); + } + + let mut own_groups: HashMap> = HashMap::new(); + for event in own { + let root = resolver.notification_root(&event).unwrap_or(event.id); + own_groups.entry(root).or_default().push(event); + } + + let mut roots: Vec = groups.keys().chain(own_groups.keys()).copied().collect(); + roots.sort(); + roots.dedup(); + + let mut items: Vec = roots + .into_iter() + .map(|root| { + let mut events = groups.remove(&root).unwrap_or_default(); + let mut own_events = own_groups.remove(&root).unwrap_or_default(); + sort_newest_first(&mut events); + sort_newest_first(&mut own_events); + + let root_event = lookup(root); + + let mut item = InboxItem { + root, + address: root_event + .as_ref() + .and_then(|event| event.tags.coordinates().next()) + .map(RepoAddr::from), + root_event, + events, + own_events, + unread_ids: Vec::new(), + archived: false, + }; + item.apply_state(state); + item + }) + .collect(); + + items.sort_by(|a, b| { + b.latest_activity() + .cmp(&a.latest_activity()) + .then_with(|| b.root.to_hex().cmp(&a.root.to_hex())) + }); + + items +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Derives deterministic signer keys from a seed byte. + fn keys(seed: u8) -> Keys { + let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string(); + hex.push_str(&format!("{seed:02x}")); + Keys::new(SecretKey::from_hex(&hex).expect("valid secret key")) + } + + /// Builds a signed event with a fixed timestamp. + fn signed(author: &Keys, kind: Kind, tags: Vec, created_at: u64) -> Event { + EventBuilder::new(kind, "") + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .finalize(author) + .expect("signed event") + } + + /// Builds an uppercase `E` tag for the event. + fn uppercase_e_tag(event: &Event) -> Tag { + Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag") + } + + /// Builds an event lookup closure over a slice. + fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option + '_ { + move |id| events.iter().find(|event| event.id == id).cloned() + } + + /// Builds a git issue event with a subject tag. + fn titled_issue(author: &Keys, title: &str, at: u64) -> Event { + signed( + author, + Kind::GitIssue, + vec![Tag::parse(["subject", title]).expect("valid subject tag")], + at, + ) + } + + /// Merges the user's own events into the matching thread. + #[test] + fn group_merges_own_events_into_the_matching_thread() { + let me = keys(1); + let issue = titled_issue(&me, "Add retry logic", 100); + let mine = signed( + &me, + Kind::Comment, + vec![ + uppercase_e_tag(&issue), + Tag::parse(["K", "1621"]).expect("K tag"), + ], + 150, + ); + let reply = signed( + &keys(2), + Kind::Comment, + vec![ + uppercase_e_tag(&issue), + Tag::parse(["K", "1621"]).expect("K tag"), + ], + 200, + ); + + let context = [issue.clone(), mine.clone(), reply.clone()]; + let items = group( + [reply.clone()], + [issue.clone(), mine.clone()], + me.public_key(), + &InboxReadState::default(), + &lookup(&context), + ); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].root, issue.id); + assert_eq!( + items[0].root_event.as_ref().map(|event| event.id), + Some(issue.id) + ); + assert_eq!(items[0].kind(), Some(Kind::GitIssue)); + assert_eq!(items[0].title(), "Add retry logic"); + assert_eq!(items[0].events, vec![reply.clone()]); + assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]); + assert_eq!( + items[0] + .timeline(5) + .iter() + .map(|event| event.id) + .collect::>(), + vec![issue.id, mine.id, reply.id] + ); + } +} diff --git a/crates/signed_core/src/inbox/item.rs b/crates/signed_core/src/inbox/item.rs new file mode 100644 index 0000000..d4a7d07 --- /dev/null +++ b/crates/signed_core/src/inbox/item.rs @@ -0,0 +1,99 @@ +use std::collections::HashSet; + +use nostr::prelude::*; + +use super::InboxReadState; +use crate::{GitEvent, RepoAddr}; + +#[derive(Debug, Clone)] +pub struct InboxItem { + pub root: EventId, + pub root_event: Option, + pub address: Option, + pub events: Vec, + pub own_events: Vec, + pub unread_ids: Vec, + pub archived: bool, +} + +impl InboxItem { + /// Returns the thread title from the root, own events, or events. + pub fn title(&self) -> String { + self.root_event + .as_ref() + .or_else(|| self.own_events.first()) + .or_else(|| self.events.first()) + .map(|event| event.activity_subject()) + .unwrap_or_else(|| "Untitled".to_string()) + } + + /// Returns the kind of the thread's primary event. + pub fn kind(&self) -> Option { + self.root_event + .as_ref() + .or_else(|| self.own_events.first()) + .or_else(|| self.events.first()) + .map(|event| event.kind) + } + + /// Returns the newest timestamp among the root, own, and other events. + pub fn latest_activity(&self) -> Timestamp { + self.root_event + .as_ref() + .into_iter() + .chain(self.own_events.first()) + .chain(self.events.first()) + .map(|event| event.created_at) + .max() + .unwrap_or_default() + } + + /// Builds a chronological timeline capped at `limit` events, root first. + pub fn timeline(&self, limit: usize) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut events: Vec = Vec::new(); + + if let Some(root) = &self.root_event { + seen.insert(root.id); + events.push(root.clone()); + } + + let mut rest: Vec = self + .own_events + .iter() + .chain(self.events.iter()) + .filter(|event| seen.insert(event.id)) + .cloned() + .collect(); + + rest.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.id.to_hex().cmp(&a.id.to_hex())) + }); + rest.truncate(limit.saturating_sub(events.len())); + events.extend(rest); + + events.sort_by_key(|event| event.created_at); + events + } + + /// Returns whether the thread has unread events and is not archived. + pub fn is_unread(&self) -> bool { + !self.archived && !self.unread_ids.is_empty() + } + + /// Recomputes unread ids and the archived flag from the read state. + pub fn apply_state(&mut self, state: &InboxReadState) { + self.unread_ids = self + .events + .iter() + .rev() + .filter(|event| !state.is_read(event)) + .map(|event| event.id) + .collect(); + + self.archived = + !self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event)); + } +} diff --git a/crates/signed_core/src/inbox/mod.rs b/crates/signed_core/src/inbox/mod.rs new file mode 100644 index 0000000..b78d5b7 --- /dev/null +++ b/crates/signed_core/src/inbox/mod.rs @@ -0,0 +1,9 @@ +mod group; +mod item; +mod read_state; +mod resolver; + +pub use group::group; +pub use item::InboxItem; +pub use read_state::InboxReadState; +pub use resolver::ThreadResolver; diff --git a/crates/signed_core/src/inbox/read_state.rs b/crates/signed_core/src/inbox/read_state.rs new file mode 100644 index 0000000..acb8546 --- /dev/null +++ b/crates/signed_core/src/inbox/read_state.rs @@ -0,0 +1,109 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use nostr::prelude::*; +use serde::{Deserialize, Serialize}; + +const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60); +const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60); + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct InboxReadState { + #[serde(default)] + pub read_before: Timestamp, + #[serde(default)] + pub read_ids: HashSet, + #[serde(default)] + pub archived_before: Timestamp, + #[serde(default)] + pub archived_ids: HashSet, +} + +impl InboxReadState { + /// Returns whether the event falls under the read high-water mark or id set. + pub fn is_read(&self, event: &Event) -> bool { + event.created_at <= self.read_before || self.read_ids.contains(&event.id) + } + + /// Returns whether the event falls under the archived high-water mark or id set. + pub fn is_archived(&self, event: &Event) -> bool { + event.created_at <= self.archived_before || self.archived_ids.contains(&event.id) + } + + /// Records the event as read when it is past the current cutoff. + pub fn mark_read(&mut self, event: &Event) { + if event.created_at > self.read_before { + self.read_ids.insert(event.id); + } + } + + /// Records the event as archived when it is past the current cutoff. + pub fn mark_archived(&mut self, event: &Event) { + if event.created_at > self.archived_before { + self.archived_ids.insert(event.id); + } + } + + /// Marks all events from other authors within the window as read. + pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { + let cutoff = now - MARK_ALL_WINDOW; + self.read_before = cutoff; + self.read_ids = all + .iter() + .filter(|event| event.pubkey != me && event.created_at > cutoff) + .map(|event| event.id) + .collect(); + } + + /// Advances the read cutoff as far as possible, then prunes the id set. + pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { + let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event)); + self.read_before = cutoff; + prune_ids(&mut self.read_ids, all, cutoff); + } + + /// Advances the archived cutoff as far as possible, then prunes the id set. + pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) { + let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| { + self.is_archived(event) + }); + self.archived_before = cutoff; + prune_ids(&mut self.archived_ids, all, cutoff); + } +} + +/// Computes the newest cutoff that leaves unmarked events uncovered. +fn advance_cutoff( + all: &[Event], + me: PublicKey, + now: Timestamp, + current: Timestamp, + is_marked: M, +) -> Timestamp +where + M: Fn(&Event) -> bool, +{ + let fallback = now - ADVANCE_WINDOW; + + let oldest = all + .iter() + .filter(|event| event.pubkey != me && !is_marked(event)) + .map(|event| event.created_at) + .min(); + + let candidate = match oldest { + Some(at) if at < fallback => at - 1, + _ => fallback, + }; + + candidate.max(current) +} + +/// Drops ids whose events predate the cutoff. +fn prune_ids(ids: &mut HashSet, all: &[Event], cutoff: Timestamp) { + let created_at: HashMap = all + .iter() + .map(|event| (event.id, event.created_at)) + .collect(); + ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff)); +} diff --git a/crates/signed_core/src/inbox/resolver.rs b/crates/signed_core/src/inbox/resolver.rs new file mode 100644 index 0000000..48e2918 --- /dev/null +++ b/crates/signed_core/src/inbox/resolver.rs @@ -0,0 +1,249 @@ +use std::collections::HashSet; + +use nostr::prelude::*; + +pub struct ThreadResolver<'a, L: ?Sized> { + lookup: &'a L, +} + +impl<'a, L> ThreadResolver<'a, L> +where + L: Fn(EventId) -> Option + ?Sized, +{ + /// Creates a resolver over the given event lookup. + pub fn new(lookup: &'a L) -> Self { + Self { lookup } + } + + /// Maps a git event kind to the thread root it notifies about. + pub fn notification_root(&self, event: &Event) -> Option { + match event.kind { + Kind::GitIssue | Kind::GitPullRequest => Some(event.id), + Kind::GitPatch => Some(match self.first_e_id(event) { + Some(parent) => self.resolve_thread_root(parent), + None => event.id, + }), + Kind::Comment => match nip22::extract_root(event) { + Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)), + _ => None, + }, + Kind::GitPullRequestUpdate => self + .first_uppercase_e_id(event) + .map(|root| self.resolve_thread_root(root)), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => self + .nip10_root_id(event) + .map(|root| self.resolve_thread_root(root)), + _ => None, + } + } + + /// Follows parent pointers until a git issue or pull request root is reached. + pub fn resolve_thread_root(&self, id: EventId) -> EventId { + let mut seen = HashSet::new(); + let mut root = id; + + loop { + if !seen.insert(root) { + return id; + } + + let Some(event) = (self.lookup)(root) else { + return root; + }; + + if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) { + return root; + } + + match self.parent_id(&event) { + Some(parent) => root = parent, + None => return root, + } + } + } + + /// Mirrors gitworkshop's `getParentId` parent resolution. + fn parent_id(&self, event: &Event) -> Option { + for marker in ["reply", "root"] { + if let Some(id) = event + .tags + .iter() + .find_map(|tag| self.e_tag_with_marker(tag, marker)) + { + return Some(id); + } + } + + if let Some(id) = event.tags.iter().find_map(|tag| { + if tag.kind() != "e" { + return None; + } + + let slice = tag.as_slice(); + let is_mention = slice.len() == 4 && slice[3] == "mention"; + + if is_mention { + return None; + } + + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) { + return Some(id); + } + + self.first_uppercase_e_id(event) + } + + /// Reads the NIP-10 `root` marker, falling back to the first `e` tag. + fn nip10_root_id(&self, event: &Event) -> Option { + event + .tags + .iter() + .find_map(|tag| self.e_tag_with_marker(tag, "root")) + .or_else(|| self.first_e_id(event)) + } + + /// Reads the first lowercase `e` tag id. + fn first_e_id(&self, event: &Event) -> Option { + self.first_tag_id(event, "e") + } + + /// Reads the first uppercase `E` tag id. + fn first_uppercase_e_id(&self, event: &Event) -> Option { + self.first_tag_id(event, "E") + } + + /// Reads the first event id from tags of the given kind. + fn first_tag_id(&self, event: &Event, name: &str) -> Option { + event.tags.iter().find_map(|tag| { + if tag.kind() != name { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) + } + + /// Parses a four-value `e` tag carrying the given marker. + fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option { + let slice = tag.as_slice(); + if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Derives deterministic signer keys from a seed byte. + fn keys(seed: u8) -> Keys { + let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string(); + hex.push_str(&format!("{seed:02x}")); + Keys::new(SecretKey::from_hex(&hex).expect("valid secret key")) + } + + /// Builds a signed event with a fixed timestamp. + fn signed(author: &Keys, kind: Kind, tags: Vec, created_at: u64) -> Event { + EventBuilder::new(kind, "") + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .finalize(author) + .expect("signed event") + } + + /// Builds a plain lowercase `e` tag for the event. + fn e_tag(event: &Event) -> Tag { + Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag") + } + + /// Builds a four-value `e` tag with the given marker. + fn marked_e_tag(event: &Event, marker: &str) -> Tag { + Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker]) + .expect("valid e tag") + } + + /// Builds an uppercase `E` tag for the event. + fn uppercase_e_tag(event: &Event) -> Tag { + Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag") + } + + /// Builds an event lookup closure over a slice. + fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option + '_ { + move |id| events.iter().find(|event| event.id == id).cloned() + } + + /// Builds a git issue event. + fn issue(author: &Keys, at: u64) -> Event { + signed(author, Kind::GitIssue, Vec::new(), at) + } + + /// Resolves a comment to its uppercase `E` root. + #[test] + fn comment_resolves_to_its_uppercase_root() { + let issue = issue(&keys(1), 100); + let comment = signed( + &keys(2), + Kind::Comment, + vec![ + uppercase_e_tag(&issue), + Tag::parse(["K", "1621"]).expect("valid K tag"), + ], + 200, + ); + let events = [issue.clone(), comment.clone()]; + assert_eq!( + ThreadResolver::new(&lookup(&events)).notification_root(&comment), + Some(issue.id) + ); + } + + /// Resolves a child patch to its root patch. + #[test] + fn child_patch_resolves_to_the_root_patch() { + let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100); + let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200); + let events = [root_patch.clone(), child_patch.clone()]; + assert_eq!( + ThreadResolver::new(&lookup(&events)).notification_root(&child_patch), + Some(root_patch.id) + ); + } + + /// Resolves a status through its `root` marker tag. + #[test] + fn status_resolves_via_the_root_marker() { + let issue = issue(&keys(1), 100); + let status = signed( + &keys(2), + Kind::GitStatusClosed, + vec![marked_e_tag(&issue, "root")], + 200, + ); + let events = [issue.clone(), status.clone()]; + assert_eq!( + ThreadResolver::new(&lookup(&events)).notification_root(&status), + Some(issue.id) + ); + } + + /// Follows a nested comment chain to the issue root. + #[test] + fn nested_comment_chain_follows_to_the_root() { + let issue = issue(&keys(1), 100); + let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200); + let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300); + let events = [issue.clone(), reply, nested.clone()]; + assert_eq!( + ThreadResolver::new(&lookup(&events)).notification_root(&nested), + Some(issue.id) + ); + } +} diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 328e3fc..66e6812 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -1,15 +1,21 @@ pub mod addr; +pub mod announcement; pub mod deletions; pub mod filters; +pub mod git_event; pub mod inbox; -pub mod model; +pub mod pull_request; pub mod state; pub mod status; +pub mod upstream; pub use addr::RepoAddr; +pub use announcement::Announcement; pub use deletions::Deletions; pub use filters::Filters; +pub use git_event::GitEvent; pub use inbox::{InboxItem, InboxReadState, ThreadResolver}; -pub use model::{Announcement, GitEvent, PullRequest}; +pub use pull_request::PullRequest; pub use state::RepoState; pub use status::RepoStatus; +pub use upstream::Upstream; diff --git a/crates/signed_core/src/pull_request.rs b/crates/signed_core/src/pull_request.rs new file mode 100644 index 0000000..88ea4c9 --- /dev/null +++ b/crates/signed_core/src/pull_request.rs @@ -0,0 +1,279 @@ +use nostr::prelude::*; + +use crate::git_event::GitEvent; + +pub struct PullRequest<'a>(pub &'a Event); + +impl<'a> PullRequest<'a> { + /// Wraps a pull request root event. + pub fn new(event: &'a Event) -> Self { + Self(event) + } + + /// Returns the ordered patch series, found via the root reference or the tip commit. + pub fn patches(&self, patches: impl IntoIterator) -> Vec<&'a Event> { + let pr = self.0; + let patches: Vec<&'a Event> = patches.into_iter().collect(); + + if let Some(root_id) = pr.tags.event_ids().next() + && let Some(root) = patches.iter().find(|patch| patch.id == root_id) + { + return Self::forward_series(root, &patches); + } + + let Some(tip) = pr.current_commit() else { + return Vec::new(); + }; + let Some(last) = patches + .iter() + .filter(|patch| Self::patch_produces_commit(patch, &tip)) + .max_by_key(|patch| patch.created_at) + .copied() + else { + return Vec::new(); + }; + + let mut series = vec![last]; + loop { + let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else { + break; + }; + let Some(prev) = patches + .iter() + .find(|patch| patch.id == prev_id && !series.contains(patch)) + .copied() + else { + break; + }; + series.push(prev); + } + series.reverse(); + series + } + + /// Joins the patch series with newlines, falling back to the event content. + pub fn patch(&self, patches: impl IntoIterator) -> String { + let pr = self.0; + let patches: Vec<&'a Event> = patches.into_iter().collect(); + let series = self.patches(patches.iter().copied()); + if series.is_empty() { + return pr.content.clone(); + } + series + .iter() + .map(|patch| patch.content.as_str()) + .collect::>() + .join("\n") + } + + /// Returns the root author's newest update, since only the author may move the tip. + pub fn latest_update( + events: impl Iterator, + root: &Event, + ) -> Option<&'a Event> { + let root_hex = root.id.to_hex(); + events + .filter(|e| e.kind == Kind::GitPullRequestUpdate) + .filter(|e| e.pubkey == root.pubkey) + .filter(|e| { + e.tags + .iter() + .any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str())) + }) + .max_by_key(|e| e.created_at) + } + + /// Walks reply links outward from the root patch, taking the newest child first. + fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { + let mut series = vec![root]; + loop { + let next = patches + .iter() + .filter(|patch| !series.contains(patch)) + .filter(|patch| { + patch + .tags + .event_ids() + .any(|id| id == series.last().unwrap().id) + }) + .max_by_key(|patch| patch.created_at); + let Some(next) = next else { + break; + }; + series.push(next); + } + series + } + + /// Checks the commit against the patch's `c` and `r` tags. + fn patch_produces_commit(patch: &Event, commit: &str) -> bool { + patch + .tags + .iter() + .any(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit, + _ => false, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Returns the test signer keys. + fn keys() -> Keys { + Keys::new( + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .expect("valid secret key"), + ) + } + + /// Builds a signed pull request event. + fn pr_event(content: &str, tags: Vec) -> Event { + EventBuilder::new(Kind::GitPullRequest, content) + .tags(tags) + .finalize(&keys()) + .expect("signed event") + } + + /// Builds a signed patch event with a fixed timestamp. + fn patch_event(content: &str, tags: Vec, created_at: u64) -> Event { + EventBuilder::new(Kind::GitPatch, content) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .finalize(&keys()) + .expect("signed event") + } + + /// Joins the full patch series into one diff. + #[test] + fn pull_request_patch_joins_the_whole_patch_set() { + let root = patch_event("patch-one", vec![], 100); + let second = patch_event("patch-two", vec![Tag::event(root.id)], 200); + let pr = pr_event("description", vec![Tag::event(root.id)]); + + assert_eq!( + PullRequest::new(&pr).patch([&root, &second]), + "patch-one\npatch-two" + ); + assert_eq!( + PullRequest::new(&pr).patches([&root, &second]), + vec![&root, &second] + ); + } + + /// Orders patches by walking the reply chain. + #[test] + fn pull_request_patches_walks_the_reply_chain_in_order() { + let root = patch_event("patch-one", vec![], 100); + let second = patch_event("patch-two", vec![Tag::event(root.id)], 200); + let third = patch_event("patch-three", vec![Tag::event(second.id)], 300); + let pr = pr_event("description", vec![Tag::event(root.id)]); + + let series = PullRequest::new(&pr).patches([&third, &root, &second]); + assert_eq!( + series + .iter() + .map(|p| p.content.as_str()) + .collect::>(), + vec!["patch-one", "patch-two", "patch-three"] + ); + } + + /// Finds the patch series through the declared tip commit. + #[test] + fn pull_request_patches_finds_the_set_via_the_tip_commit() { + let root = patch_event("patch-one", vec![], 100); + let tip = "1111111111111111111111111111111111111111"; + let last = patch_event( + "patch-two", + vec![ + Tag::event(root.id), + Tag::parse(["r", tip]).expect("valid tag"), + ], + 200, + ); + let pr = pr_event( + "description", + vec![Tag::parse(["c", tip]).expect("valid tag")], + ); + + let series = PullRequest::new(&pr).patches([&root, &last]); + assert_eq!( + series + .iter() + .map(|p| p.content.as_str()) + .collect::>(), + vec!["patch-one", "patch-two"] + ); + } + + const COMMIT_HEX: &str = "1111111111111111111111111111111111111111"; + const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222"; + + /// Builds a signed event with a fixed timestamp. + fn signed_at(kind: Kind, tags: Vec, created_at: u64) -> Event { + EventBuilder::new(kind, "") + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .finalize(&keys()) + .expect("signed event") + } + + /// Builds the pull request root used by the update tests. + fn pr_root() -> Event { + signed_at( + Kind::GitPullRequest, + vec![ + Tag::parse(["c", COMMIT_HEX]).expect("valid tag"), + Tag::parse(["branch-name", "feature/x"]).expect("valid tag"), + ], + 100, + ) + } + + /// Picks the newest update targeting the root. + #[test] + fn latest_update_picks_newest_revision_of_the_root() { + let root = pr_root(); + let root_hex = root.id.to_hex(); + + let revision = |created_at: u64| { + signed_at( + Kind::GitPullRequestUpdate, + vec![Tag::parse(["E", &root_hex]).expect("valid tag")], + created_at, + ) + }; + let unrelated = signed_at( + Kind::GitPullRequestUpdate, + vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")], + 999, + ); + + let events = [unrelated, revision(200), root.clone(), revision(300)]; + let latest = PullRequest::latest_update(events.iter(), &root).expect("an update"); + + assert_eq!(latest.created_at.as_secs(), 300); + assert_eq!(latest.kind, Kind::GitPullRequestUpdate); + } + + /// Ignores updates from authors other than the root owner. + #[test] + fn latest_update_ignores_other_authors() { + let root = pr_root(); + let root_hex = root.id.to_hex(); + let other = Keys::new( + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002") + .expect("valid secret key"), + ); + let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "") + .tags([Tag::parse(["E", &root_hex]).expect("valid tag")]) + .custom_created_at(Timestamp::from(999)) + .finalize(&other) + .expect("signed event"); + + assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none()); + } +} diff --git a/crates/signed_core/src/state.rs b/crates/signed_core/src/state.rs index 16f444b..f7af5a2 100644 --- a/crates/signed_core/src/state.rs +++ b/crates/signed_core/src/state.rs @@ -7,17 +7,19 @@ pub struct RepoState { } impl RepoState { - // The `d` tag matches the repository id. pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { let mut tags: Vec = vec![Tag::identifier(id.to_owned())]; + for (name, commit) in refs { tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag")); } + if let Some(head) = head { tags.push( Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"), ); } + EventBuilder::new(Kind::RepoState, "").tags(tags) } diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 17f73d9..2bb2f18 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -28,7 +28,6 @@ impl RepoStatus { } } - // Defaults to Open when no authorized status event exists. pub fn resolve<'a, I>( status_events: I, root_author: &PublicKey, diff --git a/crates/signed_core/src/upstream.rs b/crates/signed_core/src/upstream.rs new file mode 100644 index 0000000..df0e2c5 --- /dev/null +++ b/crates/signed_core/src/upstream.rs @@ -0,0 +1,33 @@ +use nostr::prelude::*; + +use crate::RepoAddr; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Upstream { + pub raw: String, + pub addr: Option, +} + +impl Upstream { + /// Parses a raw `u` tag value, extracting the coordinate when present. + pub(crate) fn parse(raw: &str) -> Self { + let coordinate = raw.split('|').next().unwrap_or(raw); + let addr = coordinate + .parse::() + .ok() + .filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement) + .map(RepoAddr::from); + Self { + raw: raw.to_owned(), + addr, + } + } + + /// Shows the coordinate when parsed, otherwise the raw value. + pub fn display(&self) -> String { + match &self.addr { + Some(addr) => addr.to_string(), + None => self.raw.clone(), + } + } +}