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}