1use crate::history::{Category, Group, NormalizedEvent, Outcome};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Row {
18 Group {
20 group: usize,
22 expanded: bool,
23 },
24 Event {
26 group: usize,
27 event: usize,
29 },
30}
31
32pub struct Outline {
34 events: Vec<NormalizedEvent>,
35 groups: Vec<Group>,
36 expanded: Vec<bool>,
38 visible: Vec<usize>,
40 offsets: Vec<usize>,
43 show_plumbing: bool,
46}
47
48impl Outline {
49 pub fn new(events: Vec<NormalizedEvent>, groups: Vec<Group>) -> Self {
50 let expanded = vec![false; groups.len()];
51 let mut o = Self {
52 events,
53 groups,
54 expanded,
55 visible: Vec::new(),
56 offsets: Vec::new(),
57 show_plumbing: false,
58 };
59 o.reindex();
60 o
61 }
62
63 pub fn replace(&mut self, events: Vec<NormalizedEvent>, groups: Vec<Group>) {
71 self.expanded.resize(groups.len(), false);
72 self.expanded.truncate(groups.len());
73 self.events = events;
74 self.groups = groups;
75 self.reindex();
76 }
77
78 pub fn groups(&self) -> &[Group] {
79 &self.groups
80 }
81
82 pub fn events(&self) -> &[NormalizedEvent] {
83 &self.events
84 }
85
86 pub fn show_plumbing(&self) -> bool {
87 self.show_plumbing
88 }
89
90 pub fn len(&self) -> usize {
92 self.offsets.last().copied().unwrap_or(0)
93 }
94
95 pub fn is_empty(&self) -> bool {
96 self.len() == 0
97 }
98
99 pub fn row_at(&self, row: usize) -> Option<Row> {
101 if row >= self.len() {
102 return None;
103 }
104 let slot = match self.offsets.binary_search(&row) {
106 Ok(i) => i,
107 Err(i) => i - 1,
108 };
109 let group = self.visible[slot];
110 let local = row - self.offsets[slot];
111 Some(if local == 0 {
112 Row::Group {
113 group,
114 expanded: self.expanded[group],
115 }
116 } else {
117 let event_id = self.groups[group].events[local - 1];
118 Row::Event {
119 group,
120 event: self.event_index(event_id).unwrap_or(0),
121 }
122 })
123 }
124
125 pub fn slice(&self, first: usize, count: usize) -> Vec<Row> {
127 (first..first.saturating_add(count))
128 .map_while(|r| self.row_at(r))
129 .collect()
130 }
131
132 pub fn group(&self, index: usize) -> Option<&Group> {
133 self.groups.get(index)
134 }
135
136 pub fn event(&self, index: usize) -> Option<&NormalizedEvent> {
137 self.events.get(index)
138 }
139
140 pub fn toggle(&mut self, group: usize) -> Option<usize> {
143 *self.expanded.get_mut(group)? = !self.expanded[group];
144 self.reindex();
145 self.row_of_group(group)
146 }
147
148 pub fn is_expanded(&self, group: usize) -> bool {
149 self.expanded.get(group).copied().unwrap_or(false)
150 }
151
152 pub fn expand_all(&mut self) {
153 self.expanded.iter_mut().for_each(|e| *e = true);
154 self.reindex();
155 }
156
157 pub fn collapse_all(&mut self) {
158 self.expanded.iter_mut().for_each(|e| *e = false);
159 self.reindex();
160 }
161
162 pub fn set_show_plumbing(&mut self, show: bool) {
164 self.show_plumbing = show;
165 self.reindex();
166 }
167
168 pub fn row_of_group(&self, group: usize) -> Option<usize> {
170 let slot = self.visible.iter().position(|g| *g == group)?;
171 Some(self.offsets[slot])
172 }
173
174 pub fn next_failure(&self, from: usize) -> Option<usize> {
178 self.visible
179 .iter()
180 .copied()
181 .filter(|g| self.groups[*g].outcome.is_failure())
182 .find(|g| self.row_of_group(*g).is_some_and(|r| r > from))
183 .and_then(|g| self.row_of_group(g))
184 }
185
186 pub fn prev_failure(&self, from: usize) -> Option<usize> {
188 self.visible
189 .iter()
190 .copied()
191 .filter(|g| self.groups[*g].outcome.is_failure())
192 .filter_map(|g| self.row_of_group(g))
193 .rfind(|r| *r < from)
194 }
195
196 fn reindex(&mut self) {
199 self.visible.clear();
200 self.offsets.clear();
201
202 let mut total = 0usize;
203 for (i, g) in self.groups.iter().enumerate() {
204 if !self.show_plumbing && g.category.is_plumbing() {
205 continue;
206 }
207 self.visible.push(i);
208 self.offsets.push(total);
209 total += 1 + if self.expanded[i] { g.events.len() } else { 0 };
210 }
211 self.offsets.push(total);
213 }
214
215 fn event_index(&self, id: i64) -> Option<usize> {
217 self.events.binary_search_by_key(&id, |e| e.id).ok()
218 }
219}
220
221pub fn summarize(groups: &[Group]) -> Summary {
223 let mut s = Summary::default();
224 for g in groups {
225 match g.category {
226 Category::Activity => s.activities += 1,
227 Category::Timer => s.timers += 1,
228 Category::ChildWorkflow => s.children += 1,
229 Category::WorkflowTask
230 | Category::Workflow
231 | Category::ExternalWorkflow
232 | Category::Update
233 | Category::Nexus
234 | Category::Marker
235 | Category::SearchAttributes => {}
236 }
237 if g.outcome.is_failure() {
238 s.failures += 1;
239 }
240 if g.is_open() && g.category != Category::Workflow && !g.category.is_plumbing() {
243 s.in_flight += 1;
244 }
245 if g.category == Category::Workflow {
246 s.outcome = g.outcome;
247 }
248 }
249 s
250}
251
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
253pub struct Summary {
254 pub activities: usize,
255 pub timers: usize,
256 pub children: usize,
257 pub failures: usize,
258 pub in_flight: usize,
260 pub outcome: Outcome,
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::history::{GroupRef, Role, group_events};
268
269 fn ev(id: i64, group: GroupRef, role: Role, cat: Category) -> NormalizedEvent {
270 NormalizedEvent::new(id, "E", cat, group, role).with_time(Some(id * 10))
271 }
272
273 fn outline() -> Outline {
275 let events = vec![
276 ev(1, GroupRef::Workflow, Role::Opens, Category::Workflow).with_subject("Order"),
277 ev(2, GroupRef::Opened(2), Role::Opens, Category::WorkflowTask),
278 ev(3, GroupRef::Opened(2), Role::Closes, Category::WorkflowTask),
279 ev(4, GroupRef::Opened(4), Role::Opens, Category::Activity).with_subject("Charge"),
280 ev(5, GroupRef::Opened(4), Role::Continues, Category::Activity),
281 ev(6, GroupRef::Opened(4), Role::Closes, Category::Activity)
282 .with_outcome(Outcome::Completed),
283 ev(7, GroupRef::Opened(7), Role::Opens, Category::Activity).with_subject("Ship"),
284 ev(8, GroupRef::Opened(7), Role::Closes, Category::Activity)
285 .with_outcome(Outcome::Failed),
286 ];
287 let groups = group_events(&events);
288 Outline::new(events, groups)
289 }
290
291 #[test]
292 fn collapsed_rows_are_one_per_group_with_plumbing_hidden() {
293 let o = outline();
294 assert_eq!(o.len(), 3);
296 assert_eq!(
297 o.row_at(0),
298 Some(Row::Group {
299 group: 0,
300 expanded: false
301 })
302 );
303 assert_eq!(o.row_at(3), None, "past the end");
304 }
305
306 #[test]
307 fn showing_plumbing_adds_the_workflow_task_group() {
308 let mut o = outline();
309 o.set_show_plumbing(true);
310 assert_eq!(o.len(), 4);
311 assert!(o.show_plumbing());
312 }
313
314 #[test]
315 fn expanding_a_group_inserts_exactly_its_events() {
316 let mut o = outline();
317 let before = o.len();
318
319 let row = o.toggle(2).expect("the group is visible");
321 assert_eq!(row, 1, "its own line stays where it was");
322 assert_eq!(o.len(), before + 3, "three events joined the outline");
323
324 assert_eq!(
325 o.row_at(1),
326 Some(Row::Group {
327 group: 2,
328 expanded: true
329 })
330 );
331 for (offset, id) in [(2usize, 4i64), (3, 5), (4, 6)] {
333 let Some(Row::Event { group, event }) = o.row_at(offset) else {
334 panic!(
335 "row {offset} should be an event, got {:?}",
336 o.row_at(offset)
337 );
338 };
339 assert_eq!(group, 2);
340 assert_eq!(o.event(event).unwrap().id, id);
341 }
342 assert!(matches!(o.row_at(5), Some(Row::Group { group: 3, .. })));
344 }
345
346 #[test]
347 fn collapsing_restores_the_previous_shape() {
348 let mut o = outline();
349 let before = o.len();
350 o.toggle(2);
351 o.toggle(2);
352 assert_eq!(o.len(), before);
353 assert!(!o.is_expanded(2));
354 }
355
356 #[test]
357 fn a_row_lookup_does_not_depend_on_reading_earlier_rows() {
358 let events: Vec<NormalizedEvent> = (1..=30_000)
361 .map(|i| {
362 let g = GroupRef::Opened(i - (i % 3));
363 let role = match i % 3 {
364 0 => Role::Opens,
365 1 => Role::Continues,
366 _ => Role::Closes,
367 };
368 ev(i, g, role, Category::Activity)
369 })
370 .collect();
371 let groups = group_events(&events);
372 let mut o = Outline::new(events, groups);
373 o.expand_all();
374
375 let last = o.len() - 1;
376 assert!(o.row_at(last).is_some());
377 assert!(o.row_at(last / 2).is_some());
378 assert_eq!(o.row_at(o.len()), None);
379 }
380
381 #[test]
382 fn expand_and_collapse_all_move_together() {
383 let mut o = outline();
384 let collapsed = o.len();
385 o.expand_all();
386 assert!(o.len() > collapsed);
387 assert!(o.is_expanded(2) && o.is_expanded(3));
388 o.collapse_all();
389 assert_eq!(o.len(), collapsed);
390 }
391
392 #[test]
393 fn failures_are_reachable_without_scrolling_to_them() {
394 let o = outline();
395 let at = o.next_failure(0).expect("there is a failure below row 0");
397 assert!(matches!(o.row_at(at), Some(Row::Group { group: 3, .. })));
398 assert_eq!(o.next_failure(at), None, "nothing after the last failure");
399 assert_eq!(o.prev_failure(at), None, "nothing before the first");
400 assert_eq!(o.prev_failure(o.len()), Some(at));
401 }
402
403 #[test]
404 fn hidden_groups_are_not_reachable_by_row() {
405 let o = outline();
408 for r in 0..o.len() {
409 let group = match o.row_at(r).unwrap() {
410 Row::Group { group, .. } | Row::Event { group, .. } => group,
411 };
412 assert!(
413 !o.group(group).unwrap().category.is_plumbing(),
414 "row {r} resolved to a hidden group"
415 );
416 }
417 }
418
419 #[test]
420 fn a_new_page_does_not_fold_shut_what_the_reader_opened() {
421 let mut o = outline();
422 o.toggle(2);
423 assert!(o.is_expanded(2));
424 let rows_before = o.len();
425
426 let mut events: Vec<NormalizedEvent> = o.events().to_vec();
428 events.push(ev(9, GroupRef::Opened(9), Role::Opens, Category::Timer).with_subject("wait"));
429 events.push(
430 ev(10, GroupRef::Opened(9), Role::Closes, Category::Timer)
431 .with_outcome(Outcome::Completed),
432 );
433 let groups = group_events(&events);
434 o.replace(events, groups);
435
436 assert!(o.is_expanded(2), "expansion must survive a new page");
437 assert_eq!(o.len(), rows_before + 1, "one new collapsed group");
438 assert_eq!(o.groups().len(), 5);
439 }
440
441 #[test]
442 fn replacing_with_fewer_groups_does_not_panic() {
443 let mut o = outline();
446 o.expand_all();
447 o.replace(Vec::new(), Vec::new());
448 assert!(o.is_empty());
449 assert_eq!(o.row_at(0), None);
450 }
451
452 #[test]
453 fn an_empty_history_has_no_rows() {
454 let o = Outline::new(Vec::new(), Vec::new());
455 assert!(o.is_empty());
456 assert_eq!(o.row_at(0), None);
457 assert_eq!(o.slice(0, 10), Vec::new());
458 assert_eq!(o.next_failure(0), None);
459 }
460
461 #[test]
462 fn a_slice_builds_only_what_was_asked_for() {
463 let mut o = outline();
464 o.expand_all();
465 let rows = o.slice(1, 3);
466 assert_eq!(rows.len(), 3);
467 assert_eq!(rows[0], o.row_at(1).unwrap());
468 assert_eq!(o.slice(o.len() - 1, 50).len(), 1);
470 }
471
472 #[test]
473 fn the_summary_counts_what_the_header_shows() {
474 let s = summarize(outline().groups());
475 assert_eq!(s.activities, 2);
476 assert_eq!(s.failures, 1);
477 assert_eq!(s.timers, 0);
478 assert_eq!(
479 s.outcome,
480 Outcome::Pending,
481 "the workflow group never closed"
482 );
483 }
484
485 #[test]
486 fn the_summary_never_counts_something_the_outline_hides() {
487 let events = vec![
491 ev(1, GroupRef::Workflow, Role::Opens, Category::Workflow),
492 ev(2, GroupRef::Opened(2), Role::Opens, Category::WorkflowTask),
493 ];
494 let groups = group_events(&events);
495 assert_eq!(summarize(&groups).in_flight, 0);
496
497 let o = Outline::new(events, groups);
498 assert_eq!(o.len(), 1, "only the workflow group is visible");
499 }
500}