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