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:
@@ -1 +1,4 @@
|
|||||||
A monorepo for tools interacting with LLMs.
|
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).
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+4601
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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).
|
||||||
Vendored
+11
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user