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:
2026-06-08 12:08:13 +02:00
parent 32ac8a30ea
commit b73d7076c3
2 changed files with 67 additions and 25 deletions
+30 -9
View File
@@ -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)
}