Skip to main content

mach/
slash.rs

1//! The `/` command palette: navigation, information, task actions, updates and quit.
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum SlashCommand {
5    Search,
6    Settings,
7    Hints,
8    Labels,
9    Help,
10    WhatsNew,
11    /// Copy the selected task's title to the clipboard.
12    CopyTitle,
13    /// Copy the selected task's title and description to the clipboard.
14    CopyTask,
15    /// Export every task, category, and image to a portable archive.
16    Export,
17    /// Safely merge a portable archive into the current store.
18    Import,
19    /// Toggle whether completed tasks are shown in the list.
20    Done,
21    /// Permanently remove done tasks (current category, or all in All Tasks).
22    Purge,
23    /// Install the latest checksum-verified GitHub release.
24    Update,
25    Quit,
26}
27
28impl SlashCommand {
29    /// Fixed palette order. Search is first when the menu opens empty.
30    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    /// Whether this command matches the typed query (first word / prefix).
131    fn matches_head(self, head: &str) -> bool {
132        self.keywords().iter().any(|k| {
133            // Keyword starts with what was typed ("set" → settings),
134            // or typed text starts with the keyword ("search milk").
135            k.starts_with(head) || (head.starts_with(k) && k.len() >= 3)
136        })
137    }
138}
139
140/// Commands matching `query`, in fixed palette order.
141/// Empty query → all commands (Search first). Unknown text matches nothing
142/// (task search is type-to-jump in the list, or `/search …` explicitly).
143/// An exact keyword hit (`copy`) wins over a longer progressive match
144/// (`copytitle`), so short names stay unambiguous.
145pub 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
163/// Text after the command keyword, e.g. `"search milk"` → `"milk"`.
164pub 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        // No separate purge-all command; free-text "all" is not a keyword hit alone
240        // after "purge", so only Purge matches via head "purge".
241        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        // "s" hits search and settings; palette order keeps search first.
259        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}