use std::io::{self, Write};
use colored::Colorize;
use petgraph::visit::{EdgeRef, IntoEdgeReferences};
use crate::graph::types::*;
use super::layout::{LayoutResult, sugiyama_layout};
#[cfg(not(tarpaulin_include))]
fn warn_if_too_wide(graph: &LineageGraph) {
if graph.node_count() == 0 {
return;
}
let layout = sugiyama_layout(graph);
if layout.num_layers == 0 {
return;
}
let col_widths = calculate_column_widths(graph, &layout);
let col_spacing = 4;
let total_width: usize =
col_widths.iter().sum::<usize>() + col_spacing * col_widths.len().saturating_sub(1);
if let Some((term_width, _)) = term_size()
&& total_width > term_width
{
crate::warn!(
"graph width ({}) exceeds terminal width ({}). Consider using --output dot or filtering with -u/-d.",
total_width,
term_width
);
}
}
#[cfg(not(tarpaulin_include))]
pub fn render_ascii(graph: &LineageGraph) {
warn_if_too_wide(graph);
super::handle_stdout_result(render_ascii_to_writer(graph, &mut std::io::stdout().lock()));
}
fn compute_col_offsets(col_widths: &[usize], spacing: usize) -> Vec<usize> {
let mut offsets = vec![0usize; col_widths.len()];
for i in 1..col_widths.len() {
offsets[i] = offsets[i - 1] + col_widths[i - 1] + spacing;
}
offsets
}
fn render_row(
graph: &LineageGraph,
layout: &LayoutResult,
row: usize,
col_widths: &[usize],
col_offsets: &[usize],
) -> String {
let mut line = String::new();
let mut cursor = 0;
for (layer_idx, layer) in layout.layers.iter().enumerate() {
let col_start = col_offsets[layer_idx];
let col_width = col_widths[layer_idx];
while cursor < col_start {
line.push(' ');
cursor += 1;
}
if row < layer.len() {
let node = &graph[layer[row]];
let display = node.display_name();
let box_str = format!("[ {} ]", display);
let colored_box = colorize_node(&box_str, node.node_type);
let padding = col_width.saturating_sub(box_str.len()) / 2;
for _ in 0..padding {
line.push(' ');
cursor += 1;
}
line.push_str(&colored_box);
cursor += box_str.len();
let remaining = col_start + col_width - cursor;
for _ in 0..remaining {
line.push(' ');
cursor += 1;
}
} else {
for _ in 0..col_width {
line.push(' ');
cursor += 1;
}
}
}
line
}
fn format_edge_arrow(edge_type: EdgeType) -> &'static str {
match edge_type {
EdgeType::Ref => "──ref──>",
EdgeType::Source => "──src──>",
EdgeType::Test => "──test─>",
EdgeType::Exposure => "──exp──>",
}
}
fn render_ascii_to_writer<W: Write>(graph: &LineageGraph, w: &mut W) -> io::Result<()> {
if graph.node_count() == 0 {
writeln!(w, "(empty graph — no nodes to display)")?;
return Ok(());
}
let layout = sugiyama_layout(graph);
if layout.num_layers == 0 {
return Ok(());
}
let col_widths = calculate_column_widths(graph, &layout);
let col_offsets = compute_col_offsets(&col_widths, 4);
for row in 0..layout.max_layer_width {
let line = render_row(graph, &layout, row, &col_widths, &col_offsets);
writeln!(w, "{}", line.trim_end())?;
}
writeln!(w)?;
writeln!(w, "{}", "Edges:".bold())?;
for edge in graph.edge_references() {
let source = &graph[edge.source()];
let target = &graph[edge.target()];
writeln!(
w,
" {} {} {}",
colorize_node(&source.display_name(), source.node_type),
format_edge_arrow(edge.weight().edge_type),
colorize_node(&target.display_name(), target.node_type),
)?;
}
writeln!(w)?;
print_legend_to_writer(w)?;
Ok(())
}
fn calculate_column_widths(graph: &LineageGraph, layout: &LayoutResult) -> Vec<usize> {
layout
.layers
.iter()
.map(|layer| {
layer
.iter()
.map(|&idx| {
let node = &graph[idx];
node.display_name().len() + 4
})
.max()
.unwrap_or(0)
})
.collect()
}
fn colorize_node(text: &str, node_type: NodeType) -> String {
match node_type {
NodeType::Model => text.blue().bold().to_string(),
NodeType::Source => text.green().to_string(),
NodeType::Seed => text.yellow().to_string(),
NodeType::Snapshot => text.magenta().to_string(),
NodeType::Test => text.cyan().to_string(),
NodeType::Exposure => text.red().to_string(),
NodeType::Phantom => text.white().dimmed().to_string(),
}
}
fn print_legend_to_writer<W: Write>(w: &mut W) -> io::Result<()> {
writeln!(w, "{}", "Legend:".bold())?;
writeln!(
w,
" {} {} {} {} {} {} {}",
"model".blue().bold(),
"source".green(),
"seed".yellow(),
"snapshot".magenta(),
"test".cyan(),
"exposure".red(),
"phantom".dimmed(),
)?;
Ok(())
}
#[cfg(not(tarpaulin_include))]
fn term_size() -> Option<(usize, usize)> {
#[cfg(unix)]
{
use std::mem;
unsafe {
let mut size: libc_winsize = mem::zeroed();
if libc_ioctl(1, TIOCGWINSZ, &mut size) == 0 && size.ws_col > 0 {
return Some((size.ws_col as usize, size.ws_row as usize));
}
}
}
None
}
#[cfg(not(tarpaulin_include))]
#[cfg(unix)]
#[repr(C)]
struct libc_winsize {
ws_row: u16,
ws_col: u16,
ws_xpixel: u16,
ws_ypixel: u16,
}
#[cfg(unix)]
const TIOCGWINSZ: u64 = 0x5413;
#[cfg(not(tarpaulin_include))]
#[cfg(unix)]
unsafe fn libc_ioctl(fd: i32, request: u64, arg: *mut libc_winsize) -> i32 {
unsafe extern "C" {
fn ioctl(fd: i32, request: u64, ...) -> i32;
}
unsafe { ioctl(fd, request, arg) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::test_helpers::make_node;
fn render_to_string(graph: &LineageGraph) -> String {
let mut buf = Vec::new();
render_ascii_to_writer(graph, &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn test_empty_graph() {
let graph = LineageGraph::new();
let output = render_to_string(&graph);
assert!(output.contains("empty graph"));
}
#[test]
fn test_single_node() {
let mut graph = LineageGraph::new();
graph.add_node(make_node("model.orders", "orders", NodeType::Model));
let output = render_to_string(&graph);
assert!(output.contains("orders"));
assert!(output.contains("Legend:"));
}
#[test]
fn test_edges_section() {
let mut graph = LineageGraph::new();
let a = graph.add_node(make_node(
"source.raw.orders",
"raw.orders",
NodeType::Source,
));
let b = graph.add_node(make_node("model.stg_orders", "stg_orders", NodeType::Model));
graph.add_edge(a, b, EdgeData::direct(EdgeType::Source));
let output = render_to_string(&graph);
assert!(output.contains("Edges:"));
assert!(
output.contains("──src──>"),
"Output should contain src arrow: {}",
output
);
}
#[test]
fn test_legend() {
let mut buf = Vec::new();
print_legend_to_writer(&mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("Legend:"));
}
#[test]
fn test_colorize_all_types() {
let types = [
NodeType::Model,
NodeType::Source,
NodeType::Seed,
NodeType::Snapshot,
NodeType::Test,
NodeType::Exposure,
NodeType::Phantom,
];
for nt in types {
let result = colorize_node("test", nt);
assert!(!result.is_empty(), "colorize_node failed for {:?}", nt);
}
}
#[test]
fn test_column_widths() {
let mut graph = LineageGraph::new();
let a = graph.add_node(make_node("model.short", "short", NodeType::Model));
let b = graph.add_node(make_node(
"model.very_long_name",
"very_long_name",
NodeType::Model,
));
graph.add_edge(a, b, EdgeData::direct(EdgeType::Ref));
let layout = sugiyama_layout(&graph);
let widths = calculate_column_widths(&graph, &layout);
assert!(widths[0] >= 9); assert!(widths[1] >= 18); }
#[test]
fn test_two_nodes_with_edge() {
let mut graph = LineageGraph::new();
let a = graph.add_node(make_node("model.a", "a", NodeType::Model));
let b = graph.add_node(make_node("model.b", "b", NodeType::Model));
graph.add_edge(a, b, EdgeData::direct(EdgeType::Ref));
let output = render_to_string(&graph);
assert!(output.contains("[ a ]"), "Output:\n{}", output);
assert!(output.contains("[ b ]"), "Output:\n{}", output);
assert!(output.contains("──ref──>"));
}
#[test]
fn test_format_edge_arrow_all_types() {
assert_eq!(format_edge_arrow(EdgeType::Ref), "──ref──>");
assert_eq!(format_edge_arrow(EdgeType::Source), "──src──>");
assert_eq!(format_edge_arrow(EdgeType::Test), "──test─>");
assert_eq!(format_edge_arrow(EdgeType::Exposure), "──exp──>");
}
#[test]
fn test_uneven_layers_padding() {
let mut graph = LineageGraph::new();
let src1 = graph.add_node(make_node("source.raw.a", "raw.a", NodeType::Source));
let src2 = graph.add_node(make_node("source.raw.b", "raw.b", NodeType::Source));
let model = graph.add_node(make_node("model.combined", "combined", NodeType::Model));
graph.add_edge(src1, model, EdgeData::direct(EdgeType::Source));
graph.add_edge(src2, model, EdgeData::direct(EdgeType::Source));
let output = render_to_string(&graph);
assert!(output.contains("raw.a"));
assert!(output.contains("raw.b"));
assert!(output.contains("combined"));
assert!(output.contains("Edges:"));
}
#[test]
fn test_compute_col_offsets() {
let widths = vec![10, 20, 15];
let offsets = compute_col_offsets(&widths, 4);
assert_eq!(offsets, vec![0, 14, 38]);
}
#[test]
fn test_all_edge_arrows_in_output() {
let mut graph = LineageGraph::new();
let a = graph.add_node(make_node("model.a", "a", NodeType::Model));
let b = graph.add_node(make_node("model.b", "b", NodeType::Model));
let t = graph.add_node(make_node("test.t", "t", NodeType::Test));
let e = graph.add_node(make_node("exposure.e", "e", NodeType::Exposure));
let s = graph.add_node(make_node("source.raw.s", "raw.s", NodeType::Source));
graph.add_edge(s, a, EdgeData::direct(EdgeType::Source));
graph.add_edge(a, b, EdgeData::direct(EdgeType::Ref));
graph.add_edge(b, t, EdgeData::direct(EdgeType::Test));
graph.add_edge(b, e, EdgeData::direct(EdgeType::Exposure));
let output = render_to_string(&graph);
assert!(output.contains("──src──>"));
assert!(output.contains("──ref──>"));
assert!(output.contains("──test─>"));
assert!(output.contains("──exp──>"));
}
}