use std::collections::{BTreeMap, BTreeSet};
use colored::Colorize;
use error_stack::{Report, Result, ResultExt};
use crate::batbelt::evm::metadata::bat_metadata::{ContractMetadata, EvmBatMetadata};
use crate::batbelt::evm::miro::auto_deploy::matches_ignore;
use crate::batbelt::evm::miro::EvmMiroError;
use crate::batbelt::miro::client::{ArrowEnd, ConnectorStyle, MiroClient};
pub struct MapOptions {
pub dry_run: bool,
pub with_boundaries: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Relation {
Calls,
Creates,
WiresAtConstruction,
Unresolved,
Boundary,
}
impl Relation {
fn label(self) -> &'static str {
match self {
Self::Calls => "calls",
Self::Creates => "creates",
Self::WiresAtConstruction => "wires at construction",
Self::Unresolved => "unresolved binding",
Self::Boundary => "leaves the code",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Edge {
pub from: String,
pub to: String,
pub relation: Relation,
}
pub async fn run(options: MapOptions) -> Result<(), EvmMiroError> {
let started = std::time::Instant::now();
let mut mark = |what: &str, since: &mut std::time::Instant| {
println!(" {:>6.1}s {what}", since.elapsed().as_secs_f64());
*since = std::time::Instant::now();
};
let mut phase = started;
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
mark("reading the metadata", &mut phase);
if let Some(count) = predates_vendored(&metadata) {
return Err(Report::new(EvmMiroError).attach_printable(format!(
"this scan predates the `vendored` flag: {count} contract(s) under lib/ are marked \
as in scope — run `bat-cli sonar` to rescan before drawing the map"
)));
}
let prepared = prepare(&metadata, &options)?;
let edges = prepared.edges.clone();
mark("working out the relations, laying out and rendering", &mut phase);
for line in report(&edges) {
println!("{line}");
}
let undeclared = undeclared_interfaces(&metadata);
if !undeclared.is_empty() {
println!();
println!(
"{} interface(s) of this repository nothing in scope declares to implement — \
treated as outside the code:",
undeclared.len()
);
for name in &undeclared {
println!(" {name:<28} if a contract here implements it: bat-cli resolve {name} <Contract>");
}
}
let alone = unconnected(&metadata, &prepared.edges);
if !alone.is_empty() {
println!();
println!(
"{} deployable contract(s) with no relation at all — no box on the map:",
alone.len()
);
for name in &alone {
println!(" {name}");
}
}
let created_elsewhere = vendored_creations(&metadata);
if !created_elsewhere.is_empty() {
println!();
println!(
"{} `new` of code this repository does not own — not drawn:",
created_elsewhere.len()
);
for line in &created_elsewhere {
println!(" {line}");
}
}
let untyped = untyped_receivers(&metadata);
if !untyped.is_empty() {
println!();
println!(
"{} call(s) whose receiver the scan could not type as a contract — not drawn:",
untyped.len()
);
for line in &untyped {
println!(" {line}");
}
}
println!();
for line in prepared.estimate() {
println!(" {line}");
}
let calls = prepared.calls;
if options.dry_run {
prepared.discard();
return Ok(());
}
let client = MiroClient::new_refreshed().await.change_context(EvmMiroError)?;
mark("opening the Miro session", &mut phase);
let board = client.list_frames().await.change_context(EvmMiroError)?;
mark(&format!("reading the board ({} frames)", board.len()), &mut phase);
let obstacles: Vec<crate::batbelt::miro::layout::Rect> = board
.iter()
.map(|frame| crate::batbelt::miro::layout::Rect {
x: frame.x,
y: frame.y,
width: frame.width,
height: frame.height,
})
.collect();
let (left, top) =
crate::batbelt::miro::layout::ColumnLayout::new().open_left(&obstacles, prepared.width);
let outcome = draw(&client, prepared, left, top).await;
mark("drawing it", &mut phase);
let (retries, waits) = client.stats();
println!(
" {:>6.1}s in total ({} API call(s), {retries} retr(ies), {waits} budget wait(s))",
started.elapsed().as_secs_f64(),
calls.total()
);
outcome
}
const NODE_HEIGHT: f64 = 320.0;
const NODE_FONT: usize = 120;
const NODE_CHAR: f64 = 70.0;
const NODE_PAD: f64 = 160.0;
const MAP_MARGIN: f64 = 600.0;
const EDGE_WIDTH: &str = "16";
const LANE_PITCH: f64 = 80.0;
const MARKER_SIZE: f64 = 8.0;
const LEGEND_ROWS: usize = 5;
fn node_width(name: &str) -> f64 {
name.chars().count() as f64 * NODE_CHAR + NODE_PAD * 2.0
}
fn stroke_of(relation: Relation) -> (&'static str, crate::batbelt::miro::client::ConnectorStroke) {
use crate::batbelt::miro::client::ConnectorStroke;
match relation {
Relation::Calls => ("#2d9bf0", ConnectorStroke::Solid),
Relation::Creates => ("#8e44ad", ConnectorStroke::Solid),
Relation::WiresAtConstruction => ("#8fd14f", ConnectorStroke::Solid),
Relation::Unresolved => ("#f24726", ConnectorStroke::Dashed),
Relation::Boundary => ("#ff8c00", ConnectorStroke::Dotted),
}
}
fn route_arrows(
edges: &[Edge],
layout: &crate::batbelt::miro::layout::GraphLayout,
) -> Vec<(crate::batbelt::evm::miro::auto_deploy::PlannedLaneArrow, ConnectorStyle)> {
use crate::batbelt::evm::miro::auto_deploy::{plan_lane_chain, LaneEnd};
use crate::batbelt::miro::layout::{assign_lanes, LANE_PITCH};
let mut crossing: BTreeMap<usize, Vec<((usize, usize), f64, f64)>> = BTreeMap::new();
let mut routed: Vec<(usize, &Edge, Vec<usize>, Vec<f64>, (f64, f64), (f64, f64))> = Vec::new();
for (index, edge) in edges.iter().enumerate() {
let (Some(from), Some(to)) = (layout.node(&edge.from), layout.node(&edge.to)) else {
continue;
};
let (near, far) = (to.layer, from.layer);
if far <= near {
continue;
}
let corridors: Vec<f64> = layout
.routes
.get(index)
.map(|bends| bends.iter().map(|(_, y)| *y).collect())
.unwrap_or_default();
let gutters: Vec<usize> = (near..far).rev().collect();
let mut enters = from.y;
for (step, gutter) in gutters.iter().enumerate() {
let leaves = corridors.get(step).copied().unwrap_or(to.y);
crossing
.entry(*gutter)
.or_default()
.push(((index, step), enters, leaves));
enters = leaves;
}
routed.push((
index,
edge,
gutters,
corridors,
(from.x - from.width / 2.0, from.y),
(to.x + to.width / 2.0, to.y),
));
}
let mut lane_of: BTreeMap<(usize, usize), f64> = BTreeMap::new();
for (gutter, arrows) in &crossing {
let near = layout
.nodes
.iter()
.filter(|node| node.layer == *gutter)
.map(|node| node.x + node.width / 2.0)
.fold(f64::MIN, f64::max);
let far = layout
.nodes
.iter()
.filter(|node| node.layer == gutter + 1)
.map(|node| node.x - node.width / 2.0)
.fold(f64::MAX, f64::min);
if !near.is_finite() || !far.is_finite() {
continue;
}
for (key, x) in assign_lanes(arrows, near, far, LANE_PITCH) {
lane_of.insert(key, x);
}
}
let mut planned: Vec<(crate::batbelt::evm::miro::auto_deploy::PlannedLaneArrow, ConnectorStyle)> =
Vec::new();
for (index, edge, gutters, corridors, start, end) in routed {
let lanes: Vec<f64> = gutters
.iter()
.enumerate()
.filter_map(|(step, _)| lane_of.get(&(index, step)).copied())
.collect();
if lanes.is_empty() {
continue;
}
let (colour, stroke) = stroke_of(edge.relation);
planned.push((
plan_lane_chain(
&LaneEnd::Point { x: start.0, y: start.1 },
&LaneEnd::Point { x: end.0, y: end.1 },
&lanes,
&corridors,
),
ConnectorStyle {
stroke_color: colour.to_string(),
stroke_width: EDGE_WIDTH.to_string(),
stroke,
caption: None,
arrow: ArrowEnd::End,
},
));
}
planned
}
pub fn prepare(metadata: &EvmBatMetadata, options: &MapOptions) -> Result<PreparedMap, EvmMiroError> {
use crate::batbelt::path::BatFolder;
let all = relations(metadata);
let edges = drawn_edges(&all, options.with_boundaries);
if edges.is_empty() {
return Err(Report::new(EvmMiroError)
.attach_printable("no relations between contracts — run `bat-cli sonar` first"));
}
let layout = layout_map(&edges);
BatFolder::Figures.create_folder().change_context(EvmMiroError)?;
let destination = BatFolder::Figures.get_path(true).change_context(EvmMiroError)?;
let render_lines = |lines: Vec<String>, name: &str| -> Result<Option<_>, EvmMiroError> {
if lines.is_empty() {
return Ok(None);
}
crate::batbelt::evm::miro::storage::render(&lines, &destination, name, 0, &[])
.map(Some)
.map_err(|report| report.change_context(EvmMiroError))
};
let matrix = render_lines(matrix(&edges), "map_matrix")?;
let outside = render_lines(outside_panel(&all), "map_outside")?;
let beside_width = matrix
.as_ref()
.map(|rendered| rendered.width)
.unwrap_or(0.0)
.max(outside.as_ref().map(|rendered| rendered.width).unwrap_or(0.0));
let beside_height = matrix.as_ref().map(|rendered| rendered.height).unwrap_or(0.0)
+ outside.as_ref().map(|rendered| rendered.height).unwrap_or(0.0)
+ if matrix.is_some() && outside.is_some() { MAP_MARGIN } else { 0.0 };
let width = layout.frame_width
+ if beside_width > 0.0 { MAP_MARGIN + beside_width } else { 0.0 };
let height = layout.frame_height.max(beside_height + MAP_MARGIN * 2.0);
let arrows = route_arrows(&edges, &layout);
let calls = ApiCalls {
frame: 1,
boxes: layout.nodes.len() + LEGEND_ROWS,
images: matrix.is_some() as usize + outside.is_some() as usize,
markers: arrows.iter().map(|(arrow, _)| arrow.points.len()).sum(),
connectors: arrows.iter().map(|(arrow, _)| arrow.legs.len()).sum(),
};
Ok(PreparedMap {
edges,
layout,
arrows,
matrix,
outside,
width,
height,
calls,
})
}
impl PreparedMap {
pub fn discard(&self) {
for rendered in [self.matrix.as_ref(), self.outside.as_ref()].into_iter().flatten() {
let _ = std::fs::remove_file(&rendered.png_path);
}
}
pub fn estimate(&self) -> Vec<String> {
vec![
format!(
"frame: {} × {} board units, {} boxes, {} relations, {} panel(s)",
self.width.round(),
self.height.round(),
self.layout.nodes.len(),
self.edges.len(),
self.calls.images
),
format!(
"API calls: 1 frame + {} boxes + {} images + {} markers + {} connectors = {}",
self.calls.boxes,
self.calls.images,
self.calls.markers,
self.calls.connectors,
self.calls.total()
),
format!(
" {:.1}s at Miro's ceiling of 16.7 writes a second, if nothing else limits it",
self.calls.seconds_at_the_ceiling()
),
"placement: needs the board, which a dry run does not read".to_string(),
]
}
}
pub fn unconnected(metadata: &EvmBatMetadata, edges: &[Edge]) -> Vec<String> {
use crate::batbelt::evm::types::EvmContractType;
let drawn: BTreeSet<&str> = edges
.iter()
.flat_map(|edge| [edge.from.as_str(), edge.to.as_str()])
.collect();
metadata
.contracts
.iter()
.filter(|contract| {
!contract.vendored
&& matches!(contract.contract_type, EvmContractType::Contract)
&& !drawn.contains(contract.name.as_str())
})
.map(|contract| contract.name.clone())
.collect()
}
pub fn vendored_creations(metadata: &EvmBatMetadata) -> Vec<String> {
let mut found: BTreeSet<String> = BTreeSet::new();
for contract in metadata.contracts.iter().filter(|c| !c.vendored) {
for function in &contract.functions {
for callee in callees_of(metadata, &function.metadata_id) {
let Some(name) = callee.strip_suffix(".constructor") else {
continue;
};
let vendored = metadata
.contract_in_scope(name, &contract.file_path)
.map(|target| target.vendored)
.unwrap_or(true);
if vendored {
found.insert(format!("{}.{} creates {name}", contract.name, function.name));
}
}
}
}
found.into_iter().collect()
}
pub struct PreparedMap {
edges: Vec<Edge>,
layout: crate::batbelt::miro::layout::GraphLayout,
arrows: Vec<(crate::batbelt::evm::miro::auto_deploy::PlannedLaneArrow, ConnectorStyle)>,
matrix: Option<crate::batbelt::evm::miro::storage::Rendered>,
outside: Option<crate::batbelt::evm::miro::storage::Rendered>,
pub width: f64,
pub height: f64,
pub calls: ApiCalls,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ApiCalls {
pub frame: usize,
pub boxes: usize,
pub images: usize,
pub markers: usize,
pub connectors: usize,
}
impl ApiCalls {
pub fn total(&self) -> usize {
self.frame + self.boxes + self.images + self.markers + self.connectors
}
pub fn seconds_at_the_ceiling(&self) -> f64 {
self.total() as f64 / 16.7
}
}
pub fn matrix(edges: &[Edge]) -> Vec<String> {
let mut names: Vec<&str> = edges
.iter()
.flat_map(|edge| [edge.from.as_str(), edge.to.as_str()])
.collect();
names.sort();
names.dedup();
if names.len() < 3 {
return Vec::new();
}
let mark = |from: &str, to: &str| -> &'static str {
match edges
.iter()
.find(|edge| edge.from == from && edge.to == to)
.map(|edge| edge.relation)
{
Some(Relation::Calls) => "●",
Some(Relation::Creates) => "◆",
Some(Relation::WiresAtConstruction) => "○",
Some(Relation::Unresolved) => "?",
Some(Relation::Boundary) => "▲",
None => "·",
}
};
let widest = names.iter().map(|name| name.len()).max().unwrap_or(0);
let mut lines = vec![
"who talks to whom — a row is a caller, a column is the same list".to_string(),
"● calls ◆ creates ○ wired at construction ? unresolved ▲ leaves the code"
.to_string(),
String::new(),
];
let header: String = (1..=names.len())
.map(|index| format!("{:>3}", index))
.collect();
lines.push(format!("{:width$} {header}", "", width = widest + 3));
for (index, from) in names.iter().enumerate() {
let row: String = names
.iter()
.map(|to| format!("{:>3}", mark(from, to)))
.collect();
let out = edges.iter().filter(|edge| &edge.from == from).count();
let into = edges.iter().filter(|edge| &edge.to == from).count();
lines.push(format!(
"{:>2} {:width$} {row} → {out}, ← {into}",
index + 1,
from,
width = widest,
));
}
lines
}
pub fn outside_panel(edges: &[Edge]) -> Vec<String> {
let mut reached: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for edge in edges.iter().filter(|edge| edge.relation == Relation::Boundary) {
reached
.entry(edge.to.as_str())
.or_default()
.insert(edge.from.as_str());
}
if reached.is_empty() {
return Vec::new();
}
let mut lines = vec![
format!("outside this repository ({})", reached.len()),
"no source here, so what they do with the call is unknown".to_string(),
String::new(),
];
for (name, callers) in reached {
lines.push(format!(
" {name:<26} ← {}",
callers.into_iter().collect::<Vec<_>>().join(", ")
));
}
lines
}
fn drawn_edges(edges: &[Edge], with_boundaries: bool) -> Vec<Edge> {
edges
.iter()
.filter(|edge| with_boundaries || edge.relation != Relation::Boundary)
.cloned()
.collect()
}
fn layout_map(edges: &[Edge]) -> crate::batbelt::miro::layout::GraphLayout {
use crate::batbelt::miro::layout::{layout_graph, LayoutConfig, LayoutEdge, LayoutNode};
let mut names: Vec<String> = edges
.iter()
.flat_map(|edge| [edge.from.clone(), edge.to.clone()])
.collect();
names.sort();
names.dedup();
let nodes: Vec<LayoutNode> = names
.iter()
.map(|name| LayoutNode {
id: name.clone(),
width: node_width(name),
height: NODE_HEIGHT,
})
.collect();
let laid_edges: Vec<LayoutEdge> = edges
.iter()
.map(|edge| LayoutEdge {
from: edge.to.clone(),
to: edge.from.clone(),
from_line_fraction: 0.5,
})
.collect();
let depends_on_something: BTreeSet<&str> = edges.iter().map(|edge| edge.from.as_str()).collect();
let root = names
.iter()
.find(|name| !depends_on_something.contains(name.as_str()))
.cloned()
.unwrap_or_else(|| names[0].clone());
let config = |gutter_x: f64| LayoutConfig {
padding_x: MAP_MARGIN,
padding_y: MAP_MARGIN + (NODE_HEIGHT + 60.0) * LEGEND_ROWS as f64,
gutter_x,
..Default::default()
};
let first = layout_graph(&root, &nodes, &laid_edges, config(LayoutConfig::default().gutter_x));
let busiest = busiest_gutter(edges, &first);
let wanted = crate::batbelt::miro::layout::gutter_for(busiest);
if wanted <= LayoutConfig::default().gutter_x {
return first;
}
layout_graph(&root, &nodes, &laid_edges, config(wanted))
}
fn busiest_gutter(edges: &[Edge], layout: &crate::batbelt::miro::layout::GraphLayout) -> usize {
let mut per_gutter: BTreeMap<usize, usize> = BTreeMap::new();
for edge in edges {
let (Some(from), Some(to)) = (layout.node(&edge.from), layout.node(&edge.to)) else {
continue;
};
let (low, high) = (from.layer.min(to.layer), from.layer.max(to.layer));
for gutter in low..high {
*per_gutter.entry(gutter).or_default() += 1;
}
}
per_gutter.into_values().max().unwrap_or(0)
}
async fn draw(
client: &MiroClient,
prepared: PreparedMap,
left: f64,
top: f64,
) -> Result<(), EvmMiroError> {
let PreparedMap {
edges,
layout,
arrows: prepared_arrows,
matrix,
outside,
width: frame_width,
height: frame_height,
..
} = prepared;
let edges = edges.as_slice();
let frame_id = client
.create_frame(
"map: who talks to whom",
left + frame_width / 2.0,
top + frame_height / 2.0,
frame_width,
frame_height,
None,
)
.await
.change_context(EvmMiroError)?;
let mut uploading = tokio::task::JoinSet::new();
let panel_left = layout.frame_width + MAP_MARGIN;
let mut panel_top = MAP_MARGIN;
for rendered in [matrix.as_ref(), outside.as_ref()].into_iter().flatten() {
let (x, y) = (
panel_left + rendered.width / 2.0,
panel_top + rendered.height / 2.0,
);
panel_top += rendered.height + MAP_MARGIN;
let client = client.clone();
let frame_id = frame_id.clone();
let png = rendered.png_path.clone();
let width = rendered.width;
uploading.spawn(async move {
let item = client
.create_image_in_frame(&png, &frame_id, "map", x, y, width)
.await;
let _ = std::fs::remove_file(&png);
item
});
}
let mut legend = tokio::task::JoinSet::new();
for (index, relation) in [
Relation::Calls,
Relation::Creates,
Relation::WiresAtConstruction,
Relation::Unresolved,
Relation::Boundary,
]
.into_iter()
.enumerate()
{
let (colour, _) = stroke_of(relation);
let text = relation.label();
let width = node_width(text);
let client = client.clone();
let frame_id = frame_id.clone();
let y = MAP_MARGIN / 2.0 + index as f64 * (NODE_HEIGHT + 60.0);
legend.spawn(async move {
client
.create_label(
text,
MAP_MARGIN / 2.0 + width / 2.0,
y,
width,
NODE_HEIGHT,
NODE_FONT,
colour,
Some(&frame_id),
)
.await
});
}
while let Some(joined) = legend.join_next().await {
joined
.map_err(|error| Report::new(EvmMiroError).attach_printable(error.to_string()))?
.change_context(EvmMiroError)?;
}
let mut placing = tokio::task::JoinSet::new();
for placed in &layout.nodes {
let client = client.clone();
let frame_id = frame_id.clone();
let name = placed.id.clone();
let (x, y) = (placed.x, placed.y);
placing.spawn(async move {
client
.create_label(
&name,
x,
y,
node_width(&name),
NODE_HEIGHT,
NODE_FONT,
"#ffffff",
Some(&frame_id),
)
.await
.map(|id| (name, id))
});
}
let mut boxes: BTreeMap<String, String> = BTreeMap::new();
while let Some(joined) = placing.join_next().await {
let (name, id) = joined
.map_err(|error| Report::new(EvmMiroError).attach_printable(error.to_string()))?
.change_context(EvmMiroError)?;
boxes.insert(name, id);
}
while let Some(joined) = uploading.join_next().await {
joined
.map_err(|error| Report::new(EvmMiroError).attach_printable(error.to_string()))?
.change_context(EvmMiroError)?;
}
println!(" {} {} contract(s) drawn", "✓".green(), boxes.len());
let mut drawing = tokio::task::JoinSet::new();
use crate::batbelt::evm::miro::auto_deploy::LaneSide;
let planned = prepared_arrows;
let planned_names: Vec<String> = planned
.iter()
.enumerate()
.map(|(index, _)| {
edges
.get(index)
.map(|edge| format!("{} → {}", edge.from, edge.to))
.unwrap_or_default()
})
.collect();
let mut skipped: Vec<String> = Vec::new();
for (index, (arrow, _)) in planned.iter().enumerate() {
for (point, (x, y)) in arrow.points.iter().enumerate() {
let client = client.clone();
let frame_id = frame_id.clone();
let (x, y) = (*x, *y);
drawing.spawn(async move {
client
.create_anchor_marker(&frame_id, x, y, MARKER_SIZE)
.await
.map(|id| (index, point, id))
.map_err(|report| report.change_context(EvmMiroError))
});
}
}
let mut markers: BTreeMap<(usize, usize), String> = BTreeMap::new();
let mut failures: Vec<String> = Vec::new();
while let Some(joined) = drawing.join_next().await {
match joined {
Ok(Ok((index, point, id))) => {
markers.insert((index, point), id);
}
Ok(Err(report)) => failures.push(format!("marker: {}", report.current_context())),
Err(error) => failures.push(format!("marker: {error}")),
}
}
for (index, (arrow, style)) in planned.iter().enumerate() {
for (from_side, from_anchor, to_side, to_anchor, head) in &arrow.legs {
let resolve = |side: &LaneSide| match side {
LaneSide::Marker(point) => markers.get(&(index, *point)).cloned(),
LaneSide::Item(id) => Some(id.clone()),
};
let (Some(from_id), Some(to_id)) = (resolve(from_side), resolve(to_side)) else {
skipped.push(format!(
"{} → {}",
planned_names.get(index).map(String::as_str).unwrap_or("?"),
"a leg whose marker failed"
));
continue;
};
let mut leg = style.clone();
leg.arrow = if *head { ArrowEnd::End } else { ArrowEnd::None };
let (from_anchor, to_anchor) = (*from_anchor, *to_anchor);
let client = client.clone();
drawing.spawn(async move {
client
.create_connector(&from_id, from_anchor, &to_id, to_anchor, leg)
.await
.map(|id| (0usize, 0usize, id))
.map_err(|report| report.change_context(EvmMiroError))
});
}
}
let mut legs = 0usize;
while let Some(joined) = drawing.join_next().await {
match joined {
Ok(Ok(_)) => legs += 1,
Ok(Err(report)) => failures.push(format!("leg: {}", report.current_context())),
Err(error) => failures.push(format!("leg: {error}")),
}
}
for line in skipped.iter().chain(failures.iter()) {
println!(" {} {line}", "skipped:".yellow());
}
println!(
" {} {} relation(s) drawn, {legs} leg(s)",
"✓".green(),
planned.len()
);
println!(" {}", client.frame_url(&frame_id).blue());
if !failures.is_empty() {
return Err(Report::new(EvmMiroError).attach_printable(format!(
"{} item(s) of this map could not be drawn — the frame above has the rest",
failures.len()
)));
}
Ok(())
}
pub fn relations(metadata: &EvmBatMetadata) -> Vec<Edge> {
let in_scope: BTreeSet<&str> = metadata
.contracts
.iter()
.filter(|contract| !contract.vendored)
.map(|contract| contract.name.as_str())
.collect();
let ignored = |name: &str, path: &str| {
metadata
.ignored_contracts
.iter()
.any(|pattern| matches_ignore(pattern, name, path))
};
let hosted_by = hosts(metadata);
let folded: BTreeSet<&str> = metadata
.contracts
.iter()
.filter(|contract| {
!contract.vendored
&& matches!(
contract.contract_type,
crate::batbelt::evm::types::EvmContractType::Library
| crate::batbelt::evm::types::EvmContractType::Abstract
)
})
.map(|contract| contract.name.as_str())
.collect();
let mut found: BTreeSet<Edge> = BTreeSet::new();
for contract in metadata.contracts.iter().filter(|c| !c.vendored) {
if ignored(&contract.name, &contract.file_path) || folded.contains(contract.name.as_str())
{
continue;
}
let mut record = |to: &str, relation: Relation| {
if to.is_empty() || to == contract.name || folded.contains(to) {
return;
}
found.insert(Edge {
from: contract.name.clone(),
to: to.to_string(),
relation,
});
};
let aim = |type_name: &str, relation: Relation| -> Option<(String, Relation)> {
match classify_type(metadata, type_name) {
TypeTarget::Contract(target) => Some((target, relation)),
TypeTarget::Open(name) => Some((name, Relation::Unresolved)),
TypeTarget::Outside(name) => Some((name, Relation::Boundary)),
TypeTarget::NotAContract => None,
}
};
let mut sources: Vec<&ContractMetadata> = vec![contract];
sources.extend(
hosted_by
.get(contract.name.as_str())
.into_iter()
.flatten()
.filter_map(|name| {
metadata
.contract_in_scope(name, &contract.file_path)
.or_else(|| metadata.contracts.iter().find(|c| &c.name == name))
}),
);
for function in sources.iter().flat_map(|source| source.functions.iter()) {
for created in created_by(metadata, contract, &function.metadata_id) {
record(&created, Relation::Creates);
}
for call in &function.resolved_calls {
if in_scope.contains(call.contract.as_str()) {
record(&call.contract, Relation::Calls);
}
}
let typed = function
.unresolved_calls
.iter()
.map(|call| call.inferred_type.as_str())
.chain(
function
.unknown_external_calls
.iter()
.map(|call| call.inferred_type.as_str()),
);
for type_name in typed {
if let Some((to, relation)) = aim(type_name, Relation::Calls) {
record(&to, relation);
}
}
}
for wired in sources.iter().flat_map(|source| wired_at_construction(source)) {
if let Some((to, relation)) = aim(&wired, Relation::WiresAtConstruction) {
record(&to, relation);
}
}
}
let mut best: BTreeMap<(String, String), Relation> = BTreeMap::new();
for edge in found {
best.entry((edge.from.clone(), edge.to.clone()))
.and_modify(|existing| *existing = (*existing).min(edge.relation))
.or_insert(edge.relation);
}
best.into_iter()
.map(|((from, to), relation)| Edge { from, to, relation })
.collect()
}
fn hosts(metadata: &EvmBatMetadata) -> BTreeMap<String, BTreeSet<String>> {
use crate::batbelt::evm::types::EvmContractType;
let folded: BTreeMap<&str, &ContractMetadata> = metadata
.contracts
.iter()
.filter(|contract| {
!contract.vendored
&& matches!(
contract.contract_type,
EvmContractType::Library | EvmContractType::Abstract
)
})
.map(|contract| (contract.name.as_str(), contract))
.collect();
if folded.is_empty() {
return BTreeMap::new();
}
let mut uses: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for contract in metadata.contracts.iter().filter(|c| !c.vendored) {
let mut used: BTreeSet<String> = contract
.base_contracts
.iter()
.filter(|base| folded.contains_key(base.as_str()))
.cloned()
.collect();
for function in &contract.functions {
for callee in callees_of(metadata, &function.metadata_id) {
if let Some((receiver, _)) = callee.split_once('.') {
if folded.contains_key(receiver) {
used.insert(receiver.to_string());
}
}
}
}
uses.insert(contract.name.clone(), used);
}
let mut hosted: BTreeMap<String, BTreeSet<String>> = uses
.iter()
.filter(|(name, _)| !folded.contains_key(name.as_str()))
.map(|(name, used)| (name.clone(), used.clone()))
.collect();
for _ in 0..metadata.contracts.len().max(1) {
let mut grew = false;
for set in hosted.values_mut() {
let reached: BTreeSet<String> = set
.iter()
.filter_map(|library| uses.get(library))
.flatten()
.cloned()
.collect();
for name in reached {
grew |= set.insert(name);
}
}
if !grew {
break;
}
}
hosted
}
fn callees_of<'a>(metadata: &'a EvmBatMetadata, function_id: &str) -> &'a [String] {
metadata
.function_dependencies
.iter()
.find(|dependency| dependency.function_metadata_id == function_id)
.map(|dependency| dependency.callees.as_slice())
.unwrap_or(&[])
}
fn created_by(
metadata: &EvmBatMetadata,
caller: &ContractMetadata,
function_id: &str,
) -> Vec<String> {
callees_of(metadata, function_id)
.iter()
.filter_map(|callee| callee.strip_suffix(".constructor"))
.filter_map(|name| metadata.contract_in_scope(name, &caller.file_path))
.filter(|target| !target.vendored)
.map(|target| target.name.clone())
.collect()
}
fn wired_at_construction(contract: &ContractMetadata) -> Vec<String> {
let constructor_writes: BTreeSet<&str> = contract
.functions
.iter()
.filter(|function| function.is_constructor)
.flat_map(|function| function.storage_writes.iter())
.map(|write| write.split(['.', '[']).next().unwrap_or(write))
.collect();
contract
.state_variables
.iter()
.filter(|variable| {
variable.is_immutable || constructor_writes.contains(variable.name.as_str())
})
.map(|variable| variable.type_name.trim().to_string())
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TypeTarget {
Contract(String),
Open(String),
Outside(String),
NotAContract,
}
fn classify_type(metadata: &EvmBatMetadata, type_name: &str) -> TypeTarget {
use crate::batbelt::evm::types::EvmContractType;
let Some(name) = contract_type_name(type_name) else {
return TypeTarget::NotAContract;
};
let copies: Vec<&ContractMetadata> =
metadata.contracts.iter().filter(|c| c.name == name).collect();
if copies.is_empty() {
return TypeTarget::NotAContract;
}
let is_interface = copies
.iter()
.any(|c| c.contract_type == EvmContractType::Interface);
let in_scope = copies.iter().find(|c| !c.vendored);
let deployable_in_scope = in_scope
.is_some_and(|c| c.contract_type == EvmContractType::Contract);
if deployable_in_scope {
return TypeTarget::Contract(name.to_string());
}
if let Some(recorded) = metadata.resolutions.get(name) {
if metadata.contracts.iter().any(|c| !c.vendored && c.name == *recorded) {
return TypeTarget::Contract(recorded.clone());
}
}
let collapsible = is_interface
|| in_scope.is_some_and(|c| c.contract_type == EvmContractType::Abstract);
if !collapsible {
return TypeTarget::Outside(name.to_string());
}
let mut implementers = metadata
.contracts
.iter()
.filter(|c| !c.vendored && c.contract_type == EvmContractType::Contract)
.filter(|c| c.base_contracts.iter().any(|base| base == name));
match (implementers.next(), implementers.next()) {
(Some(only), None) => TypeTarget::Contract(only.name.clone()),
(Some(_), Some(_)) => TypeTarget::Open(name.to_string()),
(None, _) if is_interface => TypeTarget::Outside(name.to_string()),
(None, _) => TypeTarget::Open(name.to_string()),
}
}
fn contract_type_name(type_name: &str) -> Option<&str> {
let mut name = type_name.trim();
if name.is_empty() || name.starts_with("mapping(") {
return None;
}
while let Some(open) = name.rfind('[') {
if !name.ends_with(']') {
break;
}
name = name[..open].trim_end();
}
name.chars()
.next()
.filter(|first| first.is_ascii_uppercase())
.map(|_| name)
}
pub fn untyped_receivers(metadata: &EvmBatMetadata) -> Vec<String> {
let mut lines: BTreeSet<String> = BTreeSet::new();
for contract in metadata.contracts.iter().filter(|c| !c.vendored) {
for function in &contract.functions {
let calls = function
.unresolved_calls
.iter()
.map(|call| (&call.receiver, &call.method, &call.inferred_type))
.chain(
function
.unknown_external_calls
.iter()
.map(|call| (&call.receiver, &call.method, &call.inferred_type)),
);
for (receiver, method, inferred_type) in calls {
if classify_type(metadata, inferred_type) == TypeTarget::NotAContract {
let shown = if inferred_type.is_empty() {
"untyped".to_string()
} else {
inferred_type.clone()
};
lines.insert(format!(
"{}.{}: {receiver}.{method} ({shown})",
contract.name, function.name
));
}
}
}
}
lines.into_iter().collect()
}
pub fn undeclared_interfaces(metadata: &EvmBatMetadata) -> Vec<String> {
use crate::batbelt::evm::types::EvmContractType;
let own_interfaces: BTreeSet<&str> = metadata
.contracts
.iter()
.filter(|c| !c.vendored && c.contract_type == EvmContractType::Interface)
.map(|c| c.name.as_str())
.collect();
let mut found: BTreeSet<String> = BTreeSet::new();
for contract in metadata.contracts.iter().filter(|c| !c.vendored) {
let referenced = contract
.functions
.iter()
.flat_map(|function| {
function
.unresolved_calls
.iter()
.map(|call| call.inferred_type.clone())
.chain(
function
.unknown_external_calls
.iter()
.map(|call| call.inferred_type.clone()),
)
})
.chain(wired_at_construction(contract));
for type_name in referenced {
if let TypeTarget::Outside(name) = classify_type(metadata, &type_name) {
if own_interfaces.contains(name.as_str()) {
found.insert(name);
}
}
}
}
found.into_iter().collect()
}
pub fn predates_vendored(metadata: &EvmBatMetadata) -> Option<usize> {
if metadata.contracts.iter().any(|contract| contract.vendored) {
return None;
}
let under_lib = metadata
.contracts
.iter()
.filter(|contract| {
contract
.file_path
.split('/')
.any(|segment| segment == "lib")
})
.count();
(under_lib > 0).then_some(under_lib)
}
pub fn report(edges: &[Edge]) -> Vec<String> {
let mut lines = vec![
format!(
"{} relation(s) between contracts — one per pair, whatever the number of calls",
edges.len()
),
String::new(),
];
let mut current = String::new();
for edge in edges {
if edge.from != current {
current = edge.from.clone();
lines.push(current.clone());
}
lines.push(format!(" → {:<28} {}", edge.to, edge.relation.label()));
}
let mut incoming: BTreeMap<&str, usize> = BTreeMap::new();
for edge in edges {
*incoming.entry(edge.to.as_str()).or_default() += 1;
}
let mut ranked: Vec<(&str, usize)> = incoming.into_iter().collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
lines.push(String::new());
lines.push("reached by the most contracts:".to_string());
for (name, count) in ranked.iter().take(5) {
lines.push(format!(" {name:<28} {count} contract(s) talk to it"));
}
lines
}
#[cfg(test)]
mod map_test {
use super::{drawn_edges, Edge, Relation};
#[test]
fn the_stronger_relation_wins_a_pair() {
assert!(Relation::Calls < Relation::WiresAtConstruction);
assert!(Relation::WiresAtConstruction < Relation::Unresolved);
}
#[test]
fn edges_group_by_their_source() {
let mut edges = vec![
Edge { from: "B".into(), to: "C".into(), relation: Relation::Calls },
Edge { from: "A".into(), to: "C".into(), relation: Relation::Calls },
Edge { from: "A".into(), to: "B".into(), relation: Relation::Calls },
];
edges.sort();
assert_eq!(edges[0].from, "A");
assert_eq!(edges[0].to, "B");
assert_eq!(edges[2].from, "B");
}
use super::{classify_type, predates_vendored, relations, undeclared_interfaces, TypeTarget};
use crate::batbelt::evm::metadata::bat_metadata::{
ContractMetadata, EvmBatMetadata, FunctionMetadata, UnresolvedCall,
};
use crate::batbelt::evm::types::{
EvmContractType, EvmFileItem, EvmFileItemKind, EvmMutability, EvmVisibility,
StorageVariable,
};
fn contract(
name: &str,
kind: EvmContractType,
bases: &[&str],
vendored: bool,
) -> ContractMetadata {
ContractMetadata {
metadata_id: name.to_string(),
name: name.to_string(),
using_libraries: Vec::new(),
file_path: format!("./{}/{name}.sol", if vendored { "lib/dep/src" } else { "src" }),
contract_type: kind,
base_contracts: bases.iter().map(|b| b.to_string()).collect(),
functions: Vec::new(),
state_variables: Vec::new(),
events: Vec::new(),
modifiers: Vec::new(),
line: 1,
vendored,
}
}
fn calling(inferred_type: &str) -> FunctionMetadata {
FunctionMetadata {
metadata_id: format!("f_{inferred_type}"),
name: "poke".to_string(),
contract_name: String::new(),
visibility: EvmVisibility::External,
mutability: EvmMutability::NonPayable,
modifiers: Vec::new(),
params: Vec::new(),
returns: Vec::new(),
line: 1,
end_line: 2,
is_constructor: false,
is_stub: false,
storage_writes: Vec::new(),
storage_write_sites: Vec::new(),
unresolved_calls: vec![UnresolvedCall {
receiver: "target".to_string(),
method: "poke".to_string(),
inferred_type: inferred_type.to_string(),
candidates: Vec::new(),
assigned_in: Vec::new(),
}],
unknown_external_calls: Vec::new(),
resolved_calls: Vec::new(),
}
}
fn immutable(type_name: &str) -> StorageVariable {
StorageVariable {
name: type_name.to_lowercase(),
type_name: type_name.to_string(),
visibility: EvmVisibility::Internal,
is_constant: false,
is_immutable: true,
line: 3,
}
}
fn pairs(edges: &[Edge]) -> Vec<(String, String, Relation)> {
edges
.iter()
.map(|edge| (edge.from.clone(), edge.to.clone(), edge.relation))
.collect()
}
#[test]
fn an_interface_extending_an_interface_does_not_block_the_collapse() {
let mut caller = contract("AscendCtoReceiver", EvmContractType::Contract, &[], false);
caller.functions.push(calling("IAscendLaunchpad"));
let metadata = EvmBatMetadata {
contracts: vec![
caller,
contract("AscendLaunchpad", EvmContractType::Contract, &["IAscendLaunchpad"], false),
contract("IAscendLaunchpad", EvmContractType::Interface, &[], false),
contract("IListingLaunchpad", EvmContractType::Interface, &["IAscendLaunchpad"], false),
],
..Default::default()
};
assert_eq!(
classify_type(&metadata, "IAscendLaunchpad"),
TypeTarget::Contract("AscendLaunchpad".to_string())
);
assert_eq!(
pairs(&relations(&metadata)),
vec![("AscendCtoReceiver".to_string(), "AscendLaunchpad".to_string(), Relation::Calls)]
);
}
#[test]
fn two_deployable_implementers_stay_an_open_question() {
let mut caller = contract("Router", EvmContractType::Contract, &[], false);
caller.functions.push(calling("IVault"));
let metadata = EvmBatMetadata {
contracts: vec![
caller,
contract("IVault", EvmContractType::Interface, &[], false),
contract("VaultA", EvmContractType::Contract, &["IVault"], false),
contract("VaultB", EvmContractType::Contract, &["IVault"], false),
],
..Default::default()
};
assert_eq!(classify_type(&metadata, "IVault"), TypeTarget::Open("IVault".to_string()));
assert_eq!(
pairs(&drawn_edges(&relations(&metadata), false)),
vec![("Router".to_string(), "IVault".to_string(), Relation::Unresolved)]
);
}
#[test]
fn a_struct_typed_immutable_is_not_a_relation() {
let mut vault = contract("AscendYieldVault", EvmContractType::Contract, &[], false);
vault.state_variables.extend([
immutable("Limits"),
immutable("CurveStep[]"),
immutable("mapping(Id => PendingUint192)"),
immutable("PriceFeed"),
]);
let metadata = EvmBatMetadata {
contracts: vec![vault, contract("PriceFeed", EvmContractType::Contract, &[], false)],
file_items: vec![EvmFileItem {
name: "Limits".to_string(),
kind: EvmFileItemKind::Struct,
file_path: "./src/AscendYieldVault.sol".to_string(),
line: 5,
end_line: 9,
vendored: false,
owner: "AscendYieldVault".to_string(),
}],
..Default::default()
};
assert_eq!(classify_type(&metadata, "Limits"), TypeTarget::NotAContract);
assert_eq!(classify_type(&metadata, "CurveStep[]"), TypeTarget::NotAContract);
assert_eq!(classify_type(&metadata, "mapping(Id => PendingUint192)"), TypeTarget::NotAContract);
assert_eq!(
pairs(&relations(&metadata)),
vec![(
"AscendYieldVault".to_string(),
"PriceFeed".to_string(),
Relation::WiresAtConstruction
)]
);
}
#[test]
fn a_vendored_contract_type_is_the_outside_not_its_in_scope_child() {
let mut token = contract("AscendCoreToken", EvmContractType::Contract, &["ERC20"], false);
token.state_variables.push(immutable("ERC20"));
let metadata = EvmBatMetadata {
contracts: vec![token, contract("ERC20", EvmContractType::Abstract, &["IERC20"], true)],
..Default::default()
};
assert_eq!(classify_type(&metadata, "ERC20"), TypeTarget::Outside("ERC20".to_string()));
assert_eq!(
pairs(&relations(&metadata)),
vec![("AscendCoreToken".to_string(), "ERC20".to_string(), Relation::Boundary)]
);
assert!(drawn_edges(&relations(&metadata), false).is_empty());
}
#[test]
fn an_in_scope_abstract_type_collapses_into_its_deployable_child() {
let metadata = EvmBatMetadata {
contracts: vec![
contract("BaseVault", EvmContractType::Abstract, &[], false),
contract("Vault", EvmContractType::Contract, &["BaseVault"], false),
],
..Default::default()
};
assert_eq!(classify_type(&metadata, "BaseVault"), TypeTarget::Contract("Vault".to_string()));
}
#[test]
fn an_own_interface_nothing_declares_is_named_as_a_question() {
let mut caller = contract("AscendCtoReceiver", EvmContractType::Contract, &[], false);
caller.functions.push(calling("IListingLaunchpad"));
let mut metadata = EvmBatMetadata {
contracts: vec![
caller,
contract("ListingDesk", EvmContractType::Contract, &["ReentrancyGuard"], false),
contract("IListingLaunchpad", EvmContractType::Interface, &[], false),
contract("IMirrorFactory", EvmContractType::Interface, &[], true),
],
..Default::default()
};
metadata.contracts[0].functions.push(calling("IMirrorFactory"));
assert_eq!(undeclared_interfaces(&metadata), vec!["IListingLaunchpad".to_string()]);
metadata
.resolutions
.insert("IListingLaunchpad".to_string(), "ListingDesk".to_string());
assert!(undeclared_interfaces(&metadata).is_empty());
assert!(pairs(&drawn_edges(&relations(&metadata), false)).contains(&(
"AscendCtoReceiver".to_string(),
"ListingDesk".to_string(),
Relation::Calls
)));
}
#[test]
fn a_scan_before_the_vendored_flag_is_refused() {
let mut stale = EvmBatMetadata {
contracts: vec![
contract("Vault", EvmContractType::Contract, &[], false),
contract("Test", EvmContractType::Abstract, &[], false),
],
..Default::default()
};
stale.contracts[1].file_path = "./lib/forge-std/src/Test.sol".to_string();
assert_eq!(predates_vendored(&stale), Some(1));
let mut current = stale.clone();
current.contracts[1].vendored = true;
assert_eq!(predates_vendored(¤t), None);
let bare = EvmBatMetadata {
contracts: vec![contract("Vault", EvmContractType::Contract, &[], false)],
..Default::default()
};
assert_eq!(predates_vendored(&bare), None);
}
}
#[cfg(test)]
mod folding_test {
use super::{hosts, Relation};
use crate::batbelt::evm::metadata::bat_metadata::{
ContractMetadata, EvmBatMetadata, FunctionDependency,
};
use crate::batbelt::evm::types::EvmContractType;
fn contract(name: &str, kind: EvmContractType, bases: &[&str]) -> ContractMetadata {
let mut built: ContractMetadata =
serde_json::from_str(&format!(
r#"{{"metadata_id":"{name}","name":"{name}","file_path":"./src/{name}.sol",
"contract_type":"{kind:?}","base_contracts":[],"functions":[],
"state_variables":[],"events":[],"modifiers":[],"line":1}}"#
))
.expect("fixture parses");
built.base_contracts = bases.iter().map(|base| base.to_string()).collect();
built
}
fn with_function(mut contract: ContractMetadata, id: &str) -> ContractMetadata {
let function = serde_json::from_str(&format!(
r#"{{"metadata_id":"{id}","name":"f","contract_name":"{}","visibility":"Internal",
"mutability":"NonPayable","modifiers":[],"params":[],"returns":[],"line":1,
"end_line":2,"is_constructor":false}}"#,
contract.name
))
.expect("fixture parses");
contract.functions.push(function);
contract
}
fn metadata(contracts: Vec<ContractMetadata>, deps: Vec<(&str, &str)>) -> EvmBatMetadata {
let mut built: EvmBatMetadata = serde_json::from_str(
r#"{"contracts":[],"entry_points":[],"function_dependencies":[],"interfaces":[],
"file_items":[]}"#,
)
.expect("an empty scan");
built.contracts = contracts;
built.function_dependencies = deps
.into_iter()
.map(|(id, callee)| FunctionDependency {
function_metadata_id: id.to_string(),
callees: vec![callee.to_string()],
})
.collect();
built
}
#[test]
fn a_library_is_hosted_by_the_contract_that_uses_it() {
let built = metadata(
vec![
with_function(contract("Vault", EvmContractType::Contract, &[]), "vault_f"),
contract("MathLib", EvmContractType::Library, &[]),
],
vec![("vault_f", "MathLib.wadMul")],
);
let hosted = hosts(&built);
assert!(hosted["Vault"].contains("MathLib"));
assert!(!hosted.contains_key("MathLib"), "a library hosts nobody");
}
#[test]
fn hosting_is_transitive() {
let built = metadata(
vec![
with_function(contract("FLAMM", EvmContractType::Contract, &[]), "flamm_f"),
with_function(contract("FlowLib", EvmContractType::Library, &[]), "flow_f"),
contract("StoreLib", EvmContractType::Library, &[]),
],
vec![("flamm_f", "FlowLib.run"), ("flow_f", "StoreLib.read")],
);
let hosted = hosts(&built);
assert!(hosted["FLAMM"].contains("FlowLib"));
assert!(hosted["FLAMM"].contains("StoreLib"), "reached through FlowLib");
}
#[test]
fn an_abstract_base_is_hosted_by_what_inherits_it() {
let built = metadata(
vec![
contract("Router", EvmContractType::Contract, &["Pausable"]),
contract("Pausable", EvmContractType::Abstract, &[]),
],
vec![],
);
assert!(hosts(&built)["Router"].contains("Pausable"));
}
#[test]
fn a_created_and_called_pair_keeps_the_call() {
assert!(Relation::Calls < Relation::Creates);
assert!(Relation::Creates < Relation::WiresAtConstruction);
}
}
#[cfg(test)]
mod report_test {
use super::{matrix, outside_panel, Edge, Relation};
fn edge(from: &str, to: &str, relation: Relation) -> Edge {
Edge {
from: from.to_string(),
to: to.to_string(),
relation,
}
}
#[test]
fn the_matrix_marks_each_pair_and_counts_both_ways() {
let edges = vec![
edge("A", "C", Relation::Calls),
edge("B", "C", Relation::Creates),
edge("A", "B", Relation::WiresAtConstruction),
];
let lines = matrix(&edges);
let row_a = lines.iter().find(|line| line.contains(" A ")).expect("A's row");
assert!(row_a.contains('●'), "A calls C");
assert!(row_a.contains('○'), "A wires B");
assert!(row_a.ends_with("→ 2, ← 0"), "A reaches two and is reached by none: {row_a}");
let row_c = lines.iter().find(|line| line.contains(" C ")).expect("C's row");
assert!(row_c.ends_with("→ 0, ← 2"), "C is the one everything points at: {row_c}");
}
#[test]
fn a_tiny_system_gets_no_matrix() {
assert!(matrix(&[edge("A", "B", Relation::Calls)]).is_empty());
}
#[test]
fn the_panel_lists_what_leaves_and_who_reaches_it() {
let edges = vec![
edge("Vault", "IERC20", Relation::Boundary),
edge("Router", "IERC20", Relation::Boundary),
edge("Vault", "Oracle", Relation::Calls),
];
let lines = outside_panel(&edges);
let erc20 = lines.iter().find(|line| line.contains("IERC20")).expect("the line");
assert!(erc20.contains("Router") && erc20.contains("Vault"), "both callers: {erc20}");
assert!(!lines.iter().any(|line| line.contains("Oracle")), "a call in scope is not outside");
}
#[test]
fn nothing_leaving_is_no_panel() {
assert!(outside_panel(&[edge("A", "B", Relation::Calls)]).is_empty());
}
}