open_sesame/util/
mru.rs

1//! MRU (Most Recently Used) window tracking
2//!
3//! Tracks current and previous windows to enable proper Alt+Tab behavior.
4//! Quick Alt+Tab switches to the previous window by ID lookup.
5//!
6//! Uses file locking to prevent race conditions during concurrent access.
7
8use crate::util::paths;
9use std::fs::{File, OpenOptions};
10use std::io::{Read, Seek, Write};
11use std::os::unix::io::AsRawFd;
12use std::path::PathBuf;
13
14/// MRU state containing current and previous window IDs
15#[derive(Debug, Default)]
16pub struct MruState {
17    /// The currently focused window (what we just switched TO)
18    pub current: Option<String>,
19    /// The previously focused window (what quick Alt+Tab should switch TO)
20    pub previous: Option<String>,
21}
22
23/// Returns the MRU state file path.
24///
25/// Uses ~/.cache/open-sesame/mru with secure permissions on directory.
26fn mru_path() -> PathBuf {
27    match paths::mru_file() {
28        Ok(path) => path,
29        Err(e) => {
30            tracing::warn!(
31                "Failed to get secure MRU path: {}. MRU tracking disabled.",
32                e
33            );
34            PathBuf::from("/nonexistent/open-sesame-mru")
35        }
36    }
37}
38
39/// Acquires exclusive lock on file, returning the locked file handle.
40fn lock_file_exclusive(file: &File) -> bool {
41    let fd = file.as_raw_fd();
42    unsafe { libc::flock(fd, libc::LOCK_EX) == 0 }
43}
44
45/// Acquires shared lock on file for reading.
46fn lock_file_shared(file: &File) -> bool {
47    let fd = file.as_raw_fd();
48    unsafe { libc::flock(fd, libc::LOCK_SH) == 0 }
49}
50
51/// Parses MRU state from file contents.
52fn parse_mru_contents(contents: &str) -> MruState {
53    let lines: Vec<&str> = contents.lines().collect();
54    let previous = lines
55        .first()
56        .map(|s| s.trim().to_string())
57        .filter(|s| !s.is_empty());
58    let current = lines
59        .get(1)
60        .map(|s| s.trim().to_string())
61        .filter(|s| !s.is_empty());
62
63    MruState { current, previous }
64}
65
66/// Saves MRU state when activating a window.
67///
68/// Uses file locking to prevent race conditions during read-modify-write.
69/// Origin window becomes "previous", and new window becomes "current".
70///
71/// # Arguments
72/// * `origin_window_id` - The window the user was on when they started the launcher (window of origin)
73/// * `new_window_id` - The window being activated
74pub fn save_activated_window(origin_window_id: Option<&str>, new_window_id: &str) {
75    let path = mru_path();
76
77    // No update when activating same window as origin
78    if origin_window_id == Some(new_window_id) {
79        tracing::debug!("MRU: activating same window as origin, not updating");
80        return;
81    }
82
83    // File opened for read+write with exclusive lock
84    let file = match OpenOptions::new()
85        .read(true)
86        .write(true)
87        .create(true)
88        .truncate(false)
89        .open(&path)
90    {
91        Ok(f) => f,
92        Err(e) => {
93            tracing::warn!("Failed to open MRU file: {}", e);
94            return;
95        }
96    };
97
98    // Exclusive lock acquired (blocking) for atomic read-modify-write
99    if !lock_file_exclusive(&file) {
100        tracing::warn!("Failed to lock MRU file for writing");
101        return;
102    }
103
104    // New state written: origin becomes previous, new becomes current
105    let previous = origin_window_id.unwrap_or("");
106    let new_state = format!("{}\n{}", previous, new_window_id);
107
108    let mut file = file;
109    if let Err(e) = file.seek(std::io::SeekFrom::Start(0)) {
110        tracing::warn!("Failed to seek MRU file: {}", e);
111        return;
112    }
113
114    if let Err(e) = file.set_len(0) {
115        tracing::warn!("Failed to truncate MRU file: {}", e);
116        return;
117    }
118
119    if let Err(e) = file.write_all(new_state.as_bytes()) {
120        tracing::warn!("Failed to write MRU state: {}", e);
121        return;
122    }
123
124    tracing::info!(
125        "MRU: saved state - previous={:?}, current={}",
126        origin_window_id,
127        new_window_id
128    );
129    // Lock released on drop
130}
131
132/// Loads MRU state with shared lock for consistent reads.
133pub fn load_mru_state() -> MruState {
134    let path = mru_path();
135
136    let file = match File::open(&path) {
137        Ok(f) => f,
138        Err(_) => {
139            tracing::debug!("MRU: no state file found");
140            return MruState::default();
141        }
142    };
143
144    // Shared lock acquired for consistent read
145    if !lock_file_shared(&file) {
146        tracing::warn!("Failed to lock MRU file for reading");
147        return MruState::default();
148    }
149
150    let mut contents = String::new();
151    let mut file = file;
152    if file.read_to_string(&mut contents).is_err() {
153        return MruState::default();
154    }
155
156    let state = parse_mru_contents(&contents);
157
158    tracing::debug!(
159        "MRU: loaded state - previous={:?}, current={:?}",
160        state.previous,
161        state.current
162    );
163
164    state
165    // Lock released on drop
166}
167
168/// Returns the previous window ID for quick Alt+Tab.
169pub fn get_previous_window() -> Option<String> {
170    let state = load_mru_state();
171    state.previous
172}
173
174/// Returns the current window ID.
175pub fn get_current_window() -> Option<String> {
176    let state = load_mru_state();
177    state.current
178}
179
180/// Reorders windows placing current window at the end.
181///
182/// Places "previous" window at index 0 for visual display.
183pub fn reorder_for_mru<T, F>(windows: &mut Vec<T>, get_id: F)
184where
185    F: Fn(&T) -> &str,
186{
187    let state = load_mru_state();
188
189    if let Some(current_id) = &state.current {
190        if let Some(pos) = windows.iter().position(|w| get_id(w) == current_id) {
191            if pos < windows.len() - 1 {
192                let window = windows.remove(pos);
193                windows.push(window);
194                tracing::info!("MRU: moved current window from index {} to end", pos);
195            } else {
196                tracing::debug!("MRU: current window already at end");
197            }
198        } else {
199            tracing::debug!("MRU: current window not found in list");
200        }
201    } else {
202        tracing::debug!("MRU: no current window recorded");
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_parse_mru_contents_empty() {
212        let state = parse_mru_contents("");
213        assert!(state.previous.is_none());
214        assert!(state.current.is_none());
215    }
216
217    #[test]
218    fn test_parse_mru_contents_single_line() {
219        let state = parse_mru_contents("window-id-prev");
220        assert_eq!(state.previous, Some("window-id-prev".to_string()));
221        assert!(state.current.is_none());
222    }
223
224    #[test]
225    fn test_parse_mru_contents_two_lines() {
226        let state = parse_mru_contents("window-prev\nwindow-current");
227        assert_eq!(state.previous, Some("window-prev".to_string()));
228        assert_eq!(state.current, Some("window-current".to_string()));
229    }
230
231    #[test]
232    fn test_parse_mru_contents_with_whitespace() {
233        let state = parse_mru_contents("  window-prev  \n  window-current  ");
234        assert_eq!(state.previous, Some("window-prev".to_string()));
235        assert_eq!(state.current, Some("window-current".to_string()));
236    }
237
238    #[test]
239    fn test_parse_mru_contents_empty_lines() {
240        let state = parse_mru_contents("\n");
241        assert!(state.previous.is_none());
242        assert!(state.current.is_none());
243    }
244
245    #[test]
246    fn test_mru_state_default() {
247        let state = MruState::default();
248        assert!(state.current.is_none());
249        assert!(state.previous.is_none());
250    }
251
252    #[test]
253    fn test_reorder_for_mru_basic() {
254        // Reorder logic tested independently of file system
255        // Algorithm tested via mocked data structures
256
257        #[derive(Debug, Clone, PartialEq)]
258        struct MockWindow {
259            id: String,
260            name: String,
261        }
262
263        let mut windows = vec![
264            MockWindow {
265                id: "a".to_string(),
266                name: "Window A".to_string(),
267            },
268            MockWindow {
269                id: "b".to_string(),
270                name: "Window B".to_string(),
271            },
272            MockWindow {
273                id: "c".to_string(),
274                name: "Window C".to_string(),
275            },
276        ];
277
278        // Simulates reorder_for_mru behavior with current_id = "a"
279        // (Full function testing requires file system mocking)
280        let current_id = "a";
281        if let Some(pos) = windows.iter().position(|w| w.id == current_id)
282            && pos < windows.len() - 1
283        {
284            let window = windows.remove(pos);
285            windows.push(window);
286        }
287
288        assert_eq!(windows[0].id, "b");
289        assert_eq!(windows[1].id, "c");
290        assert_eq!(windows[2].id, "a"); // Moved to end position
291    }
292
293    #[test]
294    fn test_reorder_already_at_end() {
295        #[derive(Debug, Clone, PartialEq)]
296        struct MockWindow {
297            id: String,
298        }
299
300        let mut windows = vec![
301            MockWindow {
302                id: "a".to_string(),
303            },
304            MockWindow {
305                id: "b".to_string(),
306            },
307            MockWindow {
308                id: "c".to_string(),
309            },
310        ];
311
312        // current_id "c" already at end, no movement
313        let current_id = "c";
314        let original = windows.clone();
315        if let Some(pos) = windows.iter().position(|w| w.id == current_id)
316            && pos < windows.len() - 1
317        {
318            let window = windows.remove(pos);
319            windows.push(window);
320        }
321
322        assert_eq!(windows, original);
323    }
324
325    #[test]
326    fn test_reorder_not_found() {
327        #[derive(Debug, Clone, PartialEq)]
328        struct MockWindow {
329            id: String,
330        }
331
332        let mut windows = vec![
333            MockWindow {
334                id: "a".to_string(),
335            },
336            MockWindow {
337                id: "b".to_string(),
338            },
339        ];
340
341        // Nonexistent current_id causes no changes
342        let current_id = "nonexistent";
343        let original = windows.clone();
344        if let Some(pos) = windows.iter().position(|w| w.id == current_id)
345            && pos < windows.len() - 1
346        {
347            let window = windows.remove(pos);
348            windows.push(window);
349        }
350
351        assert_eq!(windows, original);
352    }
353}