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, ConnectorStroke, ConnectorStyle, MiroClient};
use crate::batbelt::miro::layout::{GraphLayout, PlacedNode};
pub struct MapOptions {
pub preview: Option<String>,
pub dry_run: bool,
pub contract: Option<String>,
pub hierarchy: bool,
pub no_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"
)));
}
if options.hierarchy {
for line in hierarchy(&relations(&metadata), options.no_boundaries) {
println!("{line}");
}
return Ok(());
}
let mut prepared = prepare(&metadata, &options)?;
if let Some(wanted) = options.contract.as_deref() {
prepared.keep_only(wanted)?;
}
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 — in the strip at the right, \
apart from the diagram:",
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 let Some(path) = options.preview.as_deref() {
preview(&prepared, path)?;
}
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);
if let Ok(drawn) = outcome.as_ref() {
let whole_map = options.contract.is_none();
match record_map(drawn.clone(), whole_map, &client).await {
Ok(cards) if whole_map => {
println!(" {} {cards} card(s) on the clusters", "✓".green())
}
Ok(_) => {}
Err(report) => println!(
" {} the map is drawn, but the registry was not updated: {}",
"note:".yellow(),
report.current_context()
),
}
mark("recording 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.map(|_| ())
}
const CARD_HEIGHT: f64 = 560.0;
const CARD_WIDTH: f64 = 4_600.0;
const CARD_GAP: f64 = 200.0;
const CARD_FONT: usize = 180;
const CARD_FILL: &str = "#ffffff";
const CARD_BORDER: &str = "#1a73e8";
const MAX_CARDS: usize = 4;
async fn record_map(
frames: Vec<crate::batbelt::evm::metadata::bat_metadata::MapFrameRef>,
whole_map: bool,
client: &MiroClient,
) -> Result<usize, EvmMiroError> {
use crate::batbelt::evm::metadata::bat_metadata::{MapCardRef, MapFrameRef};
let mut metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
if !whole_map {
let mut kept: Vec<MapFrameRef> = metadata
.miro
.map
.iter()
.filter(|old| !frames.iter().any(|new| new.root == old.root))
.cloned()
.collect();
kept.extend(frames);
metadata.miro.map = kept;
metadata.save_metadata().change_context(EvmMiroError)?;
return Ok(0);
}
let mut stale: Vec<String> = metadata
.miro
.map
.iter()
.flat_map(|frame| frame.cards.iter().map(|card| card.item_id.clone()))
.collect();
if let Ok(loose) = client.list_loose_shapes().await {
stale.extend(
loose
.into_iter()
.filter(|(_, content)| is_map_card(content))
.map(|(id, _)| id),
);
}
stale.sort();
stale.dedup();
let mut removing = tokio::task::JoinSet::new();
for id in stale {
let client = client.clone();
removing.spawn(async move { client.delete_item(&id).await });
}
while removing.join_next().await.is_some() {}
let columns: Vec<(String, String)> = metadata
.miro
.auto
.columns
.iter()
.filter(|column| !column.label_id.is_empty())
.map(|column| (column.contract.clone(), column.label_id.clone()))
.collect();
let made = draw_cards(&columns, &frames, client).await;
metadata.miro.map = frames
.into_iter()
.map(|mut frame| {
frame.cards = made
.iter()
.filter(|(_, root, _)| *root == frame.root)
.map(|(contract, _, id)| MapCardRef {
contract: contract.clone(),
item_id: id.clone(),
})
.collect();
frame
})
.collect();
metadata.save_metadata().change_context(EvmMiroError)?;
Ok(made.len())
}
async fn draw_cards(
columns: &[(String, String)],
frames: &[crate::batbelt::evm::metadata::bat_metadata::MapFrameRef],
client: &MiroClient,
) -> Vec<(String, String, String)> {
let mut asking = tokio::task::JoinSet::new();
for (contract, label_id) in columns {
let client = client.clone();
let (contract, label_id) = (contract.clone(), label_id.clone());
asking.spawn(async move { (contract, client.item_geometry(&label_id).await) });
}
let mut anchors: BTreeMap<String, (f64, f64)> = BTreeMap::new();
while let Some(Ok((contract, geometry))) = asking.join_next().await {
if let Some((x, y, _, height)) = geometry {
anchors.insert(contract, (x, y - height / 2.0));
}
}
struct Planned {
contract: String,
root: String,
item: serde_json::Value,
}
let mut planned: Vec<Planned> = Vec::new();
for (contract, (centre, top)) in &anchors {
let showing = frames_showing(frames, contract);
if showing.is_empty() {
continue;
}
let drawn = showing.len().min(MAX_CARDS);
let span = drawn as f64 * CARD_WIDTH + (drawn as f64 - 1.0) * CARD_GAP;
for (index, frame) in showing.iter().take(MAX_CARDS).enumerate() {
let x = centre - span / 2.0 + CARD_WIDTH / 2.0 + index as f64 * (CARD_WIDTH + CARD_GAP);
let y = top - CARD_GAP - CARD_HEIGHT / 2.0;
let left = showing.len().saturating_sub(MAX_CARDS);
let text = if index + 1 == MAX_CARDS && left > 0 {
format!("map · {} (+{left} more)", frame.root)
} else {
format!("map · {}", frame.root)
};
planned.push(Planned {
contract: contract.clone(),
root: frame.root.clone(),
item: MiroClient::link_card_item(
&text,
&client.frame_url(&frame.frame_id),
x,
y,
CARD_WIDTH,
CARD_HEIGHT,
CARD_FONT,
CARD_FILL,
CARD_BORDER,
),
});
}
}
const BATCH: usize = 20;
let mut making = tokio::task::JoinSet::new();
for chunk in planned.chunks(BATCH) {
let client = client.clone();
let keys: Vec<(String, String)> = chunk
.iter()
.map(|card| (card.contract.clone(), card.root.clone()))
.collect();
let items: Vec<serde_json::Value> = chunk.iter().map(|card| card.item.clone()).collect();
making.spawn(async move { (keys, client.create_items_bulk(items).await) });
}
let mut made: Vec<(String, String, String)> = Vec::new();
while let Some(joined) = making.join_next().await {
if let Ok((keys, Ok(ids))) = joined {
for ((contract, root), id) in keys.into_iter().zip(ids) {
made.push((contract, root, id));
}
}
}
made
}
fn is_map_card(content: &str) -> bool {
content.contains("map · ") && content.contains("moveToWidget=")
}
fn frames_showing<'a>(
frames: &'a [crate::batbelt::evm::metadata::bat_metadata::MapFrameRef],
contract: &str,
) -> Vec<&'a crate::batbelt::evm::metadata::bat_metadata::MapFrameRef> {
frames
.iter()
.filter(|frame| frame.contracts.iter().any(|name| name == contract))
.collect()
}
pub async fn cards_for(contract: &str, client: &MiroClient) -> Result<usize, EvmMiroError> {
use crate::batbelt::evm::metadata::bat_metadata::MapCardRef;
let mut metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
if metadata.miro.map.is_empty() {
return Ok(0);
}
let Some(label_id) = metadata
.miro
.auto
.columns
.iter()
.find(|column| column.contract == contract)
.map(|column| column.label_id.clone())
.filter(|id| !id.is_empty())
else {
return Ok(0);
};
let mut removing = tokio::task::JoinSet::new();
for frame in &metadata.miro.map {
for card in frame.cards.iter().filter(|card| card.contract == contract) {
let (client, id) = (client.clone(), card.item_id.clone());
removing.spawn(async move { client.delete_item(&id).await });
}
}
while removing.join_next().await.is_some() {}
let frames = metadata.miro.map.clone();
let made = draw_cards(&[(contract.to_string(), label_id)], &frames, client).await;
for frame in &mut metadata.miro.map {
frame.cards.retain(|card| card.contract != contract);
frame.cards.extend(
made.iter()
.filter(|(_, root, _)| root == &frame.root)
.map(|(contract, _, id)| MapCardRef {
contract: contract.clone(),
item_id: id.clone(),
}),
);
}
metadata.save_metadata().change_context(EvmMiroError)?;
Ok(made.len())
}
const NODE_HEIGHT: f64 = 320.0;
const OUTSIDE_HEIGHT: f64 = 160.0;
const OUTSIDE_FONT: usize = 60;
const OUTSIDE_CHAR: f64 = 35.0;
const OUTSIDE_PAD: f64 = 80.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 MARKER_SIZE: f64 = 8.0;
const EDGE_COLOUR: &str = "#1a1a1a";
const CYCLE_COLOUR: &str = "#f24726";
const CREATES_COLOUR: &str = "#fac710";
const BOX_FILL: &str = "#ffffff";
const BOX_BORDER: &str = "#1a1a1a";
const OUTSIDE_FILL: &str = "#e6e6e6";
const OUTSIDE_FILLS: &[&str] = &[
"#d9ecff", "#ffe0d6", "#e2f5cf", "#fdf0c2", "#ecdcff", "#cff3f5", "#ffe4cc", "#ffd9ec",
];
const BORDER_WIDTH: usize = 4;
const REPEAT_BORDER_WIDTH: usize = 23;
const REPEAT_BORDERS: &[&str] = &[
"#1a73e8", "#a259ff", "#00897b", "#e8710a", "#c2185b", "#5d4037", "#1a237e", "#827717",
];
const STRIP_FILL: &str = "#f4f4f4";
const ANCHOR_PITCH: f64 = 40.0;
const ANCHOR_INSET: f64 = 40.0;
const STRIP_HEADING: &str = "\u{0}not connected in scope";
const LEGEND_SAMPLE: f64 = 700.0;
const CYCLE_GAP: f64 = 240.0;
const CYCLE_PITCH: f64 = 160.0;
const BADGE_GAP: f64 = 30.0;
const BADGE_HEIGHT: f64 = 150.0;
fn node_width(name: &str) -> f64 {
if is_outside(name) {
return original_of(name).chars().count() as f64 * OUTSIDE_CHAR + OUTSIDE_PAD * 2.0;
}
name.chars().count() as f64 * NODE_CHAR + NODE_PAD * 2.0
}
fn node_height(id: &str) -> f64 {
if is_outside(id) {
OUTSIDE_HEIGHT
} else {
NODE_HEIGHT
}
}
fn node_font(id: &str) -> usize {
if is_outside(id) {
OUTSIDE_FONT
} else {
NODE_FONT
}
}
fn stroke_of(relation: Relation) -> (ConnectorStroke, Option<&'static str>) {
match relation {
Relation::Calls => (ConnectorStroke::Solid, None),
Relation::Creates => (ConnectorStroke::Solid, None),
Relation::WiresAtConstruction => (ConnectorStroke::Dotted, Some("wired")),
Relation::Unresolved => (ConnectorStroke::Dashed, Some("?")),
Relation::Boundary => (ConnectorStroke::Solid, None),
}
}
fn style_of(relation: Relation, backwards: bool) -> ConnectorStyle {
let (stroke, caption) = stroke_of(relation);
let colour = match (backwards, relation) {
(true, _) => CYCLE_COLOUR,
(false, Relation::Creates) => CREATES_COLOUR,
(false, _) => EDGE_COLOUR,
};
ConnectorStyle {
stroke_color: colour.to_string(),
stroke_width: EDGE_WIDTH.to_string(),
stroke,
caption: caption.map(str::to_string),
arrow: ArrowEnd::End,
}
}
#[derive(Debug, Clone)]
enum Sample {
Leg(ConnectorStyle),
Box { fill: &'static str, border: &'static str },
}
#[derive(Debug, Clone)]
struct LegendRow {
text: &'static str,
sample: Sample,
x: f64,
y: f64,
width: f64,
sample_from: (f64, f64),
sample_to: (f64, f64),
}
fn legend_rows() -> Vec<LegendRow> {
let rows: [(&'static str, Sample); 7] = [
("calls", Sample::Leg(style_of(Relation::Calls, false))),
("green: creates it — `new X(...)`", Sample::Leg(style_of(Relation::Creates, false))),
("wired at construction, never called", Sample::Leg(style_of(Relation::WiresAtConstruction, false))),
("unresolved binding — a question", Sample::Leg(style_of(Relation::Unresolved, false))),
("runs backwards — a cycle", Sample::Leg(style_of(Relation::Calls, true))),
("tinted: no source here — one colour per interface", Sample::Box { fill: OUTSIDE_FILLS[0], border: BOX_BORDER }),
("coloured border: the same contract, drawn twice", Sample::Box { fill: BOX_FILL, border: REPEAT_BORDERS[0] }),
];
rows.into_iter()
.enumerate()
.map(|(index, (text, sample))| {
let width = node_width(text);
let left = MAP_MARGIN / 2.0;
let y = MAP_MARGIN / 2.0 + index as f64 * (NODE_HEIGHT + 60.0);
let (sample_from, sample_to) = match sample {
Sample::Leg(_) => ((left + width + 100.0, y), (left + width + 100.0 + LEGEND_SAMPLE, y)),
Sample::Box { .. } => ((left, y), (left + width, y)),
};
LegendRow { text, sample, x: left + width / 2.0, y, width, sample_from, sample_to }
})
.collect()
}
const LEGEND_ROWS: usize = 7;
pub(crate) struct RoutedArrow {
pub(crate) edge: Option<usize>,
pub(crate) plan: crate::batbelt::evm::miro::auto_deploy::PlannedLaneArrow,
pub(crate) style: ConnectorStyle,
}
fn route_arrows(edges: &[Edge], layout: &GraphLayout, with_legend: bool) -> Vec<RoutedArrow> {
use crate::batbelt::evm::miro::auto_deploy::{plan_lane_chain, LaneEnd, LaneSide, PlannedLaneArrow};
use crate::batbelt::miro::client::RelativeAnchor;
use crate::batbelt::miro::layout::{assign_lanes, LANE_PITCH};
struct Forward<'a> {
index: usize,
edge: &'a Edge,
from: &'a PlacedNode,
to: &'a PlacedNode,
gutters: Vec<usize>,
corridors: Vec<f64>,
}
let mut forward: Vec<Forward> = Vec::new();
let mut backwards: Vec<(usize, &Edge, &PlacedNode, &PlacedNode)> = Vec::new();
for (index, edge) in edges.iter().enumerate() {
let (Some(from), Some(to)) = (layout.node(&edge.from), layout.node(&edge.to)) else {
continue;
};
if to.layer <= from.layer {
backwards.push((index, edge, from, to));
continue;
}
let corridors: Vec<f64> = layout
.routes
.get(index)
.map(|bends| bends.iter().map(|(_, y)| *y).collect())
.unwrap_or_default();
let gutters: Vec<usize> = (from.layer..to.layer).collect();
forward.push(Forward { index, edge, from, to, gutters, corridors });
}
let spread = |mut ends: Vec<(usize, f64)>, height: f64| -> BTreeMap<usize, f64> {
ends.sort_by(|a, b| {
a.1.partial_cmp(&b.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
let count = ends.len();
let step = if count > 1 {
ANCHOR_PITCH.min((height - 2.0 * ANCHOR_INSET).max(0.0) / (count - 1) as f64)
} else {
0.0
};
ends.into_iter()
.enumerate()
.map(|(rank, (index, _))| (index, (rank as f64 - (count as f64 - 1.0) / 2.0) * step))
.collect()
};
let mut leaving: BTreeMap<&str, (f64, Vec<(usize, f64)>)> = BTreeMap::new();
let mut landing: BTreeMap<&str, (f64, Vec<(usize, f64)>)> = BTreeMap::new();
for arrow in &forward {
let heads_to = arrow.corridors.first().copied().unwrap_or(arrow.to.y);
let comes_from = arrow.corridors.last().copied().unwrap_or(arrow.from.y);
leaving
.entry(arrow.from.id.as_str())
.or_insert_with(|| (arrow.from.height, Vec::new()))
.1
.push((arrow.index, heads_to));
landing
.entry(arrow.to.id.as_str())
.or_insert_with(|| (arrow.to.height, Vec::new()))
.1
.push((arrow.index, comes_from));
}
let mut start_offset: BTreeMap<usize, f64> = BTreeMap::new();
for (height, ends) in leaving.into_values() {
start_offset.extend(spread(ends, height));
}
let mut end_offset: BTreeMap<usize, f64> = BTreeMap::new();
for (height, ends) in landing.into_values() {
end_offset.extend(spread(ends, height));
}
let mut crossing: BTreeMap<usize, Vec<((usize, usize), f64, f64)>> = BTreeMap::new();
for arrow in &forward {
let start_y = arrow.from.y + start_offset.get(&arrow.index).copied().unwrap_or(0.0);
let end_y = arrow.to.y + end_offset.get(&arrow.index).copied().unwrap_or(0.0);
let mut enters = start_y;
for (step, gutter) in arrow.gutters.iter().enumerate() {
let leaves = arrow.corridors.get(step).copied().unwrap_or(end_y);
crossing
.entry(*gutter)
.or_default()
.push(((arrow.index, step), enters, leaves));
enters = leaves;
}
}
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<RoutedArrow> = Vec::new();
for arrow in &forward {
let lanes: Vec<f64> = arrow
.gutters
.iter()
.enumerate()
.filter_map(|(step, _)| lane_of.get(&(arrow.index, step)).copied())
.collect();
if lanes.is_empty() {
continue;
}
let start_y = arrow.from.y + start_offset.get(&arrow.index).copied().unwrap_or(0.0);
let end_y = arrow.to.y + end_offset.get(&arrow.index).copied().unwrap_or(0.0);
planned.push(RoutedArrow {
edge: Some(arrow.index),
plan: plan_lane_chain(
&LaneEnd::Point { x: arrow.from.x + arrow.from.width / 2.0, y: start_y },
&LaneEnd::Point { x: arrow.to.x - arrow.to.width / 2.0, y: end_y },
&lanes,
&arrow.corridors,
),
style: style_of(arrow.edge.relation, false),
});
}
for (rank, (index, edge, from, to)) in backwards.into_iter().enumerate() {
let corridor = from.y + from.height / 2.0 + CYCLE_GAP + rank as f64 * CYCLE_PITCH;
let anchor = RelativeAnchor::new(0.5, 0.5);
let down_x = from.x - from.width / 2.0 - CYCLE_GAP;
let up_x = to.x - to.width / 2.0 - CYCLE_GAP;
planned.push(RoutedArrow {
edge: Some(index),
plan: PlannedLaneArrow {
points: vec![
(from.x - from.width / 2.0, from.y),
(down_x, from.y),
(down_x, corridor),
(up_x, corridor),
(up_x, to.y),
(to.x - to.width / 2.0, to.y),
],
legs: vec![
(LaneSide::Marker(0), anchor, LaneSide::Marker(1), anchor, false),
(LaneSide::Marker(1), anchor, LaneSide::Marker(2), anchor, false),
(LaneSide::Marker(2), anchor, LaneSide::Marker(3), anchor, false),
(LaneSide::Marker(3), anchor, LaneSide::Marker(4), anchor, false),
(LaneSide::Marker(4), anchor, LaneSide::Marker(5), anchor, true),
],
},
style: style_of(edge.relation, true),
});
}
for row in legend_rows().into_iter().filter(|_| with_legend) {
let Sample::Leg(style) = row.sample else { continue };
planned.push(RoutedArrow {
edge: None,
plan: PlannedLaneArrow {
points: vec![row.sample_from, row.sample_to],
legs: vec![(
LaneSide::Marker(0),
RelativeAnchor::new(1.0, 0.5),
LaneSide::Marker(1),
RelativeAnchor::new(0.0, 0.5),
true,
)],
},
style,
});
}
planned
.into_iter()
.map(|arrow| RoutedArrow { plan: tighten(arrow.plan), ..arrow })
.collect()
}
fn tighten(
plan: crate::batbelt::evm::miro::auto_deploy::PlannedLaneArrow,
) -> crate::batbelt::evm::miro::auto_deploy::PlannedLaneArrow {
use crate::batbelt::evm::miro::auto_deploy::{LaneSide, PlannedLaneArrow};
let same = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 0.5 && (a.1 - b.1).abs() < 0.5;
let mut points: Vec<(f64, f64)> = Vec::new();
let mut moved: Vec<usize> = Vec::new();
for point in &plan.points {
match points.iter().position(|kept| same(*kept, *point)) {
Some(index) => moved.push(index),
None => {
points.push(*point);
moved.push(points.len() - 1);
}
}
}
let at = |side: &LaneSide| match side {
LaneSide::Marker(index) => LaneSide::Marker(moved.get(*index).copied().unwrap_or(*index)),
LaneSide::Item(id) => LaneSide::Item(id.clone()),
};
let mut legs: Vec<_> = plan
.legs
.iter()
.map(|(from, from_anchor, to, to_anchor, head)| {
(at(from), *from_anchor, at(to), *to_anchor, *head)
})
.filter(|(from, _, to, _, _)| match (from, to) {
(LaneSide::Marker(a), LaneSide::Marker(b)) => a != b,
_ => true,
})
.collect();
if !legs.is_empty() && !legs.iter().any(|leg| leg.4) {
let last = legs.len() - 1;
legs[last].4 = true;
}
PlannedLaneArrow { points, legs }
}
pub fn preview(prepared: &PreparedMap, path: &str) -> Result<(), EvmMiroError> {
use crate::batbelt::evm::miro::auto_deploy::LaneSide;
use image::{Rgba, RgbaImage};
let scale = (2_600.0 / prepared.width).min(1.0);
let width = (prepared.width * scale).round().max(1.0) as u32;
let height = (prepared.height * scale).round().max(1.0) as u32;
let mut canvas = RgbaImage::from_pixel(width, height, Rgba([250, 250, 250, 255]));
for frame in &prepared.frames {
let at = |x: f64, y: f64| {
(
((frame.left + x) * scale) as i64,
((frame.top + y) * scale) as i64,
)
};
let border = parse_colour(BOX_BORDER);
let (frame_left, frame_top) = at(0.0, 0.0);
let (frame_right, frame_bottom) = at(frame.width, frame.height);
for (from, to) in [
((frame_left, frame_top), (frame_right, frame_top)),
((frame_right, frame_top), (frame_right, frame_bottom)),
((frame_right, frame_bottom), (frame_left, frame_bottom)),
((frame_left, frame_bottom), (frame_left, frame_top)),
] {
draw_stroke(&mut canvas, from, to, border, ConnectorStroke::Solid);
}
let mut boxes: Vec<(f64, f64, f64, f64, &str, &str, usize)> = Vec::new();
for node in &frame.layout.nodes {
let (border, thickness) = frame.border_of(&node.id);
boxes.push((node.x, node.y, node.width, node.height, frame.fill_of(&node.id), border, thickness));
}
for node in &frame.strip {
boxes.push((node.x, node.y, node.width, node.height, STRIP_FILL, BOX_BORDER, BORDER_WIDTH));
}
if frame.is_legend() {
for row in legend_rows() {
let (fill, border) = match row.sample {
Sample::Leg(_) => (BOX_FILL, BOX_BORDER),
Sample::Box { fill, border } => (fill, border),
};
let thickness = if border == BOX_BORDER { BORDER_WIDTH } else { REPEAT_BORDER_WIDTH };
boxes.push((row.x, row.y, row.width, NODE_HEIGHT, fill, border, thickness));
}
}
for (x, y, box_width, box_height, fill, box_border, thickness) in boxes {
let (left, top) = at(x - box_width / 2.0, y - box_height / 2.0);
let (right, bottom) = at(x + box_width / 2.0, y + box_height / 2.0);
fill_rect(&mut canvas, (left, top), (right, bottom), parse_colour(fill));
let edge = parse_colour(box_border);
let rings = ((thickness as f64 * scale).round() as i64).max(1);
for ring in 0..rings {
for (from, to) in [
((left + ring, top + ring), (right - ring, top + ring)),
((right - ring, top + ring), (right - ring, bottom - ring)),
((right - ring, bottom - ring), (left + ring, bottom - ring)),
((left + ring, bottom - ring), (left + ring, top + ring)),
] {
draw_stroke(&mut canvas, from, to, edge, ConnectorStroke::Solid);
}
}
}
for arrow in &frame.arrows {
let colour = parse_colour(&arrow.style.stroke_color);
for (from, _, to, _, _) in &arrow.plan.legs {
let point = |side: &LaneSide| match side {
LaneSide::Marker(index) => arrow.plan.points.get(*index).copied(),
LaneSide::Item(_) => None,
};
let (Some((from_x, from_y)), Some((to_x, to_y))) = (point(from), point(to)) else {
continue;
};
draw_stroke(&mut canvas, at(from_x, from_y), at(to_x, to_y), colour, arrow.style.stroke);
}
}
}
canvas
.save(path)
.map_err(|error| {
Report::new(EvmMiroError).attach_printable(format!("cannot write {path}: {error}"))
})?;
println!(" preview written to {}", path.bold());
Ok(())
}
fn draw_stroke(
canvas: &mut image::RgbaImage,
from: (i64, i64),
to: (i64, i64),
colour: image::Rgba<u8>,
stroke: ConnectorStroke,
) {
let (on, off) = match stroke {
ConnectorStroke::Solid => (usize::MAX, 0),
ConnectorStroke::Dashed => (12, 8),
ConnectorStroke::Dotted => (3, 6),
};
let mut walked = 0usize;
for (x, y) in bresenham(from, to) {
if walked % (on.saturating_add(off)) < on {
for dy in -1..=1 {
for dx in -1..=1 {
let (px, py) = (x + dx, y + dy);
if px >= 0 && py >= 0 && (px as u32) < canvas.width() && (py as u32) < canvas.height() {
canvas.put_pixel(px as u32, py as u32, colour);
}
}
}
}
walked += 1;
}
}
fn bresenham(from: (i64, i64), to: (i64, i64)) -> Vec<(i64, i64)> {
let mut pixels = Vec::new();
let (mut x, mut y) = from;
let dx = (to.0 - x).abs();
let dy = -(to.1 - y).abs();
let step_x = if x < to.0 { 1 } else { -1 };
let step_y = if y < to.1 { 1 } else { -1 };
let mut error = dx + dy;
loop {
pixels.push((x, y));
if x == to.0 && y == to.1 {
break;
}
let double = 2 * error;
if double >= dy {
error += dy;
x += step_x;
}
if double <= dx {
error += dx;
y += step_y;
}
}
pixels
}
fn fill_rect(canvas: &mut image::RgbaImage, top_left: (i64, i64), bottom_right: (i64, i64), colour: image::Rgba<u8>) {
for y in top_left.1.max(0)..bottom_right.1.min(canvas.height() as i64) {
for x in top_left.0.max(0)..bottom_right.0.min(canvas.width() as i64) {
canvas.put_pixel(x as u32, y as u32, colour);
}
}
}
fn parse_colour(hex: &str) -> image::Rgba<u8> {
let channel = |range: std::ops::Range<usize>| {
hex.get(range)
.and_then(|part| u8::from_str_radix(part, 16).ok())
.unwrap_or(128)
};
image::Rgba([channel(1..3), channel(3..5), channel(5..7), 255])
}
pub fn prepare(metadata: &EvmBatMetadata, options: &MapOptions) -> Result<PreparedMap, EvmMiroError> {
let all = relations(metadata);
let edges = drawn_edges(&all, options.no_boundaries);
if edges.is_empty() {
return Err(Report::new(EvmMiroError)
.attach_printable("no relations between contracts — run `bat-cli sonar` first"));
}
let mut frames: Vec<PreparedFrame> = partition_frames(&edges)
.into_iter()
.map(|plan| {
let mine = plan.edges;
let layout = layout_tree(&plan.root, &mine);
let arrows = route_arrows(&mine, &layout, false);
let used = arrows
.iter()
.flat_map(|arrow| arrow.plan.points.iter())
.map(|(_, y)| *y)
.fold(0.0_f64, f64::max);
PreparedFrame {
title: frame_title(&plan.root),
fills: PreparedFrame::outside_fills(&mine),
borders: PreparedFrame::repeat_borders(&mine),
width: layout.frame_width,
height: layout.frame_height.max(used + MAP_MARGIN),
crossings: layout.crossings,
edges: mine,
layout,
arrows,
strip: Vec::new(),
left: 0.0,
top: 0.0,
}
})
.collect();
frames.insert(0, legend_frame(&unconnected(metadata, &edges)));
pack_frames(&mut frames);
let width = frames
.iter()
.map(|frame| frame.left + frame.width)
.fold(0.0_f64, f64::max);
let height = frames
.iter()
.map(|frame| frame.top + frame.height)
.fold(0.0_f64, f64::max);
let calls = ApiCalls {
frames: frames.len(),
boxes: frames
.iter()
.map(|frame| frame.layout.nodes.len() + frame.boxes_of_its_own())
.sum(),
markers: frames
.iter()
.flat_map(|frame| frame.arrows.iter())
.map(|arrow| arrow.plan.points.len())
.sum(),
connectors: frames
.iter()
.flat_map(|frame| frame.arrows.iter())
.map(|arrow| arrow.plan.legs.len())
.sum(),
};
Ok(PreparedMap { edges, frames, width, height, calls })
}
fn frame_title(root: &str) -> String {
format!("map: what {root} talks to")
}
fn legend_frame(alone: &[String]) -> PreparedFrame {
let rows = legend_rows();
let mut strip = strip_of(alone, rows.last().map(|row| row.y).unwrap_or(0.0) + NODE_HEIGHT * 2.0);
let width = rows
.iter()
.map(|row| row.x + row.width / 2.0 + LEGEND_SAMPLE + MAP_MARGIN)
.chain(strip.iter().map(|node| node.x + node.width / 2.0 + MAP_MARGIN))
.fold(0.0_f64, f64::max);
let height = rows
.iter()
.map(|row| row.y)
.chain(strip.iter().map(|node| node.y))
.fold(0.0_f64, f64::max)
+ NODE_HEIGHT / 2.0
+ MAP_MARGIN;
let arrows = route_arrows(&[], &crate::batbelt::miro::layout::GraphLayout {
nodes: Vec::new(),
routes: Vec::new(),
back_edges: Vec::new(),
bbox_width: 0.0,
bbox_height: 0.0,
frame_width: width,
frame_height: height,
crossings: 0,
}, true);
strip.shrink_to_fit();
PreparedFrame {
title: "map: legend".to_string(),
fills: BTreeMap::new(),
borders: BTreeMap::new(),
edges: Vec::new(),
layout: crate::batbelt::miro::layout::GraphLayout {
nodes: Vec::new(),
routes: Vec::new(),
back_edges: Vec::new(),
bbox_width: width,
bbox_height: height,
frame_width: width,
frame_height: height,
crossings: 0,
},
arrows,
strip,
width,
height,
crossings: 0,
left: 0.0,
top: 0.0,
}
}
fn pack_frames(frames: &mut [PreparedFrame]) {
let area: f64 = frames.iter().map(|frame| frame.width * frame.height).sum();
let widest = frames.iter().map(|frame| frame.width).fold(0.0_f64, f64::max);
let target = area.sqrt().max(widest * 2.0 + MAP_MARGIN);
let (mut x, mut y, mut row_height) = (0.0_f64, 0.0_f64, 0.0_f64);
for frame in frames.iter_mut() {
if x > 0.0 && x + frame.width > target {
x = 0.0;
y += row_height + MAP_MARGIN;
row_height = 0.0;
}
frame.left = x;
frame.top = y;
x += frame.width + MAP_MARGIN;
row_height = row_height.max(frame.height);
}
}
fn strip_of(names: &[String], top: f64) -> Vec<PlacedNode> {
if names.is_empty() {
return Vec::new();
}
let heading = strip_heading_text();
let width = names
.iter()
.map(|name| node_width(name))
.fold(node_width(heading), f64::max);
std::iter::once(STRIP_HEADING.to_string())
.chain(names.iter().cloned())
.enumerate()
.map(|(row, id)| PlacedNode {
id,
layer: 0,
x: MAP_MARGIN / 2.0 + width / 2.0,
y: top + row as f64 * (NODE_HEIGHT + 60.0),
width,
height: NODE_HEIGHT,
})
.collect()
}
fn strip_heading_text() -> &'static str {
STRIP_HEADING.trim_start_matches('\u{0}')
}
fn box_text(id: &str) -> &str {
if id == STRIP_HEADING {
strip_heading_text()
} else {
original_of(id)
}
}
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>,
frames: Vec<PreparedFrame>,
pub width: f64,
pub height: f64,
pub calls: ApiCalls,
}
pub struct PreparedFrame {
title: String,
edges: Vec<Edge>,
layout: GraphLayout,
arrows: Vec<RoutedArrow>,
fills: BTreeMap<String, &'static str>,
borders: BTreeMap<String, &'static str>,
strip: Vec<PlacedNode>,
width: f64,
height: f64,
crossings: usize,
left: f64,
top: f64,
}
impl PreparedFrame {
fn root(&self) -> &str {
self.title
.strip_prefix("map: what ")
.and_then(|rest| rest.strip_suffix(" talks to"))
.unwrap_or(&self.title)
}
fn contracts_shown(&self) -> Vec<String> {
let mut names: BTreeSet<&str> = BTreeSet::new();
for node in &self.layout.nodes {
if !is_outside(&node.id) {
names.insert(original_of(&node.id));
}
}
names.into_iter().map(str::to_string).collect()
}
fn outside_fills(edges: &[Edge]) -> BTreeMap<String, &'static str> {
let names: BTreeSet<&str> = edges
.iter()
.filter(|edge| is_outside(&edge.to))
.map(|edge| original_of(&edge.to))
.collect();
names
.into_iter()
.enumerate()
.map(|(index, name)| {
(name.to_string(), OUTSIDE_FILLS.get(index).copied().unwrap_or(OUTSIDE_FILL))
})
.collect()
}
fn repeat_borders(edges: &[Edge]) -> BTreeMap<String, &'static str> {
let mut boxes: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for edge in edges {
for id in [edge.from.as_str(), edge.to.as_str()] {
if !is_outside(id) {
boxes.entry(original_of(id)).or_default().insert(id);
}
}
}
boxes
.into_iter()
.filter(|(_, copies)| copies.len() > 1)
.enumerate()
.filter_map(|(index, (name, _))| {
REPEAT_BORDERS.get(index).map(|colour| (name.to_string(), *colour))
})
.collect()
}
fn border_of(&self, id: &str) -> (&'static str, usize) {
if is_outside(id) {
return (BOX_BORDER, BORDER_WIDTH);
}
match self.borders.get(original_of(id)) {
Some(colour) => (colour, REPEAT_BORDER_WIDTH),
None => (BOX_BORDER, BORDER_WIDTH),
}
}
fn fill_of(&self, id: &str) -> &'static str {
if !is_outside(id) {
return BOX_FILL;
}
self.fills.get(original_of(id)).copied().unwrap_or(OUTSIDE_FILL)
}
fn boxes_of_its_own(&self) -> usize {
self.strip.len() + if self.is_legend() { LEGEND_ROWS } else { 0 }
}
fn is_legend(&self) -> bool {
self.edges.is_empty()
}
}
impl PreparedMap {
fn backwards(&self) -> usize {
self.frames
.iter()
.flat_map(|frame| frame.arrows.iter())
.filter(|arrow| arrow.edge.is_some() && arrow.style.stroke_color == CYCLE_COLOUR)
.count()
}
pub fn keep_only(&mut self, contract: &str) -> Result<(), EvmMiroError> {
let wanted = frame_title(contract);
if !self.frames.iter().any(|frame| frame.title == wanted) {
let known: Vec<&str> = self
.frames
.iter()
.filter(|frame| !frame.is_legend())
.filter_map(|frame| frame.title.strip_prefix("map: what "))
.filter_map(|rest| rest.strip_suffix(" talks to"))
.collect();
return Err(Report::new(EvmMiroError)
.attach_printable(format!("no frame for `{contract}`: it is not a top caller"))
.attach_printable(format!("the map has frames for: {}", known.join(", "))));
}
self.frames.retain(|frame| frame.title == wanted || frame.is_legend());
pack_frames(&mut self.frames);
self.width = self.frames.iter().map(|frame| frame.left + frame.width).fold(0.0, f64::max);
self.height = self.frames.iter().map(|frame| frame.top + frame.height).fold(0.0, f64::max);
self.calls = ApiCalls {
frames: self.frames.len(),
boxes: self
.frames
.iter()
.map(|frame| frame.layout.nodes.len() + frame.boxes_of_its_own())
.sum(),
markers: self
.frames
.iter()
.flat_map(|frame| frame.arrows.iter())
.map(|arrow| arrow.plan.points.len())
.sum(),
connectors: self
.frames
.iter()
.flat_map(|frame| frame.arrows.iter())
.map(|arrow| arrow.plan.legs.len())
.sum(),
};
Ok(())
}
pub fn discard(&self) {}
pub fn estimate(&self) -> Vec<String> {
let mut lines = vec![format!(
"{} frame(s), {} box(es), {} relation(s) ({} backwards), {} crossing(s) left",
self.frames.len(),
self.calls.boxes,
self.edges.len(),
self.backwards(),
self.frames.iter().map(|frame| frame.crossings).sum::<usize>(),
)];
for frame in &self.frames {
if frame.is_legend() {
continue;
}
lines.push(format!(
" {:<46} {} box(es), {} relation(s), {} crossing(s)",
frame.title,
frame.layout.nodes.len(),
frame.edges.len(),
frame.crossings
));
}
lines.push(format!(
"block: {} × {} board units — API calls: {} frames + {} boxes + {} markers + {} connectors = {}",
self.width.round(),
self.height.round(),
self.calls.frames,
self.calls.boxes,
self.calls.markers,
self.calls.connectors,
self.calls.total()
));
lines.push(format!(
" {:.1}s at Miro's ceiling of 16.7 writes a second, if nothing else limits it",
self.calls.seconds_at_the_ceiling()
));
lines.push("placement: needs the board, which a dry run does not read".to_string());
lines
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ApiCalls {
pub frames: usize,
pub boxes: usize,
pub markers: usize,
pub connectors: usize,
}
impl ApiCalls {
pub fn total(&self) -> usize {
self.frames + self.boxes + self.markers + self.connectors
}
pub fn seconds_at_the_ceiling(&self) -> f64 {
self.total() as f64 / 16.7
}
}
pub fn hierarchy(all: &[Edge], no_boundaries: bool) -> Vec<String> {
let framed = drawn_edges(all, no_boundaries);
let edges: Vec<Edge> = all
.iter()
.filter(|edge| edge.relation != Relation::Boundary)
.cloned()
.collect();
let names: BTreeSet<&str> = edges
.iter()
.flat_map(|edge| [edge.from.as_str(), edge.to.as_str()])
.collect();
let called: BTreeSet<&str> = edges.iter().map(|edge| edge.to.as_str()).collect();
let calls: BTreeSet<&str> = edges.iter().map(|edge| edge.from.as_str()).collect();
let out = |name: &str| edges.iter().filter(|edge| edge.from == name).count();
let into = |name: &str| edges.iter().filter(|edge| edge.to == name).count();
let framed_names: BTreeSet<&str> = framed.iter().map(|edge| edge.from.as_str()).collect();
let framed_called: BTreeSet<&str> = framed.iter().map(|edge| edge.to.as_str()).collect();
let mut tops: Vec<&str> = framed_names
.iter()
.copied()
.filter(|name| !framed_called.contains(name))
.collect();
tops.sort_by(|a, b| out(b).cmp(&out(a)).then(a.cmp(b)));
let mut foundations: Vec<&str> = names.iter().copied().filter(|n| !calls.contains(n)).collect();
foundations.sort_by(|a, b| into(b).cmp(&into(a)).then(a.cmp(b)));
let mut lines = vec![format!(
"{} top caller(s) — nothing in scope depends on them, and the map gives each one a frame:",
tops.len()
)];
for name in &tops {
lines.push(format!(" {name:<28} talks to {} contract(s) bat-cli map --contract {name}", out(name)));
}
lines.push(String::new());
lines.push(format!(
"{} foundation(s) — they depend on nothing, so they exist before the rest:",
foundations.len()
));
for name in &foundations {
lines.push(format!(" {name:<28} {} contract(s) depend on it", into(name)));
}
lines
}
fn partition_frames(edges: &[Edge]) -> Vec<FramePlan> {
let names: BTreeSet<&str> = edges
.iter()
.flat_map(|edge| [edge.from.as_str(), edge.to.as_str()])
.collect();
let called: BTreeSet<&str> = edges.iter().map(|edge| edge.to.as_str()).collect();
let out_degree = |name: &str| edges.iter().filter(|edge| edge.from == name).count();
let mut roots: Vec<String> = names
.iter()
.copied()
.filter(|name| !called.contains(name))
.map(str::to_string)
.collect();
roots.sort_by(|a, b| out_degree(b).cmp(&out_degree(a)).then(a.cmp(b)));
let mut frames: Vec<FramePlan> = Vec::new();
let mut covered: BTreeSet<(String, String)> = BTreeSet::new();
let mut plan = |root: &str, frames: &mut Vec<FramePlan>, covered: &mut BTreeSet<(String, String)>| {
let mine = unfold(root, edges);
for edge in &mine {
covered.insert((original_of(&edge.from).to_string(), original_of(&edge.to).to_string()));
}
if !mine.is_empty() {
frames.push(FramePlan { root: root.to_string(), edges: mine });
}
};
for root in &roots {
plan(root, &mut frames, &mut covered);
}
let mut tried: BTreeSet<String> = roots.iter().cloned().collect();
while let Some(orphan) = edges
.iter()
.filter(|edge| {
!covered.contains(&(
original_of(&edge.from).to_string(),
original_of(&edge.to).to_string(),
))
})
.map(|edge| edge.from.clone())
.filter(|name| !tried.contains(name))
.max_by(|a, b| out_degree(a).cmp(&out_degree(b)).then_with(|| b.cmp(a)))
{
tried.insert(orphan.clone());
plan(&orphan, &mut frames, &mut covered);
}
frames
}
fn unfold(root: &str, edges: &[Edge]) -> Vec<Edge> {
const MAX_BOXES: usize = 400;
let mut drawn: Vec<Edge> = Vec::new();
let mut copies: BTreeMap<String, usize> = [(root.to_string(), 1)].into_iter().collect();
let mut path: Vec<(String, String)> = vec![(root.to_string(), root.to_string())];
let mut stack: Vec<(usize, usize)> = vec![(0, 0)];
while let Some((depth, next)) = stack.pop() {
let (name, id) = path[depth].clone();
let outgoing: Vec<&Edge> = edges.iter().filter(|edge| edge.from == name).collect();
let Some(edge) = outgoing.get(next) else {
path.truncate(depth + 1);
continue;
};
stack.push((depth, next + 1));
if let Some((_, ancestor)) = path.iter().take(depth + 1).find(|(on_path, _)| on_path == &edge.to) {
drawn.push(Edge { from: id, to: ancestor.clone(), relation: edge.relation });
continue;
}
if copies.values().sum::<usize>() >= MAX_BOXES {
continue;
}
let seen = copies.entry(edge.to.clone()).or_insert(0);
*seen += 1;
let child = if *seen == 1 {
edge.to.clone()
} else {
format!("{}{COPY_MARK}{}", edge.to, seen)
};
drawn.push(Edge { from: id, to: child.clone(), relation: edge.relation });
path.truncate(depth + 1);
path.push((edge.to.clone(), child));
stack.push((depth + 1, 0));
}
drawn
}
#[derive(Debug)]
pub struct FramePlan {
root: String,
edges: Vec<Edge>,
}
const COPY_MARK: char = '#';
const OUTSIDE_MARK: char = '@';
fn original_of(id: &str) -> &str {
id.split([COPY_MARK, OUTSIDE_MARK]).next().unwrap_or(id)
}
fn is_outside(id: &str) -> bool {
id.contains(OUTSIDE_MARK)
}
fn layout_tree(root: &str, edges: &[Edge]) -> GraphLayout {
const ROW_GAP: f64 = 220.0;
const COLUMN_GAP: f64 = 900.0;
let mut children: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
let mut parented: BTreeSet<&str> = BTreeSet::new();
let mut order: Vec<&str> = Vec::new();
for edge in edges {
for id in [edge.from.as_str(), edge.to.as_str()] {
if !order.contains(&id) {
order.push(id);
}
}
}
for edge in edges {
if edge.to == root || parented.contains(edge.to.as_str()) {
continue;
}
parented.insert(edge.to.as_str());
children.entry(edge.from.as_str()).or_default().push(edge.to.as_str());
}
let Some(root) = order.iter().copied().find(|id| *id == root).or_else(|| {
order.iter().copied().find(|id| !parented.contains(id))
}) else {
return GraphLayout {
nodes: Vec::new(),
routes: vec![Vec::new(); edges.len()],
back_edges: Vec::new(),
bbox_width: 0.0,
bbox_height: 0.0,
frame_width: MAP_MARGIN * 2.0,
frame_height: MAP_MARGIN * 2.0,
crossings: 0,
};
};
let mut depth: BTreeMap<&str, usize> = BTreeMap::new();
let mut stack = vec![(root, 0usize)];
while let Some((id, at)) = stack.pop() {
depth.insert(id, at);
for child in children.get(id).into_iter().flatten() {
stack.push((child, at + 1));
}
}
let deepest = depth.values().copied().max().unwrap_or(0);
let mut column_width = vec![0.0_f64; deepest + 1];
for (id, at) in &depth {
column_width[*at] = column_width[*at].max(node_width(id));
}
let mut column_x = vec![0.0_f64; deepest + 1];
let mut left = MAP_MARGIN;
for (at, width) in column_width.iter().enumerate() {
column_x[at] = left + width / 2.0;
left += width + COLUMN_GAP;
}
let mut y: BTreeMap<&str, f64> = BTreeMap::new();
let mut cursor = MAP_MARGIN;
let mut walk: Vec<(&str, bool)> = vec![(root, false)];
while let Some((id, done)) = walk.pop() {
let mine = children.get(id).cloned().unwrap_or_default();
if mine.is_empty() {
y.insert(id, cursor + node_height(id) / 2.0);
cursor += node_height(id) + ROW_GAP;
continue;
}
if done {
let first = y.get(mine.first().expect("checked")).copied().unwrap_or(cursor);
let last = y.get(mine.last().expect("checked")).copied().unwrap_or(cursor);
y.insert(id, (first + last) / 2.0);
continue;
}
walk.push((id, true));
for child in mine.iter().rev() {
walk.push((child, false));
}
}
let nodes: Vec<PlacedNode> = order
.iter()
.filter_map(|id| {
Some(PlacedNode {
id: id.to_string(),
layer: *depth.get(id)?,
x: column_x[*depth.get(id)?],
y: *y.get(id)?,
width: node_width(id),
height: node_height(id),
})
})
.collect();
let width = nodes
.iter()
.map(|node| node.x + node.width / 2.0)
.fold(0.0_f64, f64::max)
+ MAP_MARGIN;
let height = nodes
.iter()
.map(|node| node.y + node.height / 2.0)
.fold(0.0_f64, f64::max)
+ MAP_MARGIN;
GraphLayout {
nodes,
routes: vec![Vec::new(); edges.len()],
back_edges: Vec::new(),
bbox_width: width - MAP_MARGIN * 2.0,
bbox_height: height - MAP_MARGIN * 2.0,
frame_width: width,
frame_height: height,
crossings: 0,
}
}
fn drawn_edges(edges: &[Edge], no_boundaries: bool) -> Vec<Edge> {
edges
.iter()
.filter(|edge| !no_boundaries || edge.relation != Relation::Boundary)
.map(|edge| match edge.relation {
Relation::Boundary => Edge {
from: edge.from.clone(),
to: format!("{}{OUTSIDE_MARK}{}", edge.to, edge.from),
relation: edge.relation,
},
_ => edge.clone(),
})
.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(name),
})
.collect();
let laid_edges: Vec<LayoutEdge> = edges
.iter()
.map(|edge| LayoutEdge {
from: edge.from.clone(),
to: edge.to.clone(),
from_line_fraction: 0.5,
})
.collect();
let called: BTreeSet<&str> = edges.iter().map(|edge| edge.to.as_str()).collect();
let root = names
.iter()
.find(|name| !called.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,
gutter_x,
gutter_y: NODE_HEIGHT * 1.5,
..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);
let mut layout = if wanted <= LayoutConfig::default().gutter_x {
first
} else {
layout_graph(&root, &nodes, &laid_edges, config(wanted))
};
order_hub_column(&mut layout, edges);
layout
}
fn order_hub_column(layout: &mut GraphLayout, edges: &[Edge]) {
let mut incoming: BTreeMap<&str, usize> = BTreeMap::new();
for edge in edges {
*incoming.entry(edge.to.as_str()).or_default() += 1;
}
let mut members: Vec<usize> = (0..layout.nodes.len())
.filter(|&index| layout.nodes[index].layer == 0)
.collect();
if members.len() < 2 {
return;
}
let mut slots: Vec<f64> = members.iter().map(|&index| layout.nodes[index].y).collect();
slots.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
members.sort_by(|&a, &b| {
let calls = |index: usize| incoming.get(layout.nodes[index].id.as_str()).copied().unwrap_or(0);
calls(b).cmp(&calls(a)).then_with(|| layout.nodes[a].id.cmp(&layout.nodes[b].id))
});
for (rank, index) in members.into_iter().enumerate() {
layout.nodes[index].y = slots[centre_out(rank, slots.len())];
}
}
fn centre_out(rank: usize, count: usize) -> usize {
let middle = (count - 1) / 2;
let distance = (rank + 1) / 2;
if rank % 2 == 1 {
(middle + distance).min(count - 1)
} else {
middle.saturating_sub(distance)
}
}
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<Vec<crate::batbelt::evm::metadata::bat_metadata::MapFrameRef>, EvmMiroError> {
use crate::batbelt::evm::miro::auto_deploy::LaneSide;
use crate::batbelt::miro::MiroError;
let mut frames_wave: tokio::task::JoinSet<(usize, String, std::result::Result<String, Report<MiroError>>)> =
tokio::task::JoinSet::new();
for (index, frame) in prepared.frames.iter().enumerate() {
let client = client.clone();
let title = frame.title.clone();
let (x, y) = (
left + frame.left + frame.width / 2.0,
top + frame.top + frame.height / 2.0,
);
let (width, height) = (frame.width, frame.height);
frames_wave.spawn(async move {
let made = client.create_frame(&title, x, y, width, height, None).await;
(index, title, made)
});
}
let mut frame_ids: BTreeMap<usize, String> = BTreeMap::new();
let mut failures: Vec<String> = Vec::new();
while let Some(joined) = frames_wave.join_next().await {
match joined {
Ok((index, _, Ok(id))) => {
frame_ids.insert(index, id);
}
Ok((_, title, Err(report))) => {
failures.push(format!("frame {title}: {}", why(&report)))
}
Err(error) => failures.push(format!("a frame: {error}")),
}
}
if frame_ids.is_empty() {
return Err(Report::new(EvmMiroError)
.attach_printable("not one frame of the map could be created")
.attach_printable(failures.join("; ")));
}
enum Made {
Boxes(usize),
Markers(Vec<(usize, usize, usize)>, Vec<String>),
}
type Outcome = (String, std::result::Result<Made, Report<MiroError>>);
let mut wave: tokio::task::JoinSet<Outcome> = tokio::task::JoinSet::new();
const BATCH: usize = 20;
let mut plain: Vec<(String, serde_json::Value)> = Vec::new();
let mut marker_items: Vec<((usize, usize, usize), serde_json::Value)> = Vec::new();
for (index, frame) in prepared.frames.iter().enumerate() {
let Some(frame_id) = frame_ids.get(&index) else {
continue;
};
let mut boxes: Vec<(String, String, f64, f64, f64, f64, usize, &str, &str, usize)> = Vec::new();
if frame.is_legend() {
for row in legend_rows() {
let (fill, border) = match row.sample {
Sample::Leg(_) => (BOX_FILL, BOX_BORDER),
Sample::Box { fill, border } => (fill, border),
};
let thickness = if border == BOX_BORDER { BORDER_WIDTH } else { REPEAT_BORDER_WIDTH };
boxes.push((
format!("legend: {}", row.text),
row.text.to_string(),
row.x,
row.y,
row.width,
NODE_HEIGHT,
NODE_FONT,
fill,
border,
thickness,
));
}
}
for node in &frame.layout.nodes {
let (border, thickness) = frame.border_of(&node.id);
boxes.push((
format!("box {}", box_text(&node.id)),
box_text(&node.id).to_string(),
node.x,
node.y,
node.width,
node.height,
node_font(&node.id),
frame.fill_of(&node.id),
border,
thickness,
));
}
for node in &frame.strip {
boxes.push((
format!("strip: {}", box_text(&node.id)),
box_text(&node.id).to_string(),
node.x,
node.y,
node.width,
node.height,
NODE_FONT,
STRIP_FILL,
BOX_BORDER,
BORDER_WIDTH,
));
}
for (what, text, x, y, width, height, font, fill, border, thickness) in boxes {
plain.push((
what,
MiroClient::label_item(
&text,
x,
y,
width,
height,
font,
fill,
border,
thickness,
BOX_BORDER,
frame_id,
),
));
}
for (arrow_index, arrow) in frame.arrows.iter().enumerate() {
for (point, (x, y)) in arrow.plan.points.iter().enumerate() {
marker_items.push((
(index, arrow_index, point),
MiroClient::marker_item(*x, *y, MARKER_SIZE, frame_id),
));
}
}
}
for batch in plain.chunks(BATCH) {
let client = client.clone();
let what = format!("a batch of {} item(s) from {}", batch.len(), batch[0].0);
let items: Vec<serde_json::Value> = batch.iter().map(|(_, item)| item.clone()).collect();
let count = items.len();
wave.spawn(async move {
let made = client.create_items_bulk(items).await.map(|_| Made::Boxes(count));
(what, made)
});
}
for batch in marker_items.chunks(BATCH) {
let client = client.clone();
let what = format!("a batch of {} marker(s)", batch.len());
let keys: Vec<(usize, usize, usize)> = batch.iter().map(|(key, _)| *key).collect();
let items: Vec<serde_json::Value> = batch.iter().map(|(_, item)| item.clone()).collect();
wave.spawn(async move {
let made = client
.create_items_bulk(items)
.await
.map(|ids| Made::Markers(keys, ids));
(what, made)
});
}
let mut markers: BTreeMap<(usize, usize, usize), String> = BTreeMap::new();
let mut boxes_made = 0usize;
while let Some(joined) = wave.join_next().await {
match joined {
Ok((_, Ok(Made::Boxes(count)))) => boxes_made += count,
Ok((what, Ok(Made::Markers(keys, ids)))) => {
if ids.len() != keys.len() {
failures.push(format!("{what}: {} item(s) back for {} sent", ids.len(), keys.len()));
}
for (key, id) in keys.into_iter().zip(ids) {
markers.insert(key, id);
}
}
Ok((what, Err(report))) => failures.push(format!("{what}: {}", why(&report))),
Err(error) => failures.push(format!("a task: {error}")),
}
}
println!(
" {} {} frame(s), {boxes_made} box(es), {} marker(s)",
"✓".green(),
frame_ids.len(),
markers.len()
);
let mut legs_wave = tokio::task::JoinSet::new();
let mut skipped: Vec<String> = Vec::new();
for (index, frame) in prepared.frames.iter().enumerate() {
if !frame_ids.contains_key(&index) {
continue;
}
for (arrow_index, arrow) in frame.arrows.iter().enumerate() {
for (from_side, from_anchor, to_side, to_anchor, head) in &arrow.plan.legs {
let resolve = |side: &LaneSide| match side {
LaneSide::Marker(point) => markers.get(&(index, arrow_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!("{}: a leg whose marker failed", arrow_name(frame, arrow)));
continue;
};
let mut leg = arrow.style.clone();
leg.arrow = if *head { ArrowEnd::End } else { ArrowEnd::None };
if !*head {
leg.caption = None;
}
let (from_anchor, to_anchor) = (*from_anchor, *to_anchor);
let what = arrow_name(frame, arrow);
let client = client.clone();
legs_wave.spawn(async move {
let made = client
.create_connector_shaped(
&from_id, from_anchor, &to_id, to_anchor, leg, "straight",
)
.await;
(what, made)
});
}
}
}
let mut legs = 0usize;
while let Some(joined) = legs_wave.join_next().await {
match joined {
Ok((_, Ok(_))) => legs += 1,
Ok((what, Err(report))) => failures.push(format!("leg of {what}: {}", why(&report))),
Err(error) => failures.push(format!("a leg: {error}")),
}
}
for line in skipped.iter().chain(failures.iter()) {
println!(" {} {line}", "skipped:".yellow());
}
println!(
" {} {} relation(s) drawn, {legs} leg(s)",
"✓".green(),
prepared
.frames
.iter()
.flat_map(|frame| frame.arrows.iter())
.filter(|arrow| arrow.edge.is_some())
.count()
);
for (index, frame) in prepared.frames.iter().enumerate() {
if let Some(id) = frame_ids.get(&index) {
println!(" {:<46} {}", frame.title, client.frame_url(id).blue());
}
}
let drawn_frames: Vec<crate::batbelt::evm::metadata::bat_metadata::MapFrameRef> = prepared
.frames
.iter()
.enumerate()
.filter(|(_, frame)| !frame.is_legend())
.filter_map(|(index, frame)| {
Some(crate::batbelt::evm::metadata::bat_metadata::MapFrameRef {
root: frame.root().to_string(),
frame_id: frame_ids.get(&index)?.clone(),
contracts: frame.contracts_shown(),
cards: Vec::new(),
})
})
.collect();
if !failures.is_empty() {
return Err(Report::new(EvmMiroError).attach_printable(format!(
"{} item(s) of this map could not be drawn — the frames above have the rest",
failures.len()
)));
}
Ok(drawn_frames)
}
fn why(report: &Report<crate::batbelt::miro::MiroError>) -> String {
let detail: Vec<String> = report
.frames()
.filter_map(|frame| frame.downcast_ref::<String>().map(|line| line.trim().to_string()))
.collect();
if detail.is_empty() {
report.current_context().to_string()
} else {
detail.join(" — ")
}
}
fn arrow_name(frame: &PreparedFrame, arrow: &RoutedArrow) -> String {
match arrow.edge.and_then(|index| frame.edges.get(index)) {
Some(edge) => format!("{} → {}", box_text(&edge.from), box_text(&edge.to)),
None => "a legend sample".to_string(),
}
}
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) || ignored(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 rank = |relation: Relation| match relation {
Relation::Creates => 0,
Relation::Calls => 1,
Relation::WiresAtConstruction => 2,
Relation::Unresolved => 3,
Relation::Boundary => 4,
};
let mut best: BTreeMap<(String, String), Relation> = BTreeMap::new();
for edge in found {
best.entry((edge.from.clone(), edge.to.clone()))
.and_modify(|existing| {
if rank(edge.relation) < rank(*existing) {
*existing = 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, FunctionDependency, 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 a_created_and_called_pair_keeps_the_creation() {
let mut vault = contract("AscendYieldVault", EvmContractType::Contract, &[], false);
vault.functions.push(calling("YieldExitReserve"));
let maker = vault.functions[0].metadata_id.clone();
let metadata = EvmBatMetadata {
contracts: vec![
vault,
contract("YieldExitReserve", EvmContractType::Contract, &[], false),
],
function_dependencies: vec![FunctionDependency {
function_metadata_id: maker,
callees: vec!["YieldExitReserve.constructor".to_string()],
}],
..Default::default()
};
assert_eq!(
pairs(&relations(&metadata)),
vec![(
"AscendYieldVault".to_string(),
"YieldExitReserve".to_string(),
Relation::Creates
)]
);
}
#[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)]
);
let drawn = drawn_edges(&relations(&metadata), false);
assert_eq!(drawn.len(), 1);
assert_eq!(drawn[0].to, "ERC20@AscendCoreToken");
assert_eq!(super::box_text(&drawn[0].to), "ERC20");
assert!(drawn_edges(&relations(&metadata), true).is_empty(), "--no-boundaries draws none");
}
#[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"));
}
}
#[cfg(test)]
mod report_test {
use super::{hierarchy, Edge, Relation};
fn edge(from: &str, to: &str, relation: Relation) -> Edge {
Edge { from: from.to_string(), to: to.to_string(), relation }
}
#[test]
fn the_hierarchy_names_the_tops_and_the_foundations() {
let edges = vec![
edge("Launchpad", "Desk", Relation::Calls),
edge("Desk", "Treasury", Relation::Calls),
edge("Router", "Treasury", Relation::Calls),
];
let lines = hierarchy(&edges, false);
let tops: Vec<&String> = lines
.iter()
.take_while(|line| !line.is_empty())
.filter(|line| line.starts_with(" "))
.collect();
assert_eq!(tops.len(), 2, "Launchpad and Router: {tops:?}");
assert!(tops.iter().all(|line| line.contains("Launchpad") || line.contains("Router")));
let foundations: Vec<&String> = lines
.iter()
.skip_while(|line| !line.is_empty())
.filter(|line| line.starts_with(" "))
.collect();
assert_eq!(foundations.len(), 1, "only Treasury depends on nothing: {foundations:?}");
assert!(foundations[0].contains("Treasury") && foundations[0].contains('2'));
}
#[test]
fn the_outside_is_never_part_of_the_hierarchy() {
let edges = vec![
edge("Vault", "IERC20", Relation::Boundary),
edge("Vault", "Oracle", Relation::Calls),
];
for no_boundaries in [true, false] {
assert!(hierarchy(&edges, no_boundaries).iter().all(|line| !line.contains("IERC20")));
}
assert!(hierarchy(&edges, false).iter().any(|line| line.contains("Oracle")));
}
}
#[cfg(test)]
mod visual_test {
use super::{
centre_out, legend_rows, order_hub_column, route_arrows, strip_of, style_of, Edge,
Relation, Sample, CYCLE_COLOUR, EDGE_COLOUR, LEGEND_ROWS, NODE_HEIGHT, STRIP_HEADING,
};
use crate::batbelt::evm::miro::auto_deploy::LaneSide;
use crate::batbelt::miro::client::ConnectorStroke;
use crate::batbelt::miro::layout::{GraphLayout, PlacedNode};
fn edge(from: &str, to: &str, relation: Relation) -> Edge {
Edge { from: from.to_string(), to: to.to_string(), relation }
}
fn node(id: &str, layer: usize, x: f64, y: f64) -> PlacedNode {
PlacedNode { id: id.to_string(), layer, x, y, width: 1_000.0, height: NODE_HEIGHT }
}
fn layout(nodes: Vec<PlacedNode>, routes: Vec<Vec<(f64, f64)>>) -> GraphLayout {
GraphLayout {
nodes,
routes,
back_edges: Vec::new(),
bbox_width: 8_000.0,
bbox_height: 4_000.0,
frame_width: 9_200.0,
frame_height: 5_200.0,
crossings: 0,
}
}
fn segments(arrow: &super::RoutedArrow) -> Vec<((f64, f64), (f64, f64))> {
arrow
.plan
.legs
.iter()
.filter_map(|(from, _, to, _, _)| {
let at = |side: &LaneSide| match side {
LaneSide::Marker(index) => arrow.plan.points.get(*index).copied(),
LaneSide::Item(_) => None,
};
Some((at(from)?, at(to)?))
})
.collect()
}
#[test]
fn corridors_are_walked_from_the_callers_side() {
let laid = layout(
vec![node("Caller", 0, 500.0, 3_000.0), node("Callee", 3, 6_500.0, 1_000.0)],
vec![vec![(2_000.0, 2_400.0), (4_000.0, 1_700.0)]],
);
let routed = route_arrows(&[edge("Caller", "Callee", Relation::Calls)], &laid, false);
let arrow = routed.iter().find(|arrow| arrow.edge == Some(0)).expect("the arrow");
let legs = segments(arrow);
let first_drop = legs[1];
assert_eq!(first_drop.0 .1, 3_000.0, "from the caller's row: {legs:?}");
assert_eq!(first_drop.1 .1, 2_400.0, "to the corridor of the column beside the caller: {legs:?}");
assert!(arrow.plan.points.len() <= 6 + 2, "{:?}", arrow.plan.points);
assert!(arrow.plan.legs.len() <= 7);
assert!(arrow.plan.legs.last().expect("a leg").4, "the last leg still carries the head");
}
#[test]
fn arrows_meeting_one_box_are_spread_along_its_side() {
let laid = layout(
vec![
node("Top", 0, 500.0, 500.0),
node("Mid", 0, 500.0, 2_000.0),
node("Low", 0, 500.0, 3_500.0),
node("Hub", 1, 2_500.0, 2_000.0),
],
vec![Vec::new(), Vec::new(), Vec::new()],
);
let edges = [
edge("Low", "Hub", Relation::Calls),
edge("Top", "Hub", Relation::Calls),
edge("Mid", "Hub", Relation::Calls),
];
let routed = route_arrows(&edges, &laid, false);
let landing = |index: usize| {
let arrow = routed.iter().find(|arrow| arrow.edge == Some(index)).expect("drawn");
segments(arrow).last().expect("the head leg").1 .1
};
let (low, top, mid) = (landing(0), landing(1), landing(2));
assert!(top < mid && mid < low, "landings follow where the arrows come from: {top} {mid} {low}");
assert!((low - top) <= NODE_HEIGHT - 2.0 * super::ANCHOR_INSET, "and stay on the box's side");
assert_eq!(mid, 2_000.0, "the middle one lands on the middle");
}
#[test]
fn the_hub_column_puts_the_most_called_in_the_middle() {
let mut laid = layout(
vec![
node("A", 0, 500.0, 500.0),
node("B", 0, 500.0, 1_000.0),
node("Hub", 0, 500.0, 1_500.0),
node("D", 0, 500.0, 2_000.0),
node("E", 0, 500.0, 2_500.0),
node("Caller", 1, 2_500.0, 500.0),
],
Vec::new(),
);
let edges = [
edge("Caller", "A", Relation::Calls),
edge("Caller", "E", Relation::Calls),
edge("Caller", "E", Relation::Creates),
edge("Caller", "Hub", Relation::Calls),
];
order_hub_column(&mut laid, &edges);
let y = |id: &str| laid.nodes.iter().find(|node| node.id == id).expect(id).y;
assert_eq!(y("E"), 1_500.0, "the most called takes the middle slot");
assert_eq!(y("Caller"), 500.0, "other columns are untouched");
let mut ys: Vec<f64> = ["A", "B", "Hub", "D", "E"].iter().map(|id| y(id)).collect();
ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(ys, vec![500.0, 1_000.0, 1_500.0, 2_000.0, 2_500.0], "the same slots, reassigned");
}
#[test]
fn centre_out_fills_the_middle_then_alternates() {
let order: Vec<usize> = (0..5).map(|rank| centre_out(rank, 5)).collect();
assert_eq!(order, vec![2, 3, 1, 4, 0]);
let order: Vec<usize> = (0..4).map(|rank| centre_out(rank, 4)).collect();
assert_eq!(order, vec![1, 2, 0, 3]);
}
#[test]
fn one_colour_three_strokes_and_a_colour_where_it_means_something() {
for relation in [Relation::Calls, Relation::WiresAtConstruction, Relation::Unresolved] {
assert_eq!(style_of(relation, false).stroke_color, EDGE_COLOUR);
}
assert_eq!(style_of(Relation::Creates, false).stroke_color, super::CREATES_COLOUR);
assert_eq!(style_of(Relation::Creates, false).caption, None, "the colour replaced the word");
assert_eq!(style_of(Relation::Calls, false).stroke, ConnectorStroke::Solid);
assert_eq!(style_of(Relation::Unresolved, false).stroke, ConnectorStroke::Dashed);
assert_eq!(style_of(Relation::Unresolved, false).caption.as_deref(), Some("?"));
assert_eq!(style_of(Relation::WiresAtConstruction, false).stroke, ConnectorStroke::Dotted);
assert_eq!(style_of(Relation::Calls, true).stroke_color, CYCLE_COLOUR);
}
#[test]
fn a_backwards_arrow_is_drawn_red_box_to_box() {
let laid = layout(
vec![node("Left", 0, 500.0, 1_000.0), node("Right", 1, 2_500.0, 1_000.0)],
vec![Vec::new(), Vec::new()],
);
let edges = [
edge("Left", "Right", Relation::Calls),
edge("Right", "Left", Relation::Calls),
];
let routed = route_arrows(&edges, &laid, false);
let back = routed.iter().find(|arrow| arrow.edge == Some(1)).expect("the cycle is drawn");
assert_eq!(back.style.stroke_color, CYCLE_COLOUR);
let legs = segments(back);
assert_eq!(legs.len(), 5);
let row_bottom = 1_000.0 + NODE_HEIGHT / 2.0;
assert_eq!(legs[0].0, (2_000.0, 1_000.0), "out of Right's LEFT side, towards the callee");
assert!(legs[0].1 .0 < 2_000.0, "into the lane beside it: {legs:?}");
assert!(legs[1].1 .1 > row_bottom, "down just past its own row, not past the frame");
assert_eq!(legs[2].0 .1, legs[2].1 .1, "along the corridor");
assert!(legs[3].1 .0 < 0.0, "up outside the callee's column");
assert_eq!(legs[4].1, (0.0, 1_000.0), "and into Left's left side");
assert!(back.plan.legs[..4].iter().all(|leg| !leg.4), "no head before the end");
assert!(back.plan.legs[4].4, "the last leg carries the head");
}
#[test]
fn the_legend_is_rows_of_real_samples() {
let rows = legend_rows();
assert_eq!(rows.len(), LEGEND_ROWS);
let legs = rows.iter().filter(|row| matches!(row.sample, Sample::Leg(_))).count();
let laid = layout(vec![node("A", 0, 500.0, 1_000.0), node("B", 1, 2_500.0, 1_000.0)], vec![Vec::new()]);
let routed = route_arrows(&[edge("B", "A", Relation::Calls)], &laid, true);
assert_eq!(routed.iter().filter(|arrow| arrow.edge.is_none()).count(), legs);
assert!(rows.iter().any(|row| matches!(row.sample, Sample::Leg(ref style) if style.stroke_color == CYCLE_COLOUR)));
}
#[test]
fn the_strip_stacks_under_the_legend() {
let strip = strip_of(&["Alone".to_string(), "Proxy".to_string()], 4_000.0);
assert_eq!(strip.len(), 3);
assert_eq!(strip[0].id, STRIP_HEADING);
assert_eq!(strip[0].y, 4_000.0);
assert!(strip[1].y > strip[0].y && strip[2].y > strip[1].y);
assert!(strip_of(&[], 4_000.0).is_empty());
}
}
#[cfg(test)]
mod framing_test {
use super::{original_of, partition_frames, unfold, BTreeMap, Edge, Relation};
fn edge(from: &str, to: &str) -> Edge {
Edge { from: from.to_string(), to: to.to_string(), relation: Relation::Calls }
}
#[test]
fn a_frame_per_top_caller_repeats_what_they_share() {
let edges = vec![edge("Launchpad", "Treasury"), edge("Router", "Treasury")];
let frames = partition_frames(&edges);
assert_eq!(frames.len(), 2);
assert!(frames.iter().any(|plan| plan.root == "Launchpad"));
assert!(frames.iter().any(|plan| plan.root == "Router"));
assert!(
frames.iter().all(|plan| plan.edges.len() == 1 && plan.edges[0].to == "Treasury"),
"Treasury is drawn in both frames"
);
}
#[test]
fn a_frame_holds_everything_its_top_reaches() {
let edges = vec![edge("Launchpad", "Desk"), edge("Desk", "Treasury")];
let frames = partition_frames(&edges);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].root, "Launchpad");
assert_eq!(frames[0].edges.len(), 2);
}
#[test]
fn a_cycle_with_nothing_above_it_still_gets_a_frame() {
let edges = vec![edge("A", "B"), edge("B", "A")];
let frames = partition_frames(&edges);
assert_eq!(frames.len(), 1, "one frame, not none: {frames:?}");
assert_eq!(frames[0].edges.len(), 2, "both relations drawn");
}
#[test]
fn the_tree_a_frame_draws_has_no_crossings() {
let edges = vec![
edge("Top", "Middle"),
edge("Top", "Shared"),
edge("Middle", "Shared"),
edge("Top", "Other"),
edge("Other", "Shared"),
];
assert_eq!(super::layout_map(&unfold("Top", &edges)).crossings, 0);
}
#[test]
fn a_frame_holds_its_whole_tree_and_repeats_what_is_shared() {
let edges = vec![
edge("Registry", "Receiver"),
edge("Receiver", "Desk"),
edge("Registry", "Desk"),
edge("Desk", "Treasury"),
];
let frames = partition_frames(&edges);
assert_eq!(frames.len(), 1, "one top caller, one frame: {frames:?}");
let mine = &frames[0].edges;
let desks: Vec<&str> = mine
.iter()
.map(|edge| edge.to.as_str())
.filter(|to| original_of(to) == "Desk")
.collect();
assert_eq!(desks.len(), 2, "Desk is drawn once per branch that reaches it: {mine:?}");
assert_ne!(desks[0], desks[1], "and the two are different boxes");
assert_eq!(
mine.iter().filter(|edge| original_of(&edge.to) == "Treasury").count(),
2,
"its own chain comes with each copy"
);
}
#[test]
fn a_frame_is_a_tree() {
let edges = vec![
edge("Top", "Middle"),
edge("Top", "Shared"),
edge("Middle", "Shared"),
edge("Shared", "Leaf"),
];
let mine = unfold("Top", &edges);
let mut into: BTreeMap<&str, usize> = BTreeMap::new();
for edge in &mine {
*into.entry(edge.to.as_str()).or_default() += 1;
}
assert!(into.values().all(|count| *count == 1), "one arrow into each box: {into:?}");
}
#[test]
fn the_root_of_a_cycle_is_the_member_that_calls_the_most() {
let edges = vec![
edge("Quiet", "Busy"),
edge("Busy", "Quiet"),
edge("Busy", "LeafOne"),
edge("Busy", "LeafTwo"),
];
let frames = partition_frames(&edges);
assert_eq!(frames.len(), 1, "one cycle, one frame: {frames:?}");
assert_eq!(frames[0].root, "Busy");
assert_eq!(frames[0].edges.len(), 4, "and it draws every relation of the cycle");
}
#[test]
fn a_cycle_points_back_instead_of_unfolding() {
let edges = vec![edge("A", "B"), edge("B", "C"), edge("C", "A")];
let mine = unfold("A", &edges);
assert_eq!(mine.len(), 3, "three relations, drawn once each: {mine:?}");
let back = mine.iter().find(|edge| edge.to == "A").expect("the arrow home");
assert_eq!(back.from, "C");
}
#[test]
fn every_caller_gets_its_own_copy_of_what_is_outside() {
let all = vec![
Edge { from: "Vault".into(), to: "IERC20".into(), relation: Relation::Boundary },
Edge { from: "Router".into(), to: "IERC20".into(), relation: Relation::Boundary },
edge("Vault", "Oracle"),
];
let drawn = super::drawn_edges(&all, false);
let outside: Vec<&str> = drawn
.iter()
.filter(|edge| edge.relation == Relation::Boundary)
.map(|edge| edge.to.as_str())
.collect();
assert_eq!(outside, vec!["IERC20@Vault", "IERC20@Router"], "never one shared box");
for id in outside {
assert_eq!(original_of(id), "IERC20", "and both read as IERC20");
assert!(super::is_outside(id));
}
assert!(
super::drawn_edges(&all, true).iter().all(|edge| edge.to == "Oracle"),
"--no-boundaries leaves them out altogether"
);
}
#[test]
fn one_colour_per_interface_and_never_two_the_same() {
let all = vec![
Edge { from: "Vault".into(), to: "IERC20".into(), relation: Relation::Boundary },
Edge { from: "Router".into(), to: "IERC20".into(), relation: Relation::Boundary },
Edge { from: "Vault".into(), to: "IVault".into(), relation: Relation::Boundary },
edge("Vault", "Oracle"),
];
let drawn = super::drawn_edges(&all, false);
let fills = super::PreparedFrame::outside_fills(&drawn);
assert_eq!(fills.len(), 2, "one entry per interface, not per copy: {fills:?}");
assert_ne!(fills["IERC20"], fills["IVault"], "two interfaces never share a colour");
assert_eq!(fills[original_of("IERC20@Vault")], fills[original_of("IERC20@Router")]);
}
#[test]
fn a_parent_sits_beside_its_own_children() {
let edges = vec![
edge("Root", "First"),
edge("Root", "Second"),
edge("Second", "Deep"),
edge("Second", "Deeper"),
];
let layout = super::layout_tree("Root", &edges);
let at = |id: &str| layout.node(id).cloned().expect(id);
assert_eq!(at("Root").layer, 0);
assert_eq!(at("Second").layer, 1);
assert_eq!(at("Deep").layer, 2, "a column per depth");
assert!(at("Root").x < at("Second").x && at("Second").x < at("Deep").x, "left to right");
let middle = |a: f64, b: f64| (a + b) / 2.0;
assert!((at("Second").y - middle(at("Deep").y, at("Deeper").y)).abs() < 1.0);
assert!((at("Root").y - middle(at("First").y, at("Second").y)).abs() < 1.0);
assert_eq!(layout.crossings, 0, "a tidy tree cannot cross");
}
#[test]
fn the_frame_of_a_cycle_is_not_empty() {
let edges = vec![
edge("Converter", "Treasury"),
edge("Treasury", "Converter"),
edge("Converter", "Oracle"),
];
let mine = unfold("Converter", &edges);
let layout = super::layout_tree("Converter", &mine);
assert_eq!(layout.nodes.len(), 3, "every box is placed: {:?}", layout.nodes);
assert_eq!(layout.node("Converter").expect("the root").layer, 0);
assert!(layout.frame_width > 0.0 && layout.frame_height > 0.0);
}
#[test]
fn a_contract_drawn_twice_gets_a_border_of_its_own() {
let edges = vec![
edge("Registry", "Receiver"),
edge("Registry", "Desk"),
edge("Receiver", "Desk"),
];
let mine = unfold("Registry", &edges);
let borders = super::PreparedFrame::repeat_borders(&mine);
assert_eq!(borders.len(), 1, "only Desk is drawn twice: {borders:?}");
let desk = borders["Desk"];
assert_ne!(desk, super::BOX_BORDER);
let frame = super::PreparedFrame {
title: String::new(),
edges: mine.clone(),
layout: super::layout_map(&mine),
arrows: Vec::new(),
fills: BTreeMap::new(),
borders,
strip: Vec::new(),
width: 0.0,
height: 0.0,
crossings: 0,
left: 0.0,
top: 0.0,
};
assert_eq!(frame.border_of("Desk"), (desk, super::REPEAT_BORDER_WIDTH));
assert_eq!(frame.border_of("Desk#2"), (desk, super::REPEAT_BORDER_WIDTH));
assert_eq!(frame.border_of("Receiver"), (super::BOX_BORDER, super::BORDER_WIDTH));
assert!(
super::REPEAT_BORDER_WIDTH > super::BORDER_WIDTH,
"a colour nobody can see at the frame's zoom is not a signal"
);
}
#[test]
fn a_copy_carries_its_originals_name() {
assert_eq!(original_of("Treasury#2"), "Treasury");
assert_eq!(original_of("Treasury"), "Treasury");
assert_eq!(super::box_text("Treasury#3"), "Treasury");
}
}
#[cfg(test)]
mod failure_message_test {
use crate::batbelt::miro::MiroError;
use error_stack::Report;
#[test]
fn a_failure_carries_the_api_s_own_words() {
let report = Report::new(MiroError)
.attach_printable("create_connector: HTTP 400 Bad Request: {\"message\":\"too close\"}".to_string());
let line = super::why(&report);
assert!(line.contains("400"), "{line}");
assert!(line.contains("too close"), "{line}");
}
#[test]
fn a_bare_failure_still_says_something() {
assert_eq!(super::why(&Report::new(MiroError)), MiroError.to_string());
}
}
#[cfg(test)]
mod marker_test {
use super::{layout_tree, route_arrows, Edge, Relation};
#[test]
fn no_leg_of_an_arrow_is_zero_length() {
let edges = vec![Edge {
from: "AscendFeeController".into(),
to: "ICLPoolManager@AscendFeeController".into(),
relation: Relation::Boundary,
}];
let layout = layout_tree("AscendFeeController", &edges);
for arrow in route_arrows(&edges, &layout, false) {
for (index, point) in arrow.plan.points.iter().enumerate() {
for other in arrow.plan.points.iter().skip(index + 1) {
assert!(
(point.0 - other.0).abs() > 0.5 || (point.1 - other.1).abs() > 0.5,
"two markers on {point:?}: {:?}",
arrow.plan.points
);
}
}
}
}
}
#[cfg(test)]
mod card_test {
use super::{CARD_GAP, CARD_WIDTH, MAX_CARDS};
use crate::batbelt::miro::client::MiroClient;
fn placed(count: usize, centre: f64) -> Vec<f64> {
let drawn = count.min(MAX_CARDS);
let span = drawn as f64 * CARD_WIDTH + (drawn as f64 - 1.0) * CARD_GAP;
(0..drawn)
.map(|index| centre - span / 2.0 + CARD_WIDTH / 2.0 + index as f64 * (CARD_WIDTH + CARD_GAP))
.collect()
}
#[test]
fn cards_are_centred_on_the_cluster_and_evenly_spread() {
let centre = 10_000.0;
let one = placed(1, centre);
assert_eq!(one, vec![centre], "a single card sits on the centre");
let three = placed(3, centre);
assert_eq!(three.len(), 3);
let middle = (three[0] + three[2]) / 2.0;
assert!((middle - centre).abs() < 1.0, "the row is centred: {three:?}");
assert!(
((three[1] - three[0]) - (three[2] - three[1])).abs() < 1.0,
"evenly spread: {three:?}"
);
assert!((three[1] - three[0]) >= CARD_WIDTH, "and never overlapping");
}
#[test]
fn a_cluster_never_carries_more_than_the_cap() {
assert_eq!(placed(12, 0.0).len(), MAX_CARDS);
}
#[test]
fn a_card_carries_the_link_in_its_words() {
let item = MiroClient::link_card_item(
"map · YieldRouter",
"https://miro.com/app/board/x=/?moveToWidget=123",
0.0,
0.0,
100.0,
50.0,
90,
"#ffffff",
"#1a73e8",
);
let content = item["data"]["content"].as_str().expect("the words");
assert!(content.contains("moveToWidget=123"), "{content}");
assert!(content.contains("YieldRouter"), "{content}");
assert!(item["parent"].is_null(), "a card sits on the board, not inside a frame");
}
}
#[cfg(test)]
mod cards_for_test {
use super::frames_showing;
use crate::batbelt::evm::metadata::bat_metadata::MapFrameRef;
fn frame(root: &str, contracts: &[&str]) -> MapFrameRef {
MapFrameRef {
root: root.to_string(),
frame_id: format!("id-{root}"),
contracts: contracts.iter().map(|name| name.to_string()).collect(),
cards: Vec::new(),
}
}
#[test]
fn a_cluster_is_carded_for_every_frame_that_draws_it() {
let frames = [
frame("YieldRouter", &["YieldRouter", "AscendLaunchpad", "Treasury"]),
frame("AscendCtoRegistry", &["AscendCtoRegistry", "Treasury"]),
frame("ReferralPot", &["ReferralPot"]),
];
let roots: Vec<&str> = frames_showing(&frames, "Treasury")
.iter()
.map(|frame| frame.root.as_str())
.collect();
assert_eq!(roots, vec!["YieldRouter", "AscendCtoRegistry"]);
assert!(frames_showing(&frames, "BurnSink").is_empty(), "nothing drawn, nothing to point at");
}
}
#[cfg(test)]
mod stale_card_test {
use super::is_map_card;
use crate::batbelt::miro::client::MiroClient;
#[test]
fn a_card_is_recognised_by_what_it_says() {
let card = MiroClient::link_card_item(
"map · YieldRouter",
"https://miro.com/app/board/x=/?moveToWidget=123",
0.0,
0.0,
10.0,
10.0,
90,
"#ffffff",
"#1a73e8",
);
assert!(is_map_card(card["data"]["content"].as_str().expect("the words")));
}
#[test]
fn nothing_the_auditor_wrote_is_mistaken_for_one() {
assert!(!is_map_card("<p>map · YieldRouter</p>"), "a label with no link");
assert!(
!is_map_card("<p><a href=\"https://miro.com/app/board/x=/?moveToWidget=9\">ir al mapa</a></p>"),
"a link of their own, without the prefix"
);
assert!(!is_map_card("<p>revisar el map de nuevo</p>"));
}
}