Skip to main content

actl_core/
target.rs

1//! 命令目标(@eN ref / 语义选择器 / 坐标)的解析,纯逻辑。
2//!
3//! 06 §3.1 规则③:位置参数只放目标。合法形式:
4//! `@eN`(snapshot 重放序号)/ `name:子串` / `id:AutomationId 精确` /
5//! `role:Type 精确` / `xy:x,y`(屏幕坐标,视觉兜底专用——无元素可校验,
6//! 强制 `--app` 窗口边界守卫 + 显式 `--physical`,见 06 §7)。
7//! ref 的漂移风险与 fallback 定位链的根治关系见 06 §5、spike-findings。
8
9use crate::CtlError;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Target {
13    /// Strict conjunction with an optional unique container scope.
14    Uia(Box<crate::selector::Selector>),
15    /// snapshot 中的第 N 个可交互元素(DFS 序重放)
16    Ref(u32),
17    /// Name 子串匹配(首个命中)
18    Name(String),
19    /// AutomationId 精确匹配
20    Id(String),
21    /// ControlType 精确匹配(如 Button)
22    Role(String),
23    /// ControlType + 1 起序数(`role:Button#2` = 第 2 个 Button;fallback 链 L3)
24    RoleAt(String, u32),
25    /// AutomationId + 1 起序数(`id:RootWebArea#2` = 第 2 个同名 id)——多文档
26    /// 页的确定性寻址(iframe/扩展弹层会让同一 AutomationId 出现多次;
27    /// 2026-09-27 三连踩后补,与 RoleAt 同机制)
28    IdAt(String, u32),
29    /// 屏幕物理坐标(`xy:1420,856`;负值合法——多显示器左侧屏)。
30    /// 视觉兜底的"手":坐标来自调用方(screenshot+VLM/人),不经元素定位;
31    /// 强制 --app + --physical(窗口边界守卫,06 §7)
32    Coord(i32, i32),
33}
34
35impl Target {
36    /// 面向错误消息的人类可读形式
37    pub fn describe(&self) -> String {
38        match self {
39            Target::Uia(selector) => format!("uia:{}", serde_json::json!(selector)),
40            Target::Ref(n) => format!("@e{n}"),
41            Target::Name(s) => format!("name:{s}"),
42            Target::Id(s) => format!("id:{s}"),
43            Target::Role(s) => format!("role:{s}"),
44            Target::RoleAt(s, n) => format!("role:{s}#{n}"),
45            Target::IdAt(s, n) => format!("id:{s}#{n}"),
46            Target::Coord(x, y) => format!("xy:{x},{y}"),
47        }
48    }
49}
50
51/// 解析目标字符串;非法形式 → PROTOCOL(exit 2 之外的协议层错误)。
52pub fn parse_target(raw: &str) -> Result<Target, CtlError> {
53    if let Some(json) = raw.strip_prefix("uia:") {
54        return crate::selector::Selector::parse(json).map(|s| Target::Uia(Box::new(s)));
55    }
56    if let Some(n) = raw.strip_prefix("@e") {
57        let n: u32 = n.parse().map_err(|_| bad(raw))?;
58        if n == 0 {
59            return Err(bad(raw)); // ref 从 @e1 起
60        }
61        return Ok(Target::Ref(n));
62    }
63    // xy:x,y(物理屏幕坐标;负 x 合法——虚拟桌面左侧屏)
64    if let Some(rest) = raw.strip_prefix("xy:") {
65        if let Some((xs, ys)) = rest.split_once(',')
66            && let (Ok(x), Ok(y)) = (xs.trim().parse::<i32>(), ys.trim().parse::<i32>())
67        {
68            return Ok(Target::Coord(x, y));
69        }
70        return Err(bad(raw));
71    }
72    for (prefix, build) in [
73        (
74            "name:",
75            (|s: String| Target::Name(s)) as fn(String) -> Target,
76        ),
77        ("id:", |s: String| Target::Id(s)),
78        ("role:", |s: String| Target::Role(s)),
79    ] {
80        if let Some(rest) = raw.strip_prefix(prefix) {
81            if rest.is_empty() {
82                return Err(bad(raw));
83            }
84            // <Type|Id>#N → 序数形式(fallback 链 L3;#0 非法)
85            if (prefix == "role:" || prefix == "id:")
86                && let Some((key, idx)) = rest.rsplit_once('#')
87            {
88                let n: u32 = idx.parse().map_err(|_| bad(raw))?;
89                if key.is_empty() || n < 1 {
90                    return Err(bad(raw));
91                }
92                return Ok(if prefix == "role:" {
93                    Target::RoleAt(key.to_string(), n)
94                } else {
95                    Target::IdAt(key.to_string(), n)
96                });
97            }
98            return Ok(build(rest.to_string()));
99        }
100    }
101    Err(bad(raw))
102}
103
104fn bad(raw: &str) -> CtlError {
105    CtlError::protocol(format!(
106        "invalid target {raw:?}: expected @eN, name:<substr>, id:<AutomationId>, role:<Type> or xy:<x>,<y>"
107    ))
108}
109
110/// L2 模糊匹配:大小写不敏感 + 空白归一(连续空白折成一格 + 去首尾)。
111/// 名称动态变化的界面(尾缀计数、对齐空格)靠它兜住。
112pub fn fuzzy_contains(haystack: &str, needle: &str) -> bool {
113    let norm = |s: &str| {
114        s.split_whitespace()
115            .collect::<Vec<_>>()
116            .join(" ")
117            .to_lowercase()
118    };
119    let h = norm(haystack);
120    let n = norm(needle);
121    !n.is_empty() && h.contains(&n)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::ErrorCode;
128
129    #[test]
130    fn parses_all_forms() {
131        assert_eq!(parse_target("@e3").unwrap(), Target::Ref(3));
132        assert_eq!(
133            parse_target("name:确定").unwrap(),
134            Target::Name("确定".into())
135        );
136        assert_eq!(
137            parse_target("id:num1Button").unwrap(),
138            Target::Id("num1Button".into())
139        );
140        assert_eq!(
141            parse_target("role:Button").unwrap(),
142            Target::Role("Button".into())
143        );
144        assert_eq!(
145            parse_target("role:Button#2").unwrap(),
146            Target::RoleAt("Button".into(), 2)
147        );
148        assert_eq!(
149            parse_target("id:RootWebArea#2").unwrap(),
150            Target::IdAt("RootWebArea".into(), 2)
151        );
152        assert_eq!(
153            parse_target("xy:1420,856").unwrap(),
154            Target::Coord(1420, 856)
155        );
156        // 负坐标合法(虚拟桌面左侧屏)
157        assert_eq!(
158            parse_target("xy:-100,200").unwrap(),
159            Target::Coord(-100, 200)
160        );
161        assert_eq!(parse_target("xy:0,0").unwrap(), Target::Coord(0, 0));
162    }
163
164    #[test]
165    fn coord_edges_rejected() {
166        for bad in ["xy:100", "xy:100,", "xy:,200", "xy:a,b", "xy:", "xy:1,2,3"] {
167            let err = parse_target(bad).unwrap_err();
168            assert_eq!(err.code, ErrorCode::Protocol, "{bad:?} should be rejected");
169        }
170    }
171
172    #[test]
173    fn ordinal_edges() {
174        for bad in [
175            "role:Button#0",
176            "role:#2",
177            "role:Button#",
178            "role:Button#x",
179            "id:Foo#0",
180            "id:#2",
181            "id:Foo#",
182            "id:Foo#x",
183        ] {
184            assert!(parse_target(bad).is_err(), "{bad:?} should be rejected");
185        }
186    }
187
188    #[test]
189    fn fuzzy_matches_case_and_whitespace_insensitive() {
190        assert!(fuzzy_contains("保存  设置", "保存 设置"));
191        assert!(fuzzy_contains("Open File", "open   file"));
192        assert!(!fuzzy_contains("Open", "open file"));
193        assert!(!fuzzy_contains("anything", ""));
194    }
195
196    #[test]
197    fn rejects_bare_words_and_edge_cases() {
198        for bad in [
199            "button", "@e", "@e0", "@ex", "name:", "id:", "role:", "@e-1",
200        ] {
201            let err = parse_target(bad).unwrap_err();
202            assert_eq!(err.code, ErrorCode::Protocol, "{bad:?} should be PROTOCOL");
203        }
204    }
205
206    #[test]
207    fn describe_round_trips_for_messages() {
208        assert_eq!(parse_target("@e7").unwrap().describe(), "@e7");
209    }
210}