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