Skip to main content

beam_core/
triggers.rs

1use crate::config::CustomTrigger;
2
3/// Returns the first configured trigger whose keyword starts the trimmed
4/// text, respecting a word boundary after the keyword.
5pub fn resolve_custom_trigger<'a>(
6    text: &str,
7    triggers: &'a [CustomTrigger],
8) -> Option<&'a CustomTrigger> {
9    let text = text.trim_start();
10    triggers.iter().find(|entry| {
11        let keyword = entry.trigger.trim();
12        if keyword.is_empty() {
13            return false;
14        }
15        text.strip_prefix(keyword)
16            .map(is_trigger_boundary)
17            .unwrap_or(false)
18    })
19}
20
21/// Text that follows the trigger keyword, with leading separators removed.
22/// Returns `None` when the message is exactly the keyword.
23pub fn custom_trigger_rest<'a>(text: &'a str, trigger: &str) -> Option<&'a str> {
24    let text = text.trim_start();
25    let keyword = trigger.trim();
26    if keyword.is_empty() {
27        return None;
28    }
29    let rest = text.strip_prefix(keyword)?;
30    let rest = rest.trim_start_matches(|c: char| {
31        c.is_whitespace() || ",。::;;、,..!!??))]}>》」』】".contains(c)
32    });
33    if rest.is_empty() { None } else { Some(rest) }
34}
35
36/// The effective initial message for a session created by a trigger:
37/// the configured prompt plus any trailing user text, or the raw text
38/// when the trigger has no prompt.
39pub fn resolve_trigger_message(text: &str, trigger: &CustomTrigger) -> String {
40    match trigger.prompt.as_deref() {
41        None => text.to_string(),
42        Some(prompt) => match custom_trigger_rest(text, &trigger.trigger) {
43            Some(rest) => format!("{}\n\n{}", prompt, rest),
44            None => prompt.to_string(),
45        },
46    }
47}
48
49fn is_trigger_boundary(rest: &str) -> bool {
50    match rest.chars().next() {
51        None => true,
52        Some(c) => !(c.is_alphanumeric() || is_cjk_ideograph(c)),
53    }
54}
55
56fn is_cjk_ideograph(c: char) -> bool {
57    matches!(c as u32,
58        0x3400..=0x4DBF
59        | 0x4E00..=0x9FFF
60        | 0xF900..=0xFAFF
61        | 0x20000..=0x2A6DF
62        | 0x2A700..=0x2B73F
63        | 0x2B740..=0x2B81F
64        | 0x2B820..=0x2CEAF
65    )
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    fn trigger(keyword: &str, prompt: Option<&str>) -> CustomTrigger {
73        CustomTrigger {
74            trigger: keyword.to_string(),
75            prompt: prompt.map(ToOwned::to_owned),
76            skip_dir_select: false,
77            working_dir: None,
78            ack_message: None,
79        }
80    }
81
82    #[test]
83    fn exact_keyword_matches() {
84        let triggers = vec![trigger("日报", Some("生成今日日报"))];
85        let hit = resolve_custom_trigger("日报", &triggers).expect("exact match");
86        assert_eq!(hit.trigger, "日报");
87    }
88
89    #[test]
90    fn keyword_with_trailing_text_matches() {
91        let triggers = vec![trigger("日报", Some("生成今日日报"))];
92        assert!(resolve_custom_trigger("日报 今天修了三个 bug", &triggers).is_some());
93        assert!(resolve_custom_trigger("日报:今天修了三个 bug", &triggers).is_some());
94    }
95
96    #[test]
97    fn trailing_punctuation_is_stripped_from_rest() {
98        let triggers = vec![trigger("日报", Some("请以日报模板输出"))];
99        let hit = resolve_custom_trigger("日报:今天修了 bug", &triggers).expect("match");
100        assert_eq!(
101            resolve_trigger_message("日报:今天修了 bug", hit),
102            "请以日报模板输出\n\n今天修了 bug"
103        );
104    }
105
106    #[test]
107    fn keyword_inside_longer_word_does_not_match() {
108        let triggers = vec![trigger("日报", Some("生成今日日报"))];
109        assert!(resolve_custom_trigger("日报表", &triggers).is_none());
110        assert!(resolve_custom_trigger("今日日报", &triggers).is_none());
111    }
112
113    #[test]
114    fn trigger_without_prompt_keeps_raw_text() {
115        let triggers = vec![trigger("开会", None)];
116        let hit = resolve_custom_trigger("开会 讲讲方案", &triggers).expect("match");
117        assert_eq!(
118            resolve_trigger_message("开会 讲讲方案", hit),
119            "开会 讲讲方案"
120        );
121    }
122
123    #[test]
124    fn trigger_with_prompt_builds_initial_message() {
125        let triggers = vec![trigger("日报", Some("请以日报模板输出"))];
126        let hit = resolve_custom_trigger("日报", &triggers).expect("exact match");
127        assert_eq!(resolve_trigger_message("日报", hit), "请以日报模板输出");
128
129        let hit = resolve_custom_trigger("日报 今天修了 bug", &triggers).expect("match");
130        assert_eq!(
131            resolve_trigger_message("日报 今天修了 bug", hit),
132            "请以日报模板输出\n\n今天修了 bug"
133        );
134    }
135
136    #[test]
137    fn first_matching_trigger_wins() {
138        let triggers = vec![trigger("hello", None), trigger("hello world", Some("hi"))];
139        let hit = resolve_custom_trigger("hello world", &triggers).expect("match");
140        assert_eq!(hit.trigger, "hello");
141    }
142
143    #[test]
144    fn empty_or_whitespace_text_never_matches() {
145        let triggers = vec![trigger("日报", None)];
146        assert!(resolve_custom_trigger("", &triggers).is_none());
147        assert!(resolve_custom_trigger("   ", &triggers).is_none());
148    }
149}