use std::collections::HashMap;
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use consortium_nix::cascade::NodeId;
use consortium_nix::cascade_events::{CascadeEvent, EventSink};
use crate::tree::{NodeStatus, OutputFormat, TreeNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SinkKind {
LiveTree,
Jsonl,
}
pub fn sink_for_format(format: &str, tty: bool) -> Option<SinkKind> {
match format.trim().to_ascii_lowercase().as_str() {
"jsonl" => Some(SinkKind::Jsonl),
_ if tty => Some(SinkKind::LiveTree),
_ => None,
}
}
pub struct JsonlWriter {
inner: Mutex<Box<dyn Write + Send>>,
}
impl JsonlWriter {
pub fn new(w: Box<dyn Write + Send>) -> Self {
Self {
inner: Mutex::new(w),
}
}
pub fn file(path: impl AsRef<Path>) -> io::Result<Self> {
let f = File::create(path)?;
Ok(Self::new(Box::new(BufWriter::new(f))))
}
}
impl EventSink for JsonlWriter {
fn emit(&self, event: &CascadeEvent) {
if let Ok(line) = serde_json::to_string(event) {
let mut w = self.inner.lock().unwrap();
let _ = writeln!(w, "{line}");
}
}
}
pub struct EventCollector {
events: Mutex<Vec<CascadeEvent>>,
}
impl EventCollector {
pub fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
pub fn events(&self) -> Vec<CascadeEvent> {
self.events.lock().unwrap().clone()
}
pub fn len(&self) -> usize {
self.events.lock().unwrap().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn clear(&mut self) {
self.events.lock().unwrap().clear();
}
}
impl Default for EventCollector {
fn default() -> Self {
Self::new()
}
}
impl EventSink for EventCollector {
fn emit(&self, event: &CascadeEvent) {
self.events.lock().unwrap().push(event.clone());
}
}
#[derive(Debug, Clone)]
struct NodeState {
parent: Option<NodeId>,
children: Vec<NodeId>,
converged_round: Option<u32>,
duration: Option<Duration>,
has_closure: bool,
failed: bool,
in_progress: bool,
}
impl NodeState {
fn new() -> Self {
Self {
parent: None,
children: Vec::new(),
converged_round: None,
duration: None,
has_closure: false,
failed: false,
in_progress: false,
}
}
}
pub struct SnapshotAccumulator {
inner: Mutex<AccumulatorInner>,
}
#[derive(Default)]
struct AccumulatorInner {
nodes: HashMap<NodeId, NodeState>,
seeds: Vec<NodeId>,
round_durations: HashMap<u32, Duration>,
total_nodes: u32,
}
impl AccumulatorInner {
fn node(&mut self, id: NodeId) -> &mut NodeState {
self.nodes.entry(id).or_insert_with(NodeState::new)
}
}
impl SnapshotAccumulator {
pub fn new() -> Self {
Self {
inner: Mutex::new(AccumulatorInner::default()),
}
}
pub fn to_tree(&self) -> OwnedTreeNode {
let acc = self.inner.lock().unwrap();
build_tree(&acc)
}
pub fn status_counts(&self) -> (usize, usize, usize, usize) {
let acc = self.inner.lock().unwrap();
let mut ok = 0;
let mut in_progress = 0;
let mut failed = 0;
for state in acc.nodes.values() {
if state.failed {
failed += 1;
} else if state.has_closure {
ok += 1;
} else if state.in_progress {
in_progress += 1;
}
}
let active = ok + in_progress + failed;
let pending = (acc.total_nodes as usize).saturating_sub(active);
(ok, in_progress, pending, failed)
}
}
impl Default for SnapshotAccumulator {
fn default() -> Self {
Self::new()
}
}
impl EventSink for SnapshotAccumulator {
fn emit(&self, event: &CascadeEvent) {
let mut acc = self.inner.lock().unwrap();
match event {
CascadeEvent::Started {
seeded, n_nodes, ..
} => {
acc.seeds = seeded.clone();
acc.total_nodes = *n_nodes;
for i in 0..*n_nodes {
acc.node(NodeId(i));
}
for &id in seeded {
let n = acc.node(id);
n.has_closure = true;
}
}
CascadeEvent::PlanComputed { assignments, .. } => {
for edge in assignments {
let src = edge.src;
let tgt = edge.tgt;
acc.node(src);
{
let n = acc.node(tgt);
if n.parent.is_none() {
n.parent = Some(src);
}
n.in_progress = true;
}
if !acc
.nodes
.get(&src)
.map_or(false, |n| n.children.contains(&tgt))
{
acc.node(src).children.push(tgt);
}
}
}
CascadeEvent::EdgeStarted { src, tgt, .. } => {
acc.node(*src);
acc.node(*tgt);
}
CascadeEvent::EdgeCompleted {
round,
src,
tgt,
duration,
} => {
let src = *src;
let tgt = *tgt;
let round = *round;
let dur = *duration;
if !acc
.nodes
.get(&src)
.map_or(false, |n| n.children.contains(&tgt))
{
acc.node(src).children.push(tgt);
}
{
let n = acc.node(tgt);
n.parent = Some(src);
n.has_closure = true;
n.in_progress = false;
n.converged_round = Some(round);
n.duration = Some(dur);
}
}
CascadeEvent::EdgeFailed {
round: _, src, tgt, ..
} => {
let src = *src;
let tgt = *tgt;
if !acc
.nodes
.get(&src)
.map_or(false, |n| n.children.contains(&tgt))
{
acc.node(src).children.push(tgt);
}
{
let n = acc.node(tgt);
if n.parent.is_none() {
n.parent = Some(src);
}
n.failed = true;
n.in_progress = false; }
}
CascadeEvent::RoundCompleted {
round,
duration,
has_closure,
} => {
acc.round_durations.insert(*round, *duration);
for &id in has_closure {
acc.node(id).has_closure = true;
}
}
CascadeEvent::Finished { .. } => {}
}
}
}
fn build_tree(acc: &AccumulatorInner) -> OwnedTreeNode {
let root_id = acc.seeds.first().copied();
let Some(rid) = root_id else {
return OwnedTreeNode {
label: "cascade".into(),
status: Some(NodeStatus::Pending),
metadata: Vec::new(),
children: Vec::new(),
};
};
let mut root = build_node(rid, acc);
let in_tree = collect_node_ids(&root);
let mut orphans: Vec<NodeId> = acc
.nodes
.keys()
.copied()
.filter(|id| !in_tree.contains(id))
.filter(|id| {
acc.nodes
.get(id)
.and_then(|s| s.parent)
.is_none_or(|_| true)
})
.filter(|id| acc.nodes.get(id).is_some_and(|s| s.parent.is_none()))
.collect();
orphans.sort_by_key(|cid| {
let p = match acc.nodes.get(cid) {
None => 2u8,
Some(s) if s.failed => 0,
Some(s) if s.in_progress => 1,
Some(s) if !s.has_closure => 2,
Some(_) => 3,
};
(p, *cid)
});
for orphan in orphans {
root.children.push(build_node(orphan, acc));
}
root
}
fn collect_node_ids(node: &OwnedTreeNode) -> std::collections::HashSet<NodeId> {
let mut acc = std::collections::HashSet::new();
fn walk(n: &OwnedTreeNode, acc: &mut std::collections::HashSet<NodeId>) {
if let Some(num) = n
.label
.strip_prefix('n')
.and_then(|s| s.parse::<u32>().ok())
{
acc.insert(NodeId(num));
}
for c in &n.children {
walk(c, acc);
}
}
walk(node, &mut acc);
acc
}
fn build_node(id: NodeId, acc: &AccumulatorInner) -> OwnedTreeNode {
let state = acc.nodes.get(&id);
let status = match state {
None => Some(NodeStatus::Pending),
Some(s) if s.failed => Some(NodeStatus::Failed),
Some(s) if s.has_closure => Some(NodeStatus::Ok),
Some(s) if s.in_progress => Some(NodeStatus::InProgress),
Some(_) => Some(NodeStatus::Pending),
};
let mut metadata: Vec<(String, String)> = Vec::new();
if let Some(s) = state {
if let Some(round) = s.converged_round {
metadata.push(("round".into(), round.to_string()));
}
if let Some(dur) = s.duration {
let ms = dur.as_millis();
metadata.push(("duration".into(), format!("{ms}ms")));
}
}
let mut child_ids: Vec<NodeId> = state.map(|s| s.children.clone()).unwrap_or_default();
child_ids.sort();
child_ids.dedup();
child_ids.sort_by_key(|cid| {
let p = match acc.nodes.get(cid) {
None => 2u8, Some(s) if s.failed => 0, Some(s) if s.in_progress => 1, Some(s) if !s.has_closure => 2, Some(_) => 3, };
(p, *cid)
});
let children: Vec<OwnedTreeNode> = child_ids
.into_iter()
.map(|cid| build_node(cid, acc))
.collect();
OwnedTreeNode {
label: id.to_string(),
status,
metadata,
children,
}
}
pub struct OwnedTreeNode {
pub label: String,
pub status: Option<NodeStatus>,
pub metadata: Vec<(String, String)>,
pub children: Vec<OwnedTreeNode>,
}
impl TreeNode for OwnedTreeNode {
fn label(&self) -> String {
self.label.clone()
}
fn status(&self) -> Option<NodeStatus> {
self.status.clone()
}
fn metadata(&self) -> Vec<(String, String)> {
self.metadata.clone()
}
fn children(&self) -> Vec<&dyn TreeNode> {
self.children.iter().map(|c| c as &dyn TreeNode).collect()
}
}
fn truncate_to_width(s: &str, max_cols: usize) -> String {
if max_cols == usize::MAX {
return s.to_string();
}
let visible: String = strip_ansi(s);
let visible_width = visible.chars().count();
if visible_width <= max_cols {
return s.to_string();
}
let budget = max_cols.saturating_sub(1); let mut out = String::with_capacity(s.len());
let mut visible_count = 0;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
out.push(c);
for nc in chars.by_ref() {
out.push(nc);
if nc.is_ascii_alphabetic() {
break;
}
}
continue;
}
if visible_count >= budget {
break;
}
out.push(c);
visible_count += 1;
}
out.push('…');
out
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
for nc in chars.by_ref() {
if nc.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
pub struct LiveTreeRenderer {
accumulator: SnapshotAccumulator,
color: bool,
max_depth: Option<usize>,
capture: Option<Mutex<Vec<u8>>>,
last_printed_lines: Mutex<usize>,
max_height: Option<usize>,
last_paint_at: Mutex<Option<Instant>>,
header_lines: Mutex<Vec<String>>,
}
impl LiveTreeRenderer {
pub fn new(color: bool, max_depth: Option<usize>) -> Self {
Self {
accumulator: SnapshotAccumulator::new(),
color,
max_depth,
capture: None,
last_printed_lines: Mutex::new(0),
max_height: None,
last_paint_at: Mutex::new(None),
header_lines: Mutex::new(vec!["Cascade deploy".to_string()]),
}
}
#[doc(hidden)]
pub fn with_capture(color: bool, max_depth: Option<usize>) -> Self {
Self {
accumulator: SnapshotAccumulator::new(),
color,
max_depth,
capture: Some(Mutex::new(Vec::new())),
last_printed_lines: Mutex::new(0),
max_height: None,
last_paint_at: Mutex::new(None),
header_lines: Mutex::new(vec!["Cascade deploy".to_string()]),
}
}
pub fn with_max_height(mut self, h: Option<usize>) -> Self {
self.max_height = h;
self
}
pub fn with_header_text(self, text: impl Into<String>) -> Self {
*self.header_lines.lock().unwrap() = vec![text.into()];
self
}
pub fn with_header_lines(self, lines: Vec<String>) -> Self {
let lines = if lines.is_empty() {
vec!["Cascade deploy".to_string()]
} else {
lines
};
*self.header_lines.lock().unwrap() = lines;
self
}
#[doc(hidden)]
pub fn captured(&self) -> String {
self.capture
.as_ref()
.map(|m| String::from_utf8_lossy(&m.lock().unwrap()).to_string())
.unwrap_or_default()
}
fn build_frame(&self) -> String {
let tree = self.accumulator.to_tree();
let inner = crate::tree::render(
&tree,
&OutputFormat::Tree {
max_depth: self.max_depth,
color: self.color,
},
);
let (ok, in_progress, pending, failed) = self.accumulator.status_counts();
let term_cols = if self.capture.is_some() {
usize::MAX } else {
let (_, cols) = console::Term::stdout().size();
if cols == 0 {
usize::MAX
} else {
cols as usize
}
};
let mut frame = String::with_capacity(inner.len() + 256);
let header_lines = self.header_lines.lock().unwrap().clone();
for (i, line) in header_lines.iter().enumerate() {
let prefix = if i == 0 { "┏━ " } else { "┣ " };
let composed = format!("{prefix}{line}");
frame.push_str(&truncate_to_width(&composed, term_cols));
frame.push('\n');
}
for line in inner.lines() {
let bodied = format!("┃ {line}");
frame.push_str(&truncate_to_width(&bodied, term_cols));
frame.push('\n');
}
let summary = format!("┗━ ∑ || {ok} ✔ || {in_progress} ⏵ || {pending} ⏸ || {failed} ⚠");
frame.push_str(&truncate_to_width(&summary, term_cols));
frame.push('\n');
let max_height = match self.max_height {
Some(0) => return frame, Some(h) => h,
None => {
if self.capture.is_some() {
return frame;
}
let (rows, _cols) = console::Term::stdout().size();
if rows == 0 {
return frame; }
rows as usize
}
};
const ALWAYS_SHOW: usize = 5;
let lines: Vec<&str> = frame.lines().collect();
let n = lines.len();
if max_height <= ALWAYS_SHOW + 2 || n <= max_height {
return frame;
}
let drop_count = n + ALWAYS_SHOW + 2 - max_height;
let head = &lines[..1];
let tail = &lines[drop_count..];
let mut truncated = String::with_capacity(frame.len());
for l in head {
truncated.push_str(l);
truncated.push('\n');
}
truncated.push_str(" ⋮ \n");
for l in tail {
truncated.push_str(l);
truncated.push('\n');
}
truncated
}
fn repaint(&self, force: bool) {
if !force && self.capture.is_none() {
let mut last_paint = self.last_paint_at.lock().unwrap();
if let Some(t) = *last_paint {
if t.elapsed() < Duration::from_millis(60) {
return;
}
}
*last_paint = Some(Instant::now());
} else if self.capture.is_none() {
*self.last_paint_at.lock().unwrap() = Some(Instant::now());
}
let frame = self.build_frame();
let new_lines = frame.lines().count();
let mut last = self.last_printed_lines.lock().unwrap();
let mut bytes: Vec<u8> = Vec::with_capacity(frame.len() + 64);
bytes.extend_from_slice(b"\x1b[?2026h");
if *last > 0 {
bytes.extend_from_slice(b"\x1b[2K");
for _ in 0..*last {
bytes.extend_from_slice(b"\x1b[1A\x1b[2K");
}
bytes.extend_from_slice(b"\r");
}
bytes.extend_from_slice(frame.as_bytes());
bytes.extend_from_slice(b"\x1b[?2026l");
if let Some(cap) = &self.capture {
cap.lock().unwrap().extend_from_slice(&bytes);
} else {
let mut stdout = io::stdout().lock();
let _ = stdout.write_all(&bytes);
let _ = stdout.flush();
}
*last = new_lines;
}
}
impl EventSink for LiveTreeRenderer {
fn emit(&self, event: &CascadeEvent) {
self.accumulator.emit(event);
match event {
CascadeEvent::Finished { .. } => {
self.repaint(true); }
CascadeEvent::PlanComputed { .. } | CascadeEvent::RoundCompleted { .. } => {
self.repaint(false);
}
_ => {}
}
}
}
pub struct DelayingExecutor<'a> {
pub inner: &'a dyn consortium_nix::cascade::RoundExecutor,
pub delay: Duration,
}
impl<'a> consortium_nix::cascade::RoundExecutor for DelayingExecutor<'a> {
fn dispatch(
&self,
nodes: &[consortium_nix::cascade::CascadeNode],
edges: &[(
consortium_nix::cascade::NodeId,
consortium_nix::cascade::NodeId,
)],
net: &consortium_nix::cascade::NetworkProfile,
) -> std::collections::HashMap<
(
consortium_nix::cascade::NodeId,
consortium_nix::cascade::NodeId,
),
Result<Duration, consortium_nix::cascade::CascadeError>,
> {
std::thread::sleep(self.delay);
self.inner.dispatch(nodes, edges, net)
}
}
pub struct DelaySink<'a> {
pub inner: &'a dyn EventSink,
pub delay: Duration,
}
impl<'a> EventSink for DelaySink<'a> {
fn emit(&self, event: &CascadeEvent) {
self.inner.emit(event);
if matches!(event, CascadeEvent::RoundCompleted { .. }) {
std::thread::sleep(self.delay);
}
}
}
pub fn render_events(events: &[CascadeEvent], format: &OutputFormat) -> String {
match format {
OutputFormat::Tree { max_depth, color } => {
let acc = SnapshotAccumulator::new();
for ev in events {
acc.emit(ev);
}
let tree = acc.to_tree();
crate::tree::render(
&tree,
&OutputFormat::Tree {
max_depth: *max_depth,
color: *color,
},
)
}
OutputFormat::Json => serde_json::to_string_pretty(events)
.unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}")),
OutputFormat::Yaml => {
serde_yaml::to_string(events).unwrap_or_else(|e| format!("error: {e}"))
}
OutputFormat::Toml => {
let mut wrapper = std::collections::BTreeMap::new();
wrapper.insert("events", events);
match toml::to_string(&wrapper) {
Ok(t) => t,
Err(e) => format!("# toml render error: {e}\n"),
}
}
}
}
#[cfg(test)]
mod tests {
use std::time::SystemTime;
use consortium_nix::cascade::NodeId;
use consortium_nix::cascade_events::{CascadeEvent, Edge, EventSink};
use super::*;
use crate::tree::OutputFormat;
fn node(n: u32) -> NodeId {
NodeId(n)
}
fn started_event(seeds: &[u32]) -> CascadeEvent {
CascadeEvent::Started {
n_nodes: 4,
seeded: seeds.iter().map(|&n| NodeId(n)).collect(),
strategy: "log2-fanout".into(),
at: SystemTime::UNIX_EPOCH,
}
}
fn edge_completed(round: u32, src: u32, tgt: u32, ms: u64) -> CascadeEvent {
CascadeEvent::EdgeCompleted {
round,
src: node(src),
tgt: node(tgt),
duration: Duration::from_millis(ms),
}
}
fn edge_failed(round: u32, src: u32, tgt: u32) -> CascadeEvent {
use consortium_nix::cascade::CascadeError;
CascadeEvent::EdgeFailed {
round,
src: node(src),
tgt: node(tgt),
error: CascadeError::Copy {
node: node(tgt),
stderr: "rsync exited 23".into(),
},
}
}
fn finished(converged: usize, failed: usize, rounds: u32) -> CascadeEvent {
CascadeEvent::Finished {
converged,
failed,
rounds,
}
}
#[test]
fn jsonl_writer_writes_one_line_per_event() {
let buf: Vec<u8> = Vec::new();
let writer = JsonlWriter::new(Box::new(buf));
writer.emit(&started_event(&[0]));
writer.emit(&edge_completed(0, 0, 1, 10));
writer.emit(&finished(2, 0, 1));
let raw = {
let guard = writer.inner.lock().unwrap();
drop(guard);
Vec::<u8>::new() };
let cursor = std::io::Cursor::new(Vec::<u8>::new());
let writer2 = JsonlWriter::new(Box::new(cursor));
writer2.emit(&started_event(&[0]));
writer2.emit(&edge_completed(0, 0, 1, 10));
writer2.emit(&finished(2, 0, 1));
let guard = writer2.inner.lock().unwrap();
drop(guard);
let _ = raw;
use std::sync::Arc;
let shared: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let shared_clone = Arc::clone(&shared);
struct SharedBuf(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let writer3 = JsonlWriter::new(Box::new(SharedBuf(shared_clone)));
writer3.emit(&started_event(&[0]));
writer3.emit(&edge_completed(0, 0, 1, 10));
writer3.emit(&finished(2, 0, 1));
let bytes = shared.lock().unwrap().clone();
let text = String::from_utf8(bytes).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "expected 3 JSONL lines, got: {text}");
for line in &lines {
let _: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("invalid JSON on line {line:?}: {e}"));
}
}
#[test]
fn event_collector_records_in_order() {
let col = EventCollector::new();
col.emit(&started_event(&[0]));
col.emit(&edge_completed(0, 0, 1, 5));
col.emit(&finished(2, 0, 1));
assert_eq!(col.len(), 3);
let evs = col.events();
assert!(matches!(&evs[0], CascadeEvent::Started { .. }));
assert!(matches!(&evs[1], CascadeEvent::EdgeCompleted { .. }));
assert!(matches!(&evs[2], CascadeEvent::Finished { .. }));
}
#[test]
fn snapshot_accumulator_builds_correct_tree_topology() {
let acc = SnapshotAccumulator::new();
acc.emit(&started_event(&[0]));
acc.emit(&edge_completed(0, 0, 1, 10));
acc.emit(&edge_completed(0, 0, 2, 12));
acc.emit(&finished(3, 0, 1));
let tree = acc.to_tree();
assert_eq!(tree.label, "n0");
assert_eq!(
tree.children.len(),
3,
"root should have 2 served + 1 pre-populated orphan = 3 children"
);
let child_labels: Vec<&str> = tree.children.iter().map(|c| c.label.as_str()).collect();
assert!(child_labels.contains(&"n1"), "missing n1: {child_labels:?}");
assert!(child_labels.contains(&"n2"), "missing n2: {child_labels:?}");
assert!(
child_labels.contains(&"n3"),
"missing n3 (orphan): {child_labels:?}"
);
assert_eq!(tree.status, Some(NodeStatus::Ok));
for child in &tree.children {
let expected = match child.label.as_str() {
"n1" | "n2" => Some(NodeStatus::Ok),
"n3" => Some(NodeStatus::Pending),
other => panic!("unexpected child {other}"),
};
assert_eq!(child.status, expected, "wrong status for {}", child.label);
}
}
#[test]
fn accumulator_marks_failed_nodes() {
let acc = SnapshotAccumulator::new();
acc.emit(&started_event(&[0]));
acc.emit(&edge_completed(0, 0, 1, 8));
acc.emit(&edge_failed(0, 0, 2)); acc.emit(&finished(2, 1, 1));
let tree = acc.to_tree();
let n2 = tree
.children
.iter()
.find(|c| c.label == "n2")
.expect("n2 should be in tree even though it failed");
assert_eq!(n2.status, Some(NodeStatus::Failed), "n2 should be Failed");
let n1 = tree
.children
.iter()
.find(|c| c.label == "n1")
.expect("n1 should be in tree");
assert_eq!(n1.status, Some(NodeStatus::Ok));
}
#[test]
fn render_events_tree_format_includes_status_glyphs() {
let events = vec![
started_event(&[0]),
CascadeEvent::PlanComputed {
round: 0,
assignments: vec![
Edge {
src: node(0),
tgt: node(1),
},
Edge {
src: node(0),
tgt: node(2),
},
Edge {
src: node(0),
tgt: node(4),
}, ],
},
edge_completed(0, 0, 1, 5), edge_failed(0, 0, 2), finished(2, 1, 1),
];
let out = render_events(
&events,
&OutputFormat::Tree {
max_depth: None,
color: false,
},
);
assert!(out.contains("✔"), "missing ✔ (Ok glyph): {out}");
assert!(out.contains("⚠"), "missing ⚠ (Failed glyph): {out}");
assert!(
out.contains("⏵"),
"missing ⏵ (InProgress glyph for unresolved planned target): {out}"
);
}
#[test]
fn live_tree_renderer_emits_multiple_frames_with_ansi_redraw() {
let renderer = LiveTreeRenderer::with_capture(false, None);
renderer.emit(&CascadeEvent::Started {
n_nodes: 4,
seeded: vec![NodeId(0)],
strategy: "log2-fanout".into(),
at: SystemTime::UNIX_EPOCH,
});
renderer.emit(&CascadeEvent::PlanComputed {
round: 0,
assignments: vec![
Edge {
src: NodeId(0),
tgt: NodeId(1),
},
Edge {
src: NodeId(0),
tgt: NodeId(2),
},
],
});
renderer.emit(&edge_completed(0, 0, 1, 5)); renderer.emit(&edge_failed(0, 0, 2)); renderer.emit(&CascadeEvent::RoundCompleted {
round: 0,
duration: Duration::from_millis(5),
has_closure: vec![NodeId(0), NodeId(1)],
});
renderer.emit(&edge_completed(1, 1, 3, 5));
renderer.emit(&CascadeEvent::RoundCompleted {
round: 1,
duration: Duration::from_millis(5),
has_closure: vec![NodeId(0), NodeId(1), NodeId(3)],
});
renderer.emit(&CascadeEvent::Finished {
converged: 3,
failed: 1,
rounds: 2,
});
let captured = renderer.captured();
let sync_begin = captured.matches("\x1b[?2026h").count();
assert_eq!(
sync_begin, 4,
"expected exactly 4 synchronized-update begin markers (one per repaint); got {sync_begin}\n{captured:?}"
);
let sync_end = captured.matches("\x1b[?2026l").count();
assert_eq!(
sync_end, 4,
"synchronized-update markers should be balanced"
);
let clear_lines = captured.matches("\x1b[2K").count();
assert!(
clear_lines >= 3,
"expected at least 3 clear-line escapes (one per non-first frame); got {clear_lines}\n{captured:?}"
);
assert!(
captured.contains("┏━"),
"missing top border ┏━: {captured:?}"
);
assert!(
captured.contains("┗━"),
"missing bottom border ┗━: {captured:?}"
);
assert!(captured.contains("✔"), "missing ✔ glyph: {captured:?}");
assert!(captured.contains("n0"));
assert!(captured.contains("n1"));
assert!(captured.contains("n2"));
let pos_n2 = captured
.rfind("n2")
.expect("n2 should appear in captured output");
let pos_n1 = captured
.rfind("n1")
.expect("n1 should appear in captured output");
assert!(
pos_n2 < pos_n1,
"priority sort: n2 (Failed) should appear before n1 (Ok) in the final frame; \
n2 at byte {pos_n2}, n1 at byte {pos_n1}"
);
}
#[test]
fn live_tree_renderer_truncates_tall_frames() {
use consortium_nix::cascade_events::Edge;
let renderer = LiveTreeRenderer::with_capture(false, None).with_max_height(Some(20));
renderer.emit(&CascadeEvent::Started {
n_nodes: 31,
seeded: vec![NodeId(0)],
strategy: "log2-fanout".into(),
at: SystemTime::UNIX_EPOCH,
});
let assignments: Vec<Edge> = (1u32..=30)
.map(|i| Edge {
src: NodeId(0),
tgt: NodeId(i),
})
.collect();
renderer.emit(&CascadeEvent::PlanComputed {
round: 0,
assignments,
});
for i in 1u32..=30 {
renderer.emit(&edge_completed(0, 0, i, 5));
}
renderer.emit(&CascadeEvent::RoundCompleted {
round: 0,
duration: Duration::from_millis(5),
has_closure: (0u32..=30).map(NodeId).collect(),
});
renderer.emit(&CascadeEvent::Finished {
converged: 31,
failed: 0,
rounds: 1,
});
let captured = renderer.captured();
assert!(
captured.contains(" ⋮ "),
"expected truncation ellipsis ` ⋮ ` in captured output; frame:\n{captured}"
);
let last_frame_start = captured
.rfind("\x1b[?2026h")
.expect("no sync-begin marker found");
let last_frame_end = captured
.rfind("\x1b[?2026l")
.expect("no sync-end marker found");
let last_frame = &captured[last_frame_start..last_frame_end];
let visible: String = last_frame
.split('\n')
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("\n");
let line_count = visible.lines().count();
assert!(
line_count <= 22,
"truncated frame should be roughly max_height lines; got {line_count} lines"
);
}
#[test]
fn render_events_jsonl_format_round_trips() {
use std::sync::Arc;
let events = vec![
started_event(&[0]),
edge_completed(0, 0, 1, 10),
edge_completed(0, 0, 2, 15),
edge_failed(0, 0, 3),
finished(3, 1, 1),
];
let shared: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let shared_clone = Arc::clone(&shared);
struct SharedBuf(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let writer = JsonlWriter::new(Box::new(SharedBuf(shared_clone)));
for ev in &events {
writer.emit(ev);
}
let bytes = shared.lock().unwrap().clone();
let text = String::from_utf8(bytes).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), events.len(), "line count mismatch");
let mut started = 0usize;
let mut completed = 0usize;
let mut failed = 0usize;
let mut finished_count = 0usize;
for line in &lines {
let ev: CascadeEvent = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("invalid JSONL line {line:?}: {e}"));
match ev {
CascadeEvent::Started { .. } => started += 1,
CascadeEvent::EdgeCompleted { .. } => completed += 1,
CascadeEvent::EdgeFailed { .. } => failed += 1,
CascadeEvent::Finished { .. } => finished_count += 1,
_ => {}
}
}
assert_eq!(started, 1);
assert_eq!(completed, 2);
assert_eq!(failed, 1);
assert_eq!(finished_count, 1);
}
}