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// 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}