use std::collections::HashMap;
use std::io::{self, IsTerminal, Write};
use std::time::Duration;
use crate::output::Theme;
struct WatchEntry {
pid: i32,
command: String,
uid: u32,
fd: String,
first_seen: u64,
last_seen: u64,
gone: bool,
}
pub fn run_watch(path: &str, interval: u64, theme: &Theme) {
let mut table: HashMap<(i32, String), WatchEntry> = HashMap::new();
let mut iteration = 0u64;
let is_tty = io::stdout().is_terminal();
let canon = std::fs::canonicalize(path)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string());
if is_tty {
eprintln!(
"{bold}lsofrs watch{reset}: monitoring {cyan}{canon}{reset} (refresh {interval}s)",
bold = theme.bold(),
cyan = theme.cyan(),
reset = theme.reset(),
);
eprintln!();
}
loop {
iteration += 1;
for entry in table.values_mut() {
entry.gone = true;
}
let procs = crate::darwin::gather_processes();
for p in &procs {
for f in &p.files {
if !file_matches(&f.name, &canon, path) {
continue;
}
let fd_str = f.fd.with_access(f.access);
let key = (p.pid, fd_str.clone());
let entry = table.entry(key).or_insert_with(|| {
let e = WatchEntry {
pid: p.pid,
command: p.command.clone(),
uid: p.uid,
fd: fd_str.clone(),
first_seen: iteration,
last_seen: iteration,
gone: false,
};
print_event(theme, "+OPEN", p.pid, &p.command, p.uid, &fd_str, iteration);
e
});
entry.last_seen = iteration;
entry.gone = false;
}
}
let gone_keys: Vec<(i32, String)> = table
.iter()
.filter(|(_, e)| e.gone)
.map(|(k, _)| k.clone())
.collect();
for key in &gone_keys {
let entry = &table[key];
print_event(
theme,
"-CLOSE",
entry.pid,
&entry.command,
entry.uid,
&entry.fd,
iteration,
);
}
for key in &gone_keys {
table.remove(key);
}
if !is_tty {
print_snapshot(theme, &table, &canon, iteration);
break;
}
std::thread::sleep(Duration::from_secs(interval));
}
}
fn file_matches(name: &str, canon: &str, original: &str) -> bool {
name == canon
|| name == original
|| name.starts_with(&format!("{canon}/"))
|| name.starts_with(&format!("{original}/"))
}
fn print_event(theme: &Theme, tag: &str, pid: i32, cmd: &str, uid: u32, fd: &str, _iter: u64) {
let out = io::stdout();
let mut out = out.lock();
let r = theme.reset();
let username = users::get_user_by_uid(uid)
.map(|u| u.name().to_string_lossy().into_owned())
.unwrap_or_else(|| uid.to_string());
let (color, symbol) = if tag.starts_with('+') {
(theme.green(), "+")
} else {
(theme.red(), "-")
};
let cmd_display = if cmd.len() > 20 { &cmd[..20] } else { cmd };
let user_display = if username.len() > 8 {
&username[..8]
} else {
&username
};
let now = chrono::Local::now().format("%H:%M:%S");
let _ = writeln!(
out,
"{dim}{now}{r} {color}{symbol}{tag:<6}{r} {mag}{pid:>7}{r} {yellow}{user:<8}{r} {fd:<5} {cyan}{cmd}{r}",
dim = theme.dim(),
mag = theme.magenta(),
yellow = theme.yellow(),
cyan = theme.cyan(),
tag = &tag[1..], user = user_display,
cmd = cmd_display,
);
}
fn print_snapshot(
theme: &Theme,
table: &HashMap<(i32, String), WatchEntry>,
path: &str,
_iteration: u64,
) {
let out = io::stdout();
let mut out = out.lock();
let r = theme.reset();
let _ = writeln!(
out,
"{bold}Processes with {path} open:{r}",
bold = theme.bold(),
);
if table.is_empty() {
let _ = writeln!(out, " (none)");
return;
}
let _ = writeln!(
out,
"{bold}{hdr}{:>7} {:<8} {:<5} COMMAND{r}",
"PID",
"USER",
"FD",
bold = theme.bold(),
hdr = theme.hdr_bg(),
);
let mut entries: Vec<&WatchEntry> = table.values().collect();
entries.sort_by_key(|e| e.pid);
for e in entries {
let username = users::get_user_by_uid(e.uid)
.map(|u| u.name().to_string_lossy().into_owned())
.unwrap_or_else(|| e.uid.to_string());
let user_display = if username.len() > 8 {
&username[..8]
} else {
&username
};
let cmd = if e.command.len() > 25 {
&e.command[..25]
} else {
&e.command
};
let _ = writeln!(
out,
"{mag}{:>7}{r} {yellow}{:<8}{r} {green}{:<5}{r} {cyan}{cmd}{r}",
e.pid,
user_display,
e.fd,
mag = theme.magenta(),
yellow = theme.yellow(),
green = theme.green(),
cyan = theme.cyan(),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_matches_exact() {
assert!(file_matches(
"/var/log/syslog",
"/var/log/syslog",
"/var/log/syslog"
));
}
#[test]
fn file_matches_canonical_vs_original() {
assert!(file_matches(
"/private/var/log/syslog",
"/private/var/log/syslog",
"/var/log/syslog"
));
}
#[test]
fn file_matches_subpath() {
assert!(file_matches(
"/var/log/syslog/rotated",
"/var/log/syslog",
"/var/log/syslog"
));
}
#[test]
fn file_matches_no_match() {
assert!(!file_matches(
"/tmp/other",
"/var/log/syslog",
"/var/log/syslog"
));
}
#[test]
fn file_matches_partial_name_no_match() {
assert!(!file_matches("/var/logs", "/var/log", "/var/log"));
}
#[test]
fn print_snapshot_empty() {
let theme = Theme::new(false);
let table = HashMap::new();
print_snapshot(&theme, &table, "/tmp/test", 1);
}
#[test]
fn print_snapshot_with_entries() {
let theme = Theme::new(false);
let mut table = HashMap::new();
table.insert(
(100, "3r".to_string()),
WatchEntry {
pid: 100,
command: "cat".to_string(),
uid: 501,
fd: "3r".to_string(),
first_seen: 1,
last_seen: 5,
gone: false,
},
);
print_snapshot(&theme, &table, "/tmp/test", 5);
}
#[test]
fn print_event_open() {
let theme = Theme::new(false);
print_event(&theme, "+OPEN", 100, "cat", 501, "3r", 1);
}
#[test]
fn print_event_close() {
let theme = Theme::new(false);
print_event(&theme, "-CLOSE", 100, "cat", 501, "3r", 5);
}
#[test]
fn print_event_long_command() {
let theme = Theme::new(false);
print_event(
&theme,
"+OPEN",
100,
"a_very_long_command_name_exceeding_twenty",
501,
"3r",
1,
);
}
}