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