Skip to main content

mach/
slash.rs

1//! The `/` command palette: search, settings, help, copy, done, purge, quit.
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum SlashCommand {
5    Search,
6    Settings,
7    Help,
8    /// Copy the selected task's title to the clipboard.
9    CopyTitle,
10    /// Copy the selected task's title and body to the clipboard.
11    CopyTask,
12    /// Toggle whether completed tasks are shown in the list.
13    Done,
14    /// Permanently remove done tasks (current category, or all in All Tasks).
15    Purge,
16    /// Check GitHub for a newer release.
17    Update,
18    Quit,
19}
20
21impl SlashCommand {
22    /// Fixed palette order. Search is first when the menu opens empty.
23    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    /// Whether this command matches the typed query (first word / prefix).
92    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            // Keyword starts with what was typed ("set" → settings),
100            // or typed text starts with the keyword ("search milk").
101            k.starts_with(head) || (head.starts_with(k) && k.len() >= 3)
102        })
103    }
104}
105
106/// Commands matching `query`, in fixed palette order.
107/// Empty query → all commands (Search first). Unknown text matches nothing
108/// (task search is type-to-jump in the list, or `/search …` explicitly).
109/// An exact keyword hit (`copy`) wins over a longer progressive match
110/// (`copytitle`), so short names stay unambiguous.
111pub 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
129/// Text after the command keyword, e.g. `"search milk"` → `"milk"`.
130pub 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        // No separate purge-all command; free-text "all" is not a keyword hit alone
200        // after "purge", so only Purge matches via head "purge".
201        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        // "s" hits search and settings; palette order keeps search first.
213        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}