1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum SlashCommand {
5 Search,
6 Settings,
7 Labels,
8 Help,
9 WhatsNew,
10 CopyTitle,
12 CopyTask,
14 Export,
16 Import,
18 Done,
20 Purge,
22 Update,
24 Quit,
25}
26
27impl SlashCommand {
28 pub const ALL: [Self; 13] = [
30 Self::Search,
31 Self::Settings,
32 Self::Labels,
33 Self::Help,
34 Self::WhatsNew,
35 Self::CopyTitle,
36 Self::CopyTask,
37 Self::Export,
38 Self::Import,
39 Self::Done,
40 Self::Purge,
41 Self::Update,
42 Self::Quit,
43 ];
44
45 pub fn id(self) -> &'static str {
46 match self {
47 Self::Search => "search",
48 Self::Settings => "settings",
49 Self::Labels => "labels",
50 Self::Help => "help",
51 Self::WhatsNew => "whatsnew",
52 Self::CopyTitle => "copytitle",
53 Self::CopyTask => "copy",
54 Self::Export => "export",
55 Self::Import => "import",
56 Self::Done => "done",
57 Self::Purge => "purge",
58 Self::Update => "update",
59 Self::Quit => "quit",
60 }
61 }
62
63 pub fn usage(self) -> &'static str {
64 match self {
65 Self::Import => "import <FILE>",
66 _ => self.id(),
67 }
68 }
69
70 pub fn label(self) -> &'static str {
71 match self {
72 Self::Search => "Search",
73 Self::Settings => "Settings",
74 Self::Labels => "Labels",
75 Self::Help => "Help",
76 Self::WhatsNew => "What's new",
77 Self::CopyTitle => "Copy title",
78 Self::CopyTask => "Copy task",
79 Self::Export => "Export archive",
80 Self::Import => "Import archive",
81 Self::Done => "Done tasks",
82 Self::Purge => "Purge done",
83 Self::Update => "Update",
84 Self::Quit => "Quit",
85 }
86 }
87
88 pub fn hint(self) -> &'static str {
89 match self {
90 Self::Search => "search tasks by text or label",
91 Self::Settings => "sort, theme, date, preview",
92 Self::Labels => "create, color, rename, delete labels",
93 Self::Help => "key reference",
94 Self::WhatsNew => "release highlights",
95 Self::CopyTitle => "copy task title",
96 Self::CopyTask => "copy task and description",
97 Self::Export => "save archive to ./",
98 Self::Import => "merge archive from file",
99 Self::Done => "show or hide completed tasks",
100 Self::Purge => "delete completed tasks in this view",
101 Self::Update => "install latest verified release",
102 Self::Quit => "leave mach",
103 }
104 }
105
106 fn keywords(self) -> &'static [&'static str] {
107 match self {
108 Self::Search => &["search"],
109 Self::Settings => &["settings"],
110 Self::Labels => &["labels", "tags"],
111 Self::Help => &["help"],
112 Self::WhatsNew => &["whatsnew"],
113 Self::CopyTitle => &["copytitle"],
114 Self::CopyTask => &["copy", "copytask"],
115 Self::Export => &["export", "backup"],
116 Self::Import => &["import", "restore"],
117 Self::Done => &["done", "hide", "show"],
118 Self::Purge => &["purge"],
119 Self::Update => &["update", "upgrade", "version"],
120 Self::Quit => &["quit"],
121 }
122 }
123
124 fn matches_head(self, head: &str) -> bool {
126 self.keywords().iter().any(|k| {
127 k.starts_with(head) || (head.starts_with(k) && k.len() >= 3)
130 })
131 }
132}
133
134pub fn matching(query: &str) -> Vec<SlashCommand> {
140 let head = query.split_whitespace().next().unwrap_or("").to_lowercase();
141 if head.is_empty() {
142 return SlashCommand::ALL.to_vec();
143 }
144 let mut hits: Vec<SlashCommand> = SlashCommand::ALL
145 .into_iter()
146 .filter(|c| c.matches_head(&head))
147 .collect();
148 if hits
149 .iter()
150 .any(|command| command.keywords().contains(&head.as_str()))
151 {
152 hits.retain(|command| command.keywords().contains(&head.as_str()));
153 }
154 hits
155}
156
157pub fn args_for(command: SlashCommand, query: &str) -> String {
159 let q = query.trim();
160 if q.is_empty() {
161 return String::new();
162 }
163 let lower = q.to_lowercase();
164 for key in command.keywords() {
165 if lower == *key {
166 return String::new();
167 }
168 if let Some(rest) = lower.strip_prefix(key)
169 && (rest.is_empty() || rest.starts_with(char::is_whitespace))
170 {
171 let n = key.len().min(q.len());
172 return q[n..].trim().to_string();
173 }
174 }
175 String::new()
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn search_is_first_when_unfiltered() {
184 let m = matching("");
185 assert_eq!(m[0], SlashCommand::Search);
186 assert!(m.contains(&SlashCommand::Settings));
187 assert!(m.contains(&SlashCommand::Done));
188 assert!(m.contains(&SlashCommand::Purge));
189 assert_eq!(m.len(), SlashCommand::ALL.len());
190 }
191
192 #[test]
193 fn typing_settings_is_only_settings() {
194 let m = matching("settings");
195 assert_eq!(m, vec![SlashCommand::Settings]);
196 }
197
198 #[test]
199 fn typing_set_is_only_settings() {
200 let m = matching("set");
201 assert_eq!(m, vec![SlashCommand::Settings]);
202 }
203
204 #[test]
205 fn search_args() {
206 assert_eq!(args_for(SlashCommand::Search, "search milk"), "milk");
207 assert_eq!(args_for(SlashCommand::Search, "search"), "");
208 assert_eq!(args_for(SlashCommand::Search, "milk"), "");
209 }
210
211 #[test]
212 fn free_text_is_not_a_command() {
213 assert!(matching("milk").is_empty());
214 }
215
216 #[test]
217 fn typing_done_is_done_command() {
218 let m = matching("done");
219 assert_eq!(m, vec![SlashCommand::Done]);
220 }
221
222 #[test]
223 fn typing_purge_is_only_purge() {
224 let m = matching("purge");
225 assert_eq!(m, vec![SlashCommand::Purge]);
226 let m = matching("purge all");
227 assert_eq!(m, vec![SlashCommand::Purge]);
230 }
231
232 #[test]
233 fn typing_update_is_update() {
234 assert_eq!(matching("update"), vec![SlashCommand::Update]);
235 assert_eq!(matching("upgrade"), vec![SlashCommand::Update]);
236 }
237
238 #[test]
239 fn whats_new_is_the_only_reopenable_launch_page() {
240 assert_eq!(matching("whatsnew"), vec![SlashCommand::WhatsNew]);
241 assert!(matching("about").is_empty());
242 }
243
244 #[test]
245 fn fixed_order_when_several_match() {
246 let m = matching("s");
248 assert_eq!(m[0], SlashCommand::Search);
249 assert!(m.contains(&SlashCommand::Settings));
250 }
251
252 #[test]
253 fn copy_commands_match() {
254 assert_eq!(matching("copy"), vec![SlashCommand::CopyTask]);
255 assert!(matching("title").is_empty());
256 assert_eq!(matching("copytitle"), vec![SlashCommand::CopyTitle]);
257 let m = matching("copyt");
258 assert!(m.contains(&SlashCommand::CopyTitle));
259 assert!(m.contains(&SlashCommand::CopyTask));
260 }
261
262 #[test]
263 fn archive_commands_match_and_preserve_path_arguments() {
264 assert_eq!(matching("export"), vec![SlashCommand::Export]);
265 assert_eq!(matching("backup"), vec![SlashCommand::Export]);
266 assert_eq!(matching("import"), vec![SlashCommand::Import]);
267 assert_eq!(matching("restore"), vec![SlashCommand::Import]);
268 assert_eq!(
269 args_for(SlashCommand::Import, "import ~/My Tasks.mach"),
270 "~/My Tasks.mach"
271 );
272 }
273}