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    /// Toggle whether completed tasks are shown in the list.
14    Done,
15    /// Permanently remove done tasks (current category, or all in All Tasks).
16    Purge,
17    /// Install the latest verified GitHub release.
18    Update,
19    Quit,
20}
21
22impl SlashCommand {
23    /// Fixed palette order. Search is first when the menu opens empty.
24    pub const ALL: [Self; 10] = [
25        Self::Search,
26        Self::Settings,
27        Self::Help,
28        Self::WhatsNew,
29        Self::CopyTitle,
30        Self::CopyTask,
31        Self::Done,
32        Self::Purge,
33        Self::Update,
34        Self::Quit,
35    ];
36
37    pub fn id(self) -> &'static str {
38        match self {
39            Self::Search => "search",
40            Self::Settings => "settings",
41            Self::Help => "help",
42            Self::WhatsNew => "whatsnew",
43            Self::CopyTitle => "copytitle",
44            Self::CopyTask => "copy",
45            Self::Done => "done",
46            Self::Purge => "purge",
47            Self::Update => "update",
48            Self::Quit => "quit",
49        }
50    }
51
52    pub fn label(self) -> &'static str {
53        match self {
54            Self::Search => "Search",
55            Self::Settings => "Settings",
56            Self::Help => "Help",
57            Self::WhatsNew => "What's new",
58            Self::CopyTitle => "Copy title",
59            Self::CopyTask => "Copy task",
60            Self::Done => "Done tasks",
61            Self::Purge => "Purge done",
62            Self::Update => "Update",
63            Self::Quit => "Quit",
64        }
65    }
66
67    pub fn hint(self) -> &'static str {
68        match self {
69            Self::Search => "search tasks",
70            Self::Settings => "sort, theme, date, task preview",
71            Self::Help => "key reference",
72            Self::WhatsNew => "release highlights",
73            Self::CopyTitle => "copy selected task title",
74            Self::CopyTask => "copy selected task title and body",
75            Self::Done => "show or hide completed tasks",
76            Self::Purge => "delete completed tasks in this view",
77            Self::Update => "install the latest verified build",
78            Self::Quit => "leave mach",
79        }
80    }
81
82    fn keywords(self) -> &'static [&'static str] {
83        match self {
84            Self::Search => &["search"],
85            Self::Settings => &["settings"],
86            Self::Help => &["help"],
87            Self::WhatsNew => &["whatsnew"],
88            Self::CopyTitle => &["copytitle"],
89            Self::CopyTask => &["copy", "copytask"],
90            Self::Done => &["done", "hide", "show"],
91            Self::Purge => &["purge"],
92            Self::Update => &["update", "upgrade", "version"],
93            Self::Quit => &["quit"],
94        }
95    }
96
97    /// Whether this command matches the typed query (first word / prefix).
98    pub fn matches(self, query: &str) -> bool {
99        let q = query.trim().to_lowercase();
100        if q.is_empty() {
101            return true;
102        }
103        let head = q.split_whitespace().next().unwrap_or("");
104        self.keywords().iter().any(|k| {
105            // Keyword starts with what was typed ("set" → settings),
106            // or typed text starts with the keyword ("search milk").
107            k.starts_with(head) || (head.starts_with(k) && k.len() >= 3)
108        })
109    }
110}
111
112/// Commands matching `query`, in fixed palette order.
113/// Empty query → all commands (Search first). Unknown text matches nothing
114/// (task search is type-to-jump in the list, or `/search …` explicitly).
115/// An exact keyword hit (`copy`) wins over a longer progressive match
116/// (`copytitle`), so short names stay unambiguous.
117pub fn matching(query: &str) -> Vec<SlashCommand> {
118    let q = query.trim();
119    if q.is_empty() {
120        return SlashCommand::ALL.to_vec();
121    }
122    let hits: Vec<SlashCommand> = SlashCommand::ALL
123        .into_iter()
124        .filter(|c| c.matches(query))
125        .collect();
126    let head = q.split_whitespace().next().unwrap_or("").to_lowercase();
127    let exact: Vec<SlashCommand> = hits
128        .iter()
129        .copied()
130        .filter(|c| c.keywords().iter().any(|k| *k == head))
131        .collect();
132    if exact.is_empty() { hits } else { exact }
133}
134
135/// Text after the command keyword, e.g. `"search milk"` → `"milk"`.
136pub fn args_for(command: SlashCommand, query: &str) -> String {
137    let q = query.trim();
138    if q.is_empty() {
139        return String::new();
140    }
141    let lower = q.to_lowercase();
142    for key in command.keywords() {
143        if lower == *key {
144            return String::new();
145        }
146        if let Some(rest) = lower.strip_prefix(key)
147            && (rest.is_empty() || rest.starts_with(char::is_whitespace))
148        {
149            let n = key.len().min(q.len());
150            return q[n..].trim().to_string();
151        }
152    }
153    String::new()
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn search_is_first_when_unfiltered() {
162        let m = matching("");
163        assert_eq!(m[0], SlashCommand::Search);
164        assert!(m.contains(&SlashCommand::Settings));
165        assert!(m.contains(&SlashCommand::Done));
166        assert!(m.contains(&SlashCommand::Purge));
167        assert_eq!(m.len(), SlashCommand::ALL.len());
168    }
169
170    #[test]
171    fn typing_settings_is_only_settings() {
172        let m = matching("settings");
173        assert_eq!(m, vec![SlashCommand::Settings]);
174    }
175
176    #[test]
177    fn typing_set_is_only_settings() {
178        let m = matching("set");
179        assert_eq!(m, vec![SlashCommand::Settings]);
180    }
181
182    #[test]
183    fn search_args() {
184        assert_eq!(args_for(SlashCommand::Search, "search milk"), "milk");
185        assert_eq!(args_for(SlashCommand::Search, "search"), "");
186        assert_eq!(args_for(SlashCommand::Search, "milk"), "");
187    }
188
189    #[test]
190    fn free_text_is_not_a_command() {
191        assert!(matching("milk").is_empty());
192    }
193
194    #[test]
195    fn typing_done_is_done_command() {
196        let m = matching("done");
197        assert_eq!(m, vec![SlashCommand::Done]);
198    }
199
200    #[test]
201    fn typing_purge_is_only_purge() {
202        let m = matching("purge");
203        assert_eq!(m, vec![SlashCommand::Purge]);
204        let m = matching("purge all");
205        // No separate purge-all command; free-text "all" is not a keyword hit alone
206        // after "purge", so only Purge matches via head "purge".
207        assert_eq!(m, vec![SlashCommand::Purge]);
208    }
209
210    #[test]
211    fn typing_update_is_update() {
212        assert_eq!(matching("update"), vec![SlashCommand::Update]);
213        assert_eq!(matching("upgrade"), vec![SlashCommand::Update]);
214    }
215
216    #[test]
217    fn whats_new_is_the_only_reopenable_launch_page() {
218        assert_eq!(matching("whatsnew"), vec![SlashCommand::WhatsNew]);
219        assert!(matching("about").is_empty());
220    }
221
222    #[test]
223    fn fixed_order_when_several_match() {
224        // "s" hits search and settings; palette order keeps search first.
225        let m = matching("s");
226        assert_eq!(m[0], SlashCommand::Search);
227        assert!(m.contains(&SlashCommand::Settings));
228    }
229
230    #[test]
231    fn copy_commands_match() {
232        assert_eq!(matching("copy"), vec![SlashCommand::CopyTask]);
233        assert!(matching("title").is_empty());
234        assert_eq!(matching("copytitle"), vec![SlashCommand::CopyTitle]);
235        let m = matching("copyt");
236        assert!(m.contains(&SlashCommand::CopyTitle));
237        assert!(m.contains(&SlashCommand::CopyTask));
238    }
239}