use std::path::Path;
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use pixelcoords_core::geometry::Shape;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct FindReport {
pub results: Vec<FindResult>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FindResult {
pub label: String,
pub found: bool,
#[serde(default)]
pub ambiguous: bool,
#[serde(default)]
pub score: f64,
#[serde(default)]
pub delta: Option<Delta>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub monitor: usize,
#[serde(default)]
pub new_px: Option<Shape>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct Delta {
pub dx: i32,
pub dy: i32,
}
impl FindReport {
pub fn result_for(&self, label: &str) -> Option<&FindResult> {
self.results
.iter()
.find(|r| r.label.eq_ignore_ascii_case(label))
}
pub fn is_confirmed(&self, label: &str) -> bool {
self.result_for(label)
.is_some_and(|r| r.found && !r.ambiguous)
}
pub fn corrected_point(
&self,
label: &str,
) -> Option<(usize, pixelcoords_core::geometry::Point)> {
let result = self.result_for(label)?;
if !result.found || result.ambiguous {
return None;
}
let shape = result.new_px.as_ref()?;
Some((result.monitor, shape.click_point()))
}
}
pub fn find(session: &Path, label: Option<&str>) -> Result<FindReport> {
let mut command = Command::new("pixelcoords");
command.arg("find").arg("--session").arg(session);
if let Some(label) = label {
command.arg("--label").arg(label);
}
let output = command.output().context(
"cannot run `pixelcoords` — it must be on PATH to relocate or verify \
(cargo install pixelcoords)",
)?;
if output.status.code() == Some(2) {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("pixelcoords find refused: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).context("could not parse the report from pixelcoords find")
}
#[derive(Debug, Clone, Deserialize)]
pub struct WaitReport {
pub ok: bool,
#[serde(default)]
pub polls: u32,
#[serde(default)]
pub elapsed_ms: u64,
#[serde(default)]
pub results: Vec<WaitResult>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WaitResult {
#[serde(default)]
pub score: f64,
}
impl WaitReport {
#[must_use]
pub fn best_score(&self) -> f64 {
self.results.iter().map(|r| r.score).fold(0.0_f64, f64::max)
}
}
pub fn wait(
session: &Path,
label: &str,
want_present: bool,
timeout: Duration,
interval: Duration,
) -> Result<WaitReport> {
let mut command = Command::new("pixelcoords");
command
.arg("wait")
.arg("--session")
.arg(session)
.arg("--label")
.arg(label)
.arg("--for")
.arg(if want_present { "match" } else { "change" })
.arg("--timeout")
.arg(millis(timeout))
.arg("--interval")
.arg(millis(interval));
let output = command.output().context(
"cannot run `pixelcoords` — it must be on PATH to wait on a region \
(cargo install pixelcoords)",
)?;
if output.status.code() == Some(2) {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("pixelcoords wait refused: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).context("could not parse the report from pixelcoords wait")
}
fn millis(duration: Duration) -> String {
format!("{}ms", duration.as_millis())
}
#[cfg(test)]
mod tests {
use super::*;
const REPORT: &str = r#"{
"schema": 1,
"captured_utc": "2026-07-28T14:02:11Z",
"all_relocated": false,
"results": [
{ "index": 0, "label": "submit", "monitor": 0, "found": true,
"ambiguous": false, "score": 0.998,
"old_px": {"x":812,"y":440,"w":96,"h":40},
"new_px": {"x":812,"y":320,"w":96,"h":40},
"delta": {"dx":0,"dy":-120} },
{ "index": 1, "label": "gone", "monitor": 0, "found": false,
"ambiguous": false, "score": 0.42 },
{ "index": 2, "label": "twins", "monitor": 0, "found": true,
"ambiguous": true, "score": 0.97 }
]
}"#;
fn report() -> FindReport {
serde_json::from_str(REPORT).expect("fixture parses")
}
#[test]
fn a_found_unambiguous_region_is_confirmed() {
assert!(report().is_confirmed("submit"));
}
#[test]
fn a_missing_region_is_not_confirmed() {
assert!(!report().is_confirmed("gone"));
}
#[test]
fn an_ambiguous_match_is_not_confirmed_even_though_it_was_found() {
let report = report();
assert!(report.result_for("twins").expect("present").found);
assert!(!report.is_confirmed("twins"));
}
#[test]
fn labels_match_case_insensitively() {
assert!(report().is_confirmed("SUBMIT"));
}
#[test]
fn drift_is_readable_from_the_report() {
let delta = report()
.result_for("submit")
.expect("present")
.delta
.expect("moved");
assert_eq!((delta.dx, delta.dy), (0, -120));
}
#[test]
fn unknown_future_fields_in_the_report_are_ignored() {
let future = REPORT.replace(
r#""all_relocated": false,"#,
r#""all_relocated": false, "capture_ms": 680, "engine": "ncc-v2","#,
);
let parsed: FindReport = serde_json::from_str(&future).expect("still parses");
assert!(parsed.is_confirmed("submit"));
}
#[test]
fn a_moved_region_yields_a_corrected_click_point() {
let (monitor, point) = report()
.corrected_point("submit")
.expect("has new geometry");
assert_eq!(monitor, 0);
assert_eq!((point.x, point.y), (860, 340));
}
#[test]
fn an_ambiguous_or_missing_region_yields_no_correction() {
assert!(report().corrected_point("twins").is_none());
assert!(report().corrected_point("gone").is_none());
}
#[test]
fn a_report_without_optional_fields_still_parses() {
let minimal = r#"{ "all_relocated": true,
"results": [{ "label": "a", "found": true }] }"#;
let parsed: FindReport = serde_json::from_str(minimal).expect("parses");
assert!(parsed.is_confirmed("a"));
}
}