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