1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum SlashCommand {
5 Search,
6 Settings,
7 Help,
8 WhatsNew,
9 CopyTitle,
11 CopyTask,
13 Done,
15 Purge,
17 Update,
19 Quit,
20}
21
22impl SlashCommand {
23 pub const ALL: [Self; 10] = [
25 Self::Search,
26 Self::Settings,
27 Self::Help,
28 Self::WhatsNew,
29 Self::CopyTitle,
30 Self::CopyTask,
31 Self::Done,
32 Self::Purge,
33 Self::Update,
34 Self::Quit,
35 ];
36
37 pub fn id(self) -> &'static str {
38 match self {
39 Self::Search => "search",
40 Self::Settings => "settings",
41 Self::Help => "help",
42 Self::WhatsNew => "whatsnew",
43 Self::CopyTitle => "copytitle",
44 Self::CopyTask => "copy",
45 Self::Done => "done",
46 Self::Purge => "purge",
47 Self::Update => "update",
48 Self::Quit => "quit",
49 }
50 }
51
52 pub fn label(self) -> &'static str {
53 match self {
54 Self::Search => "Search",
55 Self::Settings => "Settings",
56 Self::Help => "Help",
57 Self::WhatsNew => "What's new",
58 Self::CopyTitle => "Copy title",
59 Self::CopyTask => "Copy task",
60 Self::Done => "Done tasks",
61 Self::Purge => "Purge done",
62 Self::Update => "Update",
63 Self::Quit => "Quit",
64 }
65 }
66
67 pub fn hint(self) -> &'static str {
68 match self {
69 Self::Search => "search tasks",
70 Self::Settings => "sort, theme, date, task preview",
71 Self::Help => "key reference",
72 Self::WhatsNew => "release highlights",
73 Self::CopyTitle => "copy selected task title",
74 Self::CopyTask => "copy selected task title and body",
75 Self::Done => "show or hide completed tasks",
76 Self::Purge => "delete completed tasks in this view",
77 Self::Update => "install the latest verified build",
78 Self::Quit => "leave mach",
79 }
80 }
81
82 fn keywords(self) -> &'static [&'static str] {
83 match self {
84 Self::Search => &["search"],
85 Self::Settings => &["settings"],
86 Self::Help => &["help"],
87 Self::WhatsNew => &["whatsnew"],
88 Self::CopyTitle => &["copytitle"],
89 Self::CopyTask => &["copy", "copytask"],
90 Self::Done => &["done", "hide", "show"],
91 Self::Purge => &["purge"],
92 Self::Update => &["update", "upgrade", "version"],
93 Self::Quit => &["quit"],
94 }
95 }
96
97 pub fn matches(self, query: &str) -> bool {
99 let q = query.trim().to_lowercase();
100 if q.is_empty() {
101 return true;
102 }
103 let head = q.split_whitespace().next().unwrap_or("");
104 self.keywords().iter().any(|k| {
105 k.starts_with(head) || (head.starts_with(k) && k.len() >= 3)
108 })
109 }
110}
111
112pub fn matching(query: &str) -> Vec<SlashCommand> {
118 let q = query.trim();
119 if q.is_empty() {
120 return SlashCommand::ALL.to_vec();
121 }
122 let hits: Vec<SlashCommand> = SlashCommand::ALL
123 .into_iter()
124 .filter(|c| c.matches(query))
125 .collect();
126 let head = q.split_whitespace().next().unwrap_or("").to_lowercase();
127 let exact: Vec<SlashCommand> = hits
128 .iter()
129 .copied()
130 .filter(|c| c.keywords().iter().any(|k| *k == head))
131 .collect();
132 if exact.is_empty() { hits } else { exact }
133}
134
135pub fn args_for(command: SlashCommand, query: &str) -> String {
137 let q = query.trim();
138 if q.is_empty() {
139 return String::new();
140 }
141 let lower = q.to_lowercase();
142 for key in command.keywords() {
143 if lower == *key {
144 return String::new();
145 }
146 if let Some(rest) = lower.strip_prefix(key)
147 && (rest.is_empty() || rest.starts_with(char::is_whitespace))
148 {
149 let n = key.len().min(q.len());
150 return q[n..].trim().to_string();
151 }
152 }
153 String::new()
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn search_is_first_when_unfiltered() {
162 let m = matching("");
163 assert_eq!(m[0], SlashCommand::Search);
164 assert!(m.contains(&SlashCommand::Settings));
165 assert!(m.contains(&SlashCommand::Done));
166 assert!(m.contains(&SlashCommand::Purge));
167 assert_eq!(m.len(), SlashCommand::ALL.len());
168 }
169
170 #[test]
171 fn typing_settings_is_only_settings() {
172 let m = matching("settings");
173 assert_eq!(m, vec![SlashCommand::Settings]);
174 }
175
176 #[test]
177 fn typing_set_is_only_settings() {
178 let m = matching("set");
179 assert_eq!(m, vec![SlashCommand::Settings]);
180 }
181
182 #[test]
183 fn search_args() {
184 assert_eq!(args_for(SlashCommand::Search, "search milk"), "milk");
185 assert_eq!(args_for(SlashCommand::Search, "search"), "");
186 assert_eq!(args_for(SlashCommand::Search, "milk"), "");
187 }
188
189 #[test]
190 fn free_text_is_not_a_command() {
191 assert!(matching("milk").is_empty());
192 }
193
194 #[test]
195 fn typing_done_is_done_command() {
196 let m = matching("done");
197 assert_eq!(m, vec![SlashCommand::Done]);
198 }
199
200 #[test]
201 fn typing_purge_is_only_purge() {
202 let m = matching("purge");
203 assert_eq!(m, vec![SlashCommand::Purge]);
204 let m = matching("purge all");
205 assert_eq!(m, vec![SlashCommand::Purge]);
208 }
209
210 #[test]
211 fn typing_update_is_update() {
212 assert_eq!(matching("update"), vec![SlashCommand::Update]);
213 assert_eq!(matching("upgrade"), vec![SlashCommand::Update]);
214 }
215
216 #[test]
217 fn whats_new_is_the_only_reopenable_launch_page() {
218 assert_eq!(matching("whatsnew"), vec![SlashCommand::WhatsNew]);
219 assert!(matching("about").is_empty());
220 }
221
222 #[test]
223 fn fixed_order_when_several_match() {
224 let m = matching("s");
226 assert_eq!(m[0], SlashCommand::Search);
227 assert!(m.contains(&SlashCommand::Settings));
228 }
229
230 #[test]
231 fn copy_commands_match() {
232 assert_eq!(matching("copy"), vec![SlashCommand::CopyTask]);
233 assert!(matching("title").is_empty());
234 assert_eq!(matching("copytitle"), vec![SlashCommand::CopyTitle]);
235 let m = matching("copyt");
236 assert!(m.contains(&SlashCommand::CopyTitle));
237 assert!(m.contains(&SlashCommand::CopyTask));
238 }
239}