Skip to main content

gitv_tui/ui/
issue_data.rs

1use octocrab::models::{Author, IssueState, Label, issues::Issue};
2use slotmap::{SlotMap, new_key_type};
3use std::collections::HashMap;
4
5new_key_type! { pub struct AuthorId; }
6new_key_type! { pub struct IssueId; }
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub struct StrId(u32);
10
11#[derive(Debug, Clone)]
12pub struct UiAuthor {
13    pub github_id: u64,
14    pub login: StrId,
15}
16
17#[derive(Debug, Clone)]
18pub struct UiIssue {
19    pub number: u64,
20    pub state: IssueState,
21    pub title: StrId,
22    pub body: Option<StrId>,
23    pub author: AuthorId,
24    pub created_ts: i64,
25    pub created_at_short: StrId,
26    pub created_at_full: StrId,
27    pub updated_at_short: StrId,
28    pub comments: u32,
29    pub assignees: Vec<AuthorId>,
30    pub milestone: Option<StrId>,
31    pub is_pull_request: bool,
32    pub pull_request_url: Option<StrId>,
33    pub labels: Vec<Label>,
34}
35
36impl UiIssue {
37    pub fn from_octocrab(issue: &Issue, pool: &mut UiIssuePool) -> Self {
38        let created_at_short = issue.created_at.format("%Y-%m-%d %H:%M").to_string();
39        let created_at_full = issue.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
40        let updated_at_short = issue.updated_at.format("%Y-%m-%d %H:%M").to_string();
41        Self {
42            number: issue.number,
43            state: issue.state.clone(),
44            title: pool.intern_str(issue.title.as_str()),
45            body: issue.body.as_deref().map(|body| pool.intern_str(body)),
46            author: pool.intern_author(&issue.user),
47            created_ts: issue.created_at.timestamp(),
48            created_at_short: pool.intern_str(created_at_short.as_str()),
49            created_at_full: pool.intern_str(created_at_full.as_str()),
50            updated_at_short: pool.intern_str(updated_at_short.as_str()),
51            comments: issue.comments,
52            assignees: issue
53                .assignees
54                .iter()
55                .map(|assignee| pool.intern_author(assignee))
56                .collect(),
57            milestone: issue
58                .milestone
59                .as_ref()
60                .map(|milestone| pool.intern_str(milestone.title.as_str())),
61            is_pull_request: issue.pull_request.is_some(),
62            pull_request_url: issue
63                .pull_request
64                .as_ref()
65                .map(|pr| pool.intern_str(pr.html_url.as_str())),
66            labels: issue.labels.clone(),
67        }
68    }
69}
70
71#[derive(Debug, Clone, Copy)]
72struct Span {
73    start: u32,
74    end: u32,
75}
76
77#[derive(Debug, Clone, Copy)]
78struct Node {
79    str: Option<u32>,
80    first_link: u32,
81}
82
83#[derive(Debug, Clone, Copy)]
84struct Link {
85    byte: u8,
86    node: u32,
87}
88
89#[derive(Debug, Default)]
90struct Layer {
91    links: Vec<Link>,
92}
93
94/// Courtesy of this amazing article by
95/// [@matklad](https://matklad.github.io/2020/03/22/fast-simple-rust-interner.html)
96#[derive(Debug)]
97pub struct TrieStringInterner {
98    trie: Vec<Node>,
99    links: Vec<Layer>,
100    strs: Vec<Span>,
101    buf: String,
102}
103
104impl Default for TrieStringInterner {
105    fn default() -> Self {
106        Self {
107            trie: vec![Node {
108                str: None,
109                first_link: 0,
110            }],
111            links: vec![Layer::default()],
112            strs: Vec::new(),
113            buf: String::new(),
114        }
115    }
116}
117
118impl TrieStringInterner {
119    pub fn intern(&mut self, value: &str) -> StrId {
120        let mut node_idx = 0_u32;
121        for &byte in value.as_bytes() {
122            let next = self.find_or_insert_child(node_idx, byte);
123            node_idx = next;
124        }
125
126        let node = &mut self.trie[node_idx as usize];
127        if let Some(existing) = node.str {
128            return StrId(existing);
129        }
130
131        let start = u32::try_from(self.buf.len()).expect("interner buffer exceeded u32::MAX");
132        self.buf.push_str(value);
133        let end = u32::try_from(self.buf.len()).expect("interner buffer exceeded u32::MAX");
134
135        let id = u32::try_from(self.strs.len()).expect("interner string table exceeded u32::MAX");
136        self.strs.push(Span { start, end });
137        node.str = Some(id);
138        StrId(id)
139    }
140
141    pub fn resolve(&self, id: StrId) -> &str {
142        let span = self
143            .strs
144            .get(id.0 as usize)
145            .expect("attempted to resolve an unknown string id");
146        &self.buf[span.start as usize..span.end as usize]
147    }
148
149    fn alloc_node(&mut self) -> u32 {
150        let layer_idx =
151            u32::try_from(self.links.len()).expect("interner layer table exceeded u32::MAX");
152        self.links.push(Layer::default());
153        let node_idx = u32::try_from(self.trie.len()).expect("interner trie exceeded u32::MAX");
154        self.trie.push(Node {
155            str: None,
156            first_link: layer_idx,
157        });
158        node_idx
159    }
160
161    fn find_or_insert_child(&mut self, node_idx: u32, byte: u8) -> u32 {
162        let layer_idx = self.trie[node_idx as usize].first_link;
163        let layer = &mut self.links[layer_idx as usize];
164
165        match layer.links.binary_search_by_key(&byte, |link| link.byte) {
166            Ok(found) => layer.links[found].node,
167            Err(insert_at) => {
168                let child = self.alloc_node();
169                let layer = &mut self.links[layer_idx as usize];
170                layer.links.insert(insert_at, Link { byte, node: child });
171                child
172            }
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::{AuthorId, TrieStringInterner, UiAuthor, UiIssue, UiIssuePool};
180    use octocrab::models::IssueState;
181
182    #[test]
183    fn trie_interner_reuses_existing_string_ids() {
184        let mut interner = TrieStringInterner::default();
185        let one = interner.intern("issue-123");
186        let two = interner.intern("issue-123");
187        assert_eq!(one, two);
188        assert_eq!(interner.resolve(one), "issue-123");
189    }
190
191    #[test]
192    fn trie_interner_handles_prefixes() {
193        let mut interner = TrieStringInterner::default();
194        let short = interner.intern("open");
195        let long = interner.intern("opened");
196        assert_ne!(short, long);
197        assert_eq!(interner.resolve(short), "open");
198        assert_eq!(interner.resolve(long), "opened");
199    }
200
201    fn ensure_author(pool: &mut UiIssuePool) -> AuthorId {
202        if let Some(existing) = pool.author_by_github_id.get(&1).copied() {
203            return existing;
204        }
205        let login = pool.intern_str("octo");
206        let key = pool.authors.insert(UiAuthor {
207            github_id: 1,
208            login,
209        });
210        pool.author_by_github_id.insert(1, key);
211        key
212    }
213
214    fn make_issue(pool: &mut UiIssuePool, number: u64, title: &str, state: IssueState) -> UiIssue {
215        let author = ensure_author(pool);
216        let created_at_short = pool.intern_str("2024-01-01 00:00");
217        let created_at_full = pool.intern_str("2024-01-01 00:00:00");
218        let updated_at_short = pool.intern_str("2024-01-01 00:00");
219        UiIssue {
220            number,
221            state,
222            title: pool.intern_str(title),
223            body: Some(pool.intern_str("body")),
224            author,
225            created_ts: 0,
226            created_at_short,
227            created_at_full,
228            updated_at_short,
229            comments: 0,
230            assignees: Vec::new(),
231            milestone: None,
232            is_pull_request: false,
233            pull_request_url: None,
234            labels: Vec::new(),
235        }
236    }
237
238    #[test]
239    fn upsert_issue_reuses_existing_id() {
240        let mut pool = UiIssuePool::default();
241        let first = make_issue(&mut pool, 42, "open issue", IssueState::Open);
242        let first_id = pool.upsert_issue(first);
243        let second = make_issue(&mut pool, 42, "closed issue", IssueState::Closed);
244        let second_id = pool.upsert_issue(second);
245        assert_eq!(first_id, second_id);
246        let stored = pool.get_issue(second_id);
247        assert_eq!(stored.state, IssueState::Closed);
248        assert_eq!(pool.resolve_str(stored.title), "closed issue");
249    }
250}
251
252#[derive(Debug)]
253pub struct UiIssuePool {
254    strings: TrieStringInterner,
255    authors: SlotMap<AuthorId, UiAuthor>,
256    author_by_github_id: HashMap<u64, AuthorId>,
257    issues: SlotMap<IssueId, UiIssue>,
258    issue_by_number: HashMap<u64, IssueId>,
259}
260
261impl Default for UiIssuePool {
262    fn default() -> Self {
263        Self {
264            strings: TrieStringInterner::default(),
265            authors: SlotMap::with_key(),
266            author_by_github_id: HashMap::new(),
267            issues: SlotMap::with_key(),
268            issue_by_number: HashMap::new(),
269        }
270    }
271}
272
273impl UiIssuePool {
274    pub fn intern_str(&mut self, value: &str) -> StrId {
275        self.strings.intern(value)
276    }
277
278    pub fn resolve_str(&self, id: StrId) -> &str {
279        self.strings.resolve(id)
280    }
281
282    pub fn resolve_opt_str(&self, id: Option<StrId>) -> Option<&str> {
283        id.map(|id| self.resolve_str(id))
284    }
285
286    pub fn intern_author(&mut self, author: &Author) -> AuthorId {
287        let github_id = author.id.0;
288        if let Some(existing) = self.author_by_github_id.get(&github_id).copied() {
289            return existing;
290        }
291
292        let login = self.intern_str(author.login.as_str());
293        let key = self.authors.insert(UiAuthor { github_id, login });
294        self.author_by_github_id.insert(github_id, key);
295        key
296    }
297
298    pub fn author_login(&self, author: AuthorId) -> &str {
299        let author = self
300            .authors
301            .get(author)
302            .expect("attempted to resolve an unknown author id");
303        self.resolve_str(author.login)
304    }
305
306    pub fn insert_issue(&mut self, issue: UiIssue) -> IssueId {
307        self.upsert_issue(issue)
308    }
309
310    pub fn upsert_issue(&mut self, issue: UiIssue) -> IssueId {
311        if let Some(existing) = self.issue_by_number.get(&issue.number).copied()
312            && let Some(slot) = self.issues.get_mut(existing)
313        {
314            *slot = issue;
315            return existing;
316        }
317        let number = issue.number;
318        let issue_id = self.issues.insert(issue);
319        self.issue_by_number.insert(number, issue_id);
320        issue_id
321    }
322
323    pub fn get_issue(&self, issue_id: IssueId) -> &UiIssue {
324        self.issues
325            .get(issue_id)
326            .expect("attempted to resolve an unknown issue id")
327    }
328
329    pub fn get_issue_mut(&mut self, issue_id: IssueId) -> &mut UiIssue {
330        self.issues
331            .get_mut(issue_id)
332            .expect("attempted to resolve an unknown issue id")
333    }
334}