Add tailnew: follow the newest log file in a directory

tailnew tails the most recently started file in a directory and switches
to each new one as it appears, for watching harnesses such as boxmaker's
run-plan.sh that write one log per task. A file counts as new when its
name is new, when the name points at a different file, or when it has
shrunk (a rerun truncating its old log).

Three front ends share one follower: plain tail -f style output, a
ratatui terminal view with scrollback, and an egui window with a folder
dialog and a Recursive checkbox. The Makefile provides build, gate and a
per-user install that also installs a desktop entry for the window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 20:57:26 -07:00
co-authored by Claude Opus 5.5
parent 5f8f3ecd68
commit f445675292
13 changed files with 6299 additions and 0 deletions
+3
View File
@@ -1 +1,4 @@
A monorepo for tools interacting with LLMs.
- `retrieval/`: arxiv retrieval layer (semantic and keyword search).
- `tailnew/`: follows the newest log file in a directory (CLI, terminal UI, egui window).
+1
View File
@@ -0,0 +1 @@
/target
+4601
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "tailnew"
version = "0.1.0"
edition = "2024"
description = "Follow the newest file in a directory, switching to each new file as it appears"
[dependencies]
clap = { version = "4.6", features = ["derive"] }
eframe = { version = "0.32", optional = true }
rfd = { version = "0.15", optional = true }
ratatui = "0.29"
unicode-width = "0.2"
[features]
default = ["gui"]
# The desktop window (--gui) and its folder dialog. Build with --no-default-features for a terminal-only binary.
gui = ["dep:eframe", "dep:rfd"]
[dev-dependencies]
tempfile = "3"
+31
View File
@@ -0,0 +1,31 @@
# Per-user install: the binary and the desktop entry for the window.
PREFIX ?= $(HOME)/.local
BINDIR := $(PREFIX)/bin
APPDIR := $(or $(XDG_DATA_HOME),$(HOME)/.local/share)/applications
.PHONY: build gate install uninstall
build:
cargo build --release
# Formatting, lints with and without the window, tests, and the desktop entry. Offline once
# dependencies are fetched.
gate:
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --no-default-features -- -D warnings
cargo test
desktop-file-validate dist/tailnew.desktop
@echo "gate: ok"
# dist/tailnew.desktop carries an @BINDIR@ placeholder so it points wherever PREFIX put the binary.
install: build
install -Dm755 target/release/tailnew $(BINDIR)/tailnew
install -d $(APPDIR)
sed 's|@BINDIR@|$(BINDIR)|' dist/tailnew.desktop > $(APPDIR)/tailnew.desktop
-update-desktop-database $(APPDIR)
uninstall:
rm -f $(BINDIR)/tailnew $(APPDIR)/tailnew.desktop
-update-desktop-database $(APPDIR)
+40
View File
@@ -0,0 +1,40 @@
# tailnew
Follows the newest file in a directory, like `tail -f`, and switches to each new file as it
appears. Built for watching harnesses such as boxmaker's `tools/run-plan.sh`, which write one log
per task.
make install # ~/.local/bin/tailnew and a desktop entry for the window
tailnew .state/runs/M2a # plain output, `==> file <==` banner on each switch
tailnew -t -r .state/runs # full-screen terminal view, every milestone's directory
tailnew --gui -r .state/runs # desktop window (egui)
tailnew --gui # desktop window, asks for the folder first
`make gate` checks formatting, runs clippy with and without the window, runs the tests, and
validates the desktop entry. `PREFIX=/usr/local make install` installs elsewhere;
`cargo build --no-default-features` builds without the window.
A file is new when its name has not been seen before, when the name now points at a different
file, or when it has shrunk (a rerun that truncated its old log with `>`). The directory is polled
(`-i MS`, default 250) and need not exist yet. Hidden files are ignored; `-g '*.log'` narrows the
match further.
Keys in the full-screen terminal view (`-t`):
| key | action |
|-------------------------|----------------------------------------------------|
| ↑ ↓ / k j | scroll a line |
| PgUp PgDn / b space | scroll a page (ctrl-u / ctrl-d: half a page) |
| g / G | top / back to following the tail |
| [ ] | older / newer file (pins the view to it) |
| p | pin to the current file; again to unpin and jump to the newest |
| L | unpin and jump to the newest file |
| w | toggle line wrapping |
| f | toggle the file list |
| q / esc | quit |
The window (`--gui`) has a **Folder…** button (the desktop's folder dialog, also `o`) and a
**Recursive** checkbox, buttons for the same file controls, a clickable file list, and the keys
`[` `]` `p` `L` `f`, `G`/End to jump to the end, and `q`/Esc to quit. Started without a folder,
as from the launcher, it opens the folder dialog straight away. It scrolls with the mouse,
sticks to the bottom while you are there, and does not wrap long lines (scroll sideways instead).
+11
View File
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=tailnew
GenericName=Log Follower
Comment=Follow the newest log file in a folder
Exec=@BINDIR@/tailnew --gui %f
Icon=utilities-log-viewer
Terminal=false
Categories=Development;
Keywords=log;tail;follow;
StartupWMClass=tailnew
+47
View File
@@ -0,0 +1,47 @@
//! Plain `tail -f` style output: bytes go to stdout as they arrive, with a `==> file <==` banner
//! whenever the followed file changes.
use std::io::{self, Write};
use std::thread;
use std::time::Duration;
use crate::watch::{Event, Follower};
pub fn run(mut follower: Follower, interval: Duration, quiet: bool) -> io::Result<()> {
let mut out = io::stdout().lock();
let mut at_line_start = true;
let mut first_banner = true;
let mut announced_wait = false;
loop {
let events = follower.poll()?;
if follower.current_path().is_none() && !announced_wait {
eprintln!("tailnew: waiting for files in {}", follower.dir().display());
announced_wait = true;
}
for event in events {
let banner = match &event {
Event::Switched(path) => format!("==> {} <==", path.display()),
Event::Restarted(path) => format!("==> {} (restarted) <==", path.display()),
Event::Data(bytes) => {
out.write_all(bytes)?;
at_line_start = bytes.ends_with(b"\n");
continue;
}
};
if quiet {
continue;
}
if !at_line_start {
writeln!(out)?;
}
if !first_banner {
writeln!(out)?;
}
writeln!(out, "{banner}")?;
first_banner = false;
at_line_start = true;
}
out.flush()?;
thread::sleep(interval);
}
}
+311
View File
@@ -0,0 +1,311 @@
//! Desktop window (egui): a header with the folder and file controls, an optional file list, the
//! log itself, and a status line.
use std::io;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};
use eframe::egui::{
self, Color32, Key, Label, RichText, ScrollArea, TextStyle, TextWrapMode, ViewportCommand,
};
use crate::view::View;
use crate::watch::{Follower, Options, Start};
/// Follows `opts.dir`, or with `chosen` false, opens the folder dialog first.
pub fn run(
opts: Options,
chosen: bool,
lines: usize,
interval: Duration,
scrollback: usize,
) -> io::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("tailnew")
.with_app_id("tailnew")
.with_inner_size([1100.0, 750.0]),
..Default::default()
};
let recursive = opts.recursive;
let mut gui = Gui {
view: View::new(Follower::new(opts, Start::LastLines(lines)), scrollback),
chosen,
recursive,
picking: None,
lines,
scrollback,
interval,
next_poll: Instant::now(),
show_files: true,
seen_generation: 0,
to_end: false,
};
eframe::run_native(
"tailnew",
options,
Box::new(move |cc| {
if !gui.chosen {
gui.pick(&cc.egui_ctx);
}
Ok(Box::new(gui))
}),
)
.map_err(|e| io::Error::other(e.to_string()))
}
struct Gui {
view: View,
/// False until a folder has been chosen, when started without one; nothing is polled till then.
chosen: bool,
/// The Recursive checkbox; applied by rebuilding the view.
recursive: bool,
/// The folder dialog runs on its own thread so the window keeps drawing; its answer lands here.
picking: Option<Receiver<Option<PathBuf>>>,
lines: usize,
scrollback: usize,
interval: Duration,
next_poll: Instant,
show_files: bool,
seen_generation: u64,
/// Scroll to the end on the next frame; the scroll area then sticks there as lines arrive.
to_end: bool,
}
impl eframe::App for Gui {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.check_pick();
if self.chosen && Instant::now() >= self.next_poll {
self.view.poll();
self.next_poll = Instant::now() + self.interval;
}
ctx.request_repaint_after(self.next_poll.saturating_duration_since(Instant::now()));
self.keys(ctx);
if self.view.generation() != self.seen_generation {
self.seen_generation = self.view.generation();
self.to_end = true;
}
egui::TopBottomPanel::top("header").show(ctx, |ui| self.header(ui));
egui::TopBottomPanel::bottom("status").show(ctx, |ui| self.status(ui));
if self.show_files {
egui::SidePanel::left("files")
.resizable(true)
.default_width(220.0)
.show(ctx, |ui| self.files(ui));
}
egui::CentralPanel::default().show(ctx, |ui| self.log(ui));
}
}
impl Gui {
/// Opens the folder dialog, starting from the current folder.
fn pick(&mut self, ctx: &egui::Context) {
if self.picking.is_some() {
return;
}
let start = std::fs::canonicalize(self.view.follower.dir()).ok();
let (tx, rx) = mpsc::channel();
let ctx = ctx.clone();
thread::spawn(move || {
let mut dialog = rfd::FileDialog::new().set_title("Folder to watch");
if let Some(start) = start {
dialog = dialog.set_directory(start);
}
let _ = tx.send(dialog.pick_folder());
ctx.request_repaint();
});
self.picking = Some(rx);
}
fn check_pick(&mut self) {
let Some(rx) = &self.picking else { return };
match rx.try_recv() {
Ok(Some(dir)) => {
self.picking = None;
self.watch(dir, self.recursive);
}
Ok(None) | Err(TryRecvError::Disconnected) => self.picking = None,
Err(TryRecvError::Empty) => {}
}
}
/// Starts over on `dir` with a fresh view.
fn watch(&mut self, dir: PathBuf, recursive: bool) {
let opts = Options {
dir,
recursive,
..self.view.follower.options().clone()
};
self.view = View::new(
Follower::new(opts, Start::LastLines(self.lines)),
self.scrollback,
);
self.chosen = true;
self.recursive = recursive;
self.seen_generation = 0;
self.next_poll = Instant::now();
}
fn keys(&mut self, ctx: &egui::Context) {
let pressed = |key| ctx.input(|i| i.key_pressed(key));
if pressed(Key::Q) || pressed(Key::Escape) {
ctx.send_viewport_cmd(ViewportCommand::Close);
}
if pressed(Key::OpenBracket) {
self.view.step(-1);
}
if pressed(Key::CloseBracket) {
self.view.step(1);
}
if pressed(Key::P) {
self.view.toggle_pin();
}
if pressed(Key::L) {
self.view.go_latest();
}
if pressed(Key::F) {
self.show_files = !self.show_files;
}
if pressed(Key::O) {
self.pick(ctx);
}
if pressed(Key::End) || pressed(Key::G) {
self.to_end = true;
}
}
fn header(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
let button = egui::Button::new("📁 Folder…");
if ui
.add_enabled(self.picking.is_none(), button)
.on_hover_text("Choose the folder to watch (o)")
.clicked()
{
self.pick(ui.ctx());
}
let mut recursive = self.recursive;
if ui
.checkbox(&mut recursive, "Recursive")
.on_hover_text("Also watch subfolders")
.changed()
{
if self.chosen {
let dir = self.view.follower.dir().to_path_buf();
self.watch(dir, recursive);
} else {
self.recursive = recursive;
}
}
ui.separator();
if !self.chosen {
ui.weak("no folder chosen");
return;
}
let dir = self.view.follower.dir().display().to_string();
ui.weak(dir);
// The folder is already shown, so the title's own "waiting for files in …" would repeat it.
if self.view.follower.current_path().is_some() {
ui.label(RichText::new(self.view.title()).strong().monospace());
} else {
ui.weak("waiting for files");
}
if self.view.follower.pinned() {
ui.label(
RichText::new("PINNED")
.strong()
.color(Color32::from_rgb(230, 180, 40)),
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button("Newest")
.on_hover_text("Unpin and follow the newest file (L)")
.clicked()
{
self.view.go_latest();
}
let mut pinned = self.view.follower.pinned();
if ui
.toggle_value(&mut pinned, "Pin")
.on_hover_text("Stay on this file when new ones appear (p)")
.clicked()
{
self.view.toggle_pin();
}
if ui.button("Newer ▶").on_hover_text("]").clicked() {
self.view.step(1);
}
if ui.button("◀ Older").on_hover_text("[").clicked() {
self.view.step(-1);
}
ui.toggle_value(&mut self.show_files, "Files")
.on_hover_text("Show the file list (f)");
});
});
}
fn status(&self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
if let Some(e) = self.view.error() {
ui.colored_label(ui.visuals().error_fg_color, e);
} else if let Some(status) = self.view.status() {
ui.weak(status);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.weak(format!("{} lines", self.view.total()));
});
});
}
fn files(&mut self, ui: &mut egui::Ui) {
ui.strong("Files, newest first");
ui.separator();
let mut open = None;
ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
let current = self.view.follower.current_path();
for e in self.view.follower.entries().iter().rev() {
let here = Some(e.path.as_path()) == current;
let name = RichText::new(self.view.relative(&e.path)).monospace();
if ui.selectable_label(here, name).clicked() && !here {
open = Some(e.path.clone());
}
}
});
if let Some(path) = open {
self.view.open_pinned(&path);
}
}
fn log(&mut self, ui: &mut egui::Ui) {
if !self.chosen {
ui.centered_and_justified(|ui| {
let button = egui::Button::new("📁 Choose a folder to watch…");
if ui.add_enabled(self.picking.is_none(), button).clicked() {
self.pick(ui.ctx());
}
});
return;
}
ui.spacing_mut().item_spacing.y = 0.0;
let row_height = ui.text_style_height(&TextStyle::Monospace);
let total = self.view.total();
let mut area = ScrollArea::both().auto_shrink(false).stick_to_bottom(true);
if std::mem::take(&mut self.to_end) {
// The whole content height; the scroll area clamps it to the last page. Not f32::MAX:
// show_rows turns the offset into a row index, which would overflow.
area = area.vertical_scroll_offset(total as f32 * row_height);
}
// Only the visible rows are laid out, so a long scrollback costs nothing per frame.
area.show_rows(ui, row_height, total, |ui, rows| {
for i in rows {
let text = RichText::new(self.view.line(i)).monospace();
ui.add(Label::new(text).wrap_mode(TextWrapMode::Extend));
}
});
}
}
+104
View File
@@ -0,0 +1,104 @@
mod cli;
#[cfg(feature = "gui")]
mod gui;
mod tui;
mod view;
mod watch;
use std::io;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use clap::Parser;
use watch::{Follower, Options, Start};
/// Follow the newest file in a directory, switching to each new file as it appears.
///
/// A file is new when its name has not been seen before, when the name now points at a different
/// file, or when it has shrunk (a rerun that truncated its old log). Hidden files are ignored.
#[derive(Parser)]
#[command(version)]
struct Args {
/// Directory to watch; it need not exist yet [default: ".", or a folder dialog with --gui].
dir: Option<PathBuf>,
/// Full-screen terminal view with scrollback instead of plain output.
#[arg(short, long)]
tui: bool,
/// Desktop window instead of plain output. Its folder dialog changes the directory.
#[cfg(feature = "gui")]
#[arg(long, conflicts_with = "tui")]
gui: bool,
/// Also watch subdirectories (for example `.state/runs` for every milestone).
#[arg(short, long)]
recursive: bool,
/// Only follow files whose names match, with `*` and `?` wildcards, such as '*.log'.
#[arg(short, long, value_name = "PATTERN")]
glob: Option<String>,
/// Lines of the newest file to show at startup [default: 10, or the scrollback with --tui/--gui].
#[arg(short = 'n', long, value_name = "N")]
lines: Option<usize>,
/// How often to check for new files and data, in milliseconds.
#[arg(short, long, value_name = "MS", default_value_t = 250)]
interval: u64,
/// Leave out the `==> file <==` banners in plain output.
#[arg(short, long)]
quiet: bool,
/// Lines kept for scrolling back in the full-screen view and the window.
#[arg(long, value_name = "N", default_value_t = 50_000)]
scrollback: usize,
}
fn main() -> ExitCode {
let args = Args::parse();
#[cfg(feature = "gui")]
let chosen = args.dir.is_some();
let opts = Options {
dir: args.dir.unwrap_or_else(|| PathBuf::from(".")),
recursive: args.recursive,
glob: args.glob,
};
let interval = Duration::from_millis(args.interval.max(10));
#[cfg(feature = "gui")]
if args.gui {
let lines = args.lines.unwrap_or(args.scrollback);
return finish(gui::run(opts, chosen, lines, interval, args.scrollback));
}
let result = if args.tui {
let lines = args.lines.unwrap_or(args.scrollback);
tui::run(
Follower::new(opts, Start::LastLines(lines)),
interval,
args.scrollback,
)
} else {
let lines = args.lines.unwrap_or(10);
cli::run(
Follower::new(opts, Start::LastLines(lines)),
interval,
args.quiet,
)
};
finish(result)
}
fn finish(result: io::Result<()>) -> ExitCode {
match result {
Ok(()) => ExitCode::SUCCESS,
// `tailnew | head` should end quietly.
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
Err(e) => {
eprintln!("tailnew: {e}");
ExitCode::FAILURE
}
}
}
+282
View File
@@ -0,0 +1,282 @@
//! Full-screen view: a header naming the followed file, the log itself with scrollback, an
//! optional list of the files in the directory, and a footer with the keys.
use std::io;
use std::time::{Duration, Instant};
use ratatui::crossterm::event::{
self, Event as TermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::{DefaultTerminal, Frame};
use unicode_width::UnicodeWidthChar;
use crate::view::View;
use crate::watch::Follower;
const HELP: &str = "q quit ↑↓ PgUp PgDn g G scroll [ ] older/newer p pin w wrap f files";
pub fn run(follower: Follower, interval: Duration, scrollback: usize) -> io::Result<()> {
let mut terminal = ratatui::init();
let result = App::new(follower, scrollback).event_loop(&mut terminal, interval);
ratatui::restore();
result
}
struct App {
view: View,
/// How many logical lines the view is scrolled up from the bottom; 0 follows the tail.
scroll: usize,
wrap: bool,
show_files: bool,
page: usize,
seen_generation: u64,
seen_appended: u64,
}
impl App {
fn new(follower: Follower, scrollback: usize) -> Self {
App {
view: View::new(follower, scrollback),
scroll: 0,
wrap: true,
show_files: false,
page: 20,
seen_generation: 0,
seen_appended: 0,
}
}
fn event_loop(&mut self, terminal: &mut DefaultTerminal, interval: Duration) -> io::Result<()> {
let mut next_poll = Instant::now();
loop {
if Instant::now() >= next_poll {
self.view.poll();
next_poll = Instant::now() + interval;
}
self.sync_scroll();
terminal.draw(|frame| self.render(frame))?;
let timeout = next_poll.saturating_duration_since(Instant::now());
if event::poll(timeout)?
&& let TermEvent::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
&& self.key(key)
{
return Ok(());
}
}
}
/// Follows the tail after the lines start over, and otherwise keeps a scrolled-back view
/// still while the log grows beneath it.
fn sync_scroll(&mut self) {
if self.view.generation() != self.seen_generation {
self.scroll = 0;
} else if self.scroll > 0 {
self.scroll += (self.view.appended() - self.seen_appended) as usize;
}
self.seen_generation = self.view.generation();
self.seen_appended = self.view.appended();
self.scroll = self.scroll.min(self.view.total().saturating_sub(1));
}
/// Handles a key; returns true to quit.
fn key(&mut self, key: KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let total = self.view.total();
let max = total.saturating_sub(1);
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Char('c') if ctrl => return true,
KeyCode::Up | KeyCode::Char('k') => self.scroll = (self.scroll + 1).min(max),
KeyCode::Down | KeyCode::Char('j') => self.scroll = self.scroll.saturating_sub(1),
KeyCode::PageUp | KeyCode::Char('b') => {
self.scroll = (self.scroll + self.page).min(max)
}
KeyCode::PageDown | KeyCode::Char(' ') => {
self.scroll = self.scroll.saturating_sub(self.page)
}
KeyCode::Char('u') if ctrl => self.scroll = (self.scroll + self.page / 2).min(max),
KeyCode::Char('d') if ctrl => self.scroll = self.scroll.saturating_sub(self.page / 2),
KeyCode::Home | KeyCode::Char('g') => self.scroll = total.saturating_sub(self.page),
KeyCode::End | KeyCode::Char('G') => self.scroll = 0,
KeyCode::Char('w') => self.wrap = !self.wrap,
KeyCode::Char('f') => self.show_files = !self.show_files,
KeyCode::Char('[') => self.view.step(-1),
KeyCode::Char(']') => self.view.step(1),
KeyCode::Char('p') => self.view.toggle_pin(),
KeyCode::Char('L') => self.view.go_latest(),
_ => {}
}
false
}
fn render(&mut self, frame: &mut Frame) {
let [header, body, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(frame.area());
self.render_header(frame, header);
self.render_footer(frame, footer);
let log = if self.show_files {
let [files, log] =
Layout::horizontal([Constraint::Percentage(25), Constraint::Min(20)]).areas(body);
self.render_files(frame, files);
log
} else {
body
};
self.page = (log.height as usize).max(1);
let total = self.view.total();
let end = total - self.scroll.min(total);
let rows = visible_rows(
end,
log.height as usize,
log.width as usize,
self.wrap,
|i| self.view.line(i).into_owned(),
);
let text: Vec<Line> = rows.into_iter().map(Line::from).collect();
frame.render_widget(Paragraph::new(text), log);
}
fn render_header(&self, frame: &mut Frame, area: Rect) {
let name = self.view.title();
let mut spans = vec![
Span::raw(" tailnew ").bold(),
Span::raw("│ "),
Span::raw(name).bold(),
];
if self.view.follower.pinned() {
spans.push(Span::raw(" PINNED").yellow().bold());
}
if self.scroll > 0 {
spans.push(
Span::raw(format!(" SCROLLED -{}", self.scroll))
.cyan()
.bold(),
);
} else {
spans.push(Span::raw(" FOLLOWING").green());
}
let style = Style::new().add_modifier(Modifier::REVERSED);
frame.render_widget(Paragraph::new(Line::from(spans)).style(style), area);
}
fn render_footer(&self, frame: &mut Frame, area: Rect) {
let status = if let Some(e) = self.view.error() {
Span::raw(format!(" {e} ")).red().bold()
} else if let Some(status) = self.view.status() {
Span::raw(format!(" {status} ")).dim()
} else {
Span::raw("")
};
let [help, right] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(status.width() as u16),
])
.areas(area);
frame.render_widget(Paragraph::new(Span::raw(format!(" {HELP}")).dim()), help);
frame.render_widget(Paragraph::new(status), right);
}
fn render_files(&self, frame: &mut Frame, area: Rect) {
let current = self.view.follower.current_path();
let items: Vec<ListItem> = self
.view
.follower
.entries()
.iter()
.rev()
.map(|e| {
let name = self.view.relative(&e.path);
if Some(e.path.as_path()) == current {
ListItem::new(format!("▸ {name}")).bold()
} else {
ListItem::new(format!(" {name}"))
}
})
.collect();
let block = Block::new()
.borders(Borders::RIGHT)
.title(" files, newest first ");
frame.render_widget(List::new(items).block(block), area);
}
}
/// The screen rows that show logical lines `..end`, bottom-aligned in `height` rows of `width`
/// columns. With `wrap`, long lines take several rows; without it, the terminal clips them.
fn visible_rows(
end: usize,
height: usize,
width: usize,
wrap: bool,
line: impl Fn(usize) -> String,
) -> Vec<String> {
let mut rows: Vec<String> = Vec::new();
let mut i = end;
while i > 0 && rows.len() < height {
i -= 1;
let text = line(i);
let mut pieces = if wrap {
wrap_line(&text, width)
} else {
vec![text]
};
pieces.reverse();
rows.extend(pieces);
}
rows.truncate(height);
rows.reverse();
rows
}
fn wrap_line(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut out = vec![String::new()];
let mut used = 0;
for c in text.chars() {
let w = c.width().unwrap_or(0);
if used + w > width && used > 0 {
out.push(String::new());
used = 0;
}
out.last_mut().unwrap().push(c);
used += w;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_line_splits_at_width() {
assert_eq!(wrap_line("abcdefg", 3), vec!["abc", "def", "g"]);
assert_eq!(wrap_line("", 3), vec![""]);
assert_eq!(wrap_line("日本語", 4), vec!["日本", "語"]);
}
#[test]
fn visible_rows_are_bottom_aligned() {
let lines = ["one", "two", "three", "a long line here"];
let get = |i: usize| lines[i].to_string();
assert_eq!(
visible_rows(4, 2, 80, true, get),
vec!["three", "a long line here"]
);
assert_eq!(
visible_rows(4, 3, 6, true, get),
vec!["a long", " line ", "here"]
);
assert_eq!(visible_rows(3, 2, 80, true, get), vec!["two", "three"]);
assert_eq!(visible_rows(2, 5, 80, false, get), vec!["one", "two"]);
}
}
+280
View File
@@ -0,0 +1,280 @@
//! State shared by the interactive front ends: the lines of the followed file, moving between
//! files, pinning, and the status line. Scrolling belongs to each front end.
use std::borrow::Cow;
use std::collections::VecDeque;
use std::path::Path;
use std::time::{Duration, Instant};
use crate::watch::{Event, Follower, Start};
pub struct View {
pub follower: Follower,
lines: VecDeque<String>,
/// The bytes after the last newline, shown as a live last line.
partial: Vec<u8>,
scrollback: usize,
/// Bumped whenever the lines start over (a new file, or the current one restarted).
generation: u64,
/// Complete lines added since the lines last started over, including any since dropped.
appended: u64,
since: Option<Instant>,
note: &'static str,
error: Option<String>,
}
impl View {
pub fn new(follower: Follower, scrollback: usize) -> Self {
View {
follower,
lines: VecDeque::new(),
partial: Vec::new(),
scrollback: scrollback.max(1),
generation: 0,
appended: 0,
since: None,
note: "",
error: None,
}
}
pub fn generation(&self) -> u64 {
self.generation
}
pub fn appended(&self) -> u64 {
self.appended
}
pub fn poll(&mut self) {
match self.follower.poll() {
Ok(events) => {
self.error = None;
for event in events {
self.apply(event);
}
}
Err(e) => self.error = Some(e.to_string()),
}
}
fn apply(&mut self, event: Event) {
match event {
Event::Switched(_) | Event::Restarted(_) => {
self.lines.clear();
self.partial.clear();
self.generation += 1;
self.appended = 0;
self.since = Some(Instant::now());
self.note = if matches!(event, Event::Restarted(_)) {
"restarted"
} else {
"opened"
};
}
Event::Data(bytes) => {
self.partial.extend_from_slice(&bytes);
let Some(last) = self.partial.iter().rposition(|&b| b == b'\n') else {
return;
};
let rest = self.partial.split_off(last + 1);
let complete = std::mem::replace(&mut self.partial, rest);
for raw in complete[..complete.len() - 1].split(|&b| b == b'\n') {
self.lines
.push_back(clean_line(&String::from_utf8_lossy(raw)));
if self.lines.len() > self.scrollback {
self.lines.pop_front();
}
self.appended += 1;
}
}
}
}
/// Lines available to show, counting a partial last line.
pub fn total(&self) -> usize {
self.lines.len() + usize::from(!self.partial.is_empty())
}
pub fn line(&self, i: usize) -> Cow<'_, str> {
match self.lines.get(i) {
Some(line) => Cow::Borrowed(line),
None => Cow::Owned(clean_line(&String::from_utf8_lossy(&self.partial))),
}
}
/// Opens the file `delta` places newer (positive) or older (negative) than the current one,
/// and pins it so that the next new file does not pull the view away.
pub fn step(&mut self, delta: isize) {
let entries = self.follower.entries();
if entries.is_empty() {
return;
}
let here = self
.follower
.current_path()
.and_then(|p| entries.iter().position(|e| e.path == p))
.unwrap_or(entries.len() - 1);
let to = (here as isize + delta).clamp(0, entries.len() as isize - 1) as usize;
if to != here {
let path = entries[to].path.clone();
self.open_pinned(&path);
}
}
/// Pins the current file, or if already pinned, unpins and returns to the newest.
pub fn toggle_pin(&mut self) {
if self.follower.pinned() {
self.go_latest();
} else {
self.follower.set_pinned(true);
}
}
/// Unpins and returns to the newest file.
pub fn go_latest(&mut self) {
self.follower.set_pinned(false);
let newest = self.follower.entries().last().map(|e| e.path.clone());
if let Some(path) = newest
&& self.follower.current_path() != Some(path.as_path())
{
self.open(&path);
}
}
/// Opens `path` and pins it.
pub fn open_pinned(&mut self, path: &Path) {
self.follower.set_pinned(true);
self.open(path);
}
fn open(&mut self, path: &Path) {
if let Err(e) = self.follower.open(path, Start::LastLines(self.scrollback)) {
self.error = Some(format!("{}: {e}", path.display()));
return;
}
self.poll();
}
/// `path` relative to the watched directory, for display.
pub fn relative(&self, path: &Path) -> String {
path.strip_prefix(self.follower.dir())
.unwrap_or(path)
.display()
.to_string()
}
/// The followed file, or what is being waited for.
pub fn title(&self) -> String {
match self.follower.current_path() {
Some(p) => self.relative(p),
None => format!("waiting for files in {}", self.follower.dir().display()),
}
}
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
/// Such as "opened 2m05s ago".
pub fn status(&self) -> Option<String> {
self.since
.map(|since| format!("{} {} ago", self.note, elapsed(since.elapsed())))
}
}
pub fn elapsed(d: Duration) -> String {
let s = d.as_secs();
match s {
0..60 => format!("{s}s"),
60..3600 => format!("{}m{:02}s", s / 60, s % 60),
_ => format!("{}h{:02}m", s / 3600, s % 3600 / 60),
}
}
/// Makes one line of log output safe to draw: drops ANSI escape sequences and other control
/// characters, keeps only what follows the last carriage return (as a terminal would show a
/// progress line), and expands tabs.
pub fn clean_line(raw: &str) -> String {
let raw = raw.strip_suffix('\r').unwrap_or(raw);
let raw = raw.rsplit('\r').next().unwrap_or(raw);
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\x1b' => match chars.next() {
// CSI: parameters and intermediates, then a final byte in @..~.
Some('[') => {
for c in chars.by_ref() {
if ('@'..='~').contains(&c) {
break;
}
}
}
// OSC: runs to BEL or ST (ESC \).
Some(']') => {
while let Some(c) = chars.next() {
if c == '\x07' || (c == '\x1b' && chars.next_if_eq(&'\\').is_some()) {
break;
}
}
}
_ => {}
},
'\t' => {
let col = out.chars().count();
out.extend(std::iter::repeat_n(' ', 8 - col % 8));
}
c if c.is_control() => {}
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::watch::Options;
use std::fs;
use std::path::PathBuf;
fn view(dir: PathBuf, scrollback: usize) -> View {
let opts = Options {
dir,
recursive: false,
glob: None,
};
View::new(Follower::new(opts, Start::Beginning), scrollback)
}
#[test]
fn splits_lines_keeps_partial_and_caps_scrollback() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("a.log"), "one\ntwo\nthree\nfou").unwrap();
let mut v = view(tmp.path().to_path_buf(), 2);
v.poll();
assert_eq!(v.generation(), 1);
assert_eq!(v.appended(), 3);
let shown: Vec<_> = (0..v.total()).map(|i| v.line(i).into_owned()).collect();
assert_eq!(shown, ["two", "three", "fou"]);
}
#[test]
fn clean_line_strips_escapes_and_handles_carriage_returns() {
assert_eq!(clean_line("\x1b[1;32mok\x1b[0m done"), "ok done");
assert_eq!(clean_line("\x1b]0;title\x07text"), "text");
assert_eq!(clean_line("\x1b]8;;http://x\x1b\\link"), "link");
assert_eq!(clean_line("10%\r50%\r100%"), "100%");
assert_eq!(clean_line("dos line\r"), "dos line");
assert_eq!(clean_line("a\tb"), "a b");
assert_eq!(clean_line("bell\x07"), "bell");
}
#[test]
fn elapsed_formats() {
assert_eq!(elapsed(Duration::from_secs(5)), "5s");
assert_eq!(elapsed(Duration::from_secs(125)), "2m05s");
assert_eq!(elapsed(Duration::from_secs(3720)), "1h02m");
}
}
+568
View File
@@ -0,0 +1,568 @@
//! Finds the newest file in a directory and follows it, switching whenever a new file appears.
//!
//! The directory is polled rather than watched with inotify: it is one directory, the cost is a
//! `readdir` per interval, and polling behaves the same on every filesystem.
//!
//! A file counts as new when its path has not been seen before, when the path now names a
//! different inode, or when it has shrunk since the last scan. The last case catches a harness
//! that reruns a task and truncates the old log with `>` rather than creating a fresh file.
use std::collections::{HashMap, HashSet};
use std::fs::{self, File, Metadata};
use std::io::{self, Read, Seek, SeekFrom};
use std::mem;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// The most bytes handed out by one poll, so a huge backlog cannot stall a front end.
const MAX_READ: u64 = 4 << 20;
#[derive(Clone)]
pub struct Options {
pub dir: PathBuf,
pub recursive: bool,
/// Matched against the file name only, with `*` and `?` wildcards.
pub glob: Option<String>,
}
/// Where to begin reading a file that is being opened.
#[derive(Clone, Copy, Debug)]
pub enum Start {
Beginning,
LastLines(usize),
}
#[derive(Debug, PartialEq)]
pub enum Event {
/// Now following this file; any data that follows belongs to it.
Switched(PathBuf),
/// The followed file was truncated or replaced; its data starts over.
Restarted(PathBuf),
Data(Vec<u8>),
}
#[derive(Clone, Debug)]
pub struct Entry {
pub path: PathBuf,
/// When the file's current contents began: its creation time, or when it was seen to restart.
pub started: SystemTime,
id: (u64, u64),
size: u64,
}
struct Current {
path: PathBuf,
id: (u64, u64),
file: File,
offset: u64,
}
pub struct Follower {
opts: Options,
initial: Start,
/// What the previous scan saw; `None` until the first scan.
known: Option<HashMap<PathBuf, Entry>>,
/// Oldest first.
entries: Vec<Entry>,
current: Option<Current>,
pinned: bool,
pending: Vec<Event>,
}
impl Follower {
/// `initial` is where to begin in the file that is newest at startup. Files that appear later
/// are always read from the beginning.
pub fn new(opts: Options, initial: Start) -> Self {
Follower {
opts,
initial,
known: None,
entries: Vec::new(),
current: None,
pinned: false,
pending: Vec::new(),
}
}
pub fn dir(&self) -> &Path {
&self.opts.dir
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn options(&self) -> &Options {
&self.opts
}
/// Files seen by the last scan, oldest first.
pub fn entries(&self) -> &[Entry] {
&self.entries
}
pub fn current_path(&self) -> Option<&Path> {
self.current.as_ref().map(|c| c.path.as_path())
}
pub fn pinned(&self) -> bool {
self.pinned
}
/// While pinned, new files do not take over from the current one.
pub fn set_pinned(&mut self, pinned: bool) {
self.pinned = pinned;
}
/// Follows `path` from now on. The `Switched` event arrives with the next poll.
pub fn open(&mut self, path: &Path, start: Start) -> io::Result<()> {
let mut file = File::open(path)?;
let meta = file.metadata()?;
let offset = match start {
Start::Beginning => 0,
Start::LastLines(n) => tail_offset(&mut file, meta.len(), n)?,
};
self.current = Some(Current {
path: path.to_path_buf(),
id: file_id(&meta),
file,
offset,
});
self.pending.push(Event::Switched(path.to_path_buf()));
Ok(())
}
/// Rescans the directory, switches files if a new one appeared, and returns what happened
/// along with any data written since the last poll.
pub fn poll(&mut self) -> io::Result<Vec<Event>> {
let mut entries = scan(&self.opts)?;
let mut fresh = HashSet::new();
if let Some(known) = &self.known {
let now = SystemTime::now();
for e in &mut entries {
match known.get(&e.path) {
Some(k) if k.id == e.id && e.size >= k.size => {
e.started = k.started;
continue;
}
// Truncated in place keeps its creation time, so date the restart ourselves.
Some(k) if k.id == e.id => e.started = now,
// New, or replaced by another file: its creation time is right.
_ => {}
}
fresh.insert(e.path.clone());
}
entries.sort_by(|a, b| (a.started, &a.path).cmp(&(b.started, &b.path)));
}
let target = match &self.known {
None => entries.last().map(|e| (e.path.clone(), self.initial)),
Some(_) => entries
.iter()
.rev()
.find(|e| fresh.contains(&e.path))
.map(|e| (e.path.clone(), Start::Beginning)),
};
self.known = Some(
entries
.iter()
.map(|e| (e.path.clone(), e.clone()))
.collect(),
);
self.entries = entries;
if let Some((path, start)) = target {
// A restart of the current file is handled when it is read, below.
let is_current = self.current_path() == Some(path.as_path());
if !is_current && (!self.pinned || self.current.is_none()) {
match self.open(&path, start) {
Ok(()) => {}
// Deleted between the scan and the open; the next scan will sort it out.
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
}
let mut events = mem::take(&mut self.pending);
self.read_current(&mut events)?;
Ok(events)
}
fn read_current(&mut self, events: &mut Vec<Event>) -> io::Result<()> {
let Some(cur) = self.current.as_mut() else {
return Ok(());
};
if let Ok(meta) = fs::metadata(&cur.path)
&& file_id(&meta) != cur.id
&& let Ok(file) = File::open(&cur.path)
{
cur.id = file_id(&file.metadata()?);
cur.file = file;
cur.offset = 0;
events.push(Event::Restarted(cur.path.clone()));
}
let len = cur.file.metadata()?.len();
if len < cur.offset {
cur.offset = 0;
events.push(Event::Restarted(cur.path.clone()));
}
if len > cur.offset {
cur.file.seek(SeekFrom::Start(cur.offset))?;
let mut buf = Vec::new();
(&cur.file)
.take((len - cur.offset).min(MAX_READ))
.read_to_end(&mut buf)?;
cur.offset += buf.len() as u64;
if !buf.is_empty() {
events.push(Event::Data(buf));
}
}
Ok(())
}
}
fn file_id(meta: &Metadata) -> (u64, u64) {
(meta.dev(), meta.ino())
}
/// Lists the matching regular files under `opts.dir`, oldest first. A missing directory is
/// treated as empty so that the follower can be started before the harness creates it.
fn scan(opts: &Options) -> io::Result<Vec<Entry>> {
let mut out = Vec::new();
match walk(&opts.dir, opts, &mut out) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
other => other?,
}
out.sort_by(|a, b| (a.started, &a.path).cmp(&(b.started, &b.path)));
Ok(out)
}
fn walk(dir: &Path, opts: &Options, out: &mut Vec<Entry>) -> io::Result<()> {
for dirent in fs::read_dir(dir)? {
let Ok(dirent) = dirent else { continue };
let name = dirent.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') {
continue;
}
let path = dirent.path();
let Ok(kind) = dirent.file_type() else {
continue;
};
if kind.is_dir() {
if opts.recursive {
// Unreadable or vanished subdirectories are skipped, not fatal.
let _ = walk(&path, opts, out);
}
continue;
}
if let Some(glob) = &opts.glob
&& !wildcard(glob.as_bytes(), name.as_bytes())
{
continue;
}
// Follows symlinks, so a `latest.log` link to a real file is still followed.
let Ok(meta) = fs::metadata(&path) else {
continue;
};
if !meta.is_file() {
continue;
}
let created = meta
.created()
.or_else(|_| meta.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
out.push(Entry {
path,
started: created,
id: file_id(&meta),
size: meta.len(),
});
}
Ok(())
}
/// Matches `s` against `pat`, where `*` matches any run of bytes and `?` any single byte.
pub fn wildcard(pat: &[u8], s: &[u8]) -> bool {
let (mut p, mut i) = (0, 0);
let mut star: Option<(usize, usize)> = None;
while i < s.len() {
if p < pat.len() && (pat[p] == b'?' || pat[p] == s[i]) {
p += 1;
i += 1;
} else if p < pat.len() && pat[p] == b'*' {
star = Some((p, i));
p += 1;
} else if let Some((sp, si)) = star {
p = sp + 1;
i = si + 1;
star = Some((sp, si + 1));
} else {
return false;
}
}
pat[p..].iter().all(|&c| c == b'*')
}
/// The offset at which the last `n` lines of a file of length `len` begin. A final newline does
/// not count as starting an empty last line.
fn tail_offset(file: &mut File, len: u64, n: usize) -> io::Result<u64> {
if n == 0 {
return Ok(len);
}
let mut buf = vec![0u8; 64 * 1024];
let mut pos = len;
let mut seen = 0;
while pos > 0 {
let chunk = pos.min(buf.len() as u64);
pos -= chunk;
file.seek(SeekFrom::Start(pos))?;
file.read_exact(&mut buf[..chunk as usize])?;
for i in (0..chunk as usize).rev() {
let at = pos + i as u64;
if buf[i] == b'\n' && at != len - 1 {
seen += 1;
if seen == n {
return Ok(at + 1);
}
}
}
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::OpenOptions;
use std::io::Write;
use std::thread;
use std::time::Duration;
fn follower(dir: &Path, initial: Start) -> Follower {
Follower::new(
Options {
dir: dir.to_path_buf(),
recursive: false,
glob: None,
},
initial,
)
}
/// Creation times need to differ for "newest" to mean anything.
fn write_new(path: &Path, text: &str) {
thread::sleep(Duration::from_millis(15));
fs::write(path, text).unwrap();
}
fn append(path: &Path, text: &str) {
let mut f = OpenOptions::new().append(true).open(path).unwrap();
f.write_all(text.as_bytes()).unwrap();
}
fn data(events: &[Event]) -> String {
events
.iter()
.filter_map(|e| match e {
Event::Data(d) => Some(String::from_utf8_lossy(d).into_owned()),
_ => None,
})
.collect()
}
#[test]
fn starts_on_newest_file_with_last_lines() {
let tmp = tempfile::tempdir().unwrap();
write_new(&tmp.path().join("00-a.log"), "old\n");
write_new(&tmp.path().join("01-b.log"), "1\n2\n3\n4\n");
let mut f = follower(tmp.path(), Start::LastLines(2));
let ev = f.poll().unwrap();
assert_eq!(ev[0], Event::Switched(tmp.path().join("01-b.log")));
assert_eq!(data(&ev), "3\n4\n");
}
#[test]
fn follows_appended_data() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("a.log");
write_new(&p, "one\n");
let mut f = follower(tmp.path(), Start::Beginning);
f.poll().unwrap();
assert!(f.poll().unwrap().is_empty());
append(&p, "two\npart");
assert_eq!(data(&f.poll().unwrap()), "two\npart");
}
#[test]
fn switches_to_a_new_file_from_its_beginning() {
let tmp = tempfile::tempdir().unwrap();
write_new(&tmp.path().join("00.log"), "a\n");
let mut f = follower(tmp.path(), Start::LastLines(0));
f.poll().unwrap();
let p = tmp.path().join("01.log");
write_new(&p, "first\nsecond\n");
let ev = f.poll().unwrap();
assert_eq!(ev[0], Event::Switched(p));
assert_eq!(data(&ev), "first\nsecond\n");
}
#[test]
fn waits_for_a_directory_that_does_not_exist_yet() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("runs/M2a");
let mut f = follower(&dir, Start::LastLines(10));
assert!(f.poll().unwrap().is_empty());
fs::create_dir_all(&dir).unwrap();
write_new(&dir.join("00.log"), "hello\n");
let ev = f.poll().unwrap();
assert_eq!(ev[0], Event::Switched(dir.join("00.log")));
assert_eq!(data(&ev), "hello\n");
}
#[test]
fn restarts_when_the_current_file_is_truncated() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("a.log");
write_new(&p, "a long first run\n");
let mut f = follower(tmp.path(), Start::Beginning);
f.poll().unwrap();
fs::write(&p, "rerun\n").unwrap();
let ev = f.poll().unwrap();
assert_eq!(ev[0], Event::Restarted(p));
assert_eq!(data(&ev), "rerun\n");
}
#[test]
fn restarts_when_the_current_file_is_replaced() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("a.log");
write_new(&p, "one\n");
let mut f = follower(tmp.path(), Start::Beginning);
f.poll().unwrap();
let q = tmp.path().join(".tmp");
fs::write(&q, "a whole new file\n").unwrap();
fs::rename(&q, &p).unwrap();
let ev = f.poll().unwrap();
assert_eq!(ev[0], Event::Restarted(p));
assert_eq!(data(&ev), "a whole new file\n");
}
#[test]
fn switches_to_an_older_file_that_is_rewritten() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("00.log");
write_new(&old, "a long first run of task zero\n");
write_new(&tmp.path().join("01.log"), "b\n");
let mut f = follower(tmp.path(), Start::Beginning);
f.poll().unwrap();
fs::write(&old, "rerun\n").unwrap();
let ev = f.poll().unwrap();
assert_eq!(ev[0], Event::Switched(old.clone()));
assert_eq!(data(&ev), "rerun\n");
// It now ranks as the newest file, and keeps that rank on later scans.
assert_eq!(f.entries().last().unwrap().path, old);
append(&old, "more\n");
f.poll().unwrap();
assert_eq!(f.entries().last().unwrap().path, old);
}
#[test]
fn appends_to_other_files_do_not_switch() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("00.log");
write_new(&old, "a\n");
write_new(&tmp.path().join("01.log"), "b\n");
let mut f = follower(tmp.path(), Start::Beginning);
f.poll().unwrap();
append(&old, "more\n");
assert!(f.poll().unwrap().is_empty());
}
#[test]
fn pinned_stays_put() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("00.log");
write_new(&a, "a\n");
let mut f = follower(tmp.path(), Start::Beginning);
f.poll().unwrap();
f.set_pinned(true);
write_new(&tmp.path().join("01.log"), "b\n");
append(&a, "more\n");
assert_eq!(f.poll().unwrap(), vec![Event::Data(b"more\n".to_vec())]);
assert_eq!(f.entries().len(), 2);
}
#[test]
fn open_switches_by_hand() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("00.log");
write_new(&a, "x\ny\n");
write_new(&tmp.path().join("01.log"), "b\n");
let mut f = follower(tmp.path(), Start::Beginning);
f.poll().unwrap();
f.open(&a, Start::LastLines(1)).unwrap();
let ev = f.poll().unwrap();
assert_eq!(ev[0], Event::Switched(a));
assert_eq!(data(&ev), "y\n");
}
#[test]
fn filters_by_glob_skips_hidden_and_recurses_on_request() {
let tmp = tempfile::tempdir().unwrap();
write_new(&tmp.path().join("a.log"), "log\n");
write_new(&tmp.path().join(".hidden.log"), "hidden\n");
write_new(&tmp.path().join("b.txt"), "txt\n");
let mut f = Follower::new(
Options {
dir: tmp.path().to_path_buf(),
recursive: false,
glob: Some("*.log".into()),
},
Start::Beginning,
);
assert_eq!(data(&f.poll().unwrap()), "log\n");
let sub = tmp.path().join("M2b");
fs::create_dir(&sub).unwrap();
write_new(&sub.join("00.log"), "nested\n");
assert!(f.poll().unwrap().is_empty());
let mut r = Follower::new(
Options {
dir: tmp.path().to_path_buf(),
recursive: true,
glob: Some("*.log".into()),
},
Start::Beginning,
);
assert_eq!(data(&r.poll().unwrap()), "nested\n");
}
#[test]
fn wildcard_matching() {
assert!(wildcard(b"*.log", b"04-task.log"));
assert!(!wildcard(b"*.log", b"04-task.log.swp"));
assert!(wildcard(b"??-*", b"04-task.log"));
assert!(!wildcard(b"??-*", b"4-task.log"));
assert!(wildcard(b"*", b""));
assert!(wildcard(b"a*b*c", b"aXbYbZc"));
assert!(!wildcard(b"a*b*c", b"aXbYbZ"));
}
#[test]
fn tail_offset_counts_lines_from_the_end() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("f");
let check = |text: &str, n: usize, want: &str| {
fs::write(&p, text).unwrap();
let mut file = File::open(&p).unwrap();
let off = tail_offset(&mut file, text.len() as u64, n).unwrap();
assert_eq!(&text[off as usize..], want, "text {text:?} n {n}");
};
check("a\nb\nc\n", 2, "b\nc\n");
check("a\nb\nc", 2, "b\nc");
check("a\nb\nc\n", 10, "a\nb\nc\n");
check("a\nb\nc\n", 0, "");
check("", 3, "");
check("\n\n", 1, "\n");
}
}