Skip to main content

computer_use/
menu_path.rs

1//! Parse `"File > Save"`-style menu paths into a typed list of titles.
2//!
3//! The path separator is `>` or `→`, with optional whitespace around it. Each
4//! segment is trimmed. Empty segments are rejected. A single title such as
5//! `Save` parses as a one-segment path.
6//!
7//! Paths are case-folded on the macOS backend so callers may pass either
8//! `File > Save` or `file > save`.
9
10use serde::Serialize;
11
12use crate::error::ComputerError;
13
14/// Separator between menu titles. `>` is the documented form. `→` (U+2192)
15/// is accepted as a Unicode-friendly alias.
16pub const SEPARATORS: &[char] = &['>', '→'];
17
18/// One menu path. Each entry is a title that the macOS backend matches
19/// case-insensitively against `AXTitle`.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct MenuPath {
22    /// Title segments in order.
23    pub segments: Vec<String>,
24}
25
26impl MenuPath {
27    /// Borrow the segments.
28    pub fn as_slice(&self) -> &[String] {
29        &self.segments
30    }
31}
32
33/// Split a single title path. Empty input and empty segments are rejected.
34pub fn parse(input: &str) -> Result<MenuPath, ComputerError> {
35    let trimmed = input.trim();
36    if trimmed.is_empty() {
37        return Err(ComputerError::InvalidArguments("menu path is empty".into()));
38    }
39    let mut segments = Vec::new();
40    for raw in trimmed.split(SEPARATORS) {
41        let piece = raw.trim();
42        if piece.is_empty() {
43            return Err(ComputerError::InvalidArguments(format!(
44                "menu path has an empty segment in {input:?}"
45            )));
46        }
47        segments.push(piece.to_string());
48    }
49    if segments.is_empty() {
50        return Err(ComputerError::InvalidArguments(
51            "menu path has no segments".into(),
52        ));
53    }
54    Ok(MenuPath { segments })
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn single_segment_parses() {
63        let path = parse("Save").unwrap();
64        assert_eq!(path.segments, vec!["Save".to_string()]);
65    }
66
67    #[test]
68    fn standard_separator_with_padding() {
69        let path = parse("File > Save As > Save").unwrap();
70        assert_eq!(
71            path.segments,
72            vec![
73                "File".to_string(),
74                "Save As".to_string(),
75                "Save".to_string()
76            ]
77        );
78    }
79
80    #[test]
81    fn arrow_separator_is_accepted() {
82        let path = parse("File → Save").unwrap();
83        assert_eq!(path.segments, vec!["File".to_string(), "Save".to_string()]);
84    }
85
86    #[test]
87    fn empty_input_is_rejected() {
88        let error = parse("").unwrap_err();
89        assert!(matches!(error, ComputerError::InvalidArguments(_)));
90    }
91
92    #[test]
93    fn empty_segment_is_rejected() {
94        let error = parse("File > > Save").unwrap_err();
95        assert!(matches!(error, ComputerError::InvalidArguments(_)));
96    }
97
98    #[test]
99    fn fixture_menu_path_round_trip() {
100        // A representative Finder menu path. The macOS backend matches titles
101        // case-insensitively, so any case combination here is fine.
102        let path = parse("File > New Finder Window").unwrap();
103        assert_eq!(path.segments.len(), 2);
104        assert_eq!(path.segments[1], "New Finder Window");
105    }
106
107    #[test]
108    fn trailing_separator_is_rejected() {
109        let error = parse("File > ").unwrap_err();
110        assert!(matches!(error, ComputerError::InvalidArguments(_)));
111    }
112}