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