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::TrieStringInterner;
180    use octocrab::models::IssueState;
181
182    use crate::ui::testing::{DummyDataConfig, dummy_ui_data_with};
183
184    #[test]
185    fn trie_interner_reuses_existing_string_ids() {
186        let mut interner = TrieStringInterner::default();
187        let one = interner.intern("issue-123");
188        let two = interner.intern("issue-123");
189        assert_eq!(one, two);
190        assert_eq!(interner.resolve(one), "issue-123");
191    }
192
193    #[test]
194    fn trie_interner_handles_prefixes() {
195        let mut interner = TrieStringInterner::default();
196        let short = interner.intern("open");
197        let long = interner.intern("opened");
198        assert_ne!(short, long);
199        assert_eq!(interner.resolve(short), "open");
200        assert_eq!(interner.resolve(long), "opened");
201    }
202
203    #[test]
204    fn upsert_issue_reuses_existing_id() {
205        let mut data = dummy_ui_data_with(DummyDataConfig {
206            issue_count: 1,
207            author_count: 2,
208            comments_per_issue: 0,
209            timeline_events_per_issue: 0,
210            seed: 7,
211        });
212        let first_id = data.issue_ids[0];
213        let mut second = data.pool.get_issue(first_id).clone();
214        second.state = IssueState::Closed;
215        second.title = data.pool.intern_str("closed issue");
216        let second_id = data.pool.upsert_issue(second);
217
218        assert_eq!(first_id, second_id);
219        let stored = data.pool.get_issue(second_id);
220        assert_eq!(stored.state, IssueState::Closed);
221        assert_eq!(data.pool.resolve_str(stored.title), "closed issue");
222    }
223}
224
225#[derive(Debug)]
226pub struct UiIssuePool {
227    strings: TrieStringInterner,
228    authors: SlotMap<AuthorId, UiAuthor>,
229    author_by_github_id: HashMap<u64, AuthorId>,
230    issues: SlotMap<IssueId, UiIssue>,
231    issue_by_number: HashMap<u64, IssueId>,
232}
233
234impl Default for UiIssuePool {
235    fn default() -> Self {
236        Self {
237            strings: TrieStringInterner::default(),
238            authors: SlotMap::with_key(),
239            author_by_github_id: HashMap::new(),
240            issues: SlotMap::with_key(),
241            issue_by_number: HashMap::new(),
242        }
243    }
244}
245
246impl UiIssuePool {
247    pub fn intern_str(&mut self, value: &str) -> StrId {
248        self.strings.intern(value)
249    }
250
251    pub fn resolve_str(&self, id: StrId) -> &str {
252        self.strings.resolve(id)
253    }
254
255    pub fn resolve_opt_str(&self, id: Option<StrId>) -> Option<&str> {
256        id.map(|id| self.resolve_str(id))
257    }
258
259    pub fn intern_author(&mut self, author: &Author) -> AuthorId {
260        let github_id = author.id.0;
261        if let Some(existing) = self.author_by_github_id.get(&github_id).copied() {
262            return existing;
263        }
264
265        let login = self.intern_str(author.login.as_str());
266        let key = self.authors.insert(UiAuthor { github_id, login });
267        self.author_by_github_id.insert(github_id, key);
268        key
269    }
270
271    pub fn author_login(&self, author: AuthorId) -> &str {
272        let author = self
273            .authors
274            .get(author)
275            .expect("attempted to resolve an unknown author id");
276        self.resolve_str(author.login)
277    }
278
279    #[cfg(test)]
280    pub(crate) fn intern_test_author(&mut self, github_id: u64, login: &str) -> AuthorId {
281        if let Some(existing) = self.author_by_github_id.get(&github_id).copied() {
282            return existing;
283        }
284
285        let login = self.intern_str(login);
286        let key = self.authors.insert(UiAuthor { github_id, login });
287        self.author_by_github_id.insert(github_id, key);
288        key
289    }
290
291    pub fn insert_issue(&mut self, issue: UiIssue) -> IssueId {
292        self.upsert_issue(issue)
293    }
294
295    pub fn upsert_issue(&mut self, issue: UiIssue) -> IssueId {
296        if let Some(existing) = self.issue_by_number.get(&issue.number).copied()
297            && let Some(slot) = self.issues.get_mut(existing)
298        {
299            *slot = issue;
300            return existing;
301        }
302        let number = issue.number;
303        let issue_id = self.issues.insert(issue);
304        self.issue_by_number.insert(number, issue_id);
305        issue_id
306    }
307
308    pub fn get_issue(&self, issue_id: IssueId) -> &UiIssue {
309        self.issues
310            .get(issue_id)
311            .expect("attempted to resolve an unknown issue id")
312    }
313
314    pub fn get_issue_mut(&mut self, issue_id: IssueId) -> &mut UiIssue {
315        self.issues
316            .get_mut(issue_id)
317            .expect("attempted to resolve an unknown issue id")
318    }
319}