diff --git a/crates/aura-cli/src/graph.rs b/crates/aura-cli/src/graph.rs index 2475e0a..01be71b 100644 --- a/crates/aura-cli/src/graph.rs +++ b/crates/aura-cli/src/graph.rs @@ -14,13 +14,24 @@ //! subgraph layout mis-centres wide sibling labels, the flat layout does not. use ascii_dag::graph::{Graph, RenderMode}; +use ascii_dag::render::colors::Palette; use aura_core::{LeafFactory, Node, ScalarKind}; use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, SourceSpec}; +/// Edge colouring for a rendered graph. `Plain` is monochrome — golden-stable and +/// pipe-safe (no escape codes when redirected to a file). `Ansi` emits per-edge +/// ANSI colours so crossing edges stay traceable on an interactive terminal. The +/// CLI selects `Ansi` only when stdout is a TTY; tests always render `Plain`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Color { + Plain, + Ansi, +} + /// Blueprint view: the authored structure (#38). A flat main graph wires the /// harness with each composite shown as a single opaque node; a `where:` section /// defines each distinct composite type once. Flat layout only (no subgraphs). -pub fn render_blueprint(bp: &Blueprint) -> String { +pub fn render_blueprint(bp: &Blueprint, color: Color) -> String { // pass 1: one main-graph node per top-level item (leaf -> bare-type label; // composite -> its name, opaque) and one node per source. let mut labels: Vec = Vec::new(); @@ -52,20 +63,20 @@ pub fn render_blueprint(bp: &Blueprint) -> String { } } - let main = render_flat(&labels, &edges); + let main = render_flat(&labels, &edges, color); // definitions: each distinct composite type, once, recursively. let defs = collect_distinct_composites(bp); if defs.is_empty() { return main; } - let body = defs.iter().map(|c| render_definition(c)).collect::>().join("\n"); + let body = defs.iter().map(|c| render_definition(c, color)).collect::>().join("\n"); format!("{main}\nwhere:\n\n{body}") } /// Build and render a flat (no-subgraph) ascii-dag graph from owned labels and /// edge pairs — the same idiom `render_compilat` uses. -fn render_flat(labels: &[String], edges: &[(usize, usize)]) -> String { +fn render_flat(labels: &[String], edges: &[(usize, usize)], color: Color) -> String { let mut g = Graph::with_mode(RenderMode::Vertical); for (id, l) in labels.iter().enumerate() { g.add_node(id, l); @@ -73,7 +84,12 @@ fn render_flat(labels: &[String], edges: &[(usize, usize)]) -> String { for &(from, to) in edges { g.add_edge(from, to, None); } - g.render() + match color { + Color::Plain => g.render(), + // colour lives in the layout IR's scanline pass; node labels stay default + // colour (ascii-dag only colours edges), so the box text is unaffected. + Color::Ansi => g.compute_layout().render_scanline_colored(Palette::Ansi), + } } /// Every distinct composite type in the blueprint, in first-seen order, keyed by @@ -185,7 +201,7 @@ fn leaf_label(c: &Composite, index: usize, factory: &LeafFactory) -> String { /// node per re-exported output field (wired from its producer). The title line is /// the composite's typed `signature` (`name(p:kind, …) -> (out, …)`); params live /// in the signature, not as marker nodes. -fn render_definition(c: &Composite) -> String { +fn render_definition(c: &Composite, color: Color) -> String { let mut labels: Vec = Vec::with_capacity(c.nodes().len()); for (i, inner) in c.nodes().iter().enumerate() { labels.push(match inner { @@ -210,12 +226,17 @@ fn render_definition(c: &Composite) -> String { edges.push((of.node, out_id)); } - format!("{}:\n{}", signature(c), render_flat(&labels, &edges)) + format!("{}:\n\n{}", signature(c), render_flat(&labels, &edges, color)) } /// Compiled view: the flat post-inline graph (no clusters; boundaries dissolved, /// C23). Each `Box` labels itself; node display id = node index. -pub fn render_compilat(nodes: &[Box], sources: &[SourceSpec], edges: &[Edge]) -> String { +pub fn render_compilat( + nodes: &[Box], + sources: &[SourceSpec], + edges: &[Edge], + color: Color, +) -> String { let mut labels: Vec = nodes.iter().map(|n| n.label()).collect(); let source_base = labels.len(); for src in sources { @@ -228,5 +249,5 @@ pub fn render_compilat(nodes: &[Box], sources: &[SourceSpec], edges: & edge_pairs.push((source_base + i, t.node)); } } - render_flat(&labels, &edge_pairs) + render_flat(&labels, &edge_pairs, color) } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 278d735..3f6f345 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -14,6 +14,7 @@ use aura_engine::{ ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target, }; use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub}; +use std::io::IsTerminal; use std::sync::mpsc::{self, Receiver}; /// The built-in synthetic price stream: rises through t=4 then reverses, so the @@ -329,9 +330,9 @@ fn run_macd() -> RunReport { /// Compile a blueprint under its point vector and render the flat post-inline /// (C23) view — the shared body of the `--compiled` paths. -fn render_compiled(bp: Blueprint, point: &[Scalar]) -> String { +fn render_compiled(bp: Blueprint, point: &[Scalar], color: graph::Color) -> String { let (nodes, sources, edges) = bp.compile_with_params(point).expect("valid blueprint"); - graph::render_compilat(&nodes, &sources, &edges) + graph::render_compilat(&nodes, &sources, &edges, color) } const USAGE: &str = "usage: aura run [--macd] | aura graph [--compiled | --macd [--compiled]]"; @@ -341,16 +342,23 @@ fn main() { // so an unexpected trailing token falls through to the usage-error path rather // than masquerading as a successful run (#16 strict reading). let args: Vec = std::env::args().skip(1).collect(); + // Edge colouring only when stdout is an interactive terminal; a redirect to a + // file or pipe stays plain (no escape codes). `run` output is JSON, never coloured. + let color = if std::io::stdout().is_terminal() { + graph::Color::Ansi + } else { + graph::Color::Plain + }; match args.iter().map(String::as_str).collect::>().as_slice() { ["run"] => println!("{}", run_sample().to_json()), ["run", "--macd"] => println!("{}", run_macd().to_json()), - ["graph"] => println!("{}", graph::render_blueprint(&sample_blueprint())), + ["graph"] => println!("{}", graph::render_blueprint(&sample_blueprint(), color)), ["graph", "--compiled"] => { - println!("{}", render_compiled(sample_blueprint(), &sample_point())); + println!("{}", render_compiled(sample_blueprint(), &sample_point(), color)); } - ["graph", "--macd"] => println!("{}", graph::render_blueprint(&macd_blueprint())), + ["graph", "--macd"] => println!("{}", graph::render_blueprint(&macd_blueprint(), color)), ["graph", "--macd", "--compiled"] => { - println!("{}", render_compiled(macd_blueprint(), &macd_point())); + println!("{}", render_compiled(macd_blueprint(), &macd_point(), color)); } ["--help"] | ["-h"] => println!("{USAGE}"), _ => { @@ -373,7 +381,7 @@ mod tests { #[test] fn blueprint_view_main_graph_shows_composite_as_opaque_node() { - let out = graph::render_blueprint(&sample_blueprint()); + let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain); // the composite is a single opaque main-graph node, not an expanded cluster assert!(out.contains("[sma_cross]"), "missing opaque composite node:\n{out}"); // top-level leaves render as their bare-type nodes @@ -388,7 +396,7 @@ mod tests { #[test] fn blueprint_view_defines_each_composite_once() { - let out = graph::render_blueprint(&sample_blueprint()); + let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain); // the sma_cross body is defined exactly once, with its interior + ports assert_eq!(out.matches("sma_cross(").count(), 1, "definition not rendered once:\n{out}"); for needle in ["[SMA]", "[Sub(#A,#B)]", "[price]", "[cross]"] { @@ -421,7 +429,7 @@ mod tests { vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], vec![], ); - let out = graph::render_blueprint(&bp); // must not panic (no unimplemented!) + let out = graph::render_blueprint(&bp, graph::Color::Plain); // must not panic (no unimplemented!) // outer shows the inner composite as an opaque node, and both get a definition assert!(out.contains("[outer]"), "missing opaque outer node:\n{out}"); assert!(out.contains("[inner]"), "inner must be opaque inside outer's definition:\n{out}"); @@ -447,7 +455,7 @@ mod tests { Edge { from: 1, to: 2, slot: 0, from_field: 0 }, ], ); - let out = graph::render_blueprint(&bp); + let out = graph::render_blueprint(&bp, graph::Color::Plain); assert_eq!(out.matches("[dup]").count(), 2, "two opaque uses expected:\n{out}"); assert_eq!(out.matches("dup(").count(), 1, "body defined once:\n{out}"); } @@ -457,7 +465,7 @@ mod tests { let bp = sample_blueprint(); let (nodes, sources, edges) = bp.compile_with_params(&sample_point()).expect("valid sample"); - let out = graph::render_compilat(&nodes, &sources, &edges); + let out = graph::render_compilat(&nodes, &sources, &edges, graph::Color::Plain); // node labels survive inlining... assert!(out.contains("SMA(2)") && out.contains("SMA(4)"), "labels lost:\n{out}"); // ...but the composite cluster name does NOT (boundary dissolved, C23) @@ -472,16 +480,16 @@ mod tests { // view, where the flat nodes carry their valued labels (SMA(2)/SMA(4)). let (cn, cs, ce) = sample_blueprint().compile_with_params(&sample_point()).expect("valid sample"); - let correct = graph::render_compilat(&cn, &cs, &ce); + let correct = graph::render_compilat(&cn, &cs, &ce, graph::Color::Plain); let (sn, ss, se) = sample_blueprint().compile_with_params(&swapped_point()).expect("valid sample"); - let swapped = graph::render_compilat(&sn, &ss, &se); + let swapped = graph::render_compilat(&sn, &ss, &se, graph::Color::Plain); assert_ne!(correct, swapped, "a fast/slow SMA swap must change the compiled render"); } #[test] fn blueprint_view_golden() { - let out = graph::render_blueprint(&sample_blueprint()); + let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain); // ascii-dag's Sugiyama layout is deterministic (no RNG); these are the // exact bytes `aura graph` emits — main graph (composites opaque) + the // `where:` definitions section. Re-capture via `aura graph` if intended. @@ -504,6 +512,7 @@ mod tests { where: sma_cross() -> (cross): + [price] ┌───└───┐ ↓ ↓ @@ -525,7 +534,7 @@ sma_cross() -> (cross): let bp = sample_blueprint(); let (nodes, sources, edges) = bp.compile_with_params(&sample_point()).expect("valid sample"); - let out = graph::render_compilat(&nodes, &sources, &edges); + let out = graph::render_compilat(&nodes, &sources, &edges, graph::Color::Plain); let expected = r#" [source:F64] ┌────────└─┐──────┐ ↓ ↓ │ @@ -576,7 +585,7 @@ sma_cross() -> (cross): fn macd_blueprint_renders_a_nested_composite_definition() { // the MACD blueprint view shows the composite opaque in the main graph and // defines its EMA-of-EMA interior once under `where:`. - let out = graph::render_blueprint(&macd_blueprint()); + let out = graph::render_blueprint(&macd_blueprint(), graph::Color::Plain); assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}"); assert_eq!(out.matches("macd(").count(), 1, "macd defined once:\n{out}"); assert!( @@ -592,6 +601,18 @@ sma_cross() -> (cross): assert!(!out.contains("[out:"), "output prefix dropped: {out}"); } + #[test] + fn ansi_colour_emits_escapes_only_when_requested() { + // `Color::Ansi` adds per-edge ANSI escapes for an interactive terminal; + // `Color::Plain` is byte-clean (the golden / redirected-to-file path). The + // colour is orthogonal to content — node-label text is identical either way. + let plain = graph::render_blueprint(&macd_blueprint(), graph::Color::Plain); + let coloured = graph::render_blueprint(&macd_blueprint(), graph::Color::Ansi); + assert!(!plain.contains('\x1b'), "plain render must carry no escape codes:\n{plain}"); + assert!(coloured.contains('\x1b'), "ansi render must carry escape codes"); + assert!(coloured.contains("[macd]"), "labels survive colouring: {coloured}"); + } + /// E2E acceptance (#41 / spec 0019, the worked example): the real MACD strategy /// blueprint's swept param surface relabels the three otherwise-indistinguishable /// EMA `length` slots to `macd.fast` / `macd.slow` / `macd.signal` — the named