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