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