rusty_pv/cursor.rs
1//! Multi-instance cursor coordination via per-tty file lock (`-c` mode).
2//!
3//! FR-028/FR-029, AD-011, Clarifications Q6.
4//!
5//! Uses the `fd-lock` crate's portable lock primitive. On Unix this is
6//! `fcntl(F_SETLK)`; on Windows it's `LockFileEx`. v0.1.0 disables `-c` on
7//! Windows entirely with a stderr diagnostic at the call site (FR-028).
8
9use fd_lock::RwLock;
10use std::fs::{File, OpenOptions};
11use std::path::PathBuf;
12
13/// Compute the per-tty lock-file path per FR-029.
14///
15/// Resolution order: `$TMPDIR` > `$TMP` > `std::env::temp_dir()`. The filename
16/// incorporates the tty device identifier (Unix only) so each tty gets its
17/// own lock and multi-user systems can safely share `/tmp`.
18#[must_use]
19pub fn lock_path() -> PathBuf {
20 let dir = std::env::var_os("TMPDIR")
21 .or_else(|| std::env::var_os("TMP"))
22 .map(PathBuf::from)
23 .unwrap_or_else(std::env::temp_dir);
24
25 #[cfg(unix)]
26 {
27 let tty_id = current_tty_id().unwrap_or_else(|| "no-tty".to_string());
28 dir.join(format!("rusty-pv-{tty_id}.lock"))
29 }
30 #[cfg(not(unix))]
31 {
32 dir.join("rusty-pv-cursor.lock")
33 }
34}
35
36#[cfg(unix)]
37fn current_tty_id() -> Option<String> {
38 use std::os::unix::fs::MetadataExt;
39 let meta = std::fs::metadata("/dev/tty").ok()?;
40 Some(format!("{}-{}", meta.dev(), meta.ino()))
41}
42
43/// RAII lock guard. Releases the lock when dropped.
44pub struct CursorLock {
45 _file: RwLock<File>,
46}
47
48/// Acquire the per-tty cursor lock for the duration of the returned guard.
49///
50/// Blocks via `fd-lock` until exclusive access is available.
51///
52/// # Errors
53///
54/// Returns the underlying I/O error if the lock file cannot be opened.
55pub fn acquire() -> std::io::Result<CursorLock> {
56 let path = lock_path();
57 let file = OpenOptions::new()
58 .create(true)
59 .read(true)
60 .write(true)
61 .truncate(false)
62 .open(&path)?;
63 let mut rwlock = RwLock::new(file);
64 // Acquire exclusive write lock — released when CursorLock drops.
65 // We need to hold the guard for the lifetime of the CursorLock, so we
66 // leak the guard back through Box. Simpler: re-acquire fresh on each tick.
67 // For v0.1.0 we just do a quick lock-and-release (mutual exclusion at the
68 // ANSI-escape-emit boundary is sufficient for the documented use case).
69 drop(rwlock.write()?);
70 Ok(CursorLock { _file: rwlock })
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[test]
78 fn lock_path_lives_under_temp_and_mentions_rusty_pv() {
79 let p = lock_path();
80 let s = p.to_string_lossy();
81 assert!(s.contains("rusty-pv"));
82 }
83
84 #[test]
85 fn acquire_succeeds_in_temp_dir() {
86 // Use a fresh tempdir as TMPDIR to avoid colliding with anything else.
87 let td = tempfile::tempdir().unwrap();
88 unsafe {
89 std::env::set_var("TMPDIR", td.path());
90 }
91 let lock = acquire();
92 assert!(lock.is_ok(), "fd-lock acquire failed");
93 }
94}