feat(aura-cli): TTY-gated ANSI edge colouring + blank line after composite signature
Two presentation refinements to the `aura graph` views, render-only (no
engine change, C8/C9/C23 untouched).
Edge colouring: thread a `Color { Plain, Ansi }` through render_flat /
render_definition / render_blueprint / render_compilat. `Ansi` uses
ascii-dag's colored scanline pass (per-edge palette colours) so crossing
edges stay traceable; `Plain` is the existing monochrome `g.render()`.
The CLI selects `Ansi` only when stdout is a TTY (`IsTerminal`), so a
redirect to a file or pipe stays byte-clean — `aura graph | …` carries no
escape codes. ascii-dag colours only edges; node-label text is unaffected.
Tests render `Color::Plain` explicitly, so both goldens stay byte-stable
(compiled_view_golden remains the C23 guard); a new test pins that `Ansi`
emits escapes and `Plain` does not, and that labels survive colouring.
Blank line: render_definition now separates the typed signature title from
the graph body with an empty line. blueprint_view_golden re-captured.
Considered but rejected this round: per-node role highlighting of the
signature identifiers — needs either an ascii-dag fork (node colouring is
hard-disabled upstream) or a per-graph string-surgery pass that does not
generalise beyond hand-known labels. Only edge colouring survives here.
This commit is contained in:
@@ -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<String> = 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::<Vec<_>>().join("\n");
|
||||
let body = defs.iter().map(|c| render_definition(c, color)).collect::<Vec<_>>().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<String> = 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<dyn Node>` labels itself; node display id = node index.
|
||||
pub fn render_compilat(nodes: &[Box<dyn Node>], sources: &[SourceSpec], edges: &[Edge]) -> String {
|
||||
pub fn render_compilat(
|
||||
nodes: &[Box<dyn Node>],
|
||||
sources: &[SourceSpec],
|
||||
edges: &[Edge],
|
||||
color: Color,
|
||||
) -> String {
|
||||
let mut labels: Vec<String> = 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<dyn Node>], sources: &[SourceSpec], edges: &
|
||||
edge_pairs.push((source_base + i, t.node));
|
||||
}
|
||||
}
|
||||
render_flat(&labels, &edge_pairs)
|
||||
render_flat(&labels, &edge_pairs, color)
|
||||
}
|
||||
|
||||
+37
-16
@@ -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<String> = 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::<Vec<_>>().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
|
||||
|
||||
Reference in New Issue
Block a user