Skip to main content

ferrisgrid_core/
lib.rs

1use std::collections::BTreeMap;
2use std::fmt::{self, Display};
3use std::fs::{self, OpenOptions};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::thread;
7use std::time::Duration;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10pub type Result<T> = std::result::Result<T, FerrisError>;
11
12#[derive(Debug, Clone)]
13pub struct FerrisError {
14    pub kind: ErrorKind,
15    pub message: String,
16}
17
18impl FerrisError {
19    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
20        Self {
21            kind,
22            message: message.into(),
23        }
24    }
25}
26
27impl Display for FerrisError {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        write!(f, "{}: {}", self.kind.as_str(), self.message)
30    }
31}
32
33impl std::error::Error for FerrisError {}
34
35impl From<std::io::Error> for FerrisError {
36    fn from(error: std::io::Error) -> Self {
37        Self::new(ErrorKind::Storage, error.to_string())
38    }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ErrorKind {
43    Capture,
44    Permission,
45    Coordinate,
46    Agent,
47    Protocol,
48    Execution,
49    Storage,
50    Platform,
51    UserInterrupt,
52}
53
54impl ErrorKind {
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Self::Capture => "capture_error",
58            Self::Permission => "permission_error",
59            Self::Coordinate => "coordinate_error",
60            Self::Agent => "agent_error",
61            Self::Protocol => "protocol_error",
62            Self::Execution => "execution_error",
63            Self::Storage => "storage_error",
64            Self::Platform => "platform_error",
65            Self::UserInterrupt => "user_interrupt",
66        }
67    }
68}
69
70#[derive(Debug, Clone, PartialEq)]
71pub struct ScreenInfo {
72    pub screen_id: String,
73    pub name: String,
74    pub is_primary: bool,
75    pub origin_x: i32,
76    pub origin_y: i32,
77    pub native_width: u32,
78    pub native_height: u32,
79    pub scale_factor: f32,
80}
81
82#[derive(Debug, Clone, PartialEq)]
83pub struct CapturedScreen {
84    pub screen: ScreenInfo,
85    pub image_width: u32,
86    pub image_height: u32,
87    pub screenshot_path: PathBuf,
88    pub metadata_path: PathBuf,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum CoordinateMode {
93    Normalized1000,
94    ImagePixels,
95    NativePixels,
96}
97
98impl CoordinateMode {
99    pub fn as_str(self) -> &'static str {
100        match self {
101            Self::Normalized1000 => "normalized-1000",
102            Self::ImagePixels => "image-pixels",
103            Self::NativePixels => "native-pixels",
104        }
105    }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum CaptureTarget {
110    All,
111    Screen(String),
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum ImageFormat {
116    Jpg,
117    Png,
118}
119
120impl ImageFormat {
121    pub fn extension(&self) -> &'static str {
122        match self {
123            Self::Jpg => "jpg",
124            Self::Png => "png",
125        }
126    }
127}
128
129impl std::str::FromStr for ImageFormat {
130    type Err = FerrisError;
131
132    fn from_str(value: &str) -> Result<Self> {
133        match value {
134            "jpg" | "jpeg" => Ok(Self::Jpg),
135            "png" => Ok(Self::Png),
136            other => Err(FerrisError::new(
137                ErrorKind::Protocol,
138                format!("unsupported image format: {other}"),
139            )),
140        }
141    }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum ImageSizeLimit {
146    Native,
147    FixedMaxEdge(u32),
148    Adaptive {
149        min_long_edge: u32,
150        min_short_edge: u32,
151    },
152}
153
154impl ImageSizeLimit {
155    pub fn description(self) -> String {
156        match self {
157            Self::Native => "native".to_string(),
158            Self::FixedMaxEdge(edge) => edge.to_string(),
159            Self::Adaptive {
160                min_long_edge,
161                min_short_edge,
162            } => {
163                format!("adaptive min_long_edge={min_long_edge} min_short_edge={min_short_edge}")
164            }
165        }
166    }
167}
168
169#[derive(Debug, Clone)]
170pub struct ObserveRequest {
171    pub output_dir: PathBuf,
172    pub session: Option<String>,
173    pub screen_id: Option<String>,
174    pub format: ImageFormat,
175    pub grid_overlay: bool,
176    pub image_size_limit: ImageSizeLimit,
177}
178
179#[derive(Debug, Clone)]
180pub struct ObserveResult {
181    pub session_dir: PathBuf,
182    pub step: u32,
183    pub coordinate_mode: CoordinateMode,
184    pub image_size_limit: ImageSizeLimit,
185    pub screens: Vec<CapturedScreen>,
186}
187
188#[derive(Debug, Clone)]
189pub struct ActRequest {
190    pub output_dir: PathBuf,
191    pub session: Option<String>,
192    pub input_markdown: String,
193    pub dry_run: bool,
194    pub format: ImageFormat,
195    pub image_size_limit: ImageSizeLimit,
196}
197
198#[derive(Debug, Clone)]
199pub struct ActResult {
200    pub session_dir: PathBuf,
201    pub step: u32,
202    pub action_summary: String,
203    pub wait_after_ms: u64,
204    pub result: String,
205    pub dry_run: bool,
206    pub image_size_limit: ImageSizeLimit,
207    pub screens: Vec<CapturedScreen>,
208}
209
210#[derive(Debug, Clone)]
211pub struct ActionErrorResult {
212    pub session_dir: Option<PathBuf>,
213    pub step: Option<u32>,
214    pub error_type: String,
215    pub reason: String,
216    pub available_screens: Vec<CapturedScreen>,
217}
218
219#[derive(Debug, Clone)]
220pub struct DoctorReport {
221    pub os: String,
222    pub capture: String,
223    pub input: String,
224    pub output_dir: String,
225    pub screens: Vec<ScreenInfo>,
226    pub ffmpeg: String,
227}
228
229#[derive(Debug, Clone, PartialEq)]
230pub struct AgentAction {
231    pub status: ActionStatus,
232    pub kind: Option<ActionKind>,
233    pub wait_after_ms: Option<u64>,
234    pub confidence: Option<f32>,
235    pub reason: Option<String>,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum ActionStatus {
240    Action,
241    Done,
242    Fail,
243}
244
245#[derive(Debug, Clone, PartialEq)]
246pub enum ActionKind {
247    Click {
248        screen_id: Option<String>,
249        x: i32,
250        y: i32,
251        button: MouseButton,
252    },
253    DoubleClick {
254        screen_id: Option<String>,
255        x: i32,
256        y: i32,
257        button: MouseButton,
258    },
259    RightClick {
260        screen_id: Option<String>,
261        x: i32,
262        y: i32,
263    },
264    MoveMouse {
265        screen_id: Option<String>,
266        x: i32,
267        y: i32,
268    },
269    Drag {
270        screen_id: Option<String>,
271        from_x: i32,
272        from_y: i32,
273        to_x: i32,
274        to_y: i32,
275        duration_ms: u64,
276        button: MouseButton,
277    },
278    Scroll {
279        screen_id: Option<String>,
280        x: Option<i32>,
281        y: Option<i32>,
282        delta_x: i32,
283        delta_y: i32,
284    },
285    Type {
286        text: String,
287    },
288    PressKey {
289        key: String,
290    },
291    Hotkey {
292        keys: Vec<String>,
293    },
294    Wait {
295        duration_ms: u64,
296    },
297}
298
299impl ActionKind {
300    pub fn screen_id(&self) -> Option<&str> {
301        match self {
302            Self::Click { screen_id, .. }
303            | Self::DoubleClick { screen_id, .. }
304            | Self::RightClick { screen_id, .. }
305            | Self::MoveMouse { screen_id, .. }
306            | Self::Drag { screen_id, .. }
307            | Self::Scroll { screen_id, .. } => screen_id.as_deref(),
308            Self::Type { .. } | Self::PressKey { .. } | Self::Hotkey { .. } | Self::Wait { .. } => {
309                None
310            }
311        }
312    }
313
314    pub fn with_screen_id(self, resolved: Option<String>) -> Self {
315        match self {
316            Self::Click { x, y, button, .. } => Self::Click {
317                screen_id: resolved,
318                x,
319                y,
320                button,
321            },
322            Self::DoubleClick { x, y, button, .. } => Self::DoubleClick {
323                screen_id: resolved,
324                x,
325                y,
326                button,
327            },
328            Self::RightClick { x, y, .. } => Self::RightClick {
329                screen_id: resolved,
330                x,
331                y,
332            },
333            Self::MoveMouse { x, y, .. } => Self::MoveMouse {
334                screen_id: resolved,
335                x,
336                y,
337            },
338            Self::Drag {
339                from_x,
340                from_y,
341                to_x,
342                to_y,
343                duration_ms,
344                button,
345                ..
346            } => Self::Drag {
347                screen_id: resolved,
348                from_x,
349                from_y,
350                to_x,
351                to_y,
352                duration_ms,
353                button,
354            },
355            Self::Scroll {
356                x,
357                y,
358                delta_x,
359                delta_y,
360                ..
361            } => Self::Scroll {
362                screen_id: resolved,
363                x,
364                y,
365                delta_x,
366                delta_y,
367            },
368            other => other,
369        }
370    }
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum MouseButton {
375    Left,
376    Right,
377    Middle,
378}
379
380impl MouseButton {
381    pub fn as_str(self) -> &'static str {
382        match self {
383            Self::Left => "left",
384            Self::Right => "right",
385            Self::Middle => "middle",
386        }
387    }
388}
389
390#[derive(Debug, Clone, PartialEq)]
391pub enum NativeAction {
392    Click {
393        x: i32,
394        y: i32,
395        button: MouseButton,
396    },
397    DoubleClick {
398        x: i32,
399        y: i32,
400        button: MouseButton,
401    },
402    RightClick {
403        x: i32,
404        y: i32,
405    },
406    MoveMouse {
407        x: i32,
408        y: i32,
409    },
410    Drag {
411        from_x: i32,
412        from_y: i32,
413        to_x: i32,
414        to_y: i32,
415        duration_ms: u64,
416        button: MouseButton,
417    },
418    Scroll {
419        x: Option<i32>,
420        y: Option<i32>,
421        delta_x: i32,
422        delta_y: i32,
423    },
424    Type {
425        text: String,
426    },
427    PressKey {
428        key: String,
429    },
430    Hotkey {
431        keys: Vec<String>,
432    },
433    Wait {
434        duration_ms: u64,
435    },
436}
437
438#[derive(Debug, Clone)]
439pub struct InputExecution {
440    pub summary: String,
441}
442
443#[derive(Debug, Clone)]
444pub struct InputCapabilities {
445    pub can_mouse: bool,
446    pub can_keyboard: bool,
447}
448
449pub trait CaptureBackend {
450    fn name(&self) -> &'static str;
451    fn list_screens(&self) -> Result<Vec<ScreenInfo>>;
452    fn capture(
453        &self,
454        target: CaptureTarget,
455        frame_dir: &Path,
456        format: &ImageFormat,
457        grid_overlay: bool,
458        image_size_limit: ImageSizeLimit,
459    ) -> Result<Vec<CapturedScreen>>;
460}
461
462pub trait InputBackend {
463    fn name(&self) -> &'static str;
464    fn capabilities(&self) -> InputCapabilities;
465    fn execute(&self, action: &NativeAction) -> Result<InputExecution>;
466}
467
468#[derive(Debug, Clone)]
469pub struct SessionStore {
470    root: PathBuf,
471}
472
473impl SessionStore {
474    pub fn new(root: impl Into<PathBuf>) -> Self {
475        Self { root: root.into() }
476    }
477
478    pub fn root(&self) -> &Path {
479        &self.root
480    }
481
482    pub fn ensure_root(&self) -> Result<()> {
483        fs::create_dir_all(self.root.join("sessions"))?;
484        let config = self.root.join("config.toml");
485        if !config.exists() {
486            fs::write(
487                &config,
488                "default_output_dir = \".ferrisgrid\"\nstorage_mode = \"all\"\n",
489            )?;
490        }
491        Ok(())
492    }
493
494    pub fn resolve_session(
495        &self,
496        requested: Option<&str>,
497        create_if_missing: bool,
498    ) -> Result<PathBuf> {
499        self.ensure_root()?;
500        if let Some(value) = requested {
501            let path = PathBuf::from(value);
502            let session_dir = if path.exists() || value.contains('/') {
503                path
504            } else {
505                self.root.join("sessions").join(value)
506            };
507            if session_dir.exists() || create_if_missing {
508                self.ensure_session_dirs(&session_dir)?;
509                return Ok(session_dir);
510            }
511            return Err(FerrisError::new(
512                ErrorKind::Storage,
513                format!("session not found: {}", session_dir.display()),
514            ));
515        }
516
517        if let Some(latest) = self.latest_session()? {
518            return Ok(latest);
519        }
520
521        if create_if_missing {
522            return self.create_session();
523        }
524
525        Err(FerrisError::new(
526            ErrorKind::Storage,
527            "no existing session; run ferrisgrid observe first or pass --session",
528        ))
529    }
530
531    pub fn create_session(&self) -> Result<PathBuf> {
532        self.ensure_root()?;
533        let session_id = new_session_id();
534        let session_dir = self.root.join("sessions").join(session_id);
535        self.ensure_session_dirs(&session_dir)?;
536        Ok(session_dir)
537    }
538
539    pub fn latest_session(&self) -> Result<Option<PathBuf>> {
540        let sessions_dir = self.root.join("sessions");
541        if !sessions_dir.exists() {
542            return Ok(None);
543        }
544        let mut entries = Vec::new();
545        for entry in fs::read_dir(sessions_dir)? {
546            let entry = entry?;
547            if entry.file_type()?.is_dir() {
548                entries.push(entry.path());
549            }
550        }
551        entries.sort();
552        Ok(entries.pop())
553    }
554
555    pub fn next_step(&self, session_dir: &Path) -> Result<u32> {
556        let frames = session_dir.join("frames");
557        fs::create_dir_all(&frames)?;
558        let mut max_step = 0;
559        for entry in fs::read_dir(frames)? {
560            let entry = entry?;
561            if !entry.file_type()?.is_dir() {
562                continue;
563            }
564            if let Some(name) = entry.file_name().to_str() {
565                if let Ok(step) = name.parse::<u32>() {
566                    max_step = max_step.max(step);
567                }
568            }
569        }
570        Ok(max_step + 1)
571    }
572
573    pub fn frame_dir(&self, session_dir: &Path, step: u32) -> Result<PathBuf> {
574        let dir = session_dir.join("frames").join(format!("{step:06}"));
575        fs::create_dir_all(&dir)?;
576        Ok(dir)
577    }
578
579    pub fn write_manifest_if_missing(&self, session_dir: &Path) -> Result<()> {
580        let manifest = session_dir.join("manifest.md");
581        if !manifest.exists() {
582            fs::write(
583                manifest,
584                format!(
585                    "## FerrisGrid Session\n- session_id: {}\n- created_at_unix_ms: {}\n",
586                    session_dir
587                        .file_name()
588                        .and_then(|value| value.to_str())
589                        .unwrap_or("unknown"),
590                    unix_millis()
591                ),
592            )?;
593        }
594        Ok(())
595    }
596
597    pub fn append_event(&self, session_dir: &Path, line: impl AsRef<str>) -> Result<()> {
598        let mut file = OpenOptions::new()
599            .create(true)
600            .append(true)
601            .open(session_dir.join("events.md"))?;
602        writeln!(file, "- {}", line.as_ref())?;
603        Ok(())
604    }
605
606    pub fn write_action_files(
607        &self,
608        session_dir: &Path,
609        step: u32,
610        request: &str,
611        parsed: &str,
612        result: &str,
613    ) -> Result<()> {
614        let actions = session_dir.join("actions");
615        fs::create_dir_all(&actions)?;
616        fs::write(
617            actions.join(format!("{step:06}.md")),
618            format!(
619                "## FerrisGrid Action\n- step: {step}\n\n### Request\n```text\n{}\n```\n\n### Parsed\n```text\n{}\n```\n\n### Result\n```text\n{}\n```\n",
620                request.trim(),
621                parsed.trim(),
622                result.trim()
623            ),
624        )?;
625        Ok(())
626    }
627
628    fn ensure_session_dirs(&self, session_dir: &Path) -> Result<()> {
629        fs::create_dir_all(session_dir.join("frames"))?;
630        self.write_manifest_if_missing(session_dir)?;
631        Ok(())
632    }
633}
634
635pub fn observe(request: ObserveRequest, capture: &dyn CaptureBackend) -> Result<ObserveResult> {
636    let store = SessionStore::new(request.output_dir);
637    let session_dir = store.resolve_session(request.session.as_deref(), true)?;
638    let step = store.next_step(&session_dir)?;
639    let frame_dir = store.frame_dir(&session_dir, step)?;
640    let target = match request.screen_id {
641        Some(id) => CaptureTarget::Screen(resolve_primary_alias(&id, &capture.list_screens()?)),
642        None => CaptureTarget::All,
643    };
644    let screens = match capture.capture(
645        target,
646        &frame_dir,
647        &request.format,
648        request.grid_overlay,
649        request.image_size_limit,
650    ) {
651        Ok(screens) => screens,
652        Err(error) => {
653            remove_empty_dir(&frame_dir);
654            return Err(error);
655        }
656    };
657    store.append_event(
658        &session_dir,
659        format!(
660            "{} frame_captured step={} screens={}",
661            unix_millis(),
662            step,
663            screens.len()
664        ),
665    )?;
666    Ok(ObserveResult {
667        session_dir,
668        step,
669        coordinate_mode: CoordinateMode::Normalized1000,
670        image_size_limit: request.image_size_limit,
671        screens,
672    })
673}
674
675pub fn act(
676    request: ActRequest,
677    capture: &dyn CaptureBackend,
678    input: &dyn InputBackend,
679) -> std::result::Result<ActResult, ActionErrorResult> {
680    match act_inner(request, capture, input) {
681        Ok(result) => Ok(result),
682        Err((error, context)) => Err(renderable_error(error, context)),
683    }
684}
685
686fn act_inner(
687    request: ActRequest,
688    capture: &dyn CaptureBackend,
689    input: &dyn InputBackend,
690) -> std::result::Result<ActResult, (FerrisError, ErrorContext)> {
691    let store = SessionStore::new(request.output_dir);
692    let session_dir = store
693        .resolve_session(request.session.as_deref(), false)
694        .map_err(|error| (error, ErrorContext::default()))?;
695    let step = store
696        .next_step(&session_dir)
697        .map_err(|error| (error, ErrorContext::with_session(session_dir.clone())))?;
698    let action = parse_action_block(&request.input_markdown)
699        .map_err(|error| (error, ErrorContext::with_session(session_dir.clone())))?;
700
701    if action.status == ActionStatus::Done || action.status == ActionStatus::Fail {
702        let result = if action.status == ActionStatus::Done {
703            "done"
704        } else {
705            "fail"
706        };
707        store
708            .write_action_files(
709                &session_dir,
710                step,
711                &request.input_markdown,
712                &format!("{action:?}"),
713                result,
714            )
715            .map_err(|error| (error, ErrorContext::with_session(session_dir.clone())))?;
716        return Ok(ActResult {
717            session_dir,
718            step,
719            action_summary: result.to_string(),
720            wait_after_ms: 0,
721            result: result.to_string(),
722            dry_run: request.dry_run,
723            image_size_limit: request.image_size_limit,
724            screens: Vec::new(),
725        });
726    }
727
728    let kind = action.kind.clone().ok_or_else(|| {
729        (
730            FerrisError::new(
731                ErrorKind::Protocol,
732                "status action requires an action field",
733            ),
734            ErrorContext::with_session(session_dir.clone()),
735        )
736    })?;
737
738    validate_policy(&kind)
739        .map_err(|error| (error, ErrorContext::with_session(session_dir.clone())))?;
740    let screens = capture
741        .list_screens()
742        .map_err(|error| (error, ErrorContext::with_session(session_dir.clone())))?;
743    let resolved_screen = resolve_action_screen(kind.screen_id(), &screens).map_err(|error| {
744        let mut context = ErrorContext::with_session(session_dir.clone());
745        context.available_screens = capture_latest_screens(
746            &store,
747            &session_dir,
748            step,
749            capture,
750            &request.format,
751            request.image_size_limit,
752        )
753        .unwrap_or_default();
754        (error, context)
755    })?;
756
757    let resolved_kind = kind.with_screen_id(
758        resolved_screen
759            .as_ref()
760            .map(|screen| screen.screen_id.clone()),
761    );
762    let native = to_native_action(&resolved_kind, resolved_screen)
763        .map_err(|error| (error, ErrorContext::with_session(session_dir.clone())))?;
764
765    let execution = if request.dry_run {
766        InputExecution {
767            summary: "dry_run".to_string(),
768        }
769    } else {
770        input
771            .execute(&native)
772            .map_err(|error| (error, ErrorContext::with_session(session_dir.clone())))?
773    };
774
775    let wait_after_ms = action.wait_after_ms.unwrap_or(0);
776    if wait_after_ms > 0 && !request.dry_run {
777        thread::sleep(Duration::from_millis(wait_after_ms));
778    }
779
780    let frame_dir = store
781        .frame_dir(&session_dir, step)
782        .map_err(|error| (error, ErrorContext::with_session(session_dir.clone())))?;
783    let target = match resolved_screen {
784        Some(screen) => CaptureTarget::Screen(screen.screen_id.clone()),
785        None => CaptureTarget::All,
786    };
787    let captured = match capture.capture(
788        target,
789        &frame_dir,
790        &request.format,
791        true,
792        request.image_size_limit,
793    ) {
794        Ok(captured) => captured,
795        Err(error) => {
796            remove_empty_dir(&frame_dir);
797            return Err((error, ErrorContext::with_session(session_dir.clone())));
798        }
799    };
800    let summary = action_summary(&resolved_kind);
801    let parsed_summary = action_summary_with_wait_after(&resolved_kind, wait_after_ms);
802    let result_text = if request.dry_run {
803        "dry_run"
804    } else {
805        "success"
806    };
807    store
808        .write_action_files(
809            &session_dir,
810            step,
811            &request.input_markdown,
812            &parsed_summary,
813            &execution.summary,
814        )
815        .map_err(|error| (error, ErrorContext::with_session(session_dir.clone())))?;
816    store
817        .append_event(
818            &session_dir,
819            format!(
820                "{} action_executed step={} action={} wait_after_ms={} result={}",
821                unix_millis(),
822                step,
823                summary,
824                wait_after_ms,
825                result_text
826            ),
827        )
828        .map_err(|error| (error, ErrorContext::with_session(session_dir.clone())))?;
829
830    Ok(ActResult {
831        session_dir,
832        step,
833        action_summary: summary,
834        wait_after_ms,
835        result: result_text.to_string(),
836        dry_run: request.dry_run,
837        image_size_limit: request.image_size_limit,
838        screens: captured,
839    })
840}
841
842#[derive(Default)]
843struct ErrorContext {
844    session_dir: Option<PathBuf>,
845    step: Option<u32>,
846    available_screens: Vec<CapturedScreen>,
847}
848
849impl ErrorContext {
850    fn with_session(session_dir: PathBuf) -> Self {
851        Self {
852            session_dir: Some(session_dir),
853            step: None,
854            available_screens: Vec::new(),
855        }
856    }
857}
858
859fn renderable_error(error: FerrisError, context: ErrorContext) -> ActionErrorResult {
860    ActionErrorResult {
861        session_dir: context.session_dir,
862        step: context.step,
863        error_type: error.kind.as_str().to_string(),
864        reason: error.message,
865        available_screens: context.available_screens,
866    }
867}
868
869fn capture_latest_screens(
870    store: &SessionStore,
871    session_dir: &Path,
872    step: u32,
873    capture: &dyn CaptureBackend,
874    format: &ImageFormat,
875    image_size_limit: ImageSizeLimit,
876) -> Result<Vec<CapturedScreen>> {
877    let frame_dir = store.frame_dir(session_dir, step)?;
878    capture.capture(
879        CaptureTarget::All,
880        &frame_dir,
881        format,
882        true,
883        image_size_limit,
884    )
885}
886
887fn remove_empty_dir(path: &Path) {
888    let _ = fs::remove_dir(path);
889}
890
891pub fn render_observation(result: &ObserveResult) -> String {
892    let mut out = String::new();
893    out.push_str("## FerrisGrid Observation\n");
894    out.push_str(&format!("- session: {}\n", result.session_dir.display()));
895    out.push_str(&format!("- step: {}\n", result.step));
896    out.push_str(&format!(
897        "- coordinate_mode: {}\n",
898        result.coordinate_mode.as_str()
899    ));
900    out.push_str(&format!(
901        "- image_size_limit: {}\n",
902        result.image_size_limit.description()
903    ));
904    out.push_str("- coordinate_range: x=0..1000 y=0..1000 origin=top_left scope=screen_local\n");
905    out.push_str(
906        "- action_coordinates: use these x/y values with ferrisgrid act; include screen_id when more than one screen is listed\n",
907    );
908    out.push_str(&format!("- screens: {}\n", result.screens.len()));
909    for screen in &result.screens {
910        out.push_str(&format!(
911            "- screen: {} primary={} image={}x{} native={}x{} origin={},{} coords=x:0..1000,y:0..1000 screenshot={} metadata={}\n",
912            screen.screen.screen_id,
913            screen.screen.is_primary,
914            screen.image_width,
915            screen.image_height,
916            screen.screen.native_width,
917            screen.screen.native_height,
918            screen.screen.origin_x,
919            screen.screen.origin_y,
920            screen.screenshot_path.display(),
921            screen.metadata_path.display()
922        ));
923        out.push_str(&format!(
924            "- map: {} image_x=round(x/1000*{}) image_y=round(y/1000*{}) native_x={}+round(x/1000*{}) native_y={}+round(y/1000*{})\n",
925            screen.screen.screen_id,
926            screen.image_width.saturating_sub(1),
927            screen.image_height.saturating_sub(1),
928            screen.screen.origin_x,
929            screen.screen.native_width,
930            screen.screen.origin_y,
931            screen.screen.native_height
932        ));
933    }
934    out
935}
936
937pub fn render_action_result(result: &ActResult) -> String {
938    let mut out = String::new();
939    out.push_str("## FerrisGrid Action Result\n");
940    out.push_str(&format!("- session: {}\n", result.session_dir.display()));
941    out.push_str(&format!("- step: {}\n", result.step));
942    out.push_str(&format!("- action: {}\n", result.action_summary));
943    if result.wait_after_ms > 0 {
944        out.push_str(&format!("- wait_after_ms: {}\n", result.wait_after_ms));
945    }
946    out.push_str(&format!("- result: {}\n", result.result));
947    out.push_str("- coordinate_mode: normalized-1000\n");
948    out.push_str(&format!(
949        "- image_size_limit: {}\n",
950        result.image_size_limit.description()
951    ));
952    out.push_str("- coordinate_range: x=0..1000 y=0..1000 origin=top_left scope=screen_local\n");
953    for screen in &result.screens {
954        out.push_str(&format!(
955            "- screen: {} image={}x{} native={}x{} screenshot={} metadata={}\n",
956            screen.screen.screen_id,
957            screen.image_width,
958            screen.image_height,
959            screen.screen.native_width,
960            screen.screen.native_height,
961            screen.screenshot_path.display(),
962            screen.metadata_path.display()
963        ));
964    }
965    out
966}
967
968pub fn render_action_error(error: &ActionErrorResult) -> String {
969    let mut out = String::new();
970    out.push_str("## FerrisGrid Action Error\n");
971    out.push_str(&format!("- type: {}\n", error.error_type));
972    out.push_str("- result: rejected\n");
973    out.push_str(&format!("- reason: {}\n", error.reason));
974    if let Some(session) = &error.session_dir {
975        out.push_str(&format!("- session: {}\n", session.display()));
976    }
977    for screen in &error.available_screens {
978        out.push_str(&format!(
979            "- available_screen: {} coords=x:0..1000,y:0..1000 screenshot={} metadata={}\n",
980            screen.screen.screen_id,
981            screen.screenshot_path.display(),
982            screen.metadata_path.display()
983        ));
984    }
985    out
986}
987
988pub fn render_doctor(report: &DoctorReport) -> String {
989    let mut out = String::new();
990    out.push_str("## FerrisGrid Doctor\n");
991    out.push_str(&format!("- os: {}\n", report.os));
992    out.push_str(&format!("- capture: {}\n", report.capture));
993    out.push_str(&format!("- input: {}\n", report.input));
994    out.push_str(&format!("- output_directory: {}\n", report.output_dir));
995    out.push_str(&format!("- screens: {}\n", report.screens.len()));
996    for screen in &report.screens {
997        out.push_str(&format!(
998            "- screen: {} primary={} origin={},{} native={}x{} scale={}\n",
999            screen.screen_id,
1000            screen.is_primary,
1001            screen.origin_x,
1002            screen.origin_y,
1003            screen.native_width,
1004            screen.native_height,
1005            screen.scale_factor
1006        ));
1007    }
1008    out.push_str(&format!("- ffmpeg: {}\n", report.ffmpeg));
1009    out
1010}
1011
1012pub fn parse_action_block(markdown: &str) -> Result<AgentAction> {
1013    let trimmed = markdown.trim();
1014    if trimmed.is_empty() {
1015        return Err(FerrisError::new(ErrorKind::Protocol, "empty action input"));
1016    }
1017    if trimmed.starts_with('{') || trimmed.starts_with('[') {
1018        return Err(FerrisError::new(
1019            ErrorKind::Protocol,
1020            "JSON action input is not supported; use compact Markdown",
1021        ));
1022    }
1023
1024    let mut fields = BTreeMap::new();
1025    for line in trimmed.lines() {
1026        let line = line.trim();
1027        if line.is_empty() || line.starts_with('#') {
1028            continue;
1029        }
1030        let Some((key, value)) = line.split_once(':') else {
1031            return Err(FerrisError::new(
1032                ErrorKind::Protocol,
1033                format!("invalid action line, expected key: value: {line}"),
1034            ));
1035        };
1036        fields.insert(key.trim().to_string(), value.trim().to_string());
1037    }
1038    if fields.is_empty() {
1039        return Err(FerrisError::new(
1040            ErrorKind::Protocol,
1041            "action input must contain compact Markdown key/value lines",
1042        ));
1043    }
1044
1045    let status = match fields.get("status").map(String::as_str).unwrap_or("action") {
1046        "action" => ActionStatus::Action,
1047        "done" => ActionStatus::Done,
1048        "fail" => ActionStatus::Fail,
1049        other => {
1050            return Err(FerrisError::new(
1051                ErrorKind::Protocol,
1052                format!("unsupported status: {other}"),
1053            ));
1054        }
1055    };
1056    let confidence = match fields.get("confidence") {
1057        Some(value) => Some(parse_f32(value, "confidence")?),
1058        None => None,
1059    };
1060    let reason = fields.get("reason").cloned();
1061    let wait_after_ms = match fields.get("wait_after_ms") {
1062        Some(value) => Some(parse_u64(value, "wait_after_ms")?),
1063        None => None,
1064    };
1065    validate_wait_after(wait_after_ms)?;
1066    let kind = if status == ActionStatus::Action {
1067        Some(parse_action_kind(&fields)?)
1068    } else {
1069        None
1070    };
1071
1072    Ok(AgentAction {
1073        status,
1074        kind,
1075        wait_after_ms,
1076        confidence,
1077        reason,
1078    })
1079}
1080
1081fn validate_wait_after(wait_after_ms: Option<u64>) -> Result<()> {
1082    if let Some(wait_after_ms) = wait_after_ms {
1083        if wait_after_ms > 30_000 {
1084            return Err(FerrisError::new(
1085                ErrorKind::Protocol,
1086                "wait_after_ms exceeds 30000 ms",
1087            ));
1088        }
1089    }
1090    Ok(())
1091}
1092
1093fn parse_action_kind(fields: &BTreeMap<String, String>) -> Result<ActionKind> {
1094    let action = required(fields, "action")?;
1095    let screen_id = fields.get("screen_id").cloned();
1096    match action.as_str() {
1097        "click" => Ok(ActionKind::Click {
1098            screen_id,
1099            x: parse_i32_required(fields, "x")?,
1100            y: parse_i32_required(fields, "y")?,
1101            button: parse_button(fields.get("button").map(String::as_str).unwrap_or("left"))?,
1102        }),
1103        "double_click" => Ok(ActionKind::DoubleClick {
1104            screen_id,
1105            x: parse_i32_required(fields, "x")?,
1106            y: parse_i32_required(fields, "y")?,
1107            button: parse_button(fields.get("button").map(String::as_str).unwrap_or("left"))?,
1108        }),
1109        "right_click" => Ok(ActionKind::RightClick {
1110            screen_id,
1111            x: parse_i32_required(fields, "x")?,
1112            y: parse_i32_required(fields, "y")?,
1113        }),
1114        "move_mouse" => Ok(ActionKind::MoveMouse {
1115            screen_id,
1116            x: parse_i32_required(fields, "x")?,
1117            y: parse_i32_required(fields, "y")?,
1118        }),
1119        "drag" => Ok(ActionKind::Drag {
1120            screen_id,
1121            from_x: parse_i32_required(fields, "from_x")?,
1122            from_y: parse_i32_required(fields, "from_y")?,
1123            to_x: parse_i32_required(fields, "to_x")?,
1124            to_y: parse_i32_required(fields, "to_y")?,
1125            duration_ms: parse_u64(
1126                fields
1127                    .get("duration_ms")
1128                    .map(String::as_str)
1129                    .unwrap_or("450"),
1130                "duration_ms",
1131            )?,
1132            button: parse_button(fields.get("button").map(String::as_str).unwrap_or("left"))?,
1133        }),
1134        "scroll" => Ok(ActionKind::Scroll {
1135            screen_id,
1136            x: parse_i32_optional(fields.get("x").map(String::as_str), "x")?,
1137            y: parse_i32_optional(fields.get("y").map(String::as_str), "y")?,
1138            delta_x: parse_i32_optional(fields.get("delta_x").map(String::as_str), "delta_x")?
1139                .unwrap_or(0),
1140            delta_y: parse_i32_required(fields, "delta_y")?,
1141        }),
1142        "type" => Ok(ActionKind::Type {
1143            text: required(fields, "text")?,
1144        }),
1145        "press_key" => Ok(ActionKind::PressKey {
1146            key: required(fields, "key")?,
1147        }),
1148        "hotkey" => Ok(ActionKind::Hotkey {
1149            keys: required(fields, "keys")?
1150                .split('+')
1151                .map(str::trim)
1152                .filter(|value| !value.is_empty())
1153                .map(ToOwned::to_owned)
1154                .collect(),
1155        }),
1156        "wait" => Ok(ActionKind::Wait {
1157            duration_ms: parse_u64(&required(fields, "duration_ms")?, "duration_ms")?,
1158        }),
1159        other => Err(FerrisError::new(
1160            ErrorKind::Protocol,
1161            format!("unknown action: {other}"),
1162        )),
1163    }
1164}
1165
1166fn validate_policy(action: &ActionKind) -> Result<()> {
1167    match action {
1168        ActionKind::Click { x, y, .. }
1169        | ActionKind::DoubleClick { x, y, .. }
1170        | ActionKind::RightClick { x, y, .. }
1171        | ActionKind::MoveMouse { x, y, .. } => validate_agent_point(*x, *y),
1172        ActionKind::Drag {
1173            from_x,
1174            from_y,
1175            to_x,
1176            to_y,
1177            duration_ms,
1178            ..
1179        } => {
1180            validate_agent_point(*from_x, *from_y)?;
1181            validate_agent_point(*to_x, *to_y)?;
1182            if *duration_ms > 5_000 {
1183                return Err(FerrisError::new(
1184                    ErrorKind::Protocol,
1185                    "drag duration exceeds 5000 ms",
1186                ));
1187            }
1188            Ok(())
1189        }
1190        ActionKind::Scroll {
1191            x,
1192            y,
1193            delta_x,
1194            delta_y,
1195            ..
1196        } => {
1197            if let (Some(x), Some(y)) = (x, y) {
1198                validate_agent_point(*x, *y)?;
1199            }
1200            if delta_x.abs() > 2_000 || delta_y.abs() > 2_000 {
1201                return Err(FerrisError::new(
1202                    ErrorKind::Protocol,
1203                    "scroll delta exceeds 2000",
1204                ));
1205            }
1206            Ok(())
1207        }
1208        ActionKind::Type { text } => {
1209            if text.chars().count() > 500 {
1210                return Err(FerrisError::new(
1211                    ErrorKind::Protocol,
1212                    "typed text exceeds 500 characters",
1213                ));
1214            }
1215            Ok(())
1216        }
1217        ActionKind::Hotkey { keys } => {
1218            if keys.is_empty() || keys.len() > 4 {
1219                return Err(FerrisError::new(
1220                    ErrorKind::Protocol,
1221                    "hotkey must contain 1 to 4 keys",
1222                ));
1223            }
1224            Ok(())
1225        }
1226        ActionKind::PressKey { key } => {
1227            if key.trim().is_empty() {
1228                return Err(FerrisError::new(ErrorKind::Protocol, "key is required"));
1229            }
1230            Ok(())
1231        }
1232        ActionKind::Wait { duration_ms } => {
1233            if *duration_ms > 30_000 {
1234                return Err(FerrisError::new(
1235                    ErrorKind::Protocol,
1236                    "wait duration exceeds 30000 ms",
1237                ));
1238            }
1239            Ok(())
1240        }
1241    }
1242}
1243
1244fn validate_agent_point(x: i32, y: i32) -> Result<()> {
1245    if !(0..=1000).contains(&x) || !(0..=1000).contains(&y) {
1246        return Err(FerrisError::new(
1247            ErrorKind::Coordinate,
1248            "coordinates must be within 0..1000",
1249        ));
1250    }
1251    Ok(())
1252}
1253
1254fn resolve_action_screen<'a>(
1255    screen_id: Option<&str>,
1256    screens: &'a [ScreenInfo],
1257) -> Result<Option<&'a ScreenInfo>> {
1258    if screens.is_empty() {
1259        return Err(FerrisError::new(ErrorKind::Capture, "no screens available"));
1260    }
1261    if let Some(id) = screen_id {
1262        let id = resolve_primary_alias(id, screens);
1263        return screens
1264            .iter()
1265            .find(|screen| screen.screen_id == id)
1266            .map(Some)
1267            .ok_or_else(|| {
1268                FerrisError::new(ErrorKind::Coordinate, format!("unknown screen_id: {id}"))
1269            });
1270    }
1271    if screens.len() == 1 {
1272        return Ok(screens.first());
1273    }
1274    Err(FerrisError::new(
1275        ErrorKind::Coordinate,
1276        "screen_id is required because multiple screens are active",
1277    ))
1278}
1279
1280fn resolve_primary_alias(id: &str, screens: &[ScreenInfo]) -> String {
1281    if id == "primary" {
1282        if let Some(primary) = screens.iter().find(|screen| screen.is_primary) {
1283            return primary.screen_id.clone();
1284        }
1285    }
1286    id.to_string()
1287}
1288
1289fn to_native_action(action: &ActionKind, screen: Option<&ScreenInfo>) -> Result<NativeAction> {
1290    match action {
1291        ActionKind::Click { x, y, button, .. } => {
1292            let (x, y) = map_point(
1293                *x,
1294                *y,
1295                screen.ok_or_else(|| {
1296                    FerrisError::new(ErrorKind::Coordinate, "screen_id required for click")
1297                })?,
1298            )?;
1299            Ok(NativeAction::Click {
1300                x,
1301                y,
1302                button: *button,
1303            })
1304        }
1305        ActionKind::DoubleClick { x, y, button, .. } => {
1306            let (x, y) = map_point(
1307                *x,
1308                *y,
1309                screen.ok_or_else(|| {
1310                    FerrisError::new(ErrorKind::Coordinate, "screen_id required for double_click")
1311                })?,
1312            )?;
1313            Ok(NativeAction::DoubleClick {
1314                x,
1315                y,
1316                button: *button,
1317            })
1318        }
1319        ActionKind::RightClick { x, y, .. } => {
1320            let (x, y) = map_point(
1321                *x,
1322                *y,
1323                screen.ok_or_else(|| {
1324                    FerrisError::new(ErrorKind::Coordinate, "screen_id required for right_click")
1325                })?,
1326            )?;
1327            Ok(NativeAction::RightClick { x, y })
1328        }
1329        ActionKind::MoveMouse { x, y, .. } => {
1330            let (x, y) = map_point(
1331                *x,
1332                *y,
1333                screen.ok_or_else(|| {
1334                    FerrisError::new(ErrorKind::Coordinate, "screen_id required for move_mouse")
1335                })?,
1336            )?;
1337            Ok(NativeAction::MoveMouse { x, y })
1338        }
1339        ActionKind::Drag {
1340            from_x,
1341            from_y,
1342            to_x,
1343            to_y,
1344            duration_ms,
1345            button,
1346            ..
1347        } => {
1348            let screen = screen.ok_or_else(|| {
1349                FerrisError::new(ErrorKind::Coordinate, "screen_id required for drag")
1350            })?;
1351            let (from_x, from_y) = map_point(*from_x, *from_y, screen)?;
1352            let (to_x, to_y) = map_point(*to_x, *to_y, screen)?;
1353            Ok(NativeAction::Drag {
1354                from_x,
1355                from_y,
1356                to_x,
1357                to_y,
1358                duration_ms: *duration_ms,
1359                button: *button,
1360            })
1361        }
1362        ActionKind::Scroll {
1363            x,
1364            y,
1365            delta_x,
1366            delta_y,
1367            ..
1368        } => {
1369            let point = match (x, y, screen) {
1370                (Some(x), Some(y), Some(screen)) => Some(map_point(*x, *y, screen)?),
1371                _ => None,
1372            };
1373            Ok(NativeAction::Scroll {
1374                x: point.map(|value| value.0),
1375                y: point.map(|value| value.1),
1376                delta_x: *delta_x,
1377                delta_y: *delta_y,
1378            })
1379        }
1380        ActionKind::Type { text } => Ok(NativeAction::Type { text: text.clone() }),
1381        ActionKind::PressKey { key } => Ok(NativeAction::PressKey { key: key.clone() }),
1382        ActionKind::Hotkey { keys } => Ok(NativeAction::Hotkey { keys: keys.clone() }),
1383        ActionKind::Wait { duration_ms } => Ok(NativeAction::Wait {
1384            duration_ms: *duration_ms,
1385        }),
1386    }
1387}
1388
1389pub fn map_point(agent_x: i32, agent_y: i32, screen: &ScreenInfo) -> Result<(i32, i32)> {
1390    validate_agent_point(agent_x, agent_y)?;
1391    let native_x =
1392        screen.origin_x + ((agent_x as f64 / 1000.0) * screen.native_width as f64).round() as i32;
1393    let native_y =
1394        screen.origin_y + ((agent_y as f64 / 1000.0) * screen.native_height as f64).round() as i32;
1395    let max_x = screen.origin_x + screen.native_width.saturating_sub(1) as i32;
1396    let max_y = screen.origin_y + screen.native_height.saturating_sub(1) as i32;
1397    Ok((
1398        native_x.clamp(screen.origin_x, max_x),
1399        native_y.clamp(screen.origin_y, max_y),
1400    ))
1401}
1402
1403fn action_summary(action: &ActionKind) -> String {
1404    match action {
1405        ActionKind::Click {
1406            screen_id,
1407            x,
1408            y,
1409            button,
1410        } => format!(
1411            "click screen_id={} x={} y={} button={}",
1412            screen_id.as_deref().unwrap_or(""),
1413            x,
1414            y,
1415            button.as_str()
1416        ),
1417        ActionKind::DoubleClick {
1418            screen_id,
1419            x,
1420            y,
1421            button,
1422        } => format!(
1423            "double_click screen_id={} x={} y={} button={}",
1424            screen_id.as_deref().unwrap_or(""),
1425            x,
1426            y,
1427            button.as_str()
1428        ),
1429        ActionKind::RightClick { screen_id, x, y } => format!(
1430            "right_click screen_id={} x={} y={}",
1431            screen_id.as_deref().unwrap_or(""),
1432            x,
1433            y
1434        ),
1435        ActionKind::MoveMouse { screen_id, x, y } => format!(
1436            "move_mouse screen_id={} x={} y={}",
1437            screen_id.as_deref().unwrap_or(""),
1438            x,
1439            y
1440        ),
1441        ActionKind::Drag {
1442            screen_id,
1443            from_x,
1444            from_y,
1445            to_x,
1446            to_y,
1447            duration_ms,
1448            button,
1449        } => format!(
1450            "drag screen_id={} from_x={} from_y={} to_x={} to_y={} duration_ms={} button={}",
1451            screen_id.as_deref().unwrap_or(""),
1452            from_x,
1453            from_y,
1454            to_x,
1455            to_y,
1456            duration_ms,
1457            button.as_str()
1458        ),
1459        ActionKind::Scroll {
1460            screen_id,
1461            x,
1462            y,
1463            delta_x,
1464            delta_y,
1465        } => format!(
1466            "scroll screen_id={} x={} y={} delta_x={} delta_y={}",
1467            screen_id.as_deref().unwrap_or(""),
1468            x.map(|v| v.to_string()).unwrap_or_default(),
1469            y.map(|v| v.to_string()).unwrap_or_default(),
1470            delta_x,
1471            delta_y
1472        ),
1473        ActionKind::Type { .. } => "type text=<redacted>".to_string(),
1474        ActionKind::PressKey { key } => format!("press_key key={key}"),
1475        ActionKind::Hotkey { keys } => format!("hotkey keys={}", keys.join("+")),
1476        ActionKind::Wait { duration_ms } => format!("wait duration_ms={duration_ms}"),
1477    }
1478}
1479
1480fn action_summary_with_wait_after(action: &ActionKind, wait_after_ms: u64) -> String {
1481    let summary = action_summary(action);
1482    if wait_after_ms == 0 {
1483        summary
1484    } else {
1485        format!("{summary}\nwait_after_ms={wait_after_ms}")
1486    }
1487}
1488
1489fn required(fields: &BTreeMap<String, String>, key: &str) -> Result<String> {
1490    fields
1491        .get(key)
1492        .cloned()
1493        .filter(|value| !value.is_empty())
1494        .ok_or_else(|| FerrisError::new(ErrorKind::Protocol, format!("{key} is required")))
1495}
1496
1497fn parse_i32_required(fields: &BTreeMap<String, String>, key: &str) -> Result<i32> {
1498    parse_i32(&required(fields, key)?, key)
1499}
1500
1501fn parse_i32_optional(value: Option<&str>, key: &str) -> Result<Option<i32>> {
1502    value.map(|value| parse_i32(value, key)).transpose()
1503}
1504
1505fn parse_i32(value: &str, key: &str) -> Result<i32> {
1506    value
1507        .parse::<i32>()
1508        .map_err(|_| FerrisError::new(ErrorKind::Protocol, format!("{key} must be an integer")))
1509}
1510
1511fn parse_u64(value: &str, key: &str) -> Result<u64> {
1512    value
1513        .parse::<u64>()
1514        .map_err(|_| FerrisError::new(ErrorKind::Protocol, format!("{key} must be an integer")))
1515}
1516
1517fn parse_f32(value: &str, key: &str) -> Result<f32> {
1518    value
1519        .parse::<f32>()
1520        .map_err(|_| FerrisError::new(ErrorKind::Protocol, format!("{key} must be a number")))
1521}
1522
1523fn parse_button(value: &str) -> Result<MouseButton> {
1524    match value {
1525        "left" => Ok(MouseButton::Left),
1526        "right" => Ok(MouseButton::Right),
1527        "middle" => Ok(MouseButton::Middle),
1528        other => Err(FerrisError::new(
1529            ErrorKind::Protocol,
1530            format!("unsupported mouse button: {other}"),
1531        )),
1532    }
1533}
1534
1535fn new_session_id() -> String {
1536    format!("{}-{}", unix_millis(), std::process::id())
1537}
1538
1539fn unix_millis() -> u128 {
1540    SystemTime::now()
1541        .duration_since(UNIX_EPOCH)
1542        .unwrap_or_default()
1543        .as_millis()
1544}
1545
1546#[cfg(test)]
1547mod tests {
1548    use super::*;
1549
1550    fn screen() -> ScreenInfo {
1551        ScreenInfo {
1552            screen_id: "screen-1".to_string(),
1553            name: "Test".to_string(),
1554            is_primary: true,
1555            origin_x: 0,
1556            origin_y: 0,
1557            native_width: 3024,
1558            native_height: 1964,
1559            scale_factor: 2.0,
1560        }
1561    }
1562
1563    #[test]
1564    fn maps_normalized_center_to_native_center() {
1565        assert_eq!(map_point(500, 500, &screen()).unwrap(), (1512, 982));
1566    }
1567
1568    #[test]
1569    fn rejects_out_of_bounds_coordinates() {
1570        assert!(map_point(1001, 500, &screen()).is_err());
1571    }
1572
1573    #[test]
1574    fn parses_click_action_block() {
1575        let action = parse_action_block(
1576            "status: action\naction: click\nscreen_id: screen-1\nx: 742\ny: 611\nbutton: left\n",
1577        )
1578        .unwrap();
1579        assert_eq!(action.status, ActionStatus::Action);
1580        assert!(matches!(action.kind, Some(ActionKind::Click { .. })));
1581        assert_eq!(action.wait_after_ms, None);
1582    }
1583
1584    #[test]
1585    fn parses_wait_after_ms() {
1586        let action = parse_action_block(
1587            "status: action\naction: click\nscreen_id: screen-1\nx: 742\ny: 611\nbutton: left\nwait_after_ms: 750\n",
1588        )
1589        .unwrap();
1590        assert_eq!(action.wait_after_ms, Some(750));
1591    }
1592
1593    #[test]
1594    fn rejects_excessive_wait_after_ms() {
1595        let error = parse_action_block(
1596            "status: action\naction: click\nscreen_id: screen-1\nx: 742\ny: 611\nwait_after_ms: 30001\n",
1597        )
1598        .unwrap_err();
1599        assert_eq!(error.kind, ErrorKind::Protocol);
1600        assert!(error.message.contains("wait_after_ms"));
1601    }
1602
1603    #[test]
1604    fn rejects_json_action_input() {
1605        assert!(parse_action_block("{\"action\":\"click\"}").is_err());
1606    }
1607
1608    #[test]
1609    fn rejects_missing_screen_in_multi_screen_context() {
1610        let screens = vec![
1611            screen(),
1612            ScreenInfo {
1613                screen_id: "screen-2".to_string(),
1614                is_primary: false,
1615                ..screen()
1616            },
1617        ];
1618        assert!(resolve_action_screen(None, &screens).is_err());
1619    }
1620}