use crate::model::{short_interface_name, CompositionGraph};
use crate::output::{build_all_interfaces_view, build_full_view, DetailLevel, SymbolMap};
use crate::{find_chain_interfaces, get_chain_for};
pub fn generate_ascii(graph: &CompositionGraph, detail: DetailLevel, show_types: bool) -> String {
match detail {
DetailLevel::HandlerChain => generate_handler_chain_ascii(graph, show_types),
DetailLevel::AllInterfaces => generate_all_interfaces_ascii(graph, show_types),
DetailLevel::Full => generate_full_ascii(graph, show_types),
}
}
fn generate_handler_chain_ascii(graph: &CompositionGraph, show_types: bool) -> String {
let chain_interfaces = find_chain_interfaces(graph);
if chain_interfaces.is_empty() {
return box_content("Service Chains", &["No service chains found"]);
}
let mut symbols = SymbolMap::new();
let mut lines = Vec::new();
for (i, iface) in chain_interfaces.iter().enumerate() {
let chain = get_chain_for(graph, iface);
if chain.is_empty() {
continue;
}
if i > 0 {
lines.push(String::new());
}
let short = short_interface_name(iface);
let export_sym: String = show_types
.then(|| {
graph
.component_exports
.get(iface.as_str())
.and_then(|info| symbols.symbol_for_export(info, &graph.arena))
.map(str::to_string)
})
.flatten()
.unwrap_or_default();
if let Some(&first_idx) = chain.first() {
if let Some(first_node) = graph.get_node(first_idx) {
lines.push(format!(
"[Export: {}{}] ──> {}",
short,
export_sym,
first_node.display_label()
));
}
}
for window in chain.windows(2) {
if let [from_idx, to_idx] = window {
if let (Some(from_node), Some(to_node)) =
(graph.get_node(*from_idx), graph.get_node(*to_idx))
{
let conn_sym: String = show_types
.then(|| {
from_node
.imports
.iter()
.find(|c| &c.interface_name == iface)
.and_then(|c| symbols.symbol_for_conn(c, &graph.arena))
.map(str::to_string)
})
.flatten()
.unwrap_or_default();
lines.push(format!(
"{} ── {}{} ──> {}",
from_node.display_label(),
short,
conn_sym,
to_node.display_label()
));
}
}
}
}
if !symbols.is_empty() {
lines.push(String::new());
lines.extend(symbols.key_lines());
}
box_content("Service Chains", &lines)
}
fn generate_all_interfaces_ascii(graph: &CompositionGraph, show_types: bool) -> String {
let view = build_all_interfaces_view(graph, show_types);
if view.nodes.is_empty() {
return box_content("Component Instances", &["No component instances found"]);
}
let mut output = String::new();
if !view.host_names.is_empty() {
let host_lines: Vec<String> = view
.host_names
.iter()
.map(|i| format!(" {{{}}}", short_interface_name(i)))
.collect();
output.push_str(&box_content("Host Imports", &host_lines));
output.push('\n');
}
let instance_lines: Vec<String> = view
.nodes
.iter()
.map(|n| format!(" [{}]", n.display))
.collect();
output.push_str(&box_content("Component Instances", &instance_lines));
output.push('\n');
let mut symbols = SymbolMap::new();
let mut connection_lines = Vec::new();
for edge in &view.edges {
let sym = symbols.assign(
show_types,
edge.fingerprint.as_deref(),
edge.type_lines.clone(),
);
if edge.is_dashed {
connection_lines.push(format!(
" {{{}}} --- {}{} --> [{}]",
edge.from_display, edge.label, sym, edge.to_display
));
} else {
connection_lines.push(format!(
" [{}] ── {}{} ──> [{}]",
edge.from_display, edge.label, sym, edge.to_display
));
}
}
for exp in &view.exports {
let sym = symbols.assign(
show_types,
exp.fingerprint.as_deref(),
exp.type_lines.clone(),
);
connection_lines.push(format!(
" [{}] ──> (Export: {}{})",
exp.from_display, exp.short_name, sym
));
}
if !symbols.is_empty() {
connection_lines.push(String::new());
connection_lines.extend(symbols.key_lines().into_iter().map(|l| format!(" {}", l)));
}
if !connection_lines.is_empty() {
output.push_str(&box_content("Connections", &connection_lines));
}
output
}
fn generate_full_ascii(graph: &CompositionGraph, show_types: bool) -> String {
let view = build_full_view(graph, show_types);
let mut instance_lines: Vec<String> = view
.nodes
.iter()
.map(|n| {
if n.is_synthetic {
format!(" [{}] (synthetic)", n.display)
} else {
format!(" [{}] [comp:{}]", n.display, n.component_index)
}
})
.collect();
if instance_lines.is_empty() {
instance_lines.push(" No instances found".to_string());
}
let mut output = String::new();
output.push_str(&box_content("All Instances", &instance_lines));
output.push('\n');
let mut symbols = SymbolMap::new();
let mut connection_lines = Vec::new();
for edge in &view.edges {
let sym = symbols.assign(
show_types,
edge.fingerprint.as_deref(),
edge.type_lines.clone(),
);
connection_lines.push(format!(
" [{}] ── {}{} ──> [{}]",
edge.from_display, edge.label, sym, edge.to_display
));
}
for exp in &view.exports {
let sym = symbols.assign(
show_types,
exp.fingerprint.as_deref(),
exp.type_lines.clone(),
);
connection_lines.push(format!(
" [{}] ──> (Export: {}{})",
exp.from_display, exp.full_name, sym
));
}
if !symbols.is_empty() {
connection_lines.push(String::new());
connection_lines.extend(symbols.key_lines().into_iter().map(|l| format!(" {}", l)));
}
if !connection_lines.is_empty() {
output.push_str(&box_content("Connections", &connection_lines));
}
output
}
fn display_width(s: &str) -> usize {
s.chars().count()
}
fn box_content(title: &str, lines: &[impl AsRef<str>]) -> String {
let title_width = display_width(title) + 2; let max_line_width = lines
.iter()
.map(|l| display_width(l.as_ref()))
.max()
.unwrap_or(0);
let width = std::cmp::max(title_width, max_line_width) + 4;
let mut output = String::new();
output.push_str(&format!("┌{}┐\n", "─".repeat(width)));
let title_display = display_width(title);
let title_padding = (width - title_display) / 2;
let title_padding_right = width - title_display - title_padding;
output.push_str(&format!(
"│{}{}{}│\n",
" ".repeat(title_padding),
title,
" ".repeat(title_padding_right)
));
output.push_str(&format!("├{}┤\n", "─".repeat(width)));
for line in lines {
let line_str = line.as_ref();
let padding = width.saturating_sub(display_width(line_str));
output.push_str(&format!("│{}{}│\n", line_str, " ".repeat(padding)));
}
output.push_str(&format!("└{}┘", "─".repeat(width)));
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
ComponentNode, FuncSignature, InstanceInterface, InterfaceConnection, InterfaceType,
ValueType,
};
use crate::test_utils::*;
use std::collections::BTreeMap;
fn line_pos(output: &str, needle: &str) -> usize {
output
.lines()
.position(|l| l.contains(needle))
.unwrap_or_else(|| panic!("'{}' not found in output:\n{}", needle, output))
}
fn test_graph() -> CompositionGraph {
let mut graph = CompositionGraph::new();
let mut srv = ComponentNode::new("$srv".to_string(), 0, 0);
srv.add_import(InterfaceConnection {
interface_name: "wasi:http/handler@0.3.0".to_string(),
source_instance: None,
is_host_import: true,
interface_type: None,
fingerprint: None,
});
graph.add_node(1, srv);
let mut mw = ComponentNode::new("$middleware".to_string(), 1, 1);
mw.add_import(InterfaceConnection {
interface_name: "wasi:http/handler@0.3.0".to_string(),
source_instance: Some(1),
is_host_import: false,
interface_type: None,
fingerprint: None,
});
mw.add_import(InterfaceConnection {
interface_name: "wasi:logging/log@0.1.0".to_string(),
source_instance: None,
is_host_import: true,
interface_type: None,
fingerprint: None,
});
graph.add_node(2, mw);
graph.add_export("wasi:http/handler@0.3.0".to_string(), 2, None);
graph
}
fn test_graph_with_types() -> CompositionGraph {
let mut graph = CompositionGraph::new();
let u32_id = graph.arena.intern_val(ValueType::U32);
let bool_id = graph.arena.intern_val(ValueType::Bool);
let handle_sig = FuncSignature {
is_async: false,
param_names: vec![],
params: vec![u32_id],
results: vec![bool_id],
};
let mut functions = BTreeMap::new();
functions.insert("handle".to_string(), handle_sig);
let iface_type = InterfaceType::Instance(InstanceInterface {
functions,
type_exports: BTreeMap::new(),
});
let mut srv = ComponentNode::new("$srv".to_string(), 0, 0);
srv.add_import(InterfaceConnection {
interface_name: "wasi:http/handler@0.3.0".to_string(),
source_instance: None,
is_host_import: true,
interface_type: Some(iface_type.clone()),
fingerprint: Some(iface_type.fingerprint(&graph.arena)),
});
graph.add_node(1, srv);
let mut mw = ComponentNode::new("$middleware".to_string(), 1, 1);
mw.add_import(InterfaceConnection {
interface_name: "wasi:http/handler@0.3.0".to_string(),
source_instance: Some(1),
is_host_import: false,
interface_type: Some(iface_type.clone()),
fingerprint: Some(iface_type.fingerprint(&graph.arena)),
});
graph.add_node(2, mw);
graph.add_export("wasi:http/handler@0.3.0".to_string(), 2, Some(iface_type));
graph
}
#[test]
fn test_box_content() {
let result = box_content("Test", &["line 1", "line 2"]);
assert!(result.contains("Test"));
assert!(result.contains("line 1"));
assert!(result.contains("line 2"));
assert!(result.contains("┌"));
assert!(result.contains("└"));
}
#[test]
fn test_handler_chain_ascii() {
let graph = test_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, false);
assert!(output.contains("Service Chains"), "should have title");
assert!(output.contains("srv"), "should show srv node");
assert!(output.contains("middleware"), "should show middleware node");
assert!(output.contains("handler"), "should show handler label");
assert!(output.contains("Export"), "should show export at start");
assert!(
output.contains("[Export: handler] ──> middleware"),
"should show export pointing to outermost handler"
);
assert!(
output.contains("middleware ── handler ──> srv"),
"should show request flow from middleware to srv"
);
}
#[test]
fn test_all_interfaces_ascii() {
let graph = test_graph();
let output = generate_ascii(&graph, DetailLevel::AllInterfaces, false);
assert!(
output.contains("Host Imports"),
"should have host imports section"
);
assert!(output.contains("handler"), "should show handler interface");
assert!(output.contains("log"), "should show log interface");
assert!(
output.contains("Component Instances"),
"should list instances"
);
assert!(output.contains("srv"), "should show srv");
assert!(output.contains("middleware"), "should show middleware");
assert!(
output.contains("Connections"),
"should have connections section"
);
assert!(output.contains("Export"), "should show export");
}
#[test]
fn test_full_ascii() {
let graph = test_graph();
let output = generate_ascii(&graph, DetailLevel::Full, false);
assert!(
output.contains("All Instances"),
"should have instances section"
);
assert!(output.contains("srv"), "should show srv");
assert!(output.contains("middleware"), "should show middleware");
assert!(
output.contains("wasi:http/handler@0.3.0"),
"should show full interface name"
);
assert!(output.contains("Connections"), "should have connections");
}
#[test]
fn test_empty_graph_ascii() {
let graph = CompositionGraph::new();
let chain = generate_ascii(&graph, DetailLevel::HandlerChain, false);
assert!(chain.contains("No service chains found"), "{}", chain);
let all = generate_ascii(&graph, DetailLevel::AllInterfaces, false);
assert!(all.contains("No component instances found"));
let full = generate_ascii(&graph, DetailLevel::Full, false);
assert!(full.contains("No instances found"));
}
#[test]
fn test_show_types_all_interfaces() {
let graph = test_graph_with_types();
let output = generate_ascii(&graph, DetailLevel::AllInterfaces, true);
assert!(
output.contains("`handle`: (u32) -> bool"),
"should show function signature"
);
}
#[test]
fn test_show_types_full() {
let graph = test_graph_with_types();
let output = generate_ascii(&graph, DetailLevel::Full, true);
assert!(
output.contains("`handle`: (u32) -> bool"),
"should show function signature"
);
}
#[test]
fn test_hide_types_all_interfaces() {
let graph = test_graph_with_types();
let output = generate_ascii(&graph, DetailLevel::AllInterfaces, false);
assert!(
!output.contains("`handle`: (u32) -> bool"),
"should not show signatures when types disabled"
);
}
#[test]
fn test_chain_request_flow_order() {
let graph = simple_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, false);
let export_pos = line_pos(&output, "[Export: handler] ──>");
let edge_pos = line_pos(&output, "middleware ── handler ──> srv");
assert!(
export_pos < edge_pos,
"export entry should precede chain edge"
);
}
#[test]
fn test_long_chain_order() {
let graph = long_chain_graph(); let output = generate_ascii(&graph, DetailLevel::HandlerChain, false);
let export_pos = line_pos(&output, "[Export: consumer] ──>");
let first_edge_pos = line_pos(&output, "gateway ── consumer ──> service");
let second_edge_pos = line_pos(&output, "service ── consumer ──> backend");
assert!(
export_pos < first_edge_pos,
"export should precede gateway→service"
);
assert!(
first_edge_pos < second_edge_pos,
"gateway→service should precede service→backend"
);
}
#[test]
fn test_two_chains_ascii() {
let graph = two_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, false);
assert!(
output.contains("[Export: handler]"),
"should show http handler export"
);
assert!(
output.contains("[Export: store]"),
"should show keyvalue store export"
);
}
#[test]
fn test_two_chains_both_edges() {
let graph = two_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, false);
assert!(
output.contains("mw-http ── handler ──> srv-http"),
"should show http handler chain edge, got:\n{}",
output
);
assert!(
output.contains("cache ── store ──> db"),
"should show keyvalue chain edge, got:\n{}",
output
);
}
#[test]
fn test_utility_node_absent_in_handler_chain() {
let graph = chain_plus_utility_graph();
let chain_out = generate_ascii(&graph, DetailLevel::HandlerChain, false);
assert!(
!chain_out.contains("logger"),
"utility node should not appear in HandlerChain output"
);
let all_out = generate_ascii(&graph, DetailLevel::AllInterfaces, false);
assert!(
all_out.contains("logger"),
"utility node should appear in AllInterfaces output"
);
}
#[test]
fn test_long_chain_ascii() {
let graph = long_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, false);
assert!(output.contains("gateway"), "should show gateway node");
assert!(output.contains("service"), "should show service node");
assert!(output.contains("backend"), "should show backend node");
assert!(
output.contains("gateway ── consumer ──> service"),
"should show first hop"
);
assert!(
output.contains("service ── consumer ──> backend"),
"should show second hop"
);
}
#[test]
fn test_handler_chain_types_key_appears() {
let graph = typed_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, true);
assert!(
output.contains("`handle`: (u32) -> bool"),
"key should contain function signature"
);
}
#[test]
fn test_handler_chain_types_symbol_in_export() {
let graph = typed_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, true);
let export_line = output
.lines()
.find(|l| l.contains("[Export: handler"))
.unwrap_or_else(|| panic!("no export line found in:\n{}", output));
assert!(
export_line.len() > "[Export: handler]".len() + 5,
"export label should include a type symbol, got: {}",
export_line
);
}
#[test]
fn test_handler_chain_types_key_content() {
let graph = typed_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, true);
let key_line = output
.lines()
.find(|l| l.contains("`handle`"))
.unwrap_or_else(|| panic!("no key line found in:\n{}", output));
assert!(
key_line.contains("(u32) -> bool"),
"key line should contain full signature"
);
}
#[test]
fn test_two_typed_chains_distinct_symbols() {
let graph = two_typed_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, true);
let key_lines: Vec<&str> = output.lines().filter(|l| l.contains("->")).collect();
assert!(
key_lines.len() >= 2,
"should have two key entries for two distinct types, got:\n{}",
output
);
let symbols: Vec<&str> = key_lines
.iter()
.map(|l| l.split_whitespace().next().unwrap_or(""))
.collect();
assert_ne!(
symbols[0], symbols[1],
"distinct types should get distinct symbols"
);
}
#[test]
fn test_same_type_same_symbol() {
let graph = typed_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, true);
let sig = "`handle`: (u32) -> bool";
let occurrences = output.matches(sig).count();
assert_eq!(
occurrences, 1,
"same type fingerprint should produce exactly one key entry"
);
}
#[test]
fn test_all_interfaces_host_edge_format() {
let graph = simple_chain_graph();
let output = generate_ascii(&graph, DetailLevel::AllInterfaces, false);
assert!(
output.contains("{handler} --- handler --> [srv]"),
"host edge should use dashed format, got:\n{}",
output
);
}
#[test]
fn test_all_interfaces_component_edge_format() {
let graph = simple_chain_graph();
let output = generate_ascii(&graph, DetailLevel::AllInterfaces, false);
assert!(
output.contains("[srv] ── handler ──> [middleware]"),
"component edge should use solid arrow format, got:\n{}",
output
);
}
#[test]
fn test_all_interfaces_export_format() {
let graph = simple_chain_graph();
let output = generate_ascii(&graph, DetailLevel::AllInterfaces, false);
assert!(
output.contains("[middleware] ──> (Export: handler)"),
"export line should use expected format, got:\n{}",
output
);
}
#[test]
fn test_all_interfaces_type_lines_on_export() {
let graph = typed_chain_graph();
let output = generate_ascii(&graph, DetailLevel::AllInterfaces, true);
let export_pos = line_pos(&output, "──> (Export: handler");
let sig_after_export = output
.lines()
.skip(export_pos + 1)
.any(|l| l.contains("`handle`: (u32) -> bool"));
assert!(
sig_after_export,
"type signature should appear after the export line, got:\n{}",
output
);
}
#[test]
fn test_all_interfaces_two_chains() {
let graph = two_chain_graph();
let output = generate_ascii(&graph, DetailLevel::AllInterfaces, false);
assert!(output.contains("srv-http"), "should show srv-http");
assert!(output.contains("mw-http"), "should show mw-http");
assert!(output.contains("db"), "should show db");
assert!(output.contains("cache"), "should show cache");
assert!(
output.contains("[srv-http] ── handler ──> [mw-http]"),
"should show handler edge"
);
assert!(
output.contains("[db] ── store ──> [cache]"),
"should show store edge"
);
assert!(
output.contains("(Export: handler)"),
"should show handler export"
);
assert!(
output.contains("(Export: store)"),
"should show store export"
);
}
#[test]
fn test_full_synthetic_node_visible() {
use crate::model::{ComponentNode, SYNTHETIC_COMPONENT};
let mut graph = CompositionGraph::new();
let real = ComponentNode::new("$real".to_string(), 0, 0);
graph.add_node(1, real);
let synthetic = ComponentNode::new(
"$synth".to_string(),
SYNTHETIC_COMPONENT,
SYNTHETIC_COMPONENT,
);
graph.add_node(99, synthetic);
let output = generate_ascii(&graph, DetailLevel::Full, false);
assert!(
output.contains("synth"),
"synthetic node should appear in Full output"
);
assert!(
output.contains("(synthetic)"),
"synthetic node should be labelled as synthetic"
);
}
#[test]
fn test_handler_chain_no_key_when_types_disabled() {
let graph = typed_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, false);
assert!(!output.contains("`handle`"), "no key when show_types=false");
assert!(
!output.contains("(u32) -> bool"),
"no sig when show_types=false"
);
}
#[test]
fn test_handler_chain_key_after_chain_content() {
let graph = typed_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, true);
let chain_edge_pos = line_pos(&output, "──> srv");
let key_pos = line_pos(&output, "`handle`: (u32) -> bool");
assert!(
key_pos > chain_edge_pos,
"key should appear after chain edges"
);
}
#[test]
fn test_two_chains_blank_line_separator() {
let graph = two_chain_graph();
let output = generate_ascii(&graph, DetailLevel::HandlerChain, false);
let has_separator = output
.lines()
.any(|l| l.contains('│') && l.chars().all(|c| c == '│' || c == ' '));
assert!(
has_separator,
"blank separator line should exist between two chains, got:\n{}",
output
);
}
}