1use crate::config::CustomTrigger;
2
3pub 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
21pub 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
36pub 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}