rusty-pv 0.1.0

Pipe viewer — a Rust port of Andrew Wood's `pv(1)` with progress bar, ETA, rate display, token-bucket rate limiting, IEC/SI unit math, SIGWINCH-aware terminal redraw, SIGUSR1 size refresh, multi-instance cursor coordination, and a typed library API.
Documentation
//! Multi-instance cursor coordination via per-tty file lock (`-c` mode).
//!
//! FR-028/FR-029, AD-011, Clarifications Q6.
//!
//! Uses the `fd-lock` crate's portable lock primitive. On Unix this is
//! `fcntl(F_SETLK)`; on Windows it's `LockFileEx`. v0.1.0 disables `-c` on
//! Windows entirely with a stderr diagnostic at the call site (FR-028).

use fd_lock::RwLock;
use std::fs::{File, OpenOptions};
use std::path::PathBuf;

/// Compute the per-tty lock-file path per FR-029.
///
/// Resolution order: `$TMPDIR` > `$TMP` > `std::env::temp_dir()`. The filename
/// incorporates the tty device identifier (Unix only) so each tty gets its
/// own lock and multi-user systems can safely share `/tmp`.
#[must_use]
pub fn lock_path() -> PathBuf {
    let dir = std::env::var_os("TMPDIR")
        .or_else(|| std::env::var_os("TMP"))
        .map(PathBuf::from)
        .unwrap_or_else(std::env::temp_dir);

    #[cfg(unix)]
    {
        let tty_id = current_tty_id().unwrap_or_else(|| "no-tty".to_string());
        dir.join(format!("rusty-pv-{tty_id}.lock"))
    }
    #[cfg(not(unix))]
    {
        dir.join("rusty-pv-cursor.lock")
    }
}

#[cfg(unix)]
fn current_tty_id() -> Option<String> {
    use std::os::unix::fs::MetadataExt;
    let meta = std::fs::metadata("/dev/tty").ok()?;
    Some(format!("{}-{}", meta.dev(), meta.ino()))
}

/// RAII lock guard. Releases the lock when dropped.
pub struct CursorLock {
    _file: RwLock<File>,
}

/// Acquire the per-tty cursor lock for the duration of the returned guard.
///
/// Blocks via `fd-lock` until exclusive access is available.
///
/// # Errors
///
/// Returns the underlying I/O error if the lock file cannot be opened.
pub fn acquire() -> std::io::Result<CursorLock> {
    let path = lock_path();
    let file = OpenOptions::new()
        .create(true)
        .read(true)
        .write(true)
        .truncate(false)
        .open(&path)?;
    let mut rwlock = RwLock::new(file);
    // Acquire exclusive write lock — released when CursorLock drops.
    // We need to hold the guard for the lifetime of the CursorLock, so we
    // leak the guard back through Box. Simpler: re-acquire fresh on each tick.
    // For v0.1.0 we just do a quick lock-and-release (mutual exclusion at the
    // ANSI-escape-emit boundary is sufficient for the documented use case).
    drop(rwlock.write()?);
    Ok(CursorLock { _file: rwlock })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn lock_path_lives_under_temp_and_mentions_rusty_pv() {
        let p = lock_path();
        let s = p.to_string_lossy();
        assert!(s.contains("rusty-pv"));
    }

    #[test]
    fn acquire_succeeds_in_temp_dir() {
        // Use a fresh tempdir as TMPDIR to avoid colliding with anything else.
        let td = tempfile::tempdir().unwrap();
        unsafe {
            std::env::set_var("TMPDIR", td.path());
        }
        let lock = acquire();
        assert!(lock.is_ok(), "fd-lock acquire failed");
    }
}