use pixelcoords_core::session::SessionFile;
use crate::convert::{ResolvedPoint, Space, to_space};
use crate::flow::{Flow, Step};
#[derive(Debug, Clone, PartialEq)]
pub struct PlannedStep {
pub index: usize,
pub summary: String,
pub step: Step,
pub points: Vec<ResolvedPoint>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Plan {
pub steps: Vec<PlannedStep>,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum PlanError {
#[error("no selection labeled {label:?} in this session — it has: {available}")]
UnknownLabel { label: String, available: String },
#[error("selection {label:?} sits on monitor {monitor}, which this session does not describe")]
UnknownMonitor { label: String, monitor: usize },
#[error(
"selection {label:?} resolves to ({x}, {y}), which is outside every monitor in this session"
)]
PointOffscreen { label: String, x: i32, y: i32 },
}
pub fn plan(flow: &Flow, session: &SessionFile, space: Space) -> Result<Plan, PlanError> {
let mut steps = Vec::with_capacity(flow.steps.len());
for (index, step) in flow.steps.iter().enumerate() {
let mut points = Vec::new();
for label in step.targets() {
points.push(resolve_label(session, label, space)?);
}
steps.push(PlannedStep {
index,
summary: step.summary(),
step: step.clone(),
points,
});
}
Ok(Plan { steps })
}
fn resolve_label(
session: &SessionFile,
label: &str,
space: Space,
) -> Result<ResolvedPoint, PlanError> {
let selection = session
.selections
.iter()
.find(|s| s.label.eq_ignore_ascii_case(label))
.ok_or_else(|| PlanError::UnknownLabel {
label: label.to_string(),
available: available_labels(session),
})?;
let monitor = session
.monitors
.iter()
.find(|m| m.index == selection.monitor)
.ok_or_else(|| PlanError::UnknownMonitor {
label: label.to_string(),
monitor: selection.monitor,
})?;
let local = selection.px.click_point();
let global_x = monitor.origin_px.x + local.x;
let global_y = monitor.origin_px.y + local.y;
to_space(&session.monitors, global_x, global_y, space).ok_or(PlanError::PointOffscreen {
label: label.to_string(),
x: global_x,
y: global_y,
})
}
fn available_labels(session: &SessionFile) -> String {
let labels: Vec<&str> = session
.selections
.iter()
.map(|s| s.label.as_str())
.filter(|l| !l.is_empty())
.collect();
if labels.is_empty() {
return "no labeled selections".to_string();
}
labels.join(", ")
}
#[cfg(test)]
mod tests {
use pixelcoords_core::geometry::{Point, Size};
use pixelcoords_core::geometry::{Rect, Shape, ToolKind};
use pixelcoords_core::session::{MonitorRecord, SelectionRecord};
use super::*;
use crate::flow::Flow;
fn session() -> SessionFile {
SessionFile {
schema: 1,
app: pixelcoords_core::session::AppInfo {
name: "pixelcoords".into(),
version: "0.1.1".into(),
},
created_utc: "2026-07-28T00:00:00Z".into(),
platform: Some("macos".into()),
capture: None,
name: None,
monitors: vec![
MonitorRecord {
index: 0,
name: "built-in".into(),
primary: true,
origin_px: Point::new(0, 0),
size_px: Size::new(3024, 1964),
scale: 2.0,
},
MonitorRecord {
index: 1,
name: "external".into(),
primary: false,
origin_px: Point::new(3024, 0),
size_px: Size::new(1920, 1080),
scale: 1.0,
},
],
target: None,
selections: vec![
SelectionRecord {
shape: ToolKind::Rect,
label: "submit".into(),
monitor: 0,
px: Shape::Rect(Rect::new(800, 400, 100, 80)),
global_px: Shape::Rect(Rect::new(800, 400, 100, 80)),
rot_deg: None,
window_px: None,
crop: "crop-0-submit.png".into(),
},
SelectionRecord {
shape: ToolKind::Rect,
label: "far".into(),
monitor: 1,
px: Shape::Rect(Rect::new(100, 100, 40, 40)),
global_px: Shape::Rect(Rect::new(3124, 100, 40, 40)),
rot_deg: None,
window_px: None,
crop: "crop-1-far.png".into(),
},
],
}
}
fn flow(body: &str) -> Flow {
Flow::parse(&format!("session = \"s\"\n{body}")).expect("valid flow")
}
#[test]
fn a_click_resolves_to_the_regions_click_point_in_logical_points() {
let plan = plan(
&flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
&session(),
Space::Logical,
)
.expect("planned");
let point = plan.steps[0].points[0];
assert!((point.x - 425.0).abs() < f64::EPSILON);
assert!((point.y - 220.0).abs() < f64::EPSILON);
assert_eq!(point.monitor, 0);
}
#[test]
fn a_selection_on_a_second_monitor_uses_that_monitors_scale() {
let plan = plan(
&flow("[[step]]\naction = \"click\"\ntarget = \"far\"\n"),
&session(),
Space::Logical,
)
.expect("planned");
let point = plan.steps[0].points[0];
assert_eq!(point.monitor, 1);
assert!((point.x - 3144.0).abs() < f64::EPSILON);
}
#[test]
fn labels_match_case_insensitively_like_the_sister_tool() {
assert!(
plan(
&flow("[[step]]\naction = \"click\"\ntarget = \"SUBMIT\"\n"),
&session(),
Space::Physical,
)
.is_ok()
);
}
#[test]
fn an_unknown_label_fails_planning_and_lists_the_real_ones() {
let error = plan(
&flow("[[step]]\naction = \"click\"\ntarget = \"nope\"\n"),
&session(),
Space::Auto,
)
.expect_err("should refuse");
let PlanError::UnknownLabel { available, .. } = &error else {
panic!("wrong error: {error}");
};
assert!(
available.contains("submit"),
"names the options: {available}"
);
}
#[test]
fn planning_fails_before_any_step_when_a_later_label_is_missing() {
let result = plan(
&flow(
"[[step]]\naction = \"click\"\ntarget = \"submit\"\n\n[[step]]\naction = \"click\"\ntarget = \"ghost\"\n",
),
&session(),
Space::Auto,
);
assert!(matches!(result, Err(PlanError::UnknownLabel { .. })));
}
#[test]
fn a_drag_resolves_both_ends() {
let plan = plan(
&flow("[[step]]\naction = \"drag\"\nfrom = \"submit\"\nto = \"far\"\n"),
&session(),
Space::Physical,
)
.expect("planned");
assert_eq!(plan.steps[0].points.len(), 2);
assert_eq!(plan.steps[0].points[0].monitor, 0);
assert_eq!(plan.steps[0].points[1].monitor, 1);
}
#[test]
fn keyboard_steps_resolve_to_no_points() {
let plan = plan(
&flow("[[step]]\naction = \"type\"\ntext = \"hi\"\n"),
&session(),
Space::Auto,
)
.expect("planned");
assert!(plan.steps[0].points.is_empty());
}
#[test]
fn a_selection_on_an_undescribed_monitor_is_an_error() {
let mut broken = session();
broken.selections[0].monitor = 9;
let error = plan(
&flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
&broken,
Space::Auto,
)
.expect_err("should refuse");
assert!(matches!(
error,
PlanError::UnknownMonitor { monitor: 9, .. }
));
}
#[test]
fn a_point_outside_every_monitor_is_refused_rather_than_guessed() {
let mut broken = session();
broken.selections[0].px = Shape::Rect(Rect::new(99_000, 400, 10, 10));
let error = plan(
&flow("[[step]]\naction = \"click\"\ntarget = \"submit\"\n"),
&broken,
Space::Auto,
)
.expect_err("should refuse");
assert!(matches!(error, PlanError::PointOffscreen { .. }));
}
}