consortium_cli/event_render.rs
1//! Event consumers for the cascade event protocol.
2//!
3//! Three sinks, all implementing [`EventSink`]:
4//!
5//! - [`JsonlWriter`] — streams events as JSONL to any `Write + Send`
6//! - [`EventCollector`] — accumulates events into a `Vec` for batch use / tests
7//! - [`SnapshotAccumulator`] — folds events into a cascade-tree view wired
8//! into `tree.rs`'s [`TreeNode`] trait
9//!
10//! Plus [`render_events`] — same event slice, four output formats.
11
12use std::collections::HashMap;
13use std::fs::File;
14use std::io::{self, BufWriter, Write};
15use std::path::Path;
16use std::sync::Mutex;
17use std::time::{Duration, Instant};
18
19use consortium_nix::cascade::NodeId;
20use consortium_nix::cascade_events::{CascadeEvent, EventSink};
21
22use crate::tree::{NodeStatus, OutputFormat, TreeNode};
23
24// ============================================================================
25// JsonlWriter
26// ============================================================================
27
28/// Streams events as JSONL (one JSON object per line) to any `Write + Send`.
29///
30/// Wraps the writer in a `Mutex` so `JsonlWriter` is `Send + Sync` and can be
31/// shared across the coordinator threads.
32pub struct JsonlWriter {
33 inner: Mutex<Box<dyn Write + Send>>,
34}
35
36impl JsonlWriter {
37 /// Create from any `Write + Send` — e.g. `Box::new(std::io::stdout())`.
38 pub fn new(w: Box<dyn Write + Send>) -> Self {
39 Self {
40 inner: Mutex::new(w),
41 }
42 }
43
44 /// Open `path` for appending and write JSONL there.
45 pub fn file(path: impl AsRef<Path>) -> io::Result<Self> {
46 let f = File::create(path)?;
47 Ok(Self::new(Box::new(BufWriter::new(f))))
48 }
49}
50
51impl EventSink for JsonlWriter {
52 fn emit(&self, event: &CascadeEvent) {
53 if let Ok(line) = serde_json::to_string(event) {
54 let mut w = self.inner.lock().unwrap();
55 let _ = writeln!(w, "{line}");
56 }
57 }
58}
59
60// ============================================================================
61// EventCollector
62// ============================================================================
63
64/// Accumulates all events into a `Vec<CascadeEvent>` for batch processing.
65/// Useful for tests and for feeding `render_events` after a run.
66pub struct EventCollector {
67 events: Mutex<Vec<CascadeEvent>>,
68}
69
70impl EventCollector {
71 pub fn new() -> Self {
72 Self {
73 events: Mutex::new(Vec::new()),
74 }
75 }
76
77 /// Clone out the full accumulated event list.
78 pub fn events(&self) -> Vec<CascadeEvent> {
79 self.events.lock().unwrap().clone()
80 }
81
82 pub fn len(&self) -> usize {
83 self.events.lock().unwrap().len()
84 }
85
86 pub fn is_empty(&self) -> bool {
87 self.len() == 0
88 }
89
90 pub fn clear(&mut self) {
91 self.events.lock().unwrap().clear();
92 }
93}
94
95impl Default for EventCollector {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl EventSink for EventCollector {
102 fn emit(&self, event: &CascadeEvent) {
103 self.events.lock().unwrap().push(event.clone());
104 }
105}
106
107// ============================================================================
108// SnapshotAccumulator + OwnedTreeNode
109// ============================================================================
110
111/// Per-node state built up as events arrive.
112#[derive(Debug, Clone)]
113struct NodeState {
114 /// NodeId of the parent (established from EdgeCompleted or EdgeFailed).
115 parent: Option<NodeId>,
116 /// Children NodeIds (targets for which this node was src).
117 children: Vec<NodeId>,
118 /// Round in which this node converged (EdgeCompleted).
119 converged_round: Option<u32>,
120 /// Duration of the completing edge.
121 duration: Option<Duration>,
122 /// True if this node has the closure (either seeded or EdgeCompleted).
123 has_closure: bool,
124 /// True if this node appeared in any EdgeFailed as tgt.
125 failed: bool,
126 /// True if this node is currently being served — set on PlanComputed
127 /// (we know the strategy assigned it this round), cleared on
128 /// EdgeCompleted (success → has_closure) or EdgeFailed (failure).
129 /// Drives the "spinning" ⏵ glyph during the wall-time the cascade
130 /// is actually doing the copy.
131 in_progress: bool,
132}
133
134impl NodeState {
135 fn new() -> Self {
136 Self {
137 parent: None,
138 children: Vec::new(),
139 converged_round: None,
140 duration: None,
141 has_closure: false,
142 failed: false,
143 in_progress: false,
144 }
145 }
146}
147
148/// Folds `CascadeEvent`s into per-node snapshots that can be rendered as a
149/// cascade tree.
150pub struct SnapshotAccumulator {
151 inner: Mutex<AccumulatorInner>,
152}
153
154#[derive(Default)]
155struct AccumulatorInner {
156 /// All nodes seen, indexed by NodeId.
157 nodes: HashMap<NodeId, NodeState>,
158 /// Seed nodes (from `Started`), in order.
159 seeds: Vec<NodeId>,
160 /// Round totals we track for metadata (round → max edge duration).
161 round_durations: HashMap<u32, Duration>,
162 /// Total node count from the `Started` event. Used by
163 /// `status_counts()` to compute pending as
164 /// `total - (ok + in_progress + failed)`. Without this, untouched
165 /// nodes (the majority of the fleet during early rounds) wouldn't
166 /// appear in the summary at all — e.g. a 121-node cascade in
167 /// round 0 would show `1✔ 2⏵ 0⏸ 0⚠` instead of `1✔ 2⏵ 118⏸ 0⚠`.
168 total_nodes: u32,
169}
170
171impl AccumulatorInner {
172 fn node(&mut self, id: NodeId) -> &mut NodeState {
173 self.nodes.entry(id).or_insert_with(NodeState::new)
174 }
175}
176
177impl SnapshotAccumulator {
178 pub fn new() -> Self {
179 Self {
180 inner: Mutex::new(AccumulatorInner::default()),
181 }
182 }
183
184 /// Build a self-contained `OwnedTreeNode` tree.
185 ///
186 /// Root = `seeded[0]` (or an artificial root labelled "cascade" if no
187 /// `Started` event was received). Children are nodes whose parent edge
188 /// (EdgeCompleted or EdgeFailed) established `src→tgt`. Nodes that
189 /// appear in both EdgeCompleted and EdgeFailed keep their last status.
190 ///
191 /// Status per node:
192 /// - `Ok` — `has_closure` is true
193 /// - `Failed` — appeared as `tgt` in at least one `EdgeFailed`
194 /// - `Pending` — neither
195 ///
196 /// Judgment call on orphan failed nodes: both `EdgeCompleted` and
197 /// `EdgeFailed` contribute to parent-of relationships so that failed
198 /// targets still appear in the tree. If we only used `EdgeCompleted`,
199 /// failed nodes would be orphans and invisible.
200 pub fn to_tree(&self) -> OwnedTreeNode {
201 let acc = self.inner.lock().unwrap();
202 build_tree(&acc)
203 }
204
205 /// Aggregate counts per status: `(ok, in_progress, pending, failed)`.
206 /// Used by [`LiveTreeRenderer`] to render the nom-style summary
207 /// row at the bottom of each frame: `∑ N✔ M⏵ K⏸ J⚠`.
208 ///
209 /// Pending is computed as `total_nodes - (ok + in_progress + failed)`
210 /// so untouched nodes (which never appear in the events map until
211 /// the strategy schedules them) show up correctly in the summary.
212 /// Falls back to `nodes.values().count()` if no `Started` event
213 /// has been seen yet (degenerate case — defensive).
214 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
215 let acc = self.inner.lock().unwrap();
216 let mut ok = 0;
217 let mut in_progress = 0;
218 let mut failed = 0;
219 for state in acc.nodes.values() {
220 if state.failed {
221 failed += 1;
222 } else if state.has_closure {
223 ok += 1;
224 } else if state.in_progress {
225 in_progress += 1;
226 }
227 // Touched-but-still-pending nodes are absorbed into the
228 // total_nodes-based pending count below — counting them
229 // both here and there would double-count.
230 }
231 let active = ok + in_progress + failed;
232 let pending = (acc.total_nodes as usize).saturating_sub(active);
233 (ok, in_progress, pending, failed)
234 }
235}
236
237impl Default for SnapshotAccumulator {
238 fn default() -> Self {
239 Self::new()
240 }
241}
242
243impl EventSink for SnapshotAccumulator {
244 fn emit(&self, event: &CascadeEvent) {
245 let mut acc = self.inner.lock().unwrap();
246 match event {
247 CascadeEvent::Started {
248 seeded, n_nodes, ..
249 } => {
250 acc.seeds = seeded.clone();
251 acc.total_nodes = *n_nodes;
252 // Pre-populate ALL nodes so the renderer can show
253 // pending ones (⏸) from frame 1 — without this, only
254 // nodes that appear in some event are visible, which
255 // makes the user see "✔ + ⚠" but no ⏸ even when the
256 // summary says there are dozens pending.
257 for i in 0..*n_nodes {
258 acc.node(NodeId(i));
259 }
260 for &id in seeded {
261 let n = acc.node(id);
262 n.has_closure = true;
263 }
264 }
265 CascadeEvent::PlanComputed { assignments, .. } => {
266 // Register all planned edges as tentative parent→child relationships
267 // AND mark targets as in_progress — that drives the "spinning"
268 // ⏵ glyph during the actual copy. EdgeCompleted/EdgeFailed will
269 // clear the spinner when the edge resolves.
270 for edge in assignments {
271 let src = edge.src;
272 let tgt = edge.tgt;
273 acc.node(src);
274 {
275 let n = acc.node(tgt);
276 if n.parent.is_none() {
277 n.parent = Some(src);
278 }
279 n.in_progress = true;
280 }
281 if !acc
282 .nodes
283 .get(&src)
284 .map_or(false, |n| n.children.contains(&tgt))
285 {
286 acc.node(src).children.push(tgt);
287 }
288 }
289 }
290 CascadeEvent::EdgeStarted { src, tgt, .. } => {
291 acc.node(*src);
292 acc.node(*tgt);
293 }
294 CascadeEvent::EdgeCompleted {
295 round,
296 src,
297 tgt,
298 duration,
299 } => {
300 // Establish parent→child relationship.
301 let src = *src;
302 let tgt = *tgt;
303 let round = *round;
304 let dur = *duration;
305
306 // Register child on src.
307 if !acc
308 .nodes
309 .get(&src)
310 .map_or(false, |n| n.children.contains(&tgt))
311 {
312 acc.node(src).children.push(tgt);
313 }
314 // Register parent on tgt + clear in_progress (it's done now).
315 {
316 let n = acc.node(tgt);
317 n.parent = Some(src);
318 n.has_closure = true;
319 n.in_progress = false;
320 n.converged_round = Some(round);
321 n.duration = Some(dur);
322 }
323 }
324 CascadeEvent::EdgeFailed {
325 round: _, src, tgt, ..
326 } => {
327 let src = *src;
328 let tgt = *tgt;
329 if !acc
330 .nodes
331 .get(&src)
332 .map_or(false, |n| n.children.contains(&tgt))
333 {
334 acc.node(src).children.push(tgt);
335 }
336 {
337 let n = acc.node(tgt);
338 if n.parent.is_none() {
339 n.parent = Some(src);
340 }
341 n.failed = true;
342 n.in_progress = false; // resolved (to failure)
343 }
344 }
345 CascadeEvent::RoundCompleted {
346 round,
347 duration,
348 has_closure,
349 } => {
350 acc.round_durations.insert(*round, *duration);
351 for &id in has_closure {
352 acc.node(id).has_closure = true;
353 }
354 }
355 CascadeEvent::Finished { .. } => {}
356 }
357 }
358}
359
360/// Build the `OwnedTreeNode` tree from accumulated state.
361fn build_tree(acc: &AccumulatorInner) -> OwnedTreeNode {
362 let root_id = acc.seeds.first().copied();
363
364 let Some(rid) = root_id else {
365 return OwnedTreeNode {
366 label: "cascade".into(),
367 status: Some(NodeStatus::Pending),
368 metadata: Vec::new(),
369 children: Vec::new(),
370 };
371 };
372
373 let mut root = build_node(rid, acc);
374
375 // Adopt orphans: nodes that have no parent set yet but exist in
376 // the accumulator (pre-populated from Started's n_nodes count).
377 // Without this, pending nodes that haven't been planned yet are
378 // invisible — the user's summary says "61 pending" but the tree
379 // shows nothing for them. Adopting under root makes them visible
380 // as ⏸; once a real parent emerges from EdgeCompleted/Failed,
381 // they move to their proper subtree on the next render.
382 let in_tree = collect_node_ids(&root);
383 let mut orphans: Vec<NodeId> = acc
384 .nodes
385 .keys()
386 .copied()
387 .filter(|id| !in_tree.contains(id))
388 .filter(|id| {
389 // Skip nodes that have a parent set (they should appear
390 // under their parent — if they're not in `in_tree` then
391 // their parent is unreachable, but we'll still show them
392 // here so the user can see them).
393 acc.nodes
394 .get(id)
395 .and_then(|s| s.parent)
396 .is_none_or(|_| true)
397 })
398 .filter(|id| acc.nodes.get(id).is_some_and(|s| s.parent.is_none()))
399 .collect();
400 orphans.sort_by_key(|cid| {
401 let p = match acc.nodes.get(cid) {
402 None => 2u8,
403 Some(s) if s.failed => 0,
404 Some(s) if s.in_progress => 1,
405 Some(s) if !s.has_closure => 2,
406 Some(_) => 3,
407 };
408 (p, *cid)
409 });
410 for orphan in orphans {
411 root.children.push(build_node(orphan, acc));
412 }
413 root
414}
415
416/// Collect every NodeId that appears in the rooted-from-seed subtree.
417/// Used to identify orphans (nodes in the accumulator but not yet
418/// reachable from the seed via parent links).
419fn collect_node_ids(node: &OwnedTreeNode) -> std::collections::HashSet<NodeId> {
420 let mut acc = std::collections::HashSet::new();
421 fn walk(n: &OwnedTreeNode, acc: &mut std::collections::HashSet<NodeId>) {
422 // Parse the NodeId from the label (`nN`). If unparseable
423 // (synthetic root), skip — orphan check is best-effort.
424 if let Some(num) = n
425 .label
426 .strip_prefix('n')
427 .and_then(|s| s.parse::<u32>().ok())
428 {
429 acc.insert(NodeId(num));
430 }
431 for c in &n.children {
432 walk(c, acc);
433 }
434 }
435 walk(node, &mut acc);
436 acc
437}
438
439fn build_node(id: NodeId, acc: &AccumulatorInner) -> OwnedTreeNode {
440 let state = acc.nodes.get(&id);
441
442 let status = match state {
443 None => Some(NodeStatus::Pending),
444 Some(s) if s.failed => Some(NodeStatus::Failed),
445 Some(s) if s.has_closure => Some(NodeStatus::Ok),
446 Some(s) if s.in_progress => Some(NodeStatus::InProgress),
447 Some(_) => Some(NodeStatus::Pending),
448 };
449
450 let mut metadata: Vec<(String, String)> = Vec::new();
451 if let Some(s) = state {
452 if let Some(round) = s.converged_round {
453 metadata.push(("round".into(), round.to_string()));
454 }
455 if let Some(dur) = s.duration {
456 let ms = dur.as_millis();
457 metadata.push(("duration".into(), format!("{ms}ms")));
458 }
459 }
460
461 // Build children, sorted by build-state priority (nom/State/Sorting.hs):
462 // Failed > InProgress > Pending > Ok, then NodeId for stable tie-breaking.
463 // This ensures failed/running nodes float to top and survive truncation.
464 let mut child_ids: Vec<NodeId> = state.map(|s| s.children.clone()).unwrap_or_default();
465 child_ids.sort();
466 child_ids.dedup();
467 child_ids.sort_by_key(|cid| {
468 let p = match acc.nodes.get(cid) {
469 None => 2u8, // Pending (unknown)
470 Some(s) if s.failed => 0, // Failed — highest priority
471 Some(s) if s.in_progress => 1, // InProgress
472 Some(s) if !s.has_closure => 2, // Pending
473 Some(_) => 3, // Ok — least interesting
474 };
475 (p, *cid)
476 });
477
478 let children: Vec<OwnedTreeNode> = child_ids
479 .into_iter()
480 .map(|cid| build_node(cid, acc))
481 .collect();
482
483 OwnedTreeNode {
484 label: id.to_string(),
485 status,
486 metadata,
487 children,
488 }
489}
490
491// ============================================================================
492// OwnedTreeNode — self-contained tree that implements TreeNode
493// ============================================================================
494
495/// A self-contained tree node that owns its children. Implements [`TreeNode`]
496/// so it can be handed directly to `tree::render`.
497///
498/// Constructed by [`SnapshotAccumulator::to_tree`]; also usable standalone.
499pub struct OwnedTreeNode {
500 pub label: String,
501 pub status: Option<NodeStatus>,
502 pub metadata: Vec<(String, String)>,
503 pub children: Vec<OwnedTreeNode>,
504}
505
506impl TreeNode for OwnedTreeNode {
507 fn label(&self) -> String {
508 self.label.clone()
509 }
510
511 fn status(&self) -> Option<NodeStatus> {
512 self.status.clone()
513 }
514
515 fn metadata(&self) -> Vec<(String, String)> {
516 self.metadata.clone()
517 }
518
519 fn children(&self) -> Vec<&dyn TreeNode> {
520 self.children.iter().map(|c| c as &dyn TreeNode).collect()
521 }
522}
523
524// ============================================================================
525// Helpers
526// ============================================================================
527
528/// Truncate `s` so its display width fits within `max_cols` terminal
529/// columns. If truncated, appends an ellipsis `…` (which is 1 column
530/// wide). ANSI escape sequences in `s` are stripped before measuring
531/// so they don't count against the budget.
532///
533/// Why this matters: a line wider than the terminal wraps onto multiple
534/// visual lines. The cursor-walk-up cleanup logic counts LOGICAL lines
535/// (one per `\n`), not visual lines, so wrapped content ends up
536/// only-partially erased between frames — leaving the wrapped
537/// portion as visual cruft.
538///
539/// Uses byte-level char iteration because `console::measure_text_width`
540/// requires the full `unicode-width` dep tree we don't pull in.
541/// Char count == display width is roughly true for our content (ASCII
542/// + a handful of single-cell box-drawing + status glyphs).
543fn truncate_to_width(s: &str, max_cols: usize) -> String {
544 if max_cols == usize::MAX {
545 return s.to_string();
546 }
547 // Strip ANSI for measurement.
548 let visible: String = strip_ansi(s);
549 let visible_width = visible.chars().count();
550 if visible_width <= max_cols {
551 return s.to_string();
552 }
553 // Need to truncate. Walk the original string char-by-char, skipping
554 // ANSI escape sequences (so they pass through without consuming
555 // budget), until visible budget is spent.
556 let budget = max_cols.saturating_sub(1); // reserve 1 col for ellipsis
557 let mut out = String::with_capacity(s.len());
558 let mut visible_count = 0;
559 let mut chars = s.chars().peekable();
560 while let Some(c) = chars.next() {
561 if c == '\x1b' {
562 // ANSI CSI sequence: copy through to terminator [a-zA-Z]
563 out.push(c);
564 for nc in chars.by_ref() {
565 out.push(nc);
566 if nc.is_ascii_alphabetic() {
567 break;
568 }
569 }
570 continue;
571 }
572 if visible_count >= budget {
573 break;
574 }
575 out.push(c);
576 visible_count += 1;
577 }
578 out.push('…');
579 out
580}
581
582/// Strip ANSI CSI escape sequences from a string for width measurement.
583fn strip_ansi(s: &str) -> String {
584 let mut out = String::with_capacity(s.len());
585 let mut chars = s.chars().peekable();
586 while let Some(c) = chars.next() {
587 if c == '\x1b' {
588 // Skip through alphabetic terminator.
589 for nc in chars.by_ref() {
590 if nc.is_ascii_alphabetic() {
591 break;
592 }
593 }
594 } else {
595 out.push(c);
596 }
597 }
598 out
599}
600
601// ============================================================================
602// LiveTreeRenderer — re-renders the tree in place on each RoundCompleted
603// ============================================================================
604
605/// EventSink that re-renders the cascade tree to stdout in place every
606/// `RoundCompleted` (and `Finished`). Uses ANSI cursor positioning so
607/// the tree fills in line-by-line as the cascade unfolds — same idiom
608/// nix-output-monitor (nom) uses.
609///
610/// Usage: hand to `Cascade::new()...events(&renderer).run()` for default
611/// "watch the cascade unfold" behavior in any TTY-attached cli bin.
612///
613/// Caller is responsible for not wiring this when stdout is a pipe — it
614/// would emit ANSI escapes into the captured output. [`crate::output`]
615/// handles the TTY check.
616pub struct LiveTreeRenderer {
617 accumulator: SnapshotAccumulator,
618 color: bool,
619 max_depth: Option<usize>,
620 /// Optional Mutex<Vec<u8>> for testing — when Some, frames go here
621 /// instead of stdout. Production passes None.
622 capture: Option<Mutex<Vec<u8>>>,
623 /// Number of lines printed in the last frame. On the next repaint
624 /// we walk the cursor up this many lines and clear each, then
625 /// write the new frame. This mirrors nix-output-monitor's exact
626 /// idiom (NOM/IO.hs `writeStateToScreen`): erase-in-place using
627 /// `\x1b[2K` + `\x1b[1A\x1b[2K`×(N-1) rather than alt-screen.
628 /// Wrapped in iTerm2 synchronized-update markers to avoid
629 /// mid-frame flicker on terminals that support them.
630 last_printed_lines: Mutex<usize>,
631 /// Maximum frame height in lines for truncation (NOM/IO.hs `truncateRows`).
632 /// None = auto-detect from terminal on first paint; Some(0) = no cap.
633 max_height: Option<usize>,
634 /// Wall-time of the last repaint. Used to gate repaints at ≥60ms
635 /// intervals (nom `minFrameDuration = 60_000 µs`). None on first paint.
636 last_paint_at: Mutex<Option<Instant>>,
637 /// Header lines rendered at the top of each frame:
638 /// - First line gets `┏━ ` prefix (top-left corner)
639 /// - Subsequent lines get `┣ ` prefix (T-junction)
640 /// Default: `vec!["Cascade deploy"]`. Override via `with_header_text()`
641 /// (single line) or `with_header_lines()` (multiple).
642 header_lines: Mutex<Vec<String>>,
643}
644
645impl LiveTreeRenderer {
646 pub fn new(color: bool, max_depth: Option<usize>) -> Self {
647 Self {
648 accumulator: SnapshotAccumulator::new(),
649 color,
650 max_depth,
651 capture: None,
652 last_printed_lines: Mutex::new(0),
653 max_height: None,
654 last_paint_at: Mutex::new(None),
655 header_lines: Mutex::new(vec!["Cascade deploy".to_string()]),
656 }
657 }
658
659 /// Test-only constructor that captures frames into an internal buffer
660 /// instead of writing to stdout.
661 #[doc(hidden)]
662 pub fn with_capture(color: bool, max_depth: Option<usize>) -> Self {
663 Self {
664 accumulator: SnapshotAccumulator::new(),
665 color,
666 max_depth,
667 capture: Some(Mutex::new(Vec::new())),
668 last_printed_lines: Mutex::new(0),
669 max_height: None,
670 last_paint_at: Mutex::new(None),
671 header_lines: Mutex::new(vec!["Cascade deploy".to_string()]),
672 }
673 }
674
675 /// Builder: set an explicit maximum frame height (lines). Used for tests
676 /// that can't rely on a real terminal. `Some(0)` disables height capping.
677 ///
678 /// In production the height is auto-detected from the terminal on each
679 /// paint via `console::Term::stdout().size()`.
680 pub fn with_max_height(mut self, h: Option<usize>) -> Self {
681 self.max_height = h;
682 self
683 }
684
685 /// Builder: replace the default `"Cascade deploy"` header with a
686 /// single custom line.
687 pub fn with_header_text(self, text: impl Into<String>) -> Self {
688 *self.header_lines.lock().unwrap() = vec![text.into()];
689 self
690 }
691
692 /// Builder: provide multiple header lines. The first renders with
693 /// `┏━ ` (top-left), each subsequent line renders with `┣ `
694 /// (T-junction) — matches nom's section convention. Use to keep
695 /// a wide header readable on narrow terminals without truncation:
696 ///
697 /// ```ignore
698 /// LiveTreeRenderer::new(color, max_depth)
699 /// .with_header_lines(vec![
700 /// format!("Strategy: {} || Nodes: {}", name, n),
701 /// format!("Fanout: {} || Failures: {}%", f, fr),
702 /// ])
703 /// ```
704 pub fn with_header_lines(self, lines: Vec<String>) -> Self {
705 let lines = if lines.is_empty() {
706 vec!["Cascade deploy".to_string()]
707 } else {
708 lines
709 };
710 *self.header_lines.lock().unwrap() = lines;
711 self
712 }
713
714 /// Test-only: read out captured frames as a String.
715 #[doc(hidden)]
716 pub fn captured(&self) -> String {
717 self.capture
718 .as_ref()
719 .map(|m| String::from_utf8_lossy(&m.lock().unwrap()).to_string())
720 .unwrap_or_default()
721 }
722
723 /// Render the current tree state, wrapped with HEAVY section
724 /// borders (┏━ ┃ ┗━) and a summary row at the bottom — exactly the
725 /// frame shape `nom` produces (NOM/Print.hs `printSections`).
726 ///
727 /// When the assembled frame exceeds the terminal height, truncates per
728 /// NOM/IO.hs `truncateRows`: keep the first line, insert ` ⋮ `, then
729 /// keep the last `rows - outputLinesToAlwaysShow` lines (5 per nom).
730 fn build_frame(&self) -> String {
731 let tree = self.accumulator.to_tree();
732 let inner = crate::tree::render(
733 &tree,
734 &OutputFormat::Tree {
735 max_depth: self.max_depth,
736 color: self.color,
737 },
738 );
739
740 // Count statuses for the summary row.
741 let (ok, in_progress, pending, failed) = self.accumulator.status_counts();
742
743 // Probe the terminal width for line truncation. If we don't
744 // truncate, lines wider than the terminal wrap onto multiple
745 // visual lines — but our `frame.lines().count()` only counts
746 // logical lines, so the cleanup walk-up is off by however many
747 // lines wrapped. Result: previous frames' wrapped portions
748 // never get erased, producing the multi-header visual stutter.
749 let term_cols = if self.capture.is_some() {
750 usize::MAX // tests: no truncation
751 } else {
752 let (_, cols) = console::Term::stdout().size();
753 if cols == 0 {
754 usize::MAX
755 } else {
756 cols as usize
757 }
758 };
759
760 let mut frame = String::with_capacity(inner.len() + 256);
761 // Multi-line header: first line gets ┏━ (top-left corner),
762 // additional lines get ┣ (T-junction) — matches nom's section
763 // convention for stacked sections.
764 let header_lines = self.header_lines.lock().unwrap().clone();
765 for (i, line) in header_lines.iter().enumerate() {
766 let prefix = if i == 0 { "┏━ " } else { "┣ " };
767 let composed = format!("{prefix}{line}");
768 frame.push_str(&truncate_to_width(&composed, term_cols));
769 frame.push('\n');
770 }
771 // Body: prefix every tree line with ┃ (the vertical chrome).
772 for line in inner.lines() {
773 let bodied = format!("┃ {line}");
774 frame.push_str(&truncate_to_width(&bodied, term_cols));
775 frame.push('\n');
776 }
777 // Bottom border: ┗━ + summary row.
778 // Format: `∑ || N ✔ || M ⏵ || K ⏸ || J ⚠`
779 // - `||` separator between fields makes them visually distinct
780 // without leaning on whitespace alignment (which falls apart
781 // when counts grow to 3+ digits)
782 // - single space between count and glyph for readability
783 let summary = format!("┗━ ∑ || {ok} ✔ || {in_progress} ⏵ || {pending} ⏸ || {failed} ⚠");
784 frame.push_str(&truncate_to_width(&summary, term_cols));
785 frame.push('\n');
786
787 // Truncate to terminal height (NOM/IO.hs `truncateRows`).
788 // Resolve effective max from explicit override or terminal query.
789 let max_height = match self.max_height {
790 Some(0) => return frame, // disabled
791 Some(h) => h,
792 None => {
793 // Auto-detect: only applicable when we're painting to a real TTY.
794 // capture mode has no terminal, so skip truncation there.
795 if self.capture.is_some() {
796 return frame;
797 }
798 let (rows, _cols) = console::Term::stdout().size();
799 if rows == 0 {
800 return frame; // non-TTY
801 }
802 rows as usize
803 }
804 };
805
806 // nom constant: `outputLinesToAlwaysShow = 5`.
807 // Guard: need at least 8 lines for truncation to make sense
808 // (1 header + 1 ellipsis + 5 tail + 1 footer).
809 const ALWAYS_SHOW: usize = 5;
810 let lines: Vec<&str> = frame.lines().collect();
811 let n = lines.len();
812 if max_height <= ALWAYS_SHOW + 2 || n <= max_height {
813 return frame;
814 }
815
816 // NOM formula (verbatim Haskell logic):
817 // take 1 <> [" ⋮ "] <> drop (n + outputLinesToAlwaysShow + 2 - rows)
818 // The drop count is: n + 5 + 2 - max_height.
819 let drop_count = n + ALWAYS_SHOW + 2 - max_height;
820 let head = &lines[..1];
821 let tail = &lines[drop_count..];
822
823 let mut truncated = String::with_capacity(frame.len());
824 for l in head {
825 truncated.push_str(l);
826 truncated.push('\n');
827 }
828 truncated.push_str(" ⋮ \n");
829 for l in tail {
830 truncated.push_str(l);
831 truncated.push('\n');
832 }
833 truncated
834 }
835
836 /// Erase-in-place repaint. Walks the cursor up `last_printed_lines`
837 /// and clears each line individually — exactly the sequence
838 /// `nom`'s `writeStateToScreen` emits (NOM/IO.hs). Wrapped in
839 /// synchronized-update markers so terminals that support them
840 /// (iTerm2, alacritty, kitty, recent gnome-terminal) display the
841 /// frame atomically without flicker.
842 ///
843 /// `force = true` bypasses the 60ms minimum gate (used for the final
844 /// `Finished` frame). In capture/test mode the gate is also bypassed
845 /// since wall-time pacing is irrelevant in tests.
846 ///
847 /// nom equivalence: `minFrameDuration = 60_000 µs` (NOM/IO.hs `keepPrinting`).
848 fn repaint(&self, force: bool) {
849 // 60ms minimum frame gate (nom `minFrameDuration`). Skip in capture
850 // mode where wall-time pacing is meaningless (tests run in µs).
851 if !force && self.capture.is_none() {
852 let mut last_paint = self.last_paint_at.lock().unwrap();
853 if let Some(t) = *last_paint {
854 if t.elapsed() < Duration::from_millis(60) {
855 return;
856 }
857 }
858 *last_paint = Some(Instant::now());
859 } else if self.capture.is_none() {
860 // forced paint — update timestamp so next non-forced paint
861 // measures from here.
862 *self.last_paint_at.lock().unwrap() = Some(Instant::now());
863 }
864
865 let frame = self.build_frame();
866 let new_lines = frame.lines().count();
867 let mut last = self.last_printed_lines.lock().unwrap();
868
869 let mut bytes: Vec<u8> = Vec::with_capacity(frame.len() + 64);
870 // begin synchronized update
871 bytes.extend_from_slice(b"\x1b[?2026h");
872 // Erase last frame in place. Our `frame` ends with a trailing
873 // `\n` (so user's shell prompt lands on a fresh line at end of
874 // run), which means after the previous frame the cursor is one
875 // line BELOW the last frame line. We need to clear that empty
876 // line PLUS each of the prior `last` frame lines:
877 //
878 // - clear current line (the empty line after the \n)
879 // - then `last` iterations of (cursor up, clear line) →
880 // walks from line N+1 → line N → ... → line 1, clearing each
881 // - finally `\r` to put cursor at column 0 for the new frame
882 //
883 // nom's `writeStateToScreen` does (last-1) iterations because
884 // its frames don't end with \n — the cursor stays at the END
885 // of the last line, so it only needs (last-1) up-clears to
886 // walk to line 1. Ours has the trailing \n so we need one more.
887 if *last > 0 {
888 bytes.extend_from_slice(b"\x1b[2K");
889 for _ in 0..*last {
890 bytes.extend_from_slice(b"\x1b[1A\x1b[2K");
891 }
892 bytes.extend_from_slice(b"\r");
893 }
894 bytes.extend_from_slice(frame.as_bytes());
895 // end synchronized update
896 bytes.extend_from_slice(b"\x1b[?2026l");
897
898 if let Some(cap) = &self.capture {
899 cap.lock().unwrap().extend_from_slice(&bytes);
900 } else {
901 let mut stdout = io::stdout().lock();
902 let _ = stdout.write_all(&bytes);
903 let _ = stdout.flush();
904 }
905
906 *last = new_lines;
907 }
908}
909
910impl EventSink for LiveTreeRenderer {
911 fn emit(&self, event: &CascadeEvent) {
912 // Always update the in-memory tree.
913 self.accumulator.emit(event);
914 // Repaint on round boundaries + final tick. Per-edge events
915 // accumulate silently — keeps redraw rate manageable. Following
916 // nom's pattern: erase-in-place at frame time, no alt screen.
917 // Finished is forced (final frame must always show).
918 // PlanComputed/RoundCompleted go through the 60ms gate.
919 match event {
920 CascadeEvent::Finished { .. } => {
921 self.repaint(true); // force — final frame must display
922 }
923 CascadeEvent::PlanComputed { .. } | CascadeEvent::RoundCompleted { .. } => {
924 self.repaint(false);
925 }
926 _ => {}
927 }
928 }
929}
930
931// ============================================================================
932// DelayingExecutor — wraps a RoundExecutor + sleeps inside dispatch
933// ============================================================================
934
935/// Wraps a [`consortium_nix::cascade::RoundExecutor`] and sleeps for a
936/// configurable duration *inside* `dispatch`. Critical for live demos:
937/// without this, the deterministic sim resolves a round in microseconds
938/// and the live renderer's "spinning" frame (rendered at PlanComputed)
939/// is invisible because EdgeCompleted fires before the human eye can
940/// register it.
941///
942/// The sleep happens between PlanComputed (which marks targets
943/// in_progress) and EdgeCompleted (which marks them has_closure), so
944/// the spinning ⏵ glyph is visible for `delay` real wall-time, then
945/// snaps to ✔ on the next frame.
946///
947/// **Demo / visualization only.** Putting this in production paths
948/// adds fake latency to real deploys.
949pub struct DelayingExecutor<'a> {
950 pub inner: &'a dyn consortium_nix::cascade::RoundExecutor,
951 pub delay: Duration,
952}
953
954impl<'a> consortium_nix::cascade::RoundExecutor for DelayingExecutor<'a> {
955 fn dispatch(
956 &self,
957 nodes: &[consortium_nix::cascade::CascadeNode],
958 edges: &[(
959 consortium_nix::cascade::NodeId,
960 consortium_nix::cascade::NodeId,
961 )],
962 net: &consortium_nix::cascade::NetworkProfile,
963 ) -> std::collections::HashMap<
964 (
965 consortium_nix::cascade::NodeId,
966 consortium_nix::cascade::NodeId,
967 ),
968 Result<Duration, consortium_nix::cascade::CascadeError>,
969 > {
970 std::thread::sleep(self.delay);
971 self.inner.dispatch(nodes, edges, net)
972 }
973}
974
975// ============================================================================
976// DelaySink — wraps another sink and sleeps after each RoundCompleted
977// ============================================================================
978
979/// Wraps an `EventSink` and injects a wall-time sleep after each
980/// `RoundCompleted` event. Useful for making the deterministic sim
981/// watchable in live mode — without this the cascade fires in
982/// microseconds, faster than the live renderer's frames can register
983/// to a human eye.
984///
985/// **Test/demo only.** Inject this in production and you'll add fake
986/// latency to real deploys.
987pub struct DelaySink<'a> {
988 pub inner: &'a dyn EventSink,
989 pub delay: Duration,
990}
991
992impl<'a> EventSink for DelaySink<'a> {
993 fn emit(&self, event: &CascadeEvent) {
994 self.inner.emit(event);
995 if matches!(event, CascadeEvent::RoundCompleted { .. }) {
996 std::thread::sleep(self.delay);
997 }
998 }
999}
1000
1001// ============================================================================
1002// render_events
1003// ============================================================================
1004
1005/// Render a slice of `CascadeEvent`s in the requested `OutputFormat`.
1006///
1007/// - `Tree` — folds events through `SnapshotAccumulator`, builds an
1008/// `OwnedTreeNode`, delegates to `tree::render`.
1009/// - `Json` / `Yaml` / `Toml` — serializes the raw event slice directly
1010/// (events derive `Serialize`).
1011pub fn render_events(events: &[CascadeEvent], format: &OutputFormat) -> String {
1012 match format {
1013 OutputFormat::Tree { max_depth, color } => {
1014 let acc = SnapshotAccumulator::new();
1015 for ev in events {
1016 acc.emit(ev);
1017 }
1018 let tree = acc.to_tree();
1019 crate::tree::render(
1020 &tree,
1021 &OutputFormat::Tree {
1022 max_depth: *max_depth,
1023 color: *color,
1024 },
1025 )
1026 }
1027 OutputFormat::Json => serde_json::to_string_pretty(events)
1028 .unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}")),
1029 OutputFormat::Yaml => {
1030 serde_yaml::to_string(events).unwrap_or_else(|e| format!("error: {e}"))
1031 }
1032 OutputFormat::Toml => {
1033 // TOML can't serialize a bare array at the root; wrap under "events".
1034 let mut wrapper = std::collections::BTreeMap::new();
1035 wrapper.insert("events", events);
1036 match toml::to_string(&wrapper) {
1037 Ok(t) => t,
1038 Err(e) => format!("# toml render error: {e}\n"),
1039 }
1040 }
1041 }
1042}
1043
1044// ============================================================================
1045// Tests
1046// ============================================================================
1047
1048#[cfg(test)]
1049mod tests {
1050 use std::time::SystemTime;
1051
1052 use consortium_nix::cascade::NodeId;
1053 use consortium_nix::cascade_events::{CascadeEvent, Edge, EventSink};
1054
1055 use super::*;
1056 use crate::tree::OutputFormat;
1057
1058 // ---- helpers ----
1059
1060 fn node(n: u32) -> NodeId {
1061 NodeId(n)
1062 }
1063
1064 fn started_event(seeds: &[u32]) -> CascadeEvent {
1065 CascadeEvent::Started {
1066 n_nodes: 4,
1067 seeded: seeds.iter().map(|&n| NodeId(n)).collect(),
1068 strategy: "log2-fanout".into(),
1069 at: SystemTime::UNIX_EPOCH,
1070 }
1071 }
1072
1073 fn edge_completed(round: u32, src: u32, tgt: u32, ms: u64) -> CascadeEvent {
1074 CascadeEvent::EdgeCompleted {
1075 round,
1076 src: node(src),
1077 tgt: node(tgt),
1078 duration: Duration::from_millis(ms),
1079 }
1080 }
1081
1082 fn edge_failed(round: u32, src: u32, tgt: u32) -> CascadeEvent {
1083 use consortium_nix::cascade::CascadeError;
1084 CascadeEvent::EdgeFailed {
1085 round,
1086 src: node(src),
1087 tgt: node(tgt),
1088 error: CascadeError::Copy {
1089 node: node(tgt),
1090 stderr: "rsync exited 23".into(),
1091 },
1092 }
1093 }
1094
1095 fn finished(converged: usize, failed: usize, rounds: u32) -> CascadeEvent {
1096 CascadeEvent::Finished {
1097 converged,
1098 failed,
1099 rounds,
1100 }
1101 }
1102
1103 // ---- tests ----
1104
1105 /// 1. JsonlWriter emits one line per event, each valid JSON.
1106 #[test]
1107 fn jsonl_writer_writes_one_line_per_event() {
1108 let buf: Vec<u8> = Vec::new();
1109 let writer = JsonlWriter::new(Box::new(buf));
1110
1111 writer.emit(&started_event(&[0]));
1112 writer.emit(&edge_completed(0, 0, 1, 10));
1113 writer.emit(&finished(2, 0, 1));
1114
1115 // Extract the inner buffer.
1116 let raw = {
1117 let guard = writer.inner.lock().unwrap();
1118 // Downcast is not possible through dyn Write; capture via Cursor instead.
1119 // We'll re-run the test using a Cursor to get the bytes.
1120 drop(guard);
1121 // Re-run with a cursor approach below.
1122 Vec::<u8>::new() // placeholder
1123 };
1124 // Redo using std::io::Cursor directly.
1125 let cursor = std::io::Cursor::new(Vec::<u8>::new());
1126 let writer2 = JsonlWriter::new(Box::new(cursor));
1127 writer2.emit(&started_event(&[0]));
1128 writer2.emit(&edge_completed(0, 0, 1, 10));
1129 writer2.emit(&finished(2, 0, 1));
1130
1131 let guard = writer2.inner.lock().unwrap();
1132 // Access the cursor bytes by downcasting. Since we can't downcast dyn Write,
1133 // we test via EventCollector + JsonlWriter on a shared buffer.
1134 drop(guard);
1135 let _ = raw; // suppress unused warning
1136
1137 // Use a channel-backed approach: write to a Vec<u8> captured behind Arc<Mutex>.
1138 use std::sync::Arc;
1139 let shared: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
1140 let shared_clone = Arc::clone(&shared);
1141
1142 struct SharedBuf(Arc<Mutex<Vec<u8>>>);
1143 impl Write for SharedBuf {
1144 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1145 self.0.lock().unwrap().extend_from_slice(buf);
1146 Ok(buf.len())
1147 }
1148 fn flush(&mut self) -> io::Result<()> {
1149 Ok(())
1150 }
1151 }
1152
1153 let writer3 = JsonlWriter::new(Box::new(SharedBuf(shared_clone)));
1154 writer3.emit(&started_event(&[0]));
1155 writer3.emit(&edge_completed(0, 0, 1, 10));
1156 writer3.emit(&finished(2, 0, 1));
1157
1158 let bytes = shared.lock().unwrap().clone();
1159 let text = String::from_utf8(bytes).unwrap();
1160 let lines: Vec<&str> = text.lines().collect();
1161
1162 assert_eq!(lines.len(), 3, "expected 3 JSONL lines, got: {text}");
1163 for line in &lines {
1164 let _: serde_json::Value = serde_json::from_str(line)
1165 .unwrap_or_else(|e| panic!("invalid JSON on line {line:?}: {e}"));
1166 }
1167 }
1168
1169 /// 2. EventCollector records events in emission order.
1170 #[test]
1171 fn event_collector_records_in_order() {
1172 let col = EventCollector::new();
1173 col.emit(&started_event(&[0]));
1174 col.emit(&edge_completed(0, 0, 1, 5));
1175 col.emit(&finished(2, 0, 1));
1176
1177 assert_eq!(col.len(), 3);
1178
1179 let evs = col.events();
1180 assert!(matches!(&evs[0], CascadeEvent::Started { .. }));
1181 assert!(matches!(&evs[1], CascadeEvent::EdgeCompleted { .. }));
1182 assert!(matches!(&evs[2], CascadeEvent::Finished { .. }));
1183 }
1184
1185 /// 3. SnapshotAccumulator builds correct tree topology from EdgeCompleted
1186 /// AND adopts pre-populated unparented nodes (orphans) under root.
1187 #[test]
1188 fn snapshot_accumulator_builds_correct_tree_topology() {
1189 // started_event creates 4 nodes (n0..n3). EdgeCompleted attaches
1190 // n1, n2 to n0. n3 is pre-populated by Started but never gets an
1191 // event → orphan → adopted under root as Pending.
1192 let acc = SnapshotAccumulator::new();
1193 acc.emit(&started_event(&[0]));
1194 acc.emit(&edge_completed(0, 0, 1, 10));
1195 acc.emit(&edge_completed(0, 0, 2, 12));
1196 acc.emit(&finished(3, 0, 1));
1197
1198 let tree = acc.to_tree();
1199 assert_eq!(tree.label, "n0");
1200 // 3 children: n1, n2 (Ok via EdgeCompleted) + n3 (Pending orphan).
1201 assert_eq!(
1202 tree.children.len(),
1203 3,
1204 "root should have 2 served + 1 pre-populated orphan = 3 children"
1205 );
1206
1207 let child_labels: Vec<&str> = tree.children.iter().map(|c| c.label.as_str()).collect();
1208 assert!(child_labels.contains(&"n1"), "missing n1: {child_labels:?}");
1209 assert!(child_labels.contains(&"n2"), "missing n2: {child_labels:?}");
1210 assert!(
1211 child_labels.contains(&"n3"),
1212 "missing n3 (orphan): {child_labels:?}"
1213 );
1214
1215 // Root is Ok (seeded). Served children are Ok; orphan is Pending.
1216 assert_eq!(tree.status, Some(NodeStatus::Ok));
1217 for child in &tree.children {
1218 let expected = match child.label.as_str() {
1219 "n1" | "n2" => Some(NodeStatus::Ok),
1220 "n3" => Some(NodeStatus::Pending),
1221 other => panic!("unexpected child {other}"),
1222 };
1223 assert_eq!(child.status, expected, "wrong status for {}", child.label);
1224 }
1225 }
1226
1227 /// 4. SnapshotAccumulator marks failed nodes correctly.
1228 #[test]
1229 fn accumulator_marks_failed_nodes() {
1230 let acc = SnapshotAccumulator::new();
1231 acc.emit(&started_event(&[0]));
1232 acc.emit(&edge_completed(0, 0, 1, 8));
1233 acc.emit(&edge_failed(0, 0, 2)); // n2 fails
1234 acc.emit(&finished(2, 1, 1));
1235
1236 let tree = acc.to_tree();
1237
1238 // Find n2 in tree children.
1239 let n2 = tree
1240 .children
1241 .iter()
1242 .find(|c| c.label == "n2")
1243 .expect("n2 should be in tree even though it failed");
1244 assert_eq!(n2.status, Some(NodeStatus::Failed), "n2 should be Failed");
1245
1246 // n1 should be Ok.
1247 let n1 = tree
1248 .children
1249 .iter()
1250 .find(|c| c.label == "n1")
1251 .expect("n1 should be in tree");
1252 assert_eq!(n1.status, Some(NodeStatus::Ok));
1253 }
1254
1255 /// 5. render_events Tree format includes all four status glyphs.
1256 /// Status semantics:
1257 /// - PlanComputed marks a target as InProgress (⏵ "spinning")
1258 /// - EdgeCompleted clears in_progress and sets has_closure (✔)
1259 /// - EdgeFailed clears in_progress and sets failed (⚠)
1260 /// - A node that's only listed in `Started` as a child (or never
1261 /// appears in any event but is reachable from a parent) renders
1262 /// as Pending (⏸)
1263 #[test]
1264 fn render_events_tree_format_includes_status_glyphs() {
1265 // Plan: (0,1) completes → ✔, (0,3) is in plan AND completes
1266 // before Finished → ✔. (0,2) fails → ⚠. n4 is in plan but
1267 // never resolves (no EdgeCompleted/EdgeFailed) → stays ⏵
1268 // (in_progress) when PlanComputed marked it.
1269 let events = vec![
1270 started_event(&[0]),
1271 CascadeEvent::PlanComputed {
1272 round: 0,
1273 assignments: vec![
1274 Edge {
1275 src: node(0),
1276 tgt: node(1),
1277 },
1278 Edge {
1279 src: node(0),
1280 tgt: node(2),
1281 },
1282 Edge {
1283 src: node(0),
1284 tgt: node(4),
1285 }, // never resolves → ⏵
1286 ],
1287 },
1288 edge_completed(0, 0, 1, 5), // ✔
1289 edge_failed(0, 0, 2), // ⚠
1290 // n4 left in_progress (PlanComputed marked it but no
1291 // EdgeCompleted/EdgeFailed)
1292 finished(2, 1, 1),
1293 ];
1294
1295 let out = render_events(
1296 &events,
1297 &OutputFormat::Tree {
1298 max_depth: None,
1299 color: false,
1300 },
1301 );
1302
1303 assert!(out.contains("✔"), "missing ✔ (Ok glyph): {out}");
1304 assert!(out.contains("⚠"), "missing ⚠ (Failed glyph): {out}");
1305 // n4 still in_progress at the end → ⏵ glyph visible.
1306 // This is the test that proves PlanComputed sets in_progress
1307 // and unresolved nodes keep that state.
1308 assert!(
1309 out.contains("⏵"),
1310 "missing ⏵ (InProgress glyph for unresolved planned target): {out}"
1311 );
1312 }
1313
1314 /// 7. LiveTreeRenderer emits a frame on each PlanComputed +
1315 /// RoundCompleted + Finished, uses cursor-home + clear-screen
1316 /// escapes (alt-screen idiom, like top/htop/vim/less), and
1317 /// captures multiple distinct frames over the cascade lifetime.
1318 /// Also verifies priority sorting: Failed nodes appear before Ok
1319 /// nodes in the rendered output (nom/State/Sorting.hs order).
1320 #[test]
1321 fn live_tree_renderer_emits_multiple_frames_with_ansi_redraw() {
1322 // Drive a sequence: Started, PlanComputed, EdgeCompleted(n1 ok),
1323 // EdgeFailed(n2 failed), RoundCompleted, Finished.
1324 // n2 (Failed) should appear before n1 (Ok) in the final frame
1325 // due to priority sorting (Failed → priority 0, Ok → priority 3).
1326 //
1327 // Capture mode bypasses the 60ms gate, so all 4 paint-triggering
1328 // events (PlanComputed, RoundCompleted, Finished) still produce frames.
1329 let renderer = LiveTreeRenderer::with_capture(false, None);
1330
1331 renderer.emit(&CascadeEvent::Started {
1332 n_nodes: 4,
1333 seeded: vec![NodeId(0)],
1334 strategy: "log2-fanout".into(),
1335 at: SystemTime::UNIX_EPOCH,
1336 });
1337 renderer.emit(&CascadeEvent::PlanComputed {
1338 round: 0,
1339 assignments: vec![
1340 Edge {
1341 src: NodeId(0),
1342 tgt: NodeId(1),
1343 },
1344 Edge {
1345 src: NodeId(0),
1346 tgt: NodeId(2),
1347 },
1348 ],
1349 });
1350 renderer.emit(&edge_completed(0, 0, 1, 5)); // n1 → Ok
1351 renderer.emit(&edge_failed(0, 0, 2)); // n2 → Failed
1352 renderer.emit(&CascadeEvent::RoundCompleted {
1353 round: 0,
1354 duration: Duration::from_millis(5),
1355 has_closure: vec![NodeId(0), NodeId(1)],
1356 });
1357 renderer.emit(&edge_completed(1, 1, 3, 5));
1358 renderer.emit(&CascadeEvent::RoundCompleted {
1359 round: 1,
1360 duration: Duration::from_millis(5),
1361 has_closure: vec![NodeId(0), NodeId(1), NodeId(3)],
1362 });
1363 renderer.emit(&CascadeEvent::Finished {
1364 converged: 3,
1365 failed: 1,
1366 rounds: 2,
1367 });
1368
1369 let captured = renderer.captured();
1370
1371 // Replicates nom's `writeStateToScreen` exactly: each frame is
1372 // wrapped in synchronized-update markers `\x1b[?2026h` …
1373 // `\x1b[?2026l`. The first frame has no preceding clear (last
1374 // was 0). Subsequent frames clear the previous frame line-by-
1375 // line: `\x1b[2K` for the bottom line, then `\x1b[1A\x1b[2K`
1376 // for each line above.
1377 //
1378 // Test events: PlanComputed → frame 1, RoundCompleted×2 → frames
1379 // 2-3, Finished → frame 4. Total 4 sync-update begin markers.
1380 // (Capture mode bypasses the 60ms gate so all 4 paint.)
1381 let sync_begin = captured.matches("\x1b[?2026h").count();
1382 assert_eq!(
1383 sync_begin, 4,
1384 "expected exactly 4 synchronized-update begin markers (one per repaint); got {sync_begin}\n{captured:?}"
1385 );
1386 let sync_end = captured.matches("\x1b[?2026l").count();
1387 assert_eq!(
1388 sync_end, 4,
1389 "synchronized-update markers should be balanced"
1390 );
1391 // Frames 2, 3, 4 each emit at least one clear-line escape
1392 // (\x1b[2K) since they're erasing prior content. Don't assert
1393 // exact count — depends on how many lines the prior frame had.
1394 let clear_lines = captured.matches("\x1b[2K").count();
1395 assert!(
1396 clear_lines >= 3,
1397 "expected at least 3 clear-line escapes (one per non-first frame); got {clear_lines}\n{captured:?}"
1398 );
1399 // Every frame should include the heavy section border ┏━ at top
1400 // and ┗━ at bottom — that's how nom wraps each section.
1401 assert!(
1402 captured.contains("┏━"),
1403 "missing top border ┏━: {captured:?}"
1404 );
1405 assert!(
1406 captured.contains("┗━"),
1407 "missing bottom border ┗━: {captured:?}"
1408 );
1409 // Bottom border carries a summary row with at least one ✔ count.
1410 assert!(captured.contains("✔"), "missing ✔ glyph: {captured:?}");
1411 // n0, n1, n2 should all eventually appear in the captured output.
1412 assert!(captured.contains("n0"));
1413 assert!(captured.contains("n1"));
1414 assert!(captured.contains("n2"));
1415
1416 // Priority sorting assertion (nom/State/Sorting.hs):
1417 // n2 is Failed (priority 0), n1 is Ok (priority 3).
1418 // In the final frame n2 must appear before n1.
1419 // Find positions in the last frame by scanning from the last
1420 // sync-begin marker backwards is complex; instead scan the full
1421 // captured output for relative positions (n2 always before n1).
1422 let pos_n2 = captured
1423 .rfind("n2")
1424 .expect("n2 should appear in captured output");
1425 let pos_n1 = captured
1426 .rfind("n1")
1427 .expect("n1 should appear in captured output");
1428 assert!(
1429 pos_n2 < pos_n1,
1430 "priority sort: n2 (Failed) should appear before n1 (Ok) in the final frame; \
1431 n2 at byte {pos_n2}, n1 at byte {pos_n1}"
1432 );
1433 }
1434
1435 /// 8. Truncation: a tall cascade frame is capped at max_height lines
1436 /// per NOM/IO.hs `truncateRows`, with ` ⋮ ` ellipsis inserted.
1437 #[test]
1438 fn live_tree_renderer_truncates_tall_frames() {
1439 use consortium_nix::cascade_events::Edge;
1440
1441 // Build a wide cascade: node 0 seeds 30 children (nodes 1..=30).
1442 // Each child is a leaf (Ok). With max_height=20 the frame must be
1443 // truncated — 30 body lines + 2 chrome lines (header + footer) = 32
1444 // total, which exceeds 20.
1445 let renderer = LiveTreeRenderer::with_capture(false, None).with_max_height(Some(20));
1446
1447 // Start with node 0 as seed.
1448 renderer.emit(&CascadeEvent::Started {
1449 n_nodes: 31,
1450 seeded: vec![NodeId(0)],
1451 strategy: "log2-fanout".into(),
1452 at: SystemTime::UNIX_EPOCH,
1453 });
1454
1455 // Plan all 30 edges at once.
1456 let assignments: Vec<Edge> = (1u32..=30)
1457 .map(|i| Edge {
1458 src: NodeId(0),
1459 tgt: NodeId(i),
1460 })
1461 .collect();
1462 renderer.emit(&CascadeEvent::PlanComputed {
1463 round: 0,
1464 assignments,
1465 });
1466
1467 // Complete all edges.
1468 for i in 1u32..=30 {
1469 renderer.emit(&edge_completed(0, 0, i, 5));
1470 }
1471 renderer.emit(&CascadeEvent::RoundCompleted {
1472 round: 0,
1473 duration: Duration::from_millis(5),
1474 has_closure: (0u32..=30).map(NodeId).collect(),
1475 });
1476 renderer.emit(&CascadeEvent::Finished {
1477 converged: 31,
1478 failed: 0,
1479 rounds: 1,
1480 });
1481
1482 let captured = renderer.captured();
1483
1484 // The frame must contain the ellipsis marker.
1485 assert!(
1486 captured.contains(" ⋮ "),
1487 "expected truncation ellipsis ` ⋮ ` in captured output; frame:\n{captured}"
1488 );
1489
1490 // Find the last frame (after the final sync-begin marker) and count its lines.
1491 let last_frame_start = captured
1492 .rfind("\x1b[?2026h")
1493 .expect("no sync-begin marker found");
1494 let last_frame_end = captured
1495 .rfind("\x1b[?2026l")
1496 .expect("no sync-end marker found");
1497 let last_frame = &captured[last_frame_start..last_frame_end];
1498 // Strip ANSI escape sequences for line counting.
1499 let visible: String = last_frame
1500 .split('\n')
1501 .filter(|l| !l.is_empty())
1502 .collect::<Vec<_>>()
1503 .join("\n");
1504 let line_count = visible.lines().count();
1505 assert!(
1506 line_count <= 22,
1507 "truncated frame should be roughly max_height lines; got {line_count} lines"
1508 );
1509 }
1510
1511 /// 6. JSONL round-trip: serialize events via JsonlWriter → parse back.
1512 #[test]
1513 fn render_events_jsonl_format_round_trips() {
1514 use std::sync::Arc;
1515
1516 let events = vec![
1517 started_event(&[0]),
1518 edge_completed(0, 0, 1, 10),
1519 edge_completed(0, 0, 2, 15),
1520 edge_failed(0, 0, 3),
1521 finished(3, 1, 1),
1522 ];
1523
1524 // Write via JsonlWriter.
1525 let shared: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
1526 let shared_clone = Arc::clone(&shared);
1527
1528 struct SharedBuf(Arc<Mutex<Vec<u8>>>);
1529 impl Write for SharedBuf {
1530 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1531 self.0.lock().unwrap().extend_from_slice(buf);
1532 Ok(buf.len())
1533 }
1534 fn flush(&mut self) -> io::Result<()> {
1535 Ok(())
1536 }
1537 }
1538
1539 let writer = JsonlWriter::new(Box::new(SharedBuf(shared_clone)));
1540 for ev in &events {
1541 writer.emit(ev);
1542 }
1543
1544 let bytes = shared.lock().unwrap().clone();
1545 let text = String::from_utf8(bytes).unwrap();
1546 let lines: Vec<&str> = text.lines().collect();
1547
1548 assert_eq!(lines.len(), events.len(), "line count mismatch");
1549
1550 // Parse each line back and count variants.
1551 let mut started = 0usize;
1552 let mut completed = 0usize;
1553 let mut failed = 0usize;
1554 let mut finished_count = 0usize;
1555
1556 for line in &lines {
1557 let ev: CascadeEvent = serde_json::from_str(line)
1558 .unwrap_or_else(|e| panic!("invalid JSONL line {line:?}: {e}"));
1559 match ev {
1560 CascadeEvent::Started { .. } => started += 1,
1561 CascadeEvent::EdgeCompleted { .. } => completed += 1,
1562 CascadeEvent::EdgeFailed { .. } => failed += 1,
1563 CascadeEvent::Finished { .. } => finished_count += 1,
1564 _ => {}
1565 }
1566 }
1567
1568 assert_eq!(started, 1);
1569 assert_eq!(completed, 2);
1570 assert_eq!(failed, 1);
1571 assert_eq!(finished_count, 1);
1572 }
1573}