1use std::fs::File;
14use std::io::{self, BufRead, BufReader};
15
16use anyhow::{Context, Result};
17use clap::{Args, Subcommand};
18use is_terminal::IsTerminal;
19
20use crate::event_render::{
21 render_events, DelayingExecutor, EventCollector, JsonlWriter, LiveTreeRenderer,
22};
23use crate::tree::OutputFormat;
24use consortium_fanout_sim::fixtures::{
25 rng_from_seed, BandwidthDistribution, FailureSchedule, SeedDistribution, UplinkDistribution,
26};
27use consortium_nix::cascade::{
28 Cascade, CascadeNode, FanoutOrder, NetworkProfile, NodeIdAlloc, OrderedFanout,
29};
30use consortium_nix::cascade_events::{CascadeEvent, EventSink};
31use consortium_nix::cascade_strategies::{
32 find_strategy, parse_strategy, strategy_catalog, strategy_takes_traversal_order,
33};
34use consortium_nix::cascade_tree::{assert_relay_shape, parse_cascade_events};
35
36#[derive(Debug, Clone, Args)]
42pub struct RenderArgs {
43 #[arg(short = 'f', long = "format", default_value = "tree")]
45 pub format: String,
46
47 #[arg(short = 'L', long = "max-depth")]
49 pub max_depth: Option<usize>,
50
51 #[arg(long = "no-color")]
53 pub no_color: bool,
54}
55
56#[derive(Debug, Args)]
57pub struct TreeArgs {
58 pub trace_file: String,
60
61 #[command(flatten)]
62 pub render: RenderArgs,
63}
64
65#[derive(Debug, Args)]
66pub struct VerifyArgs {
67 pub trace_file: String,
69
70 #[arg(long)]
73 pub fanout: u32,
74
75 #[arg(long)]
77 pub nodes: Option<u32>,
78}
79
80#[derive(Debug, Clone, Args)]
81pub struct LiveArgs {
82 #[arg(short = 'n', long = "nodes", default_value_t = 32)]
84 pub nodes: u32,
85
86 #[arg(short = 's', long = "strategy", default_value = "level-tree")]
90 pub strategy: String,
91
92 #[arg(long = "fanout", default_value_t = 2)]
95 pub fanout: u32,
96
97 #[arg(long = "seeds", default_value_t = 1)]
100 pub seeds: u32,
101
102 #[arg(long = "seed-fraction", default_value_t = 0.0)]
105 pub seed_fraction: f64,
106
107 #[arg(long = "closure-mb", default_value_t = 50)]
109 pub closure_mb: u64,
110
111 #[arg(long = "bandwidth", default_value = "uniform")]
113 pub bandwidth: String,
114
115 #[arg(long = "uplinks")]
117 pub uplinks: Option<u64>,
118
119 #[arg(long = "seed", default_value_t = 0)]
121 pub seed: u64,
122
123 #[arg(long = "failure-rate", default_value_t = 0.0)]
126 pub failure_rate: f64,
127
128 #[arg(long = "failure-seed", default_value_t = 0)]
131 pub failure_seed: u64,
132
133 #[arg(long = "no-watch")]
136 pub no_watch: bool,
137
138 #[arg(long = "per-round-delay")]
141 pub per_round_delay_ms: Option<u64>,
142}
143
144#[derive(Debug, Subcommand)]
146pub enum CascadeCommands {
147 Tree(TreeArgs),
149
150 Verify(VerifyArgs),
155
156 Live {
158 #[command(flatten)]
159 live: LiveArgs,
160
161 #[command(flatten)]
162 render: RenderArgs,
163 },
164}
165
166pub fn dispatch(command: CascadeCommands) -> Result<()> {
173 match command {
174 CascadeCommands::Tree(args) => {
175 print!("{}", render_trace_file(&args.trace_file, &args.render)?);
176 }
177 CascadeCommands::Verify(args) => {
178 println!(
179 "{}",
180 verify_trace_file(&args.trace_file, args.fanout, args.nodes)?
181 );
182 }
183 CascadeCommands::Live { live, render } => run_live(&live, &render)?,
184 }
185 Ok(())
186}
187
188fn resolve_format(render: &RenderArgs) -> Result<OutputFormat> {
193 let mut fmt = OutputFormat::parse(&render.format).map_err(|e| anyhow::anyhow!(e))?;
194 if let OutputFormat::Tree {
195 color,
196 max_depth: fmt_max_depth,
197 } = &mut fmt
198 {
199 let is_tty = io::stdout().is_terminal();
200 *color = is_tty && !render.no_color;
201 if let Some(d) = render.max_depth {
202 *fmt_max_depth = Some(d);
203 }
204 }
205 Ok(fmt)
206}
207
208pub fn render_trace_file(path: &str, render: &RenderArgs) -> Result<String> {
212 let file = File::open(path).with_context(|| format!("failed to open trace file: {path}"))?;
213 let mut events: Vec<CascadeEvent> = Vec::new();
214 for (i, line) in BufReader::new(file).lines().enumerate() {
215 let line = line.with_context(|| format!("failed to read line {}", i + 1))?;
216 if line.trim().is_empty() {
217 continue;
218 }
219 let ev: CascadeEvent = serde_json::from_str(&line)
220 .with_context(|| format!("invalid event JSON on line {}: {line}", i + 1))?;
221 events.push(ev);
222 }
223 if render.format == "jsonl" {
224 let mut out = String::new();
225 for ev in &events {
226 out.push_str(&serde_json::to_string(ev)?);
227 out.push('\n');
228 }
229 return Ok(out);
230 }
231 let fmt = resolve_format(render)?;
232 Ok(render_events(&events, &fmt))
233}
234
235pub fn verify_trace_file(path: &str, fanout: u32, nodes: Option<u32>) -> Result<String> {
245 if fanout < 1 {
246 anyhow::bail!("fanout must be at least 1, got {fanout}");
247 }
248 let stream = std::fs::read_to_string(path)
249 .with_context(|| format!("failed to open trace file: {path}"))?;
250 let topology = parse_cascade_events(&stream)
251 .map_err(|e| anyhow::anyhow!("cascade relay check failed: {e}"))?;
252 let strategy = find_strategy(&topology.strategy).ok_or_else(|| {
253 anyhow::anyhow!(
254 "unknown strategy '{}' in started event; available: {}",
255 topology.strategy,
256 strategy_catalog()
257 )
258 })?;
259 let expected_rounds =
262 (strategy.rounds_rule)(topology.n_nodes as usize, topology.seeded.len(), fanout);
263 assert_relay_shape(&topology, strategy.canonical, expected_rounds, fanout)
264 .map_err(|e| anyhow::anyhow!("cascade relay check failed: {e}"))?;
265 if let Some(count) = nodes {
268 if topology.n_nodes != count {
269 anyhow::bail!(
270 "cascade covered {} nodes, expected {}",
271 topology.n_nodes,
272 count
273 );
274 }
275 }
276 Ok(serde_json::json!({
277 "nodes": topology.n_nodes,
278 "rounds": topology.rounds,
279 "relay_depth": topology.depth(),
280 "relayed_nodes": topology.parent.len(),
281 })
282 .to_string())
283}
284
285pub fn run_live(args: &LiveArgs, render: &RenderArgs) -> Result<()> {
293 run_live_with_order(args, render, FanoutOrder::Default)
294}
295
296pub(crate) fn run_live_with_order(
302 args: &LiveArgs,
303 render: &RenderArgs,
304 order: FanoutOrder,
305) -> Result<()> {
306 let bandwidth = match args.bandwidth.as_str() {
307 "bimodal" => BandwidthDistribution::Bimodal {
308 slow: 10 * 1024 * 1024,
309 fast: 1024 * 1024 * 1024,
310 fast_fraction: 0.3,
311 },
312 _ => BandwidthDistribution::Uniform(100 * 1024 * 1024),
313 };
314 let uplinks = args.uplinks.map(UplinkDistribution::Uniform);
315 let closure_bytes = args.closure_mb * 1024 * 1024;
316
317 if render.format == "jsonl" {
320 let sink = JsonlWriter::new(Box::new(io::stdout()));
321 run_scenario(args, closure_bytes, bandwidth, uplinks, &sink, None, order)?;
322 return Ok(());
323 }
324
325 let live_eligible = render.format == "tree" && io::stdout().is_terminal() && !args.no_watch;
331 if live_eligible {
332 let color = !render.no_color;
333 let mut header_lines: Vec<String> = Vec::new();
337
338 header_lines.push(format!(
339 "Strategy: {} || Nodes: {}",
340 args.strategy, args.nodes
341 ));
342
343 let mut scenario: Vec<String> = Vec::new();
344 if args.fanout != 2 {
345 scenario.push(format!("Fanout: {}", args.fanout));
346 }
347 if args.seeds != 1 {
348 scenario.push(format!("Seeds: {}", args.seeds));
349 }
350 if args.closure_mb != 50 {
351 scenario.push(format!("Closure: {}MB", args.closure_mb));
352 }
353 if args.bandwidth != "uniform" {
354 scenario.push(format!("Bandwidth: {}", args.bandwidth));
355 }
356 if let Some(uplink) = args.uplinks {
357 scenario.push(format!("Uplinks: {}B/s", uplink));
358 }
359 if !scenario.is_empty() {
360 header_lines.push(scenario.join(" || "));
361 }
362
363 let mut runtime: Vec<String> = Vec::new();
364 if args.failure_rate > 0.0 {
365 runtime.push(format!(
366 "Failures: {:.0}% (seed={})",
367 args.failure_rate * 100.0,
368 args.failure_seed
369 ));
370 }
371 if let Some(delay_ms) = args.per_round_delay_ms {
372 runtime.push(format!("Delay: {}ms/round", delay_ms));
373 }
374 if !runtime.is_empty() {
375 header_lines.push(runtime.join(" || "));
376 }
377
378 let renderer =
379 LiveTreeRenderer::new(color, render.max_depth).with_header_lines(header_lines);
380 let delay = args
381 .per_round_delay_ms
382 .map(std::time::Duration::from_millis);
383 run_scenario(
384 args,
385 closure_bytes,
386 bandwidth,
387 uplinks,
388 &renderer,
389 delay,
390 order,
391 )?;
392 return Ok(());
395 }
396
397 let collector = EventCollector::new();
399 run_scenario(
400 args,
401 closure_bytes,
402 bandwidth,
403 uplinks,
404 &collector,
405 None,
406 order,
407 )?;
408 let events = collector.events();
409 let fmt = resolve_format(render)?;
410 print!("{}", render_events(&events, &fmt));
411 Ok(())
412}
413
414fn run_scenario<S: EventSink>(
418 args: &LiveArgs,
419 closure_bytes: u64,
420 bandwidth: BandwidthDistribution,
421 uplinks: Option<UplinkDistribution>,
422 sink: &S,
423 per_round_delay: Option<std::time::Duration>,
424 order: FanoutOrder,
425) -> Result<()> {
426 let n_nodes = args.nodes;
427
428 let mut alloc = NodeIdAlloc::new();
429 let nodes: Vec<CascadeNode> = (0..n_nodes)
430 .map(|_| {
431 let id = alloc.alloc();
432 CascadeNode::new(id, format!("user@host-{}", id.0))
433 })
434 .collect();
435
436 let seeded: std::collections::HashSet<consortium_nix::cascade::NodeId> =
437 if args.seed_fraction > 0.0 {
438 let mut rng = rng_from_seed(args.seed);
439 SeedDistribution::Random {
440 fraction: args.seed_fraction,
441 }
442 .sample(&mut rng, n_nodes)
443 } else {
444 let count = args.seeds.min(n_nodes).max(1);
447 (0..count).map(consortium_nix::cascade::NodeId).collect()
448 };
449
450 let net: NetworkProfile = {
451 let mut rng = rng_from_seed(args.seed);
452 let mut profile = NetworkProfile::default();
453 bandwidth.populate(&mut rng, &mut profile, n_nodes);
454 if let Some(up) = &uplinks {
455 up.populate(&mut rng, &mut profile, n_nodes);
456 }
457 profile
458 };
459
460 let failures = if args.failure_rate > 0.0 {
461 FailureSchedule::Random {
462 fraction: args.failure_rate,
463 seed: args.failure_seed,
464 }
465 } else {
466 FailureSchedule::None
467 };
468 let base_exec = consortium_fanout_sim::DeterministicExecutor::new(closure_bytes, failures);
469
470 let delayed_exec = per_round_delay.map(|delay| DelayingExecutor {
474 inner: &base_exec as &dyn consortium_nix::cascade::RoundExecutor,
475 delay,
476 });
477 let exec: &dyn consortium_nix::cascade::RoundExecutor = delayed_exec
478 .as_ref()
479 .map(|d| d as &dyn consortium_nix::cascade::RoundExecutor)
480 .unwrap_or(&base_exec);
481
482 let mut strategy = parse_strategy(&args.strategy, args.fanout)?;
486 if order != FanoutOrder::Default {
490 if !strategy_takes_traversal_order(&args.strategy) {
491 let order_name = consortium_nix::cascade::FANOUT_ORDERS
492 .iter()
493 .find(|spec| spec.order == order)
494 .map(|spec| spec.canonical)
495 .unwrap_or("non-default");
496 anyhow::bail!(
497 "traversal order '{}' parameterizes the log2 fanout pairing; \
498 strategy '{}' does not consume it",
499 order_name,
500 args.strategy
501 );
502 }
503 strategy = Box::new(OrderedFanout::new(order));
504 }
505 Cascade::new()
506 .nodes(nodes)
507 .seeded(seeded)
508 .network(net)
509 .strategy(strategy.as_ref())
510 .executor(exec)
511 .events(sink)
512 .run();
513 Ok(())
514}
515
516#[cfg(test)]
521mod tests {
522 use super::*;
523 use clap::Parser;
524 use serde_json::json;
525
526 #[derive(Parser)]
529 struct TestCli {
530 #[command(subcommand)]
531 cmd: CascadeCommands,
532 }
533
534 fn parse(args: &[&str]) -> CascadeCommands {
535 let cli = TestCli::parse_from(args);
536 cli.cmd
537 }
538
539 const RELAY_15: &str = concat!(
542 r#"{"kind":"started","n_nodes":15,"seeded":[0],"strategy":"level-tree","at":0}"#,
543 "\n",
544 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":100000000}"#,
545 "\n",
546 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":100000000}"#,
547 "\n",
548 r#"{"kind":"edge_completed","round":1,"src":1,"tgt":3,"duration":100000000}"#,
549 "\n",
550 r#"{"kind":"edge_completed","round":1,"src":1,"tgt":4,"duration":100000000}"#,
551 "\n",
552 r#"{"kind":"edge_completed","round":1,"src":2,"tgt":5,"duration":100000000}"#,
553 "\n",
554 r#"{"kind":"edge_completed","round":1,"src":2,"tgt":6,"duration":100000000}"#,
555 "\n",
556 r#"{"kind":"edge_completed","round":2,"src":3,"tgt":7,"duration":100000000}"#,
557 "\n",
558 r#"{"kind":"edge_completed","round":2,"src":3,"tgt":8,"duration":100000000}"#,
559 "\n",
560 r#"{"kind":"edge_completed","round":2,"src":4,"tgt":9,"duration":100000000}"#,
561 "\n",
562 r#"{"kind":"edge_completed","round":2,"src":4,"tgt":10,"duration":100000000}"#,
563 "\n",
564 r#"{"kind":"edge_completed","round":2,"src":5,"tgt":11,"duration":100000000}"#,
565 "\n",
566 r#"{"kind":"edge_completed","round":2,"src":5,"tgt":12,"duration":100000000}"#,
567 "\n",
568 r#"{"kind":"edge_completed","round":2,"src":6,"tgt":13,"duration":100000000}"#,
569 "\n",
570 r#"{"kind":"edge_completed","round":2,"src":6,"tgt":14,"duration":100000000}"#,
571 "\n",
572 r#"{"kind":"finished","converged":15,"failed":0,"rounds":3}"#,
573 "\n",
574 );
575
576 const PAIR: &str = concat!(
578 r#"{"kind":"started","n_nodes":2,"seeded":[0],"strategy":"log2-fanout","at":0}"#,
579 "\n",
580 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":100000000}"#,
581 "\n",
582 r#"{"kind":"finished","converged":2,"failed":0,"rounds":1}"#,
583 "\n",
584 );
585
586 fn write_trace(dir: &tempfile::TempDir, name: &str, contents: &str) -> String {
587 let path = dir.path().join(name);
588 std::fs::write(&path, contents).unwrap();
589 path.to_str().unwrap().to_string()
590 }
591
592 fn render_args(format: &str) -> RenderArgs {
593 RenderArgs {
594 format: format.to_string(),
595 max_depth: None,
596 no_color: true,
597 }
598 }
599
600 #[test]
603 fn tree_names_a_missing_file() {
604 let err = render_trace_file("/nonexistent/trace.jsonl", &render_args("tree")).unwrap_err();
605 assert!(
606 err.to_string().contains("failed to open trace file"),
607 "got: {err}"
608 );
609 }
610
611 #[test]
612 fn tree_renders_a_trace() {
613 let dir = tempfile::tempdir().unwrap();
614 let path = write_trace(&dir, "trace.jsonl", PAIR);
615 let out = render_trace_file(&path, &render_args("tree")).unwrap();
616 assert!(out.contains("n0"), "expected n0 in: {out}");
617 assert!(out.contains("✔"), "expected converged glyph in: {out}");
618 }
619
620 #[test]
621 fn tree_json_output_is_an_event_array() {
622 let dir = tempfile::tempdir().unwrap();
623 let path = write_trace(&dir, "trace.jsonl", PAIR);
624 let out = render_trace_file(&path, &render_args("json")).unwrap();
625 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
626 let arr = parsed.as_array().expect("JSON array of events");
627 assert!(arr.len() >= 2, "expected at least Started + Finished");
628 assert_eq!(arr[0]["kind"], "started");
629 assert_eq!(arr.last().unwrap()["kind"], "finished");
630 }
631
632 #[test]
633 fn tree_jsonl_passes_events_through() {
634 let dir = tempfile::tempdir().unwrap();
635 let path = write_trace(&dir, "trace.jsonl", PAIR);
636 let out = render_trace_file(&path, &render_args("jsonl")).unwrap();
637 let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
638 assert_eq!(lines.len(), 3, "one line per event: {out}");
639 for line in lines {
640 serde_json::from_str::<serde_json::Value>(line)
641 .unwrap_or_else(|e| panic!("invalid JSON line: {e}\n {line}"));
642 }
643 }
644
645 #[test]
646 fn tree_names_invalid_event_json_by_line() {
647 let dir = tempfile::tempdir().unwrap();
648 let path = write_trace(&dir, "trace.jsonl", "{\"kind\":\"started\",\"n_nodes\":2,\"seeded\":[0],\"strategy\":\"log2-fanout\",\"at\":0}\ngarbage\n");
649 let err = render_trace_file(&path, &render_args("tree")).unwrap_err();
650 assert!(
651 err.to_string().contains("invalid event JSON on line 2"),
652 "got: {err}"
653 );
654 }
655
656 #[test]
659 fn verify_accepts_a_relayed_trace() {
660 let dir = tempfile::tempdir().unwrap();
661 let path = write_trace(&dir, "relay.jsonl", RELAY_15);
662 let out = verify_trace_file(&path, 2, None).unwrap();
663 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
664 assert_eq!(
665 parsed,
666 json!({
667 "nodes": 15,
668 "rounds": 3,
669 "relay_depth": 3,
670 "relayed_nodes": 14,
671 })
672 );
673 }
674
675 #[test]
676 fn verify_rejects_a_round_count_the_strategy_could_not_produce() {
677 let dir = tempfile::tempdir().unwrap();
678 let path = write_trace(
679 &dir,
680 "too-fast.jsonl",
681 &RELAY_15.replace(r#""rounds":3"#, r#""rounds":2"#),
682 );
683 let err = verify_trace_file(&path, 2, None).unwrap_err();
684 assert!(
685 err.to_string().contains("2 rounds"),
686 "the diagnostic must name the offending round count, got: {err}"
687 );
688 }
689
690 #[test]
691 fn verify_rejects_a_pull_only_trace() {
692 let stream = concat!(
693 r#"{"kind":"started","n_nodes":5,"seeded":[0],"strategy":"log2-fanout","at":0}"#,
694 "\n",
695 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":1}"#,
696 "\n",
697 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":1}"#,
698 "\n",
699 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":3,"duration":1}"#,
700 "\n",
701 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":4,"duration":1}"#,
702 "\n",
703 r#"{"kind":"finished","converged":5,"failed":0,"rounds":1}"#,
704 "\n",
705 );
706 let dir = tempfile::tempdir().unwrap();
707 let path = write_trace(&dir, "pull.jsonl", stream);
708 let err = verify_trace_file(&path, 2, None).unwrap_err();
709 assert!(
710 err.to_string()
711 .starts_with("cascade relay check failed: the payload was not relayed:"),
712 "got: {err}"
713 );
714 }
715
716 #[test]
720 fn verify_rejects_a_swarm_trace_by_the_star_rule() {
721 let stream = concat!(
722 r#"{"kind":"started","n_nodes":8,"seeded":[0],"strategy":"swarm","at":0}"#,
723 "\n",
724 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":1}"#,
725 "\n",
726 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":1}"#,
727 "\n",
728 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":3,"duration":1}"#,
729 "\n",
730 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":4,"duration":1}"#,
731 "\n",
732 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":5,"duration":1}"#,
733 "\n",
734 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":6,"duration":1}"#,
735 "\n",
736 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":7,"duration":1}"#,
737 "\n",
738 r#"{"kind":"finished","converged":8,"failed":0,"rounds":1}"#,
739 "\n",
740 );
741 let dir = tempfile::tempdir().unwrap();
742 let path = write_trace(&dir, "swarm.jsonl", stream);
743 let err = verify_trace_file(&path, 2, None).unwrap_err();
744 assert!(
745 err.to_string()
746 .starts_with("cascade relay check failed: the payload was not relayed:"),
747 "swarm must trip the star rule, got: {err}"
748 );
749 }
750
751 #[test]
755 fn live_rejects_an_unknown_strategy() {
756 let args = LiveArgs {
757 nodes: 4,
758 strategy: "warp-drive".into(),
759 fanout: 2,
760 seeds: 1,
761 seed_fraction: 0.0,
762 closure_mb: 1,
763 bandwidth: "uniform".into(),
764 uplinks: None,
765 seed: 0,
766 failure_rate: 0.0,
767 failure_seed: 0,
768 no_watch: true,
769 per_round_delay_ms: None,
770 };
771 let err = run_live(&args, &render_args("json")).unwrap_err();
772 assert!(
773 err.to_string().contains("unknown strategy 'warp-drive'"),
774 "got: {err}"
775 );
776 assert!(
777 err.to_string().contains("level-tree"),
778 "the error must list what is available, got: {err}"
779 );
780 }
781
782 #[test]
783 fn verify_names_a_malformed_line() {
784 let dir = tempfile::tempdir().unwrap();
785 let path = write_trace(&dir, "bad.jsonl", "not json\n");
786 let err = verify_trace_file(&path, 2, None).unwrap_err();
787 assert!(
788 err.to_string()
789 .starts_with("cascade relay check failed: line 1 is not JSON:"),
790 "got: {err}"
791 );
792 }
793
794 #[test]
795 fn verify_requires_a_started_event() {
796 let stream = concat!(
797 r#"{"kind":"finished","converged":2,"failed":0,"rounds":1}"#,
798 "\n",
799 );
800 let dir = tempfile::tempdir().unwrap();
801 let path = write_trace(&dir, "nostart.jsonl", stream);
802 let err = verify_trace_file(&path, 2, None).unwrap_err();
803 assert!(
804 err.to_string()
805 .starts_with("cascade relay check failed: event stream has no started event"),
806 "got: {err}"
807 );
808 }
809
810 #[test]
811 fn verify_checks_the_requested_node_count() {
812 let dir = tempfile::tempdir().unwrap();
813 let path = write_trace(&dir, "relay.jsonl", RELAY_15);
814 let err = verify_trace_file(&path, 2, Some(64)).unwrap_err();
815 assert_eq!(err.to_string(), "cascade covered 15 nodes, expected 64");
816 }
817
818 #[test]
819 fn verify_rejects_a_zero_fanout() {
820 let dir = tempfile::tempdir().unwrap();
821 let path = write_trace(&dir, "relay.jsonl", RELAY_15);
822 let err = verify_trace_file(&path, 0, None).unwrap_err();
823 assert_eq!(err.to_string(), "fanout must be at least 1, got 0");
824 }
825
826 #[test]
827 fn verify_rejects_an_unknown_strategy() {
828 let stream = concat!(
829 r#"{"kind":"started","n_nodes":4,"seeded":[0],"strategy":"warp-drive","at":0}"#,
830 "\n",
831 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":1,"duration":1}"#,
832 "\n",
833 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":2,"duration":1}"#,
834 "\n",
835 r#"{"kind":"edge_completed","round":0,"src":0,"tgt":3,"duration":1}"#,
836 "\n",
837 r#"{"kind":"finished","converged":4,"failed":0,"rounds":1}"#,
838 "\n",
839 );
840 let dir = tempfile::tempdir().unwrap();
841 let path = write_trace(&dir, "warp.jsonl", stream);
842 let err = verify_trace_file(&path, 2, None).unwrap_err();
843 assert!(
844 err.to_string()
845 .contains("unknown strategy 'warp-drive' in started event"),
846 "got: {err}"
847 );
848 }
849
850 #[test]
853 fn tree_parses_the_contract_flags() {
854 let CascadeCommands::Tree(args) = parse(&[
855 "cast",
856 "tree",
857 "t.jsonl",
858 "--format",
859 "json",
860 "--max-depth",
861 "3",
862 "--no-color",
863 ]) else {
864 panic!("expected Tree");
865 };
866 assert_eq!(args.trace_file, "t.jsonl");
867 assert_eq!(args.render.format, "json");
868 assert_eq!(args.render.max_depth, Some(3));
869 assert!(args.render.no_color);
870 }
871
872 #[test]
873 fn verify_requires_a_fanout() {
874 let missing = TestCli::try_parse_from(["cast", "verify", "t.jsonl"]);
875 assert!(missing.is_err(), "--fanout is required");
876 let CascadeCommands::Verify(args) = parse(&[
877 "cast", "verify", "t.jsonl", "--fanout", "2", "--nodes", "64",
878 ]) else {
879 panic!("expected Verify");
880 };
881 assert_eq!(args.fanout, 2);
882 assert_eq!(args.nodes, Some(64));
883 assert_eq!(args.trace_file, "t.jsonl");
884 }
885
886 #[test]
887 fn live_preserves_the_viz_flags() {
888 let CascadeCommands::Live { live: args, render } = parse(&[
889 "cast",
890 "live",
891 "-n",
892 "8",
893 "-s",
894 "steiner",
895 "--fanout",
896 "3",
897 "--seed",
898 "7",
899 "--no-watch",
900 "--format",
901 "jsonl",
902 ]) else {
903 panic!("expected Live");
904 };
905 assert_eq!(args.nodes, 8);
906 assert_eq!(args.strategy, "steiner");
907 assert_eq!(args.fanout, 3);
908 assert_eq!(args.seed, 7);
909 assert!(args.no_watch);
910 assert_eq!(render.format, "jsonl");
911 }
912}