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