Skip to main content

actl_core/
target.rs

1//! 命令目标(@eN ref / 语义选择器)的解析,纯逻辑。
2//!
3//! 06 §3.1 规则③:位置参数只放目标。合法形式四种:
4//! `@eN`(snapshot 重放序号)/ `name:子串` / `id:AutomationId 精确` / `role:Type 精确`。
5//! ref 的漂移风险与 fallback 定位链的根治关系见 06 §5、spike-findings。
6
7use crate::CtlError;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum Target {
11    /// snapshot 中的第 N 个可交互元素(DFS 序重放)
12    Ref(u32),
13    /// Name 子串匹配(首个命中)
14    Name(String),
15    /// AutomationId 精确匹配
16    Id(String),
17    /// ControlType 精确匹配(如 Button)
18    Role(String),
19    /// ControlType + 1 起序数(`role:Button#2` = 第 2 个 Button;fallback 链 L3)
20    RoleAt(String, u32),
21}
22
23impl Target {
24    /// 面向错误消息的人类可读形式
25    pub fn describe(&self) -> String {
26        match self {
27            Target::Ref(n) => format!("@e{n}"),
28            Target::Name(s) => format!("name:{s}"),
29            Target::Id(s) => format!("id:{s}"),
30            Target::Role(s) => format!("role:{s}"),
31            Target::RoleAt(s, n) => format!("role:{s}#{n}"),
32        }
33    }
34}
35
36/// 解析目标字符串;非法形式 → PROTOCOL(exit 2 之外的协议层错误)。
37pub fn parse_target(raw: &str) -> Result<Target, CtlError> {
38    if let Some(n) = raw.strip_prefix("@e") {
39        let n: u32 = n.parse().map_err(|_| bad(raw))?;
40        if n == 0 {
41            return Err(bad(raw)); // ref 从 @e1 起
42        }
43        return Ok(Target::Ref(n));
44    }
45    for (prefix, build) in [
46        (
47            "name:",
48            (|s: String| Target::Name(s)) as fn(String) -> Target,
49        ),
50        ("id:", |s: String| Target::Id(s)),
51        ("role:", |s: String| Target::Role(s)),
52    ] {
53        if let Some(rest) = raw.strip_prefix(prefix) {
54            if rest.is_empty() {
55                return Err(bad(raw));
56            }
57            // role:Type#N → 序数形式(fallback 链 L3;#0 非法)
58            if prefix == "role:" {
59                if let Some((role, idx)) = rest.rsplit_once('#') {
60                    let n: u32 = idx.parse().map_err(|_| bad(raw))?;
61                    if !role.is_empty() && n >= 1 {
62                        return Ok(Target::RoleAt(role.to_string(), n));
63                    }
64                    return Err(bad(raw));
65                }
66            }
67            return Ok(build(rest.to_string()));
68        }
69    }
70    Err(bad(raw))
71}
72
73fn bad(raw: &str) -> CtlError {
74    CtlError::protocol(format!(
75        "invalid target {raw:?}: expected @eN, name:<substr>, id:<AutomationId> or role:<Type>"
76    ))
77}
78
79/// L2 模糊匹配:大小写不敏感 + 空白归一(连续空白折成一格 + 去首尾)。
80/// 名称动态变化的界面(尾缀计数、对齐空格)靠它兜住。
81pub fn fuzzy_contains(haystack: &str, needle: &str) -> bool {
82    let norm = |s: &str| {
83        s.split_whitespace()
84            .collect::<Vec<_>>()
85            .join(" ")
86            .to_lowercase()
87    };
88    let h = norm(haystack);
89    let n = norm(needle);
90    !n.is_empty() && h.contains(&n)
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::ErrorCode;
97
98    #[test]
99    fn parses_all_four_forms() {
100        assert_eq!(parse_target("@e3").unwrap(), Target::Ref(3));
101        assert_eq!(
102            parse_target("name:确定").unwrap(),
103            Target::Name("确定".into())
104        );
105        assert_eq!(
106            parse_target("id:num1Button").unwrap(),
107            Target::Id("num1Button".into())
108        );
109        assert_eq!(
110            parse_target("role:Button").unwrap(),
111            Target::Role("Button".into())
112        );
113        assert_eq!(
114            parse_target("role:Button#2").unwrap(),
115            Target::RoleAt("Button".into(), 2)
116        );
117    }
118
119    #[test]
120    fn role_ordinal_edges() {
121        for bad in ["role:Button#0", "role:#2", "role:Button#", "role:Button#x"] {
122            assert!(parse_target(bad).is_err(), "{bad:?} should be rejected");
123        }
124    }
125
126    #[test]
127    fn fuzzy_matches_case_and_whitespace_insensitive() {
128        assert!(fuzzy_contains("保存  设置", "保存 设置"));
129        assert!(fuzzy_contains("Open File", "open   file"));
130        assert!(!fuzzy_contains("Open", "open file"));
131        assert!(!fuzzy_contains("anything", ""));
132    }
133
134    #[test]
135    fn rejects_bare_words_and_edge_cases() {
136        for bad in [
137            "button", "@e", "@e0", "@ex", "name:", "id:", "role:", "@e-1",
138        ] {
139            let err = parse_target(bad).unwrap_err();
140            assert_eq!(err.code, ErrorCode::Protocol, "{bad:?} should be PROTOCOL");
141        }
142    }
143
144    #[test]
145    fn describe_round_trips_for_messages() {
146        assert_eq!(parse_target("@e7").unwrap().describe(), "@e7");
147    }
148}