Skip to main content

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}