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#[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}