use colored::Colorize;
use error_stack::{Report, Result, ResultExt};
use crate::batbelt::evm::metadata::bat_metadata::{AutoDeployedFrame, EvmBatMetadata, LineMap};
use crate::batbelt::evm::miro::auto_deploy::PATH_HEADER_LINES;
use crate::batbelt::evm::miro::EvmMiroError;
use crate::batbelt::miro::client::MiroClient;
use crate::batbelt::silicon;
const HIGHLIGHT_COLOR: &str = "#2d9bf0";
pub struct HighlightOptions {
pub deployment: Option<String>,
pub function: Option<String>,
pub frame_url: Option<String>,
pub lines: String,
pub clear: bool,
}
pub async fn run(options: HighlightOptions) -> Result<(), EvmMiroError> {
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
let frame = pick_frame(&metadata, &options)?;
if options.clear {
return clear(frame).await;
}
let wanted = parse_lines(&options.lines)?;
if frame.line_maps.is_empty() {
return Err(Report::new(EvmMiroError).attach_printable(format!(
"`{}` was drawn before bat-cli recorded what each screenshot shows, so a line \
cannot be located on it — deploy it again",
frame.entry_point
)));
}
let positions: Vec<(&LineMap, f64, f64)> = frame
.line_maps
.iter()
.filter_map(|map| {
frame
.node_positions
.iter()
.find(|(id, _, _)| *id == map.node_id)
.map(|(_, x, y)| (map, *x, *y))
})
.collect();
let mut bands: Vec<(f64, f64, f64, f64, String, usize)> = Vec::new();
let mut unplaced: Vec<usize> = Vec::new();
for line in &wanted {
let mut found = false;
for (map, x, y) in &positions {
if *line < map.start_line || *line > map.end_line || map.png_height == 0 {
continue;
}
let geometry = silicon::line_geometry(Some(map.font_size));
let height = (map.height * geometry.line_height as f64 / map.png_height as f64).max(1.0);
let rendered_index = PATH_HEADER_LINES + (line - map.start_line);
let fraction = geometry.line_center_fraction(rendered_index, map.png_height);
let center_y = y - map.height / 2.0 + map.height * fraction;
bands.push((*x, center_y, map.width, height, map.node_id.clone(), *line));
found = true;
}
if !found {
unplaced.push(*line);
}
}
if !unplaced.is_empty() {
let shown: Vec<String> = unplaced.iter().map(|l| l.to_string()).collect();
println!(
" {} line(s) {} are not drawn on `{}`",
"note:".yellow(),
shown.join(", "),
frame.entry_point
);
}
if bands.is_empty() {
return Err(Report::new(EvmMiroError)
.attach_printable("none of those lines appear on that frame"));
}
let client = MiroClient::new_refreshed().await.change_context(EvmMiroError)?;
let mut drawn = Vec::new();
let mut first: Option<String> = None;
if frame.highlights.is_empty() {
let id = client
.create_frame_outline(&frame.frame_id, frame.width, frame.height, HIGHLIGHT_COLOR)
.await
.change_context(EvmMiroError)?;
drawn.push(id);
}
let mut bordered: Vec<&str> = Vec::new();
for (_, _, _, _, node_id, _) in &bands {
if bordered.contains(&node_id.as_str()) {
continue;
}
bordered.push(node_id);
let Some((map, x, y)) = positions.iter().find(|(m, _, _)| m.node_id == *node_id) else {
continue;
};
let id = client
.create_highlight_border(&frame.frame_id, *x, *y, map.width, map.height, HIGHLIGHT_COLOR)
.await
.change_context(EvmMiroError)?;
drawn.push(id);
}
for (x, y, width, height, node_id, line) in bands {
let id = client
.create_colored_line_band(&frame.frame_id, x, y, width, height, HIGHLIGHT_COLOR)
.await
.change_context(EvmMiroError)?;
println!(" {} {}:{}", "●".blue(), node_id.replacen("::", ".", 1), line);
first.get_or_insert_with(|| id.clone());
drawn.push(id);
}
let entry_point = frame.entry_point.clone();
let cluster_root = frame.cluster_root.clone();
EvmBatMetadata::update_metadata(move |m| {
if let Some(record) = m
.miro
.auto
.frames
.iter_mut()
.find(|f| f.entry_point == entry_point && f.cluster_root == cluster_root)
{
record.highlights.extend(drawn.iter().cloned());
}
})
.change_context(EvmMiroError)?;
if let Some(id) = first {
println!(" {}", client.frame_url(&id).blue());
}
Ok(())
}
async fn clear(frame: &AutoDeployedFrame) -> Result<(), EvmMiroError> {
if frame.highlights.is_empty() {
println!(" {} nothing to clear on `{}`", "note:".yellow(), frame.entry_point);
return Ok(());
}
let client = MiroClient::new_refreshed().await.change_context(EvmMiroError)?;
let count = frame.highlights.len();
for id in &frame.highlights {
client.delete_item(id).await.change_context(EvmMiroError)?;
}
let entry_point = frame.entry_point.clone();
let cluster_root = frame.cluster_root.clone();
EvmBatMetadata::update_metadata(move |m| {
if let Some(record) = m
.miro
.auto
.frames
.iter_mut()
.find(|f| f.entry_point == entry_point && f.cluster_root == cluster_root)
{
record.highlights.clear();
}
})
.change_context(EvmMiroError)?;
println!(" {} {count} band(s) removed", "✓".green());
println!(" {}", client.frame_url(&frame.frame_id).blue());
Ok(())
}
fn parse_lines(spec: &str) -> Result<Vec<usize>, EvmMiroError> {
let mut out = Vec::new();
for part in spec.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let parsed = match part.split_once('-') {
Some((from, to)) => {
let from: usize = from.trim().parse().map_err(|_| bad(part))?;
let to: usize = to.trim().parse().map_err(|_| bad(part))?;
if to < from {
return Err(bad(part));
}
(from..=to).collect::<Vec<usize>>()
}
None => vec![part.parse().map_err(|_| bad(part))?],
};
out.extend(parsed);
}
out.sort_unstable();
out.dedup();
if out.is_empty() {
return Err(bad(spec));
}
Ok(out)
}
fn bad(spec: &str) -> Report<EvmMiroError> {
Report::new(EvmMiroError)
.attach_printable(format!("`{spec}` is not a line or a range; use `288` or `288-291`"))
}
fn pick_frame<'a>(
metadata: &'a EvmBatMetadata,
options: &HighlightOptions,
) -> Result<&'a AutoDeployedFrame, EvmMiroError> {
let frames = &metadata.miro.auto.frames;
if let Some(url) = &options.frame_url {
let id = url.rsplit("moveToWidget=").next().unwrap_or("").trim();
return frames
.iter()
.find(|f| f.frame_id == id)
.ok_or_else(|| Report::new(EvmMiroError).attach_printable(format!("no frame {id}")));
}
let deployment = options.deployment.as_deref();
let wanted = options.function.as_deref().or(deployment);
let Some(wanted) = wanted else {
return Err(Report::new(EvmMiroError)
.attach_printable("say which frame: --deployment, --function, or --frame-url"));
};
let in_deployment =
|f: &&AutoDeployedFrame| deployment.is_none_or(|root| f.cluster_root == root);
let named = |f: &&AutoDeployedFrame| {
f.entry_point == wanted || f.entry_point.ends_with(&format!(".{wanted}"))
};
let mut matches: Vec<&AutoDeployedFrame> =
frames.iter().filter(named).filter(in_deployment).collect();
if matches.is_empty() {
let node_key = wanted.replacen('.', "::", 1);
matches = frames
.iter()
.filter(in_deployment)
.filter(|f| {
f.line_maps.iter().any(|map| {
map.node_id == node_key || map.node_id.starts_with(&format!("{node_key}("))
})
})
.collect();
}
match matches.len() {
1 => Ok(matches[0]),
0 => Err(Report::new(EvmMiroError).attach_printable(format!(
"nothing called `{wanted}` is drawn{}",
deployment.map(|d| format!(" in the deployment of {d}")).unwrap_or_default()
))),
_ => {
let urls: Vec<String> = matches.iter().map(|f| f.frame_url.clone()).collect();
Err(Report::new(EvmMiroError).attach_printable(format!(
"`{wanted}` is drawn on {} frames; narrow it with --deployment, or pass \
--frame-url with one of:\n {}",
matches.len(),
urls.join("\n ")
)))
}
}
}
#[cfg(test)]
mod line_spec_test {
use super::*;
#[test]
fn a_single_line_a_range_and_a_mix_all_parse() {
assert_eq!(parse_lines("288").unwrap(), vec![288]);
assert_eq!(parse_lines("288-291").unwrap(), vec![288, 289, 290, 291]);
assert_eq!(parse_lines("291, 288-289").unwrap(), vec![288, 289, 291]);
}
#[test]
fn a_line_named_twice_is_marked_once() {
assert_eq!(parse_lines("288,288-289,289").unwrap(), vec![288, 289]);
}
#[test]
fn nonsense_is_refused_rather_than_guessed() {
assert!(parse_lines("").is_err());
assert!(parse_lines("abc").is_err());
assert!(parse_lines("291-288").is_err(), "a backwards range is a typo");
}
}