Skip to main content

pixelcoords_core/
session.rs

1//! The versioned `session.json` schema.
2//!
3//! Coordinates are stored twice per selection: `px` (monitor-local physical
4//! pixels — the authoritative space everything is drawn and cropped in) and
5//! `global_px` (derived: monitor origin + local). Per-monitor `scale` lets
6//! consumers reconstruct logical points; the schema does not pretend there
7//! is a universal logical space.
8
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12use crate::geometry::{Point, Shape, Size, ToolKind};
13use crate::selection::Selection;
14
15pub const SCHEMA_VERSION: u32 = 1;
16pub const APP_NAME: &str = "pixelcoords";
17
18pub use crate::geometry::MAX_COORD;
19
20/// The longest label a selection or measure may carry.
21///
22/// A label becomes a filename component — `crop-<index>-<slug>.png` — and
23/// most filesystems stop at 255 bytes. That is the real constraint; 64
24/// leaves room for the prefix, the index, and multi-byte characters that
25/// slug to more bytes than they are characters.
26///
27/// Enforced here as well as at the keyboard, because a session is a file
28/// a human can edit: a label that only the overlay checked would sail in
29/// through `resume` and fail at the filesystem on the next save, which is
30/// a confusing place to learn about it.
31pub const MAX_LABEL_LEN: usize = 64;
32
33/// Why a session file is not usable, even though it parsed.
34///
35/// Parsing proves the shape; this proves the values mean something. The
36/// split matters because `serde` will happily accept `"scale": 0.0` — it
37/// is a valid `f64` — and every consumer downstream then divides by it.
38#[derive(Debug, Error, PartialEq)]
39pub enum SessionError {
40    #[error("monitor {index} ({name:?}) has scale {scale}, which is not a positive finite number")]
41    Scale {
42        index: usize,
43        name: String,
44        scale: f64,
45    },
46    #[error("monitor {index} ({name:?}) has size {w}x{h}; a display cannot be empty")]
47    MonitorSize {
48        index: usize,
49        name: String,
50        w: i32,
51        h: i32,
52    },
53    #[error("the target window has size {w}x{h}; a window cannot be empty")]
54    TargetSize { w: i32, h: i32 },
55    #[error(
56        "{what} carries the coordinate {value}, beyond the +/-{MAX_COORD} a session may describe"
57    )]
58    Coordinate { what: String, value: i32 },
59    #[error(
60        "{what} has a {len}-character label; the limit is {MAX_LABEL_LEN}, because the label \
61         becomes part of a crop's filename"
62    )]
63    Label { what: String, len: usize },
64}
65
66/// How the session's frames were obtained. Optional in the schema —
67/// sessions written before it existed simply lack it.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum CaptureKind {
71    /// Whole monitors, no window attachment.
72    Desktop,
73    /// Attached to a window via `--target`: the `target` record carries
74    /// the window's identity for re-attachment.
75    Window,
76    /// One window chosen in the desktop portal's picker (`--pick`); the
77    /// portal reveals no window identity, so `target` is a placeholder.
78    Pick,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct SessionFile {
83    pub schema: u32,
84    pub app: AppInfo,
85    pub created_utc: String,
86    /// The OS the session was captured on — what a consumer needs to know
87    /// before re-attaching to the recorded window or coordinates:
88    /// `macos`, `windows`, `linux-x11`, or `linux-wayland`.
89    #[serde(skip_serializing_if = "Option::is_none", default)]
90    pub platform: Option<String>,
91    /// See [`CaptureKind`].
92    #[serde(skip_serializing_if = "Option::is_none", default)]
93    pub capture: Option<CaptureKind>,
94    /// A human-friendly session name ("microsoft teams") for pickers and
95    /// listings; the folder name identifies, this describes.
96    #[serde(skip_serializing_if = "Option::is_none", default)]
97    pub name: Option<String>,
98    pub monitors: Vec<MonitorRecord>,
99    /// Present when the session was captured with `--target`: the matched
100    /// window's identity and bounds at freeze time.
101    #[serde(skip_serializing_if = "Option::is_none", default)]
102    pub target: Option<TargetRecord>,
103    pub selections: Vec<SelectionRecord>,
104    /// Two-point measurements, when any were taken. A separate top-level
105    /// array rather than a shape kind: a measure has no interior, so
106    /// every consumer of `selections` would have to special-case one.
107    /// Additive and omitted when empty, so the schema does not move and
108    /// sessions without measures look exactly as they always did.
109    #[serde(skip_serializing_if = "Vec::is_empty", default)]
110    pub measures: Vec<MeasureRecord>,
111}
112
113/// One measurement, with its derived values precomputed so a consumer
114/// never re-derives geometry to read a number off a ruler.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct MeasureRecord {
117    pub label: String,
118    pub monitor: usize,
119    /// Endpoints in monitor-local physical pixels.
120    pub px: LineRecord,
121    /// The same endpoints on the global desktop grid.
122    pub global_px: LineRecord,
123    pub length_px: f64,
124    pub dx: i32,
125    pub dy: i32,
126    /// Degrees in `[0, 360)`, `0` pointing right along +X, increasing
127    /// clockwise because screen Y grows downward.
128    pub angle_deg: f64,
129}
130
131/// A measure's two endpoints, flat rather than nested, so a consumer
132/// reads `ax` instead of `a.x` for a thing that is always exactly two
133/// points.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135pub struct LineRecord {
136    pub ax: i32,
137    pub ay: i32,
138    pub bx: i32,
139    pub by: i32,
140}
141
142impl From<crate::geometry::Line> for LineRecord {
143    fn from(line: crate::geometry::Line) -> Self {
144        Self {
145            ax: line.a.x,
146            ay: line.a.y,
147            bx: line.b.x,
148            by: line.b.y,
149        }
150    }
151}
152
153/// The `--target` window as it stood at the instant of the freeze.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct TargetRecord {
156    pub app: String,
157    pub title: String,
158    pub monitor: usize,
159    /// Window origin in that monitor's local physical pixels.
160    pub origin_px: Point,
161    pub size_px: Size,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct AppInfo {
166    pub name: String,
167    pub version: String,
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct MonitorRecord {
172    pub index: usize,
173    pub name: String,
174    pub primary: bool,
175    pub origin_px: Point,
176    pub size_px: Size,
177    pub scale: f64,
178}
179
180/// How a saved [`MonitorRecord`] resolved against the displays attached
181/// now. Both non-`Missing` variants carry an index into the candidate
182/// slice that was searched.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum MonitorMatch {
185    /// Same display, same geometry — safe to relocate against.
186    Found(usize),
187    /// A display of that name is attached, but its size or scale moved.
188    /// Kept distinct from `Missing` so the caller can say *what* changed
189    /// instead of "not attached", which would send the user hunting for a
190    /// cable when the real cause was a resolution change.
191    Changed(usize),
192    /// Nothing attached carries that name.
193    Missing,
194}
195
196/// Resolve a session's monitor against the live enumeration by **identity**
197/// — name, size and scale — rather than by enumeration index.
198///
199/// The index is not stable: it shuffles across replugs, reboots and
200/// dock/undock, so matching on it alone breaks re-attachment for a display
201/// that never actually changed. Everything needed to recognize the panel is
202/// already recorded at capture time; this uses it.
203///
204/// Ties (two of the same model attached at once) break toward the
205/// candidate whose index equals the record's, then toward the lowest index.
206/// Preferring the recorded index first means the common case — nothing
207/// moved, or something *else* was replugged — resolves to the same panel it
208/// did before, rather than to whichever twin happens to enumerate first.
209pub fn match_monitor(record: &MonitorRecord, candidates: &[MonitorRecord]) -> MonitorMatch {
210    // Within a pool of equally valid candidates: the one that also carries
211    // the recorded index, else the lowest index. Empty pool yields None,
212    // which is what lets the two calls below fall through in order.
213    let best = |pool: &[usize]| -> Option<usize> {
214        pool.iter()
215            .copied()
216            .find(|&i| candidates[i].index == record.index)
217            .or_else(|| pool.iter().copied().min_by_key(|&i| candidates[i].index))
218    };
219
220    let named: Vec<usize> = candidates
221        .iter()
222        .enumerate()
223        .filter(|(_, c)| c.name == record.name)
224        .map(|(i, _)| i)
225        .collect();
226    if named.is_empty() {
227        return MonitorMatch::Missing;
228    }
229    let exact: Vec<usize> = named
230        .iter()
231        .copied()
232        .filter(|&i| {
233            let c = &candidates[i];
234            // Scale is a float off the platform API; compare it the way the
235            // rest of this codebase does rather than with `==`.
236            c.size_px == record.size_px && (c.scale - record.scale).abs() < f64::EPSILON
237        })
238        .collect();
239    if let Some(i) = best(&exact) {
240        return MonitorMatch::Found(i);
241    }
242    best(&named).map_or(MonitorMatch::Missing, MonitorMatch::Changed)
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct SelectionRecord {
247    pub shape: ToolKind,
248    pub label: String,
249    pub monitor: usize,
250    pub px: Shape,
251    pub global_px: Shape,
252    /// Rotation in degrees (clockwise, `1..360`) about the bbox center of
253    /// `px`. Absent means unrotated. Never present for triangles — their
254    /// rotation is baked into the stored vertices — nor circles.
255    #[serde(skip_serializing_if = "Option::is_none", default)]
256    pub rot_deg: Option<i32>,
257    /// Coordinates relative to the target window's top-left; present only
258    /// in `--target` sessions, for selections on the target's monitor.
259    /// Negative values mean the selection lies outside the window.
260    #[serde(skip_serializing_if = "Option::is_none", default)]
261    pub window_px: Option<Shape>,
262    /// File name of this selection's PNG crop, relative to the session dir.
263    pub crop: String,
264    /// The captured pixel at this selection's click point, as uppercase
265    /// `#RRGGBB`.
266    ///
267    /// The same interior point `assert` and `emit` aim at, so it
268    /// describes the pixel automation will actually click — a consumer
269    /// can sanity-check that the button was still blue when it was
270    /// marked. Optional and additive: absent in sessions written before
271    /// it existed, and the schema does not move for it.
272    #[serde(skip_serializing_if = "Option::is_none", default)]
273    pub color: Option<String>,
274}
275
276impl SessionFile {
277    /// Assemble a session. `created_utc` is supplied by the caller (this
278    /// crate has no clock); `crops` pairs 1:1 with `selections`.
279    pub fn build(
280        app_version: &str,
281        created_utc: String,
282        monitors: Vec<MonitorRecord>,
283        selections: &[Selection],
284        crops: &[String],
285        target: Option<TargetRecord>,
286    ) -> Self {
287        assert_eq!(selections.len(), crops.len(), "one crop name per selection");
288        let records = selections
289            .iter()
290            .zip(crops)
291            .map(|(s, crop)| {
292                let origin = monitors
293                    .iter()
294                    .find(|m| m.index == s.monitor)
295                    .map_or(Point::new(0, 0), |m| m.origin_px);
296                // Triangles bake rotation into their vertices (exact);
297                // rects keep an axis-aligned box plus rot_deg metadata.
298                let shape = s.shape.with_rotation_baked(s.rot_deg);
299                let rot_deg = match shape {
300                    Shape::Rect(_) | Shape::Ellipse { .. } => {
301                        Some(crate::geometry::normalize_deg(s.rot_deg)).filter(|d| *d != 0)
302                    }
303                    _ => None,
304                };
305                let window_px = target
306                    .as_ref()
307                    .filter(|t| t.monitor == s.monitor)
308                    .map(|t| shape.translated(-t.origin_px.x, -t.origin_px.y));
309                SelectionRecord {
310                    shape: shape.kind(),
311                    label: s.label.clone(),
312                    monitor: s.monitor,
313                    global_px: shape.translated(origin.x, origin.y),
314                    px: shape,
315                    rot_deg,
316                    window_px,
317                    crop: crop.clone(),
318                    // Filled by `with_colors`: the sample comes from the
319                    // frozen frame, which this crate never sees.
320                    color: None,
321                }
322            })
323            .collect();
324        Self {
325            schema: SCHEMA_VERSION,
326            app: AppInfo {
327                name: APP_NAME.to_string(),
328                version: app_version.to_string(),
329            },
330            created_utc,
331            platform: None,
332            capture: None,
333            name: None,
334            monitors,
335            target,
336            selections: records,
337            measures: Vec::new(),
338        }
339    }
340
341    /// Stamp provenance onto a built session. A resumed session passes
342    /// through what it loaded, so a file edited on another machine keeps
343    /// saying where it was captured.
344    #[must_use]
345    pub fn with_meta(
346        mut self,
347        platform: Option<String>,
348        capture: Option<CaptureKind>,
349        name: Option<String>,
350    ) -> Self {
351        self.platform = platform;
352        self.capture = capture;
353        self.name = name;
354        self
355    }
356
357    /// Attach the session's measurements, converting each to global
358    /// coordinates through its own monitor's origin and precomputing the
359    /// derived values.
360    ///
361    /// A builder rather than an argument to `build` for the same reason
362    /// the colors are: a session without measures is an ordinary session,
363    /// so it cannot be required of every caller.
364    #[must_use]
365    pub fn with_measures(mut self, measures: &[crate::selection::Measure]) -> Self {
366        self.measures = measures
367            .iter()
368            .map(|m| {
369                let origin = self
370                    .monitors
371                    .iter()
372                    .find(|mon| mon.index == m.monitor)
373                    .map_or(Point::new(0, 0), |mon| mon.origin_px);
374                let (dx, dy) = m.line.delta();
375                MeasureRecord {
376                    label: m.label.clone(),
377                    monitor: m.monitor,
378                    px: m.line.into(),
379                    global_px: m.line.translated(origin.x, origin.y).into(),
380                    length_px: m.line.length(),
381                    dx,
382                    dy,
383                    angle_deg: m.line.angle_deg(),
384                }
385            })
386            .collect();
387        self
388    }
389
390    /// Attach the sampled click-point color to each selection, in the
391    /// same order [`Self::build`] took them.
392    ///
393    /// Separate from `build` because the color comes from the frozen
394    /// frames, which only the caller holds — and because a session
395    /// without colors is a valid session, so this cannot be a required
396    /// argument. A shorter slice leaves the rest without a color rather
397    /// than shifting them onto the wrong selection.
398    #[must_use]
399    pub fn with_colors(mut self, colors: &[Option<String>]) -> Self {
400        for (record, color) in self.selections.iter_mut().zip(colors) {
401            record.color.clone_from(color);
402        }
403        self
404    }
405}
406
407/// Rebuild editable selections from a saved session — the inverse of
408/// [`SessionFile::build`]. Shapes come back in monitor-local px; rects
409/// reclaim their `rot_deg` metadata, triangles keep rotation baked in
410/// their vertices (their records never carry `rot_deg`), and circles are
411/// rotation-free. Feed the result to `SelectionSet::seed`.
412///
413/// In a target session, selections whose shape falls outside the window's
414/// rect are **dropped**. Older builds recorded whatever the user marked
415/// on the whole monitor, including junk outside the window, and their
416/// stored `window_px` came out with negative coordinates. This build
417/// refuses to let a user act on those, so a resumed session should not
418/// bring them back. The dropped labels are returned so the caller can
419/// tell the user what happened.
420pub fn restore_selections(file: &SessionFile) -> (Vec<Selection>, Vec<String>) {
421    let target_rect = file.target.as_ref().map(|t| {
422        (
423            t.monitor,
424            crate::geometry::Rect::new(0, 0, t.size_px.w, t.size_px.h),
425        )
426    });
427    let mut kept = Vec::with_capacity(file.selections.len());
428    let mut dropped = Vec::new();
429    for record in &file.selections {
430        // Compare against `window_px` (already translated to window-local)
431        // rather than reconstructing coordinates from `px`; the two must
432        // agree for a valid record, and window_px is the primary frame in
433        // a target session.
434        if let Some((monitor, rect)) = target_rect
435            && record.monitor == monitor
436        {
437            let Some(shape) = &record.window_px else {
438                dropped.push(record.label.clone());
439                continue;
440            };
441            let bbox = shape.bbox();
442            let inside = bbox.x >= rect.x
443                && bbox.y >= rect.y
444                && bbox.x + bbox.w <= rect.x + rect.w
445                && bbox.y + bbox.h <= rect.y + rect.h;
446            if !inside {
447                dropped.push(record.label.clone());
448                continue;
449            }
450        }
451        kept.push(Selection {
452            shape: record.px.clone(),
453            label: record.label.clone(),
454            monitor: record.monitor,
455            rot_deg: record.rot_deg.unwrap_or(0),
456        });
457    }
458    (kept, dropped)
459}
460
461/// Every raw coordinate a shape carries, in no particular order.
462///
463/// Deliberately arithmetic-free. `bbox()` is the natural way to ask a
464/// shape where it is, and it is exactly the wrong tool here: computing a
465/// bounding box on an out-of-range shape performs the very subtraction
466/// this check exists to prevent.
467fn raw_values(shape: &Shape) -> Vec<i32> {
468    match shape {
469        Shape::Rect(r) => vec![r.x, r.y, r.w, r.h],
470        Shape::Circle { cx, cy, r } => vec![*cx, *cy, *r],
471        Shape::Ellipse { cx, cy, rx, ry } => vec![*cx, *cy, *rx, *ry],
472        Shape::Triangle {
473            ax,
474            ay,
475            bx,
476            by,
477            cx,
478            cy,
479        } => vec![*ax, *ay, *bx, *by, *cx, *cy],
480        Shape::Poly { points } => points.iter().flat_map(|p| [p.x, p.y]).collect(),
481    }
482}
483
484fn check_label(label: &str, what: &str) -> Result<(), SessionError> {
485    let len = label.chars().count();
486    if len > MAX_LABEL_LEN {
487        return Err(SessionError::Label {
488            what: what.to_string(),
489            len,
490        });
491    }
492    Ok(())
493}
494
495fn in_range(values: &[i32], what: &str) -> Result<(), SessionError> {
496    for &value in values {
497        if value.abs() > MAX_COORD {
498            return Err(SessionError::Coordinate {
499                what: what.to_string(),
500                value,
501            });
502        }
503    }
504    Ok(())
505}
506
507impl SessionFile {
508    /// Check that a parsed session describes something a coordinate can
509    /// mean.
510    ///
511    /// Called at the load seam so every command and `doctor` refuse the
512    /// same file, the way `Config`'s resolution is checked once when the
513    /// config is read rather than at each use. A file that fails here is
514    /// malformed, not merely unusual, and the caller reports it as such.
515    pub fn validate(&self) -> Result<(), SessionError> {
516        for monitor in &self.monitors {
517            if !monitor.scale.is_finite() || monitor.scale <= 0.0 {
518                return Err(SessionError::Scale {
519                    index: monitor.index,
520                    name: monitor.name.clone(),
521                    scale: monitor.scale,
522                });
523            }
524            if monitor.size_px.w <= 0 || monitor.size_px.h <= 0 {
525                return Err(SessionError::MonitorSize {
526                    index: monitor.index,
527                    name: monitor.name.clone(),
528                    w: monitor.size_px.w,
529                    h: monitor.size_px.h,
530                });
531            }
532            let label = format!("monitor {}", monitor.index);
533            in_range(
534                &[
535                    monitor.origin_px.x,
536                    monitor.origin_px.y,
537                    monitor.size_px.w,
538                    monitor.size_px.h,
539                ],
540                &label,
541            )?;
542        }
543        if let Some(target) = &self.target {
544            if target.size_px.w <= 0 || target.size_px.h <= 0 {
545                return Err(SessionError::TargetSize {
546                    w: target.size_px.w,
547                    h: target.size_px.h,
548                });
549            }
550            in_range(
551                &[
552                    target.origin_px.x,
553                    target.origin_px.y,
554                    target.size_px.w,
555                    target.size_px.h,
556                ],
557                "the target window",
558            )?;
559        }
560        for (index, record) in self.selections.iter().enumerate() {
561            let label = format!("selection {index}");
562            check_label(&record.label, &label)?;
563            in_range(&raw_values(&record.px), &label)?;
564            in_range(&raw_values(&record.global_px), &label)?;
565            if let Some(window) = &record.window_px {
566                in_range(&raw_values(window), &label)?;
567            }
568        }
569        for (index, record) in self.measures.iter().enumerate() {
570            let label = format!("measure {index}");
571            check_label(&record.label, &label)?;
572            for line in [&record.px, &record.global_px] {
573                in_range(&[line.ax, line.ay, line.bx, line.by], &label)?;
574            }
575        }
576        Ok(())
577    }
578}
579
580/// The measures a saved session carries, back as editable rulers.
581///
582/// Unlike `restore_selections` this drops nothing: a measure has no crop
583/// to orphan and no window-relative frame to fall outside of, so there is
584/// nothing a target session could invalidate. Derived values are
585/// recomputed from the endpoints on the next save rather than trusted, so
586/// a hand-edited file cannot smuggle a length that does not match its
587/// line.
588#[must_use]
589pub fn restore_measures(file: &SessionFile) -> Vec<crate::selection::Measure> {
590    file.measures
591        .iter()
592        .map(|record| crate::selection::Measure {
593            line: crate::geometry::Line::new(
594                Point::new(record.px.ax, record.px.ay),
595                Point::new(record.px.bx, record.px.by),
596            ),
597            label: record.label.clone(),
598            monitor: record.monitor,
599        })
600        .collect()
601}
602
603/// The selections a `--label` restricts to, paired with their index in
604/// the session — the identity every report row carries. `None` selects
605/// everything. Matching is ASCII case-insensitive, as the window matcher
606/// is.
607///
608/// An empty result is the caller's to report: each command refuses in its
609/// own error type, and only the caller knows whether an empty *session*
610/// or an unmatched *label* is the cause. Pair it with `distinct_labels`
611/// to say what the session does carry.
612pub fn select_by_label<'a>(
613    session: &'a SessionFile,
614    label: Option<&str>,
615) -> Vec<(usize, &'a SelectionRecord)> {
616    session
617        .selections
618        .iter()
619        .enumerate()
620        .filter(|(_, record)| label.is_none_or(|want| record.label.eq_ignore_ascii_case(want)))
621        .collect()
622}
623
624/// The labels a `--label` could have matched, in session order,
625/// deduplicated ASCII case-insensitively; unlabeled selections contribute
626/// nothing.
627///
628/// Takes an iterator rather than the session because the caller decides
629/// what "could have matched" means: `assert` lists labels among its
630/// *space-filtered* candidates, since a monitor-space question cannot be
631/// answered by a selection on another monitor.
632pub fn distinct_labels<'a>(records: impl Iterator<Item = &'a SelectionRecord>) -> Vec<String> {
633    let mut labels: Vec<String> = Vec::new();
634    for record in records {
635        if record.label.is_empty() {
636            continue;
637        }
638        if labels.iter().any(|l| l.eq_ignore_ascii_case(&record.label)) {
639            continue;
640        }
641        labels.push(record.label.clone());
642    }
643    labels
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use crate::geometry::Rect;
650
651    #[test]
652    fn measures_are_absent_from_a_session_that_has_none() {
653        let file = labeled(&["submit"]);
654        let json = serde_json::to_value(&file).unwrap();
655        assert!(
656            json.get("measures").is_none(),
657            "a session without measures must look exactly as it always did"
658        );
659        assert_eq!(json["schema"], 1);
660    }
661
662    #[test]
663    fn a_measure_records_its_globals_and_derived_values() {
664        use crate::geometry::Line;
665        use crate::selection::Measure;
666        // Monitor 1 sits at global x=1920, so the local ruler at x=100
667        // reports globals 1920 higher.
668        let mut m = Measure::new(Line::new(Point::new(100, 80), Point::new(262, 80)), 1);
669        m.label = "toolbar-gap".into();
670        let file = SessionFile::build(
671            "test",
672            "2026-08-01T00:00:00Z".into(),
673            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
674            &[],
675            &[],
676            None,
677        )
678        .with_measures(&[m]);
679
680        let json = serde_json::to_value(&file).unwrap();
681        let rec = &json["measures"][0];
682        assert_eq!(rec["label"], "toolbar-gap");
683        assert_eq!(rec["monitor"], 1);
684        assert_eq!(rec["px"]["ax"], 100);
685        assert_eq!(rec["global_px"]["ax"], 2020, "origin added");
686        assert_eq!(rec["global_px"]["bx"], 2182);
687        assert_eq!(rec["dx"], 162);
688        assert_eq!(rec["dy"], 0);
689        assert_eq!(rec["length_px"], 162.0);
690        assert_eq!(rec["angle_deg"], 0.0);
691        assert_eq!(json["schema"], 1, "measures are additive");
692    }
693
694    #[test]
695    fn stored_derived_values_match_recomputing_them() {
696        use crate::geometry::Line;
697        use crate::selection::Measure;
698        // The reason they are stored at all is so a consumer never has to
699        // re-derive geometry — which is only safe if they agree.
700        let line = Line::new(Point::new(-30, 12), Point::new(45, -60));
701        let file = SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None)
702            .with_measures(&[Measure::new(line, 0)]);
703        let rec = &file.measures[0];
704        assert!((rec.length_px - line.length()).abs() < f64::EPSILON);
705        assert!((rec.angle_deg - line.angle_deg()).abs() < f64::EPSILON);
706        assert_eq!((rec.dx, rec.dy), line.delta());
707    }
708
709    #[test]
710    fn restoring_measures_recovers_every_ruler_and_recomputes_nothing_wrong() {
711        let base =
712            || SessionFile::build("test", "t".into(), vec![monitor(0, 100, 0)], &[], &[], None);
713        let file = base().with_measures(&[
714            crate::selection::Measure::new(
715                crate::geometry::Line::new(Point::new(10, 20), Point::new(40, 60)),
716                0,
717            ),
718            crate::selection::Measure {
719                line: crate::geometry::Line::new(Point::new(1, 2), Point::new(3, 4)),
720                label: "gutter".into(),
721                monitor: 0,
722            },
723        ]);
724
725        let restored = restore_measures(&file);
726
727        assert_eq!(restored.len(), 2);
728        assert_eq!(
729            restored[0].line,
730            crate::geometry::Line::new(Point::new(10, 20), Point::new(40, 60)),
731            "monitor-local endpoints, not the global ones"
732        );
733        assert_eq!(restored[1].label, "gutter");
734        // A resave reproduces the same records: the round trip is closed.
735        assert_eq!(base().with_measures(&restored).measures, file.measures);
736    }
737
738    #[test]
739    fn an_empty_target_window_is_refused() {
740        let mut file =
741            SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
742        file.target = Some(TargetRecord {
743            app: "App".into(),
744            title: "T".into(),
745            monitor: 0,
746            origin_px: Point::new(0, 0),
747            size_px: Size::new(0, 400),
748        });
749        assert!(matches!(
750            file.validate(),
751            Err(SessionError::TargetSize { w: 0, h: 400 })
752        ));
753    }
754
755    #[test]
756    fn a_target_window_past_the_bound_is_refused() {
757        let mut file =
758            SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
759        file.target = Some(TargetRecord {
760            app: "App".into(),
761            title: "T".into(),
762            monitor: 0,
763            origin_px: Point::new(MAX_COORD + 1, 0),
764            size_px: Size::new(400, 400),
765        });
766        assert!(matches!(
767            file.validate(),
768            Err(SessionError::Coordinate { .. })
769        ));
770    }
771
772    #[test]
773    fn every_refusal_names_the_field_and_the_value() {
774        // These strings are what a user sees when a session is rejected,
775        // so they are output and get tested like output. A message that
776        // says "invalid session" and stops sends someone reading JSON by
777        // hand with no idea which number to look at.
778        let cases = [
779            (
780                SessionError::Scale {
781                    index: 2,
782                    name: "DELL".into(),
783                    scale: 0.0,
784                },
785                vec!["monitor 2", "DELL", "0", "positive finite"],
786            ),
787            (
788                SessionError::MonitorSize {
789                    index: 1,
790                    name: "Built-in".into(),
791                    w: 0,
792                    h: 1080,
793                },
794                vec!["monitor 1", "Built-in", "0x1080"],
795            ),
796            (
797                SessionError::TargetSize { w: 640, h: 0 },
798                vec!["target window", "640x0"],
799            ),
800            (
801                SessionError::Coordinate {
802                    what: "selection 3".into(),
803                    value: 2_000_000_000,
804                },
805                vec!["selection 3", "2000000000", "1000000"],
806            ),
807        ];
808        for (error, expected) in cases {
809            let rendered = error.to_string();
810            for needle in expected {
811                assert!(
812                    rendered.contains(needle),
813                    "{rendered:?} does not mention {needle:?}"
814                );
815            }
816        }
817    }
818
819    #[test]
820    fn a_label_too_long_for_a_filename_is_refused() {
821        // The invariant the cap protects lives in `save`: a label becomes
822        // `crop-<index>-<slug>.png`. Enforced only at the keyboard, an
823        // edited session would sail in and fail at the filesystem.
824        let mut file =
825            SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
826        file.selections.push(SelectionRecord {
827            shape: ToolKind::Rect,
828            label: "x".repeat(MAX_LABEL_LEN + 1),
829            monitor: 0,
830            px: Shape::Rect(Rect::new(0, 0, 10, 10)),
831            global_px: Shape::Rect(Rect::new(0, 0, 10, 10)),
832            rot_deg: None,
833            window_px: None,
834            crop: "c.png".into(),
835            color: None,
836        });
837        let Err(SessionError::Label { len, .. }) = file.validate() else {
838            panic!("an over-long label was accepted")
839        };
840        assert_eq!(len, MAX_LABEL_LEN + 1);
841    }
842
843    #[test]
844    fn a_label_exactly_at_the_cap_is_fine() {
845        let mut file =
846            SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
847        file.selections.push(SelectionRecord {
848            shape: ToolKind::Rect,
849            label: "x".repeat(MAX_LABEL_LEN),
850            monitor: 0,
851            px: Shape::Rect(Rect::new(0, 0, 10, 10)),
852            global_px: Shape::Rect(Rect::new(0, 0, 10, 10)),
853            rot_deg: None,
854            window_px: None,
855            crop: "c.png".into(),
856            color: None,
857        });
858        assert_eq!(file.validate(), Ok(()));
859    }
860
861    #[test]
862    fn a_measures_label_is_held_to_the_same_cap() {
863        let mut file =
864            SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None)
865                .with_measures(&[crate::selection::Measure {
866                    line: crate::geometry::Line::new(Point::new(0, 0), Point::new(5, 5)),
867                    label: "m".repeat(MAX_LABEL_LEN + 1),
868                    monitor: 0,
869                }]);
870        file.monitors = vec![monitor(0, 0, 0)];
871        assert!(matches!(file.validate(), Err(SessionError::Label { .. })));
872    }
873
874    #[test]
875    fn a_label_is_counted_in_characters_not_bytes() {
876        // A slug of multi-byte characters is longer in bytes than in
877        // chars; the cap counts what the user typed.
878        let mut file =
879            SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
880        file.selections.push(SelectionRecord {
881            shape: ToolKind::Rect,
882            label: "é".repeat(MAX_LABEL_LEN),
883            monitor: 0,
884            px: Shape::Rect(Rect::new(0, 0, 10, 10)),
885            global_px: Shape::Rect(Rect::new(0, 0, 10, 10)),
886            rot_deg: None,
887            window_px: None,
888            crop: "c.png".into(),
889            color: None,
890        });
891        assert_eq!(file.validate(), Ok(()), "64 characters, 128 bytes");
892    }
893
894    #[test]
895    fn a_valid_session_passes_validation() {
896        let file = SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
897        assert_eq!(file.validate(), Ok(()));
898    }
899
900    #[test]
901    fn a_scale_that_cannot_divide_is_refused() {
902        // The reported defect: `scale: 0` divided into an inf, which the
903        // float-to-int cast saturated into i32::MAX and reported as a
904        // successful click point.
905        for bad in [0.0, -2.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
906            let mut file =
907                SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
908            file.monitors[0].scale = bad;
909            let Err(SessionError::Scale { scale, index, .. }) = file.validate() else {
910                panic!("scale {bad} was accepted");
911            };
912            assert_eq!(index, 0);
913            // Bit-compare: the error must carry back the exact value it
914            // rejected, and NaN is not equal to itself.
915            assert_eq!(scale.to_bits(), bad.to_bits());
916        }
917    }
918
919    #[test]
920    fn a_positive_scale_below_one_is_fine() {
921        // Fractional scaling is unusual, not invalid — the check is
922        // "can this divide", not "is this a round number".
923        let mut file =
924            SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
925        file.monitors[0].scale = 0.75;
926        assert_eq!(file.validate(), Ok(()));
927    }
928
929    #[test]
930    fn an_empty_display_is_refused() {
931        for (w, h) in [(0, 1080), (1920, 0), (-1920, 1080)] {
932            let mut file =
933                SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
934            file.monitors[0].size_px = Size::new(w, h);
935            assert!(
936                matches!(file.validate(), Err(SessionError::MonitorSize { .. })),
937                "{w}x{h} was accepted"
938            );
939        }
940    }
941
942    #[test]
943    fn a_coordinate_past_the_bound_is_refused_wherever_it_hides() {
944        let far = MAX_COORD + 1;
945        let base =
946            || SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
947
948        let mut in_monitor = base();
949        in_monitor.monitors[0].origin_px = Point::new(far, 0);
950        assert!(matches!(
951            in_monitor.validate(),
952            Err(SessionError::Coordinate { .. })
953        ));
954
955        let mut in_selection = base();
956        in_selection.selections.push(SelectionRecord {
957            shape: ToolKind::Rect,
958            label: String::new(),
959            monitor: 0,
960            px: Shape::Rect(Rect::new(far, 0, 10, 10)),
961            global_px: Shape::Rect(Rect::new(0, 0, 10, 10)),
962            rot_deg: None,
963            window_px: None,
964            crop: "c.png".into(),
965            color: None,
966        });
967        assert!(matches!(
968            in_selection.validate(),
969            Err(SessionError::Coordinate { .. })
970        ));
971
972        let mut in_measure = base().with_measures(&[crate::selection::Measure::new(
973            crate::geometry::Line::new(Point::new(far, 0), Point::new(0, 0)),
974            0,
975        )]);
976        in_measure.monitors = vec![monitor(0, 0, 0)];
977        assert!(matches!(
978            in_measure.validate(),
979            Err(SessionError::Coordinate { .. })
980        ));
981    }
982
983    #[test]
984    fn the_bound_itself_is_allowed() {
985        // A boundary that rejects its own limit would be a silent
986        // off-by-one nobody would think to test for.
987        let mut file =
988            SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
989        file.monitors[0].origin_px = Point::new(MAX_COORD, -MAX_COORD);
990        assert_eq!(file.validate(), Ok(()));
991    }
992
993    #[test]
994    fn every_shape_kind_is_walked_for_coordinates() {
995        // `raw_values` matches on the variant, so a new shape kind that
996        // forgets to list its fields would silently stop being checked.
997        let far = MAX_COORD + 1;
998        let shapes = [
999            Shape::Rect(Rect::new(far, 0, 1, 1)),
1000            Shape::Circle {
1001                cx: far,
1002                cy: 0,
1003                r: 1,
1004            },
1005            Shape::Ellipse {
1006                cx: far,
1007                cy: 0,
1008                rx: 1,
1009                ry: 1,
1010            },
1011            Shape::Triangle {
1012                ax: far,
1013                ay: 0,
1014                bx: 1,
1015                by: 1,
1016                cx: 2,
1017                cy: 2,
1018            },
1019            Shape::Poly {
1020                points: vec![Point::new(far, 0), Point::new(1, 1), Point::new(2, 2)],
1021            },
1022        ];
1023        for shape in shapes {
1024            assert!(
1025                raw_values(&shape).iter().any(|v| v.abs() > MAX_COORD),
1026                "{shape:?} hid its out-of-range coordinate"
1027            );
1028        }
1029    }
1030
1031    #[test]
1032    fn restoring_a_session_without_measures_yields_none() {
1033        let file = SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None);
1034        assert!(restore_measures(&file).is_empty());
1035    }
1036
1037    #[test]
1038    fn a_session_with_measures_round_trips() {
1039        use crate::geometry::Line;
1040        use crate::selection::Measure;
1041        let file = SessionFile::build("test", "t".into(), vec![monitor(0, 0, 0)], &[], &[], None)
1042            .with_measures(&[Measure::new(
1043                Line::new(Point::new(1, 2), Point::new(3, 4)),
1044                0,
1045            )]);
1046        let text = serde_json::to_string(&file).unwrap();
1047        let back: SessionFile = serde_json::from_str(&text).unwrap();
1048        assert_eq!(back, file);
1049    }
1050
1051    fn monitor(index: usize, ox: i32, oy: i32) -> MonitorRecord {
1052        MonitorRecord {
1053            index,
1054            name: format!("Display {index}"),
1055            primary: index == 0,
1056            origin_px: Point::new(ox, oy),
1057            size_px: Size::new(1920, 1080),
1058            scale: 2.0,
1059        }
1060    }
1061
1062    /// A display identified by name, so tests can express "the same panel,
1063    /// enumerated somewhere else".
1064    fn panel(index: usize, name: &str, w: i32, h: i32, scale: f64) -> MonitorRecord {
1065        MonitorRecord {
1066            index,
1067            name: name.into(),
1068            primary: index == 0,
1069            origin_px: Point::new(0, 0),
1070            size_px: Size::new(w, h),
1071            scale,
1072        }
1073    }
1074
1075    #[test]
1076    fn a_replug_that_reorders_enumeration_still_finds_the_panel() {
1077        // The bug this matcher exists for: same two displays, swapped
1078        // enumeration order. Index-based lookup would hand back the wrong
1079        // panel — or nothing.
1080        let saved = panel(1, "DELL U2723QE", 3840, 2160, 1.0);
1081        let live = [
1082            panel(0, "DELL U2723QE", 3840, 2160, 1.0),
1083            panel(1, "Built-in Retina Display", 3600, 2338, 2.0),
1084        ];
1085        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(0));
1086    }
1087
1088    #[test]
1089    fn nothing_moved_resolves_to_the_recorded_index() {
1090        let saved = panel(1, "Built-in Retina Display", 3600, 2338, 2.0);
1091        let live = [
1092            panel(0, "DELL U2723QE", 3840, 2160, 1.0),
1093            panel(1, "Built-in Retina Display", 3600, 2338, 2.0),
1094        ];
1095        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
1096    }
1097
1098    #[test]
1099    fn identical_twins_break_toward_the_recorded_index_then_the_lowest() {
1100        let live = [
1101            panel(0, "DELL U2723QE", 3840, 2160, 1.0),
1102            panel(1, "DELL U2723QE", 3840, 2160, 1.0),
1103        ];
1104        // The recorded index is present among the twins, so it wins.
1105        let saved_one = panel(1, "DELL U2723QE", 3840, 2160, 1.0);
1106        assert_eq!(match_monitor(&saved_one, &live), MonitorMatch::Found(1));
1107
1108        // The recorded index is gone; the tie breaks toward the lowest,
1109        // deterministically rather than on enumeration luck.
1110        let saved_seven = panel(7, "DELL U2723QE", 3840, 2160, 1.0);
1111        assert_eq!(match_monitor(&saved_seven, &live), MonitorMatch::Found(0));
1112    }
1113
1114    #[test]
1115    fn the_lowest_index_wins_regardless_of_enumeration_order() {
1116        // Candidates are searched in slice order, but the tie-break is on
1117        // the recorded index — so a twin listed first does not win by
1118        // position alone.
1119        let saved = panel(9, "DELL U2723QE", 3840, 2160, 1.0);
1120        let live = [
1121            panel(3, "DELL U2723QE", 3840, 2160, 1.0),
1122            panel(1, "DELL U2723QE", 3840, 2160, 1.0),
1123        ];
1124        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
1125    }
1126
1127    #[test]
1128    fn a_resized_display_is_changed_not_missing() {
1129        // Template matching survives movement, not a resolution change —
1130        // but the user needs to hear "it changed", not "it is unplugged".
1131        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
1132        let live = [panel(0, "DELL U2723QE", 2560, 1440, 1.0)];
1133        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Changed(0));
1134    }
1135
1136    #[test]
1137    fn a_rescaled_display_is_changed_not_missing() {
1138        let saved = panel(0, "Built-in Retina Display", 3600, 2338, 2.0);
1139        let live = [panel(0, "Built-in Retina Display", 3600, 2338, 1.0)];
1140        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Changed(0));
1141    }
1142
1143    #[test]
1144    fn an_exact_match_beats_a_changed_one_of_the_same_name() {
1145        // Two panels share a name; one still matches the session exactly.
1146        // Identity must win over the recorded index.
1147        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
1148        let live = [
1149            panel(0, "DELL U2723QE", 2560, 1440, 1.0),
1150            panel(1, "DELL U2723QE", 3840, 2160, 1.0),
1151        ];
1152        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Found(1));
1153    }
1154
1155    #[test]
1156    fn an_absent_display_is_missing_even_when_something_else_fits() {
1157        // Same geometry, different panel: not the display the session used.
1158        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
1159        let live = [panel(0, "LG UltraFine", 3840, 2160, 1.0)];
1160        assert_eq!(match_monitor(&saved, &live), MonitorMatch::Missing);
1161    }
1162
1163    #[test]
1164    fn no_displays_at_all_is_missing() {
1165        let saved = panel(0, "DELL U2723QE", 3840, 2160, 1.0);
1166        assert_eq!(match_monitor(&saved, &[]), MonitorMatch::Missing);
1167    }
1168
1169    /// A session of labeled rects on monitor 0, in the given order.
1170    fn labeled(labels: &[&str]) -> SessionFile {
1171        let selections: Vec<Selection> = labels
1172            .iter()
1173            .map(|label| {
1174                let mut sel = Selection::new(Shape::Rect(Rect::new(0, 0, 10, 10)), 0);
1175                sel.label = (*label).to_string();
1176                sel
1177            })
1178            .collect();
1179        let crops: Vec<String> = (0..labels.len()).map(|i| format!("crop-{i}.png")).collect();
1180        SessionFile::build(
1181            "test",
1182            "2026-07-27T00:00:00Z".into(),
1183            vec![monitor(0, 0, 0)],
1184            &selections,
1185            &crops,
1186            None,
1187        )
1188    }
1189
1190    #[test]
1191    fn select_by_label_keeps_session_indices() {
1192        let file = labeled(&["submit", "cancel", "submit"]);
1193
1194        let all = select_by_label(&file, None);
1195        assert_eq!(all.len(), 3, "no label selects everything");
1196        assert_eq!(all.iter().map(|(i, _)| *i).collect::<Vec<_>>(), [0, 1, 2]);
1197
1198        // The index is the record's identity in the file, not its position
1199        // in the filtered result — every report row is keyed by it.
1200        let some = select_by_label(&file, Some("submit"));
1201        assert_eq!(some.iter().map(|(i, _)| *i).collect::<Vec<_>>(), [0, 2]);
1202    }
1203
1204    #[test]
1205    fn select_by_label_matches_case_insensitively_and_can_come_up_empty() {
1206        let file = labeled(&["Submit"]);
1207        assert_eq!(select_by_label(&file, Some("SUBMIT")).len(), 1);
1208        assert!(
1209            select_by_label(&file, Some("nope")).is_empty(),
1210            "an unmatched label is an empty result, not an error — the \
1211             caller decides how to refuse"
1212        );
1213    }
1214
1215    #[test]
1216    fn distinct_labels_dedupes_case_insensitively_and_drops_blanks() {
1217        let file = labeled(&["submit", "", "SUBMIT", "cancel"]);
1218        assert_eq!(
1219            distinct_labels(file.selections.iter()),
1220            ["submit", "cancel"],
1221            "first spelling wins, session order is kept, unlabeled \
1222             selections contribute nothing"
1223        );
1224    }
1225
1226    #[test]
1227    fn distinct_labels_reports_only_what_it_is_given() {
1228        // The iterator is the point: a monitor-space question lists the
1229        // labels on *that* monitor, not every label in the session.
1230        let file = labeled(&["submit", "cancel"]);
1231        let first_only = distinct_labels(file.selections.iter().take(1));
1232        assert_eq!(first_only, ["submit"]);
1233    }
1234
1235    #[test]
1236    fn global_is_origin_plus_local() {
1237        let mut sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 1);
1238        sel.label = "target".into();
1239        let file = SessionFile::build(
1240            "0.1.0",
1241            "2026-07-27T00:00:00Z".into(),
1242            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
1243            &[sel],
1244            &["crop-0-target.png".into()],
1245            None,
1246        );
1247        assert_eq!(
1248            file.selections[0].px,
1249            Shape::Rect(Rect::new(10, 20, 30, 40))
1250        );
1251        assert_eq!(
1252            file.selections[0].global_px,
1253            Shape::Rect(Rect::new(1930, 20, 30, 40))
1254        );
1255    }
1256
1257    #[test]
1258    fn json_shape_is_stable() {
1259        let sel = Selection::new(Shape::Circle { cx: 5, cy: 6, r: 7 }, 0);
1260        let file = SessionFile::build(
1261            "0.1.0",
1262            "2026-07-27T00:00:00Z".into(),
1263            vec![monitor(0, 0, 0)],
1264            &[sel],
1265            &["crop-0.png".into()],
1266            None,
1267        );
1268        let json = serde_json::to_value(&file).unwrap();
1269        assert_eq!(json["schema"], 1);
1270        assert_eq!(json["app"]["name"], "pixelcoords");
1271        assert_eq!(json["selections"][0]["shape"], "circle");
1272        assert_eq!(json["selections"][0]["px"]["cx"], 5);
1273        assert_eq!(json["selections"][0]["px"]["r"], 7);
1274        assert_eq!(json["monitors"][0]["scale"], 2.0);
1275    }
1276
1277    #[test]
1278    fn round_trips_through_json() {
1279        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
1280        let file = SessionFile::build(
1281            "0.1.0",
1282            "2026-07-27T00:00:00Z".into(),
1283            vec![monitor(0, 0, 0)],
1284            &[sel],
1285            &["c.png".into()],
1286            None,
1287        );
1288        let json = serde_json::to_string(&file).unwrap();
1289        let back: SessionFile = serde_json::from_str(&json).unwrap();
1290        assert_eq!(back, file);
1291    }
1292
1293    #[test]
1294    fn target_yields_window_relative_coords() {
1295        let on_target = Selection::new(Shape::Rect(Rect::new(500, 300, 40, 20)), 0);
1296        let elsewhere = Selection::new(Shape::Rect(Rect::new(1, 1, 5, 5)), 1);
1297        let target = TargetRecord {
1298            app: "Notepad".into(),
1299            title: "notes.txt".into(),
1300            monitor: 0,
1301            origin_px: Point::new(400, 250),
1302            size_px: Size::new(800, 600),
1303        };
1304        let file = SessionFile::build(
1305            "0.1.0",
1306            "2026-07-27T00:00:00Z".into(),
1307            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
1308            &[on_target, elsewhere],
1309            &["a.png".into(), "b.png".into()],
1310            Some(target),
1311        );
1312        assert_eq!(
1313            file.selections[0].window_px,
1314            Some(Shape::Rect(Rect::new(100, 50, 40, 20)))
1315        );
1316        assert_eq!(file.selections[1].window_px, None);
1317        assert_eq!(file.target.as_ref().unwrap().title, "notes.txt");
1318    }
1319
1320    #[test]
1321    fn rotation_is_metadata_for_rects_and_baked_for_triangles() {
1322        let mut rect_sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
1323        rect_sel.rot_deg = 45;
1324        let mut tri_sel = Selection::new(
1325            Shape::Triangle {
1326                ax: 200,
1327                ay: 100,
1328                bx: 100,
1329                by: 200,
1330                cx: 300,
1331                cy: 200,
1332            },
1333            0,
1334        );
1335        tri_sel.rot_deg = 180;
1336        let plain = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
1337
1338        let file = SessionFile::build(
1339            "0.1.0",
1340            "2026-07-27T00:00:00Z".into(),
1341            vec![monitor(0, 0, 0)],
1342            &[rect_sel, tri_sel, plain],
1343            &["a.png".into(), "b.png".into(), "c.png".into()],
1344            None,
1345        );
1346        // Rect: axis-aligned px + rot_deg metadata.
1347        assert_eq!(
1348            file.selections[0].px,
1349            Shape::Rect(Rect::new(10, 20, 30, 40))
1350        );
1351        assert_eq!(file.selections[0].rot_deg, Some(45));
1352        // Triangle: rotation baked into vertices, no rot_deg.
1353        assert_eq!(file.selections[1].rot_deg, None);
1354        assert_eq!(
1355            file.selections[1].px,
1356            Shape::Triangle {
1357                ax: 200,
1358                ay: 200,
1359                bx: 300,
1360                by: 100,
1361                cx: 100,
1362                cy: 100,
1363            }
1364        );
1365        // Unrotated: no rot_deg key at all in the JSON.
1366        let json = serde_json::to_value(&file).unwrap();
1367        assert!(json["selections"][2].get("rot_deg").is_none());
1368        assert_eq!(json["selections"][0]["rot_deg"], 45);
1369    }
1370
1371    #[test]
1372    fn untargeted_session_omits_target_fields_in_json() {
1373        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
1374        let file = SessionFile::build(
1375            "0.1.0",
1376            "2026-07-27T00:00:00Z".into(),
1377            vec![monitor(0, 0, 0)],
1378            &[sel],
1379            &["c.png".into()],
1380            None,
1381        );
1382        let json = serde_json::to_value(&file).unwrap();
1383        assert!(json.get("target").is_none());
1384        assert!(json["selections"][0].get("window_px").is_none());
1385    }
1386
1387    #[test]
1388    fn restore_then_rebuild_reproduces_every_selection_record() {
1389        // A rotated rect (metadata), a rotated triangle (baked), a circle,
1390        // and a label: build -> restore -> build must reproduce the
1391        // records exactly, which is what makes resume lossless.
1392        let mut rect_sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
1393        rect_sel.rot_deg = 45;
1394        rect_sel.label = "spun".into();
1395        let mut tri_sel = Selection::new(
1396            Shape::Triangle {
1397                ax: 200,
1398                ay: 100,
1399                bx: 100,
1400                by: 200,
1401                cx: 300,
1402                cy: 200,
1403            },
1404            1,
1405        );
1406        tri_sel.rot_deg = 90;
1407        let circle_sel = Selection::new(Shape::Circle { cx: 9, cy: 9, r: 5 }, 0);
1408
1409        let monitors = vec![monitor(0, 0, 0), monitor(1, 1920, 0)];
1410        let crops: Vec<String> = vec!["a.png".into(), "b.png".into(), "c.png".into()];
1411        let first = SessionFile::build(
1412            "test",
1413            "t".into(),
1414            monitors.clone(),
1415            &[rect_sel, tri_sel, circle_sel],
1416            &crops,
1417            None,
1418        );
1419        let (restored, _) = restore_selections(&first);
1420        let second = SessionFile::build("test", "t".into(), monitors, &restored, &crops, None);
1421        assert_eq!(first.selections, second.selections);
1422    }
1423
1424    #[test]
1425    fn provenance_is_optional_and_survives_round_trips() {
1426        let sel = Selection::new(Shape::Rect(Rect::new(1, 2, 3, 4)), 0);
1427        let file = SessionFile::build(
1428            "test",
1429            "t".into(),
1430            vec![monitor(0, 0, 0)],
1431            &[sel],
1432            &["c.png".into()],
1433            None,
1434        )
1435        .with_meta(
1436            Some("macos".into()),
1437            Some(CaptureKind::Desktop),
1438            Some("microsoft teams".into()),
1439        );
1440        let json = serde_json::to_value(&file).unwrap();
1441        assert_eq!(json["platform"], "macos");
1442        assert_eq!(json["capture"], "desktop");
1443        assert_eq!(json["name"], "microsoft teams");
1444        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
1445        assert_eq!(back, file);
1446
1447        // A session written before these fields existed still parses,
1448        // and one built without them omits the keys entirely.
1449        let old = r#"{"schema":1,"app":{"name":"pixelcoords","version":"0"},
1450            "created_utc":"t","monitors":[],"selections":[]}"#;
1451        let parsed: SessionFile = serde_json::from_str(old).unwrap();
1452        assert_eq!(parsed.platform, None);
1453        assert_eq!(parsed.capture, None);
1454        assert_eq!(parsed.name, None);
1455        let bare = SessionFile::build("test", "t".into(), vec![], &[], &[], None);
1456        let json = serde_json::to_value(&bare).unwrap();
1457        assert!(json.get("platform").is_none());
1458        assert!(json.get("capture").is_none());
1459        assert!(json.get("name").is_none());
1460    }
1461
1462    #[test]
1463    fn untagged_shape_deserializes_by_fields() {
1464        let rect: Shape = serde_json::from_str(r#"{"x":1,"y":2,"w":3,"h":4}"#).unwrap();
1465        assert_eq!(rect, Shape::Rect(Rect::new(1, 2, 3, 4)));
1466        let circle: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"r":3}"#).unwrap();
1467        assert_eq!(circle, Shape::Circle { cx: 1, cy: 2, r: 3 });
1468        let ellipse: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"rx":3,"ry":4}"#).unwrap();
1469        assert_eq!(
1470            ellipse,
1471            Shape::Ellipse {
1472                cx: 1,
1473                cy: 2,
1474                rx: 3,
1475                ry: 4,
1476            }
1477        );
1478    }
1479
1480    #[test]
1481    fn poly_records_serialize_their_vertices_and_round_trip() {
1482        let sel = Selection::new(
1483            Shape::Poly {
1484                points: vec![Point::new(1, 2), Point::new(9, 2), Point::new(5, 9)],
1485            },
1486            0,
1487        );
1488        let file = SessionFile::build(
1489            "test",
1490            "t".into(),
1491            vec![monitor(0, 0, 0)],
1492            &[sel],
1493            &["c.png".into()],
1494            None,
1495        );
1496        let json = serde_json::to_value(&file).unwrap();
1497        assert_eq!(json["selections"][0]["shape"], "poly");
1498        assert_eq!(json["selections"][0]["px"]["points"][2]["x"], 5);
1499        assert_eq!(
1500            json["selections"][0].get("rot_deg"),
1501            None,
1502            "poly rotation is baked, never metadata"
1503        );
1504        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
1505        assert_eq!(back, file);
1506    }
1507
1508    #[test]
1509    fn ellipse_records_carry_rotation_metadata_like_rects() {
1510        let mut sel = Selection::new(
1511            Shape::Ellipse {
1512                cx: 50,
1513                cy: 40,
1514                rx: 20,
1515                ry: 10,
1516            },
1517            0,
1518        );
1519        sel.rot_deg = 30;
1520        let file = SessionFile::build(
1521            "test",
1522            "t".into(),
1523            vec![monitor(0, 0, 0)],
1524            &[sel],
1525            &["c.png".into()],
1526            None,
1527        );
1528        assert_eq!(file.selections[0].rot_deg, Some(30));
1529        let json = serde_json::to_value(&file).unwrap();
1530        assert_eq!(json["selections"][0]["shape"], "ellipse");
1531        assert_eq!(json["selections"][0]["px"]["rx"], 20);
1532        let back: SessionFile = serde_json::from_str(&json.to_string()).unwrap();
1533        assert_eq!(back, file);
1534    }
1535}