open_sesame/core/
matcher.rs

1//! Input matching against hints
2//!
3//! Matches user keyboard input against assigned window hints.
4
5use crate::core::hint::WindowHint;
6use crate::core::window::WindowId;
7
8/// Result of matching user input against hints
9///
10/// Represents the outcome of matching user keyboard input against
11/// assigned window hints using [`HintMatcher`].
12///
13/// # Examples
14///
15/// ```
16/// use open_sesame::MatchResult;
17///
18/// # let result = MatchResult::None;
19/// if result.is_exact() {
20///     println!("Exact match found!");
21///     if let Some(window_id) = result.window_id() {
22///         println!("Window ID: {}", window_id);
23///     }
24/// }
25/// ```
26#[derive(Debug, Clone, PartialEq)]
27pub enum MatchResult {
28    /// No hints match the input
29    None,
30    /// Multiple hints could match (need more input)
31    Partial(Vec<usize>),
32    /// Exactly one hint matches
33    Exact {
34        /// Index of the matched hint
35        index: usize,
36        /// Window ID for activation
37        window_id: WindowId,
38    },
39}
40
41impl MatchResult {
42    /// Returns true if this is an exact match.
43    pub fn is_exact(&self) -> bool {
44        matches!(self, MatchResult::Exact { .. })
45    }
46
47    /// Returns true if there is no match.
48    pub fn is_none(&self) -> bool {
49        matches!(self, MatchResult::None)
50    }
51
52    /// Returns the window ID if this is an exact match.
53    pub fn window_id(&self) -> Option<&WindowId> {
54        match self {
55            MatchResult::Exact { window_id, .. } => Some(window_id),
56            _ => None,
57        }
58    }
59}
60
61/// Matcher for finding windows based on input
62///
63/// Matches user keyboard input against assigned window hints, supporting
64/// both exact matches and partial matches for disambiguation.
65///
66/// # Examples
67///
68/// ```
69/// use open_sesame::{HintMatcher, HintAssignment, Window};
70///
71/// let windows = vec![
72///     Window::new("win-1", "firefox", "Tab 1"),
73///     Window::new("win-2", "ghostty", "Terminal"),
74/// ];
75///
76/// let assignment = HintAssignment::assign(&windows, |app_id| {
77///     match app_id.as_str() {
78///         "firefox" => Some('f'),
79///         "ghostty" => Some('g'),
80///         _ => None,
81///     }
82/// });
83///
84/// let matcher = HintMatcher::new(assignment.hints());
85///
86/// // Match user input
87/// let result = matcher.match_input("g");
88/// assert!(result.is_exact());
89///
90/// // Filter hints by input
91/// let filtered = matcher.filter_hints("f");
92/// assert_eq!(filtered.len(), 1);
93/// ```
94pub struct HintMatcher<'a> {
95    hints: &'a [WindowHint],
96}
97
98impl<'a> HintMatcher<'a> {
99    /// Creates a new matcher with the given hints.
100    pub fn new(hints: &'a [WindowHint]) -> Self {
101        Self { hints }
102    }
103
104    /// Matches input against hints and returns the match result.
105    pub fn match_input(&self, input: &str) -> MatchResult {
106        if input.is_empty() {
107            return MatchResult::Partial(self.hints.iter().map(|h| h.index).collect());
108        }
109
110        // Finds all hints that could match the input
111        let matches: Vec<_> = self
112            .hints
113            .iter()
114            .filter(|h| h.hint.matches_input(input))
115            .collect();
116
117        match matches.len() {
118            0 => MatchResult::None,
119            1 => MatchResult::Exact {
120                index: matches[0].index,
121                window_id: matches[0].window_id.clone(),
122            },
123            _ => {
124                // Checks for exact match among partial matches
125                if let Some(exact) = matches.iter().find(|h| h.hint.equals_input(input)) {
126                    MatchResult::Exact {
127                        index: exact.index,
128                        window_id: exact.window_id.clone(),
129                    }
130                } else {
131                    MatchResult::Partial(matches.iter().map(|h| h.index).collect())
132                }
133            }
134        }
135    }
136
137    /// Returns hints that match the current input for display filtering.
138    pub fn filter_hints(&self, input: &str) -> Vec<&WindowHint> {
139        if input.is_empty() {
140            self.hints.iter().collect()
141        } else {
142            self.hints
143                .iter()
144                .filter(|h| h.hint.matches_input(input))
145                .collect()
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::core::hint::HintAssignment;
154    use crate::core::window::Window;
155
156    fn create_test_hints() -> Vec<WindowHint> {
157        let windows = vec![
158            Window::mock("firefox", "Tab 1"),
159            Window::mock("firefox", "Tab 2"),
160            Window::mock("ghostty", "Terminal"),
161        ];
162
163        HintAssignment::assign(&windows, |app_id| match app_id.as_str() {
164            "firefox" => Some('f'),
165            "ghostty" => Some('g'),
166            _ => None,
167        })
168        .hints
169    }
170
171    #[test]
172    fn test_match_exact_single() {
173        let hints = create_test_hints();
174        let matcher = HintMatcher::new(&hints);
175
176        // "g" should match ghostty exactly
177        let result = matcher.match_input("g");
178        assert!(result.is_exact());
179    }
180
181    #[test]
182    fn test_match_exact_with_multiple_windows() {
183        let hints = create_test_hints();
184        let matcher = HintMatcher::new(&hints);
185
186        // "f" is exact match for first firefox
187        let result = matcher.match_input("f");
188        assert!(result.is_exact());
189
190        // "ff" is exact match for second firefox
191        let result = matcher.match_input("ff");
192        assert!(result.is_exact());
193    }
194
195    #[test]
196    fn test_match_none() {
197        let hints = create_test_hints();
198        let matcher = HintMatcher::new(&hints);
199
200        let result = matcher.match_input("x");
201        assert!(result.is_none());
202    }
203
204    #[test]
205    fn test_match_number_pattern() {
206        let hints = create_test_hints();
207        let matcher = HintMatcher::new(&hints);
208
209        // "g1" = "g" = exact match
210        let result = matcher.match_input("g1");
211        assert!(result.is_exact());
212
213        // "f2" = "ff" = exact match for second firefox
214        let result = matcher.match_input("f2");
215        assert!(result.is_exact());
216    }
217
218    #[test]
219    fn test_filter_hints() {
220        let hints = create_test_hints();
221        let matcher = HintMatcher::new(&hints);
222
223        // Empty input shows all
224        let filtered = matcher.filter_hints("");
225        assert_eq!(filtered.len(), 3);
226
227        // "f" shows both firefox windows
228        let filtered = matcher.filter_hints("f");
229        assert_eq!(filtered.len(), 2);
230
231        // "ff" shows only second firefox
232        let filtered = matcher.filter_hints("ff");
233        assert_eq!(filtered.len(), 1);
234    }
235}