src/source/file/rotation.rs

//! Detect log rotation and truncation.
//!
//! Tools like `logrotate` typically rotate by renaming and replacing the
//! original path with a fresh, empty file. We detect rotation by watching
//! the inode and size of the currently-open file.

use std::os::unix::fs::MetadataExt;
use std::path::Path;

use anyhow::{Context, Result};
use tokio::fs;

/// Snapshot of the relevant stat fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Snapshot {
    pub inode: u64,
    pub size: u64,
    pub dev: u64,
}

impl Snapshot {
    pub async fn of(path: impl AsRef<Path>) -> Result<Self> {
        let md = fs::metadata(path.as_ref()).await
            .with_context(|| format!("stat {}", path.as_ref().display()))?;
        Ok(Self {
            inode: md.ino(),
            size: md.size(),
            dev: md.dev(),
        })
    }
}

/// Result of comparing two snapshots.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Delta {
    /// Same inode, size is >= previous: normal append.
    Appended,
    /// Same inode, size dropped: truncation.
    Truncated,
    /// Different inode: rotation.
    Rotated,
    /// Device id changed: likely atomic mount swap.
    Replaced,
    /// Nothing changed.
    Unchanged,
}

pub fn compare(prev: &Snapshot, current: &Snapshot) -> Delta {
    if prev.dev != current.dev {
        return Delta::Replaced;
    }
    if prev.inode != current.inode {
        return Delta::Rotated;
    }
    match current.size.cmp(&prev.size) {
        std::cmp::Ordering::Less => Delta::Truncated,
        std::cmp::Ordering::Greater => Delta::Appended,
        std::cmp::Ordering::Equal => Delta::Unchanged,
    }
}

/// Convenience wrapper that re-reads the file's metadata and compares it
/// against `prev`, returning the new snapshot and a delta.
pub async fn poll(path: impl AsRef<Path>, prev: &Snapshot) -> Result<(Snapshot, Delta)> {
    let current = Snapshot::of(path).await?;
    let delta = compare(prev, &current);
    Ok((current, delta))
}

impl Delta {
    pub fn requires_reopen(self) -> bool {
        matches!(self, Delta::Rotated | Delta::Replaced | Delta::Truncated)
    }
}