lsofrs 4.7.0

Modern, high-performance lsof implementation in Rust
Documentation
//! Delta highlighting — track FD changes between iterations

use std::collections::HashMap;
use std::io::{self, Write};

use crate::output::Theme;
use crate::types::*;

type DeltaKey = (i32, String, String); // (pid, fd, name)

struct DeltaEntry {
    pid: i32,
    fd: String,
    name: String,
    file_type: String,
    command: String,
    uid: u32,
}

pub struct DeltaTracker {
    prev: HashMap<DeltaKey, DeltaEntry>,
    curr: HashMap<DeltaKey, DeltaEntry>,
    pub new_count: usize,
    pub gone_count: usize,
}

impl Default for DeltaTracker {
    fn default() -> Self {
        Self::new()
    }
}

impl DeltaTracker {
    pub fn new() -> Self {
        Self {
            prev: HashMap::new(),
            curr: HashMap::new(),
            new_count: 0,
            gone_count: 0,
        }
    }

    pub fn begin_iteration(&mut self) {
        self.prev = std::mem::take(&mut self.curr);
        self.new_count = 0;
        self.gone_count = 0;
    }

    pub fn record(&mut self, proc: &Process) {
        for f in &proc.files {
            let fd_str = f.fd.with_access(f.access);
            let key = (proc.pid, fd_str.clone(), f.name.clone());
            self.curr.insert(
                key,
                DeltaEntry {
                    pid: proc.pid,
                    fd: fd_str,
                    name: f.name.clone(),
                    file_type: f.file_type.as_str().to_string(),
                    command: proc.command.clone(),
                    uid: proc.uid,
                },
            );
        }
    }

    pub fn classify(&self, pid: i32, fd: &str, name: &str) -> DeltaStatus {
        let key = (pid, fd.to_string(), name.to_string());
        if self.prev.contains_key(&key) {
            DeltaStatus::Unchanged
        } else {
            DeltaStatus::New
        }
    }

    pub fn count_gone(&mut self) {
        for key in self.prev.keys() {
            if !self.curr.contains_key(key) {
                self.gone_count += 1;
            }
        }
        self.new_count = self
            .curr
            .keys()
            .filter(|k| !self.prev.contains_key(k))
            .count();
    }

    pub fn print_gone(&self, theme: &Theme) {
        let out = io::stdout();
        let mut out = out.lock();

        for (key, entry) in &self.prev {
            if !self.curr.contains_key(key) {
                let username = users::get_user_by_uid(entry.uid)
                    .map(|u| u.name().to_string_lossy().into_owned())
                    .unwrap_or_else(|| entry.uid.to_string());

                let _ = writeln!(
                    out,
                    "{red}{cmd:<15} {pid:>7} {user:<8} {fd:<6} {type_:<5} {name} [GONE]{reset}",
                    cmd = if entry.command.len() > 15 {
                        &entry.command[..15]
                    } else {
                        &entry.command
                    },
                    pid = entry.pid,
                    user = if username.len() > 8 {
                        &username[..8]
                    } else {
                        &username
                    },
                    fd = entry.fd,
                    type_ = entry.file_type,
                    name = entry.name,
                    red = theme.red(),
                    reset = theme.reset(),
                );
            }
        }
    }

    pub fn print_summary(&self, theme: &Theme) {
        let out = io::stdout();
        let mut out = out.lock();
        let _ = writeln!(
            out,
            "{dim}[delta]{reset} {green}new: {}{reset}  {red}gone: {}{reset}",
            self.new_count,
            self.gone_count,
            dim = theme.dim(),
            green = theme.green(),
            red = theme.red(),
            reset = theme.reset(),
        );
    }
}

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

    fn make_proc(pid: i32, cmd: &str, files: Vec<(&str, &str)>) -> Process {
        Process {
            pid,
            ppid: 1,
            pgid: 1,
            uid: 501,
            command: cmd.to_string(),
            files: files
                .into_iter()
                .map(|(fd, name)| OpenFile {
                    fd: FdName::Number(fd.parse().unwrap()),
                    access: Access::ReadWrite,
                    file_type: FileType::Reg,
                    name: name.to_string(),
                    ..Default::default()
                })
                .collect(),
            sel_flags: 0,
            sel_state: 0,
        }
    }

    #[test]
    fn new_tracker_starts_empty() {
        let dt = DeltaTracker::new();
        assert_eq!(dt.new_count, 0);
        assert_eq!(dt.gone_count, 0);
    }

    #[test]
    fn first_iteration_all_new() {
        let mut dt = DeltaTracker::new();
        dt.begin_iteration();
        let p = make_proc(100, "test", vec![("3", "/tmp/a"), ("4", "/tmp/b")]);
        dt.record(&p);
        dt.count_gone();
        assert_eq!(dt.new_count, 2);
        assert_eq!(dt.gone_count, 0);
    }

    #[test]
    fn unchanged_files_not_counted() {
        let mut dt = DeltaTracker::new();

        // Iteration 1
        dt.begin_iteration();
        let p = make_proc(100, "test", vec![("3", "/tmp/a")]);
        dt.record(&p);
        dt.count_gone();

        // Iteration 2 - same files
        dt.begin_iteration();
        dt.record(&p);
        dt.count_gone();
        assert_eq!(dt.new_count, 0);
        assert_eq!(dt.gone_count, 0);
    }

    #[test]
    fn gone_files_detected() {
        let mut dt = DeltaTracker::new();

        // Iteration 1
        dt.begin_iteration();
        let p = make_proc(100, "test", vec![("3", "/tmp/a"), ("4", "/tmp/b")]);
        dt.record(&p);
        dt.count_gone();

        // Iteration 2 - one file removed
        dt.begin_iteration();
        let p2 = make_proc(100, "test", vec![("3", "/tmp/a")]);
        dt.record(&p2);
        dt.count_gone();
        assert_eq!(dt.gone_count, 1);
        assert_eq!(dt.new_count, 0);
    }

    #[test]
    fn new_files_detected() {
        let mut dt = DeltaTracker::new();

        // Iteration 1
        dt.begin_iteration();
        let p = make_proc(100, "test", vec![("3", "/tmp/a")]);
        dt.record(&p);
        dt.count_gone();

        // Iteration 2 - new file added
        dt.begin_iteration();
        let p2 = make_proc(100, "test", vec![("3", "/tmp/a"), ("5", "/tmp/c")]);
        dt.record(&p2);
        dt.count_gone();
        assert_eq!(dt.new_count, 1);
        assert_eq!(dt.gone_count, 0);
    }

    #[test]
    fn classify_new_vs_unchanged() {
        let mut dt = DeltaTracker::new();

        dt.begin_iteration();
        let p = make_proc(100, "test", vec![("3", "/tmp/a")]);
        dt.record(&p);
        dt.count_gone();

        dt.begin_iteration();
        let p2 = make_proc(100, "test", vec![("3", "/tmp/a"), ("5", "/tmp/new")]);
        dt.record(&p2);

        assert_eq!(dt.classify(100, "3u", "/tmp/a"), DeltaStatus::Unchanged);
        assert_eq!(dt.classify(100, "5u", "/tmp/new"), DeltaStatus::New);
    }

    #[test]
    fn multiple_processes_tracked() {
        let mut dt = DeltaTracker::new();

        dt.begin_iteration();
        dt.record(&make_proc(100, "a", vec![("3", "/tmp/a")]));
        dt.record(&make_proc(200, "b", vec![("4", "/tmp/b")]));
        dt.count_gone();

        dt.begin_iteration();
        dt.record(&make_proc(100, "a", vec![("3", "/tmp/a")]));
        // pid 200 gone
        dt.count_gone();
        assert_eq!(dt.gone_count, 1);
        assert_eq!(dt.new_count, 0);
    }
}