use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Context, anyhow};
use crossterm::tty::IsTty;
use crate::cli::WatchArgs;
use crate::color::{ColorProfile, SystemEnv};
use crate::core::child::{
ChildSlot, ShutdownGuard, TickEvent, not_started, run_tick, spawn_live_tick, spawn_tick,
};
use crate::core::duration::parse_interval;
use crate::core::layout::{PaneBlock, PaneChrome, compose_panes, render_pane};
use crate::core::live::Emissions;
use crate::core::measure::shift_chop;
use crate::core::pager::{PagerCommand, resolve_pagers};
use crate::core::registry::{Composition, Overflow, PaneGeometry, Registry, SourceId, SourceSpec};
use crate::core::retain::{Keep, Retention, compact_count};
use crate::core::schedule::{Due, TickSchedule};
use crate::core::snapshot::{snapshot_body, snapshot_stamp, write_snapshot};
use crate::core::trigger::{
BracketId, DebounceGate, MtimeWatchSet, PathLedger, TriggerSpec, Verdict, WindowLog,
parse_trigger, stamps,
};
use crate::exit::{AppError, AppResult};
use crate::style_spec::StyleSpec;
use crate::term::history::History;
use crate::term::inline::{InlineRenderer, truncate_to_rows};
use crate::term::marks::{GUTTER_COLS, LineMark, changed_marks, mark_cells, prefix_rows};
use crate::term::scroll::{
HSHIFT_STEP, LiveScroll, ScrollState, ScrollStep, paused_notice, scrolled_notice,
};
use crate::term::tap::TapEvent;
#[cfg(unix)]
use crate::term::tap::{TapChunk, TapScanner, TriggerReader, TtyTap};
#[cfg(unix)]
use crate::term::theme_notify::{OscColorKind, ThemeNotifyGuard, classify_colors, may_subscribe};
use crate::term::tty::{ConsoleUtf8Guard, RawModeGuard};
use crate::theme::{Appearance, AppearanceSource, Palette};
use crate::ui::key::{Key, from_crossterm};
const SLICE: Duration = Duration::from_millis(50);
const ONCE_QUIET: Duration = Duration::from_secs(5);
const RESIZE_DEBOUNCE: Duration = Duration::from_millis(250);
struct Live {
lines: Vec<String>,
hash: u64,
changed_at: jiff::Timestamp,
since: String,
panes: Option<PaneLive>,
dropped: Option<String>,
}
struct PaneLive {
marks: Vec<LineMark>,
ages: Vec<jiff::Timestamp>,
}
pub(crate) struct SessionArgs {
pub once: bool,
pub once_timeout: Option<Duration>,
pub clear: bool,
pub no_hide_cursor: bool,
pub no_sync: bool,
pub wrap: bool,
pub max_height: Option<u16>,
pub snapshot_dir: Option<std::path::PathBuf>,
pub snapshot_ansi: bool,
pub live_tail: String,
pub help_heading: &'static str,
pub help_extra: Vec<String>,
pub resize_respawn: bool,
}
pub fn run(args: WatchArgs, profile: ColorProfile, palette: Palette) -> AppResult {
let triggers = args
.trigger
.iter()
.map(|spec| parse_trigger(spec))
.collect::<anyhow::Result<Vec<TriggerSpec>>>()?;
let interval = resolve_interval(args.interval.as_deref(), !triggers.is_empty())?;
let debounce = parse_interval(&args.trigger_debounce)?;
let interval_label = args
.interval
.as_deref()
.or(triggers.is_empty().then_some("2s"));
let live_tail = live_suffix(args.once, interval_label, !triggers.is_empty());
let help_extra = trigger_help(&triggers);
let registry = Registry::single(
SourceSpec {
name: String::new(),
command: if args.shell {
vec![args.command.join(" ")]
} else {
args.command.clone()
},
shell: args.shell,
interval,
triggers,
debounce,
live: false,
},
args.title.clone(),
);
let session = SessionArgs {
once: args.once,
once_timeout: None,
clear: args.clear,
no_hide_cursor: args.no_hide_cursor,
no_sync: args.no_sync,
wrap: !args.no_wrap,
max_height: args.max_height,
snapshot_dir: args.snapshot_dir.clone(),
snapshot_ansi: args.snapshot_ansi,
live_tail,
help_heading: "rat watch — keys",
help_extra,
resize_respawn: false,
};
run_registry(registry, session, profile, palette)
}
#[cfg_attr(windows, allow(unused_mut))]
pub(crate) fn run_registry(
registry: Registry,
session: SessionArgs,
profile: ColorProfile,
mut palette: Palette,
) -> AppResult {
let (interrupted, terminated) = register_signals()?;
let plain = matches!(registry.composition(), Composition::Plain { .. });
let stdout = std::io::stdout();
let is_tty = stdout.is_tty();
let mut renderer = InlineRenderer::new(stdout.lock())
.with_cursor_hidden(is_tty && !session.no_hide_cursor)
.with_sync_output(is_tty && !session.no_sync)
.with_clear_screen(is_tty && session.clear);
let interactive = is_tty && !session.once;
if !interactive {
for id in registry.ids() {
let spec = registry.spec(id);
if spec
.triggers
.iter()
.any(|trigger| !matches!(trigger, TriggerSpec::File(_)))
{
return Err(anyhow!(
"{}fifo:/fd: triggers need an interactive terminal; use file:PATH",
pane_label(®istry, id)
)
.into());
}
}
}
let _raw_guard = if interactive {
Some(RawModeGuard::enable().context("enabling raw mode")?)
} else {
None
};
#[cfg(unix)]
let tap = if interactive {
TtyTap::spawn().ok()
} else {
None
};
#[cfg(unix)]
let mut scanner = TapScanner::new();
#[cfg(unix)]
let mut theme_sub = may_subscribe(palette.source, profile, interactive && tap.is_some())
.then(|| ThemeNotifyGuard::subscribe(std::io::stdout()))
.transpose()
.context("subscribing to theme notifications")?;
#[cfg(unix)]
let mut verify = VerifyState::default();
let title_line = match registry.composition() {
Composition::Plain { title } => title.as_ref().map(|title| {
StyleSpec {
bold: true,
..StyleSpec::default()
}
.render(title, profile)
}),
Composition::Panes { .. } => None,
};
let faint = StyleSpec {
faint: true,
..StyleSpec::default()
};
let (tx, rx) = std::sync::mpsc::channel::<TickEvent>();
let armed = !session.once;
let watched_union: Vec<std::path::PathBuf> = if armed {
let mut paths: Vec<std::path::PathBuf> = registry
.ids()
.flat_map(|id| file_paths(®istry.spec(id).triggers))
.collect();
paths.sort();
paths.dedup();
paths
} else {
Vec::new()
};
let per_source_watched: Vec<Vec<std::path::PathBuf>> = registry
.ids()
.map(|id| {
if armed {
file_paths(®istry.spec(id).triggers)
} else {
Vec::new()
}
})
.collect();
let per_source_readers: Vec<Vec<crate::core::trigger::TriggerKey>> = registry
.ids()
.map(|id| {
if cfg!(unix) && armed {
registry
.spec(id)
.triggers
.iter()
.filter(|spec| !matches!(spec, TriggerSpec::File(_)))
.map(reader_key)
.collect()
} else {
Vec::new()
}
})
.collect();
let observing =
!watched_union.is_empty() || per_source_readers.iter().any(|keys| !keys.is_empty());
let mut ledger = PathLedger::new(watched_union.clone());
let mut trace = TriggerTrace::open();
let suspicion = crate::core::trigger::LoopSuspicion {
explain: trace.is_some(),
..Default::default()
};
let mut log = WindowLog::new(suspicion.window);
let mut suspected: Vec<SourceId> = Vec::new();
let mut runtime: Vec<SourceRuntime> = registry
.ids()
.map(|id| {
let spec = registry.spec(id);
let mut files = MtimeWatchSet::new(if armed {
file_paths(&spec.triggers)
} else {
Vec::new()
});
files.fired();
SourceRuntime {
schedule: TickSchedule::new(spec.interval),
slot: ChildSlot::default(),
tx: tx.clone(),
emissions: spec.live.then(|| {
let retention = retention_for(®istry, id);
Emissions::new(retention, retention)
}),
output: None,
hash: 0,
changed_at: jiff::Timestamp::UNIX_EPOCH,
previous: None,
marks: Vec::new(),
failure: None,
truncated: None,
posted: false,
gate: DebounceGate::new(spec.debounce),
files,
looping: false,
bracket: None,
#[cfg(unix)]
readers: Vec::new(),
}
})
.collect();
let _shutdown: Vec<ShutdownGuard> = runtime.iter().map(|r| r.slot.guard()).collect();
#[cfg(unix)]
if armed {
let wake = tap.as_ref().map(TtyTap::sender);
for id in registry.ids() {
for spec in ®istry.spec(id).triggers {
if matches!(spec, TriggerSpec::File(_)) {
continue; }
if let TriggerSpec::Fd(0) = spec {
return Err(anyhow!(
"{}fd:0 is the terminal's own input while watch reads keys; \
use another descriptor",
pane_label(®istry, id)
)
.into());
}
runtime[id.0].readers.push(ReaderSlot {
reader: TriggerReader::open(spec, wake.clone())?,
spec: spec.clone(),
ended_seen: false,
});
}
}
}
let live_tail = session.live_tail.clone();
let mut size = measure_size(is_tty, (80, 24));
let mut geom = registry.geometry(size);
let mut resize_gate = DebounceGate::new(RESIZE_DEBOUNCE);
let once_started = Instant::now();
let mut once_notice_sent = false;
let mut previous_key: Option<PaintKey> = None;
let mut live: Option<Live> = None;
let mut pause: Option<PauseState> = None;
let mut live_scroll: Option<LiveScroll> = None;
let mut history = History::new();
let mut view = ViewState {
wrap: session.wrap,
hshift: 0,
gutter: false,
highlight: false,
alt_time: false,
};
loop {
if interrupted.load(Ordering::Relaxed) {
renderer.finish().context("restoring terminal")?;
return Err(AppError::Aborted);
}
if terminated.load(Ordering::Relaxed) {
renderer.finish().context("restoring terminal")?;
return Ok(());
}
let now = Instant::now();
let due: Vec<SourceId> = registry
.ids()
.filter(|id| runtime[id.0].schedule.poll(now) == Due::Spawn)
.collect();
if !due.is_empty() {
refresh_geometry_for_spawn(
session.resize_respawn,
measure_size(is_tty, size),
&mut size,
&mut geom,
®istry,
);
for id in due {
if observing {
let opened = log.open_bracket(id, Instant::now(), stamps(&watched_union));
runtime[id.0].bracket = Some(opened);
#[cfg(unix)]
for r in runtime.iter() {
fence_all(&r.readers);
}
}
let retention = retention_for(®istry, id);
let mut inline = session.once && plain;
if !inline {
let command =
source_command(®istry, id, interactive, palette.appearance, geom[id.0]);
inline = match runtime[id.0].emissions.clone() {
Some(emissions) => {
if let Err(err) = spawn_live_tick(
command,
id,
runtime[id.0].slot.clone(),
emissions,
runtime[id.0].tx.clone(),
watched_union.clone(),
) {
let _ = runtime[id.0]
.tx
.send(TickEvent::Completed(not_started(id, err)));
}
false
}
None => spawn_tick(
command,
id,
runtime[id.0].slot.clone(),
runtime[id.0].tx.clone(),
watched_union.clone(),
retention,
)
.is_err(),
};
}
if inline {
let command =
source_command(®istry, id, interactive, palette.appearance, geom[id.0]);
let _ = runtime[id.0].tx.send(TickEvent::Completed(run_tick(
command,
id,
watched_union.clone(),
retention,
)));
}
}
}
let mut moved: Vec<SourceId> = Vec::new();
let mut drained: Vec<SourceId> = Vec::new();
let mut changed: Option<jiff::Timestamp> = None;
let mut newest = jiff::Timestamp::UNIX_EPOCH;
let mut piped_stderr: Vec<u8> = Vec::new();
let mut piped_dropped: Option<String> = None;
while let Ok(event) = rx.try_recv() {
let outcome = match event {
TickEvent::Completed(outcome) => outcome,
TickEvent::Progress { source } => {
let Some(emission) = runtime[source.0]
.emissions
.as_ref()
.and_then(Emissions::take)
else {
continue;
};
let spec = registry.spec(source);
let program = spec.command.first().map_or("", String::as_str);
let lines = pane_body(
&emission.stdout.concat(),
&emission.stderr.concat(),
None,
&spec.name,
program,
);
let changed_now = record_pane_body(
&mut runtime[source.0],
lines,
None,
emission.dropped,
emission.at,
);
changed = fold_changed_at(changed, changed_now, emission.at);
newest = newest.max(emission.at);
runtime[source.0].posted = true;
moved.push(source);
continue;
}
};
let id = outcome.source;
let changed_now = match registry.composition() {
Composition::Plain { .. } => {
let (stdout, stderr) = match outcome.spawn_error {
Some(err) if session.once => {
return Err(anyhow!(
"running {:?}: {err}",
registry.spec(id).command[0]
)
.into());
}
Some(err) => (
watch_spawn_error_text(®istry.spec(id).command[0], &err)
.into_bytes(),
Vec::new(),
),
None => (outcome.stdout.concat(), outcome.stderr.concat()),
};
let truncated = dropped_badge(outcome.dropped);
let mut combined = stdout.clone();
combined.extend_from_slice(&stderr);
if let Some(truncated) = &truncated {
combined.push(b'\n');
combined.extend_from_slice(truncated.as_bytes());
}
let hash = signature(&combined);
let r = &mut runtime[id.0];
let changed_now = hash != r.hash || !r.posted;
r.output = Some(compose_frame(title_line.as_ref(), &stdout, &stderr, is_tty));
r.hash = hash;
r.changed_at = outcome.at;
r.truncated = truncated.clone();
piped_dropped = truncated;
piped_stderr = stderr;
changed_now
}
Composition::Panes { .. } => {
let spec = registry.spec(id);
let program = spec.command.first().map_or("", String::as_str);
let lines = pane_body(
&outcome.stdout.concat(),
&outcome.stderr.concat(),
outcome.spawn_error.as_ref(),
&spec.name,
program,
);
record_pane_body(
&mut runtime[id.0],
lines,
exit_badge(outcome.status),
outcome.dropped,
outcome.at,
)
}
};
let r = &mut runtime[id.0];
changed = fold_changed_at(changed, changed_now, outcome.at);
newest = newest.max(outcome.at);
r.posted = true;
moved.push(id);
drained.push(id);
if let Some(open) = r.bracket.take()
&& let Some(closed) = log
.close_bracket(open, outcome.closed_at, outcome.close_stamps)
.cloned()
{
let others = log.overlapping(&closed);
ledger.observe_bracket(&closed, &others);
}
}
if !moved.is_empty() {
#[cfg(unix)]
if observing && !drained.is_empty() {
for r in runtime.iter() {
fence_all(&r.readers);
}
}
let content = combined_hash(&runtime);
let (changed_at, since) = match (changed, live.take()) {
(Some(at), _) => (at, local_hms(at)),
(None, Some(prev)) => (prev.changed_at, prev.since),
(None, None) => (newest, local_hms(newest)),
};
refresh_geometry_for_spawn(
session.resize_respawn,
measure_size(is_tty, size),
&mut size,
&mut geom,
®istry,
);
let (lines, panes) = match registry.composition() {
Composition::Plain { .. } => (runtime[0].output.clone().unwrap_or_default(), None),
Composition::Panes { .. } => {
let block = compose_sources(
®istry,
&runtime,
&geom,
view.alt_time,
&palette,
profile,
);
(
block.lines,
Some(PaneLive {
marks: block.marks,
ages: chrome_ages(®istry, &runtime),
}),
)
}
};
let current = Live {
lines,
hash: content,
changed_at,
since,
panes,
dropped: match registry.composition() {
Composition::Plain { .. } => runtime[0].truncated.clone(),
Composition::Panes { .. } => None,
},
};
if is_tty {
history.record(current.hash, ¤t.lines, newest);
}
if let Some(p) = pause.as_mut() {
let window = usize::from(window_rows(session.max_height, size.1));
p.scroll = p.scroll.clamp(p.frozen.len(), window);
}
if let Some(ls) = live_scroll {
let window = usize::from(window_rows(session.max_height, size.1));
let re = ls.reanchor(current.lines.len(), window);
live_scroll = (!re.at_top()).then_some(re);
}
let key = paint_key(
pause.as_ref(),
live_scroll,
current.hash,
palette.appearance,
size,
view,
displayed_age_key(
pause.as_ref(),
live_scroll,
view.alt_time,
current.changed_at,
current.panes.as_ref().map_or(&[][..], |p| &p.ages),
),
);
let once_ready = !session.once || runtime.iter().all(|r| r.posted);
if once_ready && previous_key != Some(key) {
previous_key = Some(key);
if is_tty {
repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
¤t,
&live_tail,
&palette,
view,
None,
size,
session.max_height,
&faint,
profile,
&history,
)?;
} else {
let mut out = std::io::stdout().lock();
for line in ¤t.lines {
writeln!(out, "{line}").context("writing")?;
}
out.flush().context("flushing")?;
if plain && !piped_stderr.is_empty() {
let mut err = std::io::stderr().lock();
err.write_all(&piped_stderr).context("writing stderr")?;
err.flush().context("flushing stderr")?;
}
if plain && let Some(text) = &piped_dropped {
let mut err = std::io::stderr().lock();
writeln!(err, "rat watch: {text}; kept the last {MAX_RETAINED_LINES}")
.context("writing stderr")?;
err.flush().context("flushing stderr")?;
}
}
}
live = Some(current);
for id in &drained {
runtime[id.0].schedule.completed(Instant::now());
}
if session.once && runtime.iter().all(|r| r.posted) {
break;
}
}
{
let now = Instant::now();
if !watched_union.is_empty() && !log.any_open(now) {
ledger.observe(now, &[]);
}
let mut badge_moved = false;
let mut notices: Vec<String> = Vec::new();
for id in registry.ids() {
let r = &mut runtime[id.0];
#[cfg(unix)]
drain_reader_arrivals(&r.readers, &mut log, now);
#[cfg(unix)]
for slot in &mut r.readers {
if slot.reader.fired().swap(false, Ordering::SeqCst) {
r.gate.fire(now);
}
if !slot.ended_seen && slot.reader.ended().load(Ordering::SeqCst) {
slot.ended_seen = true; notices.push(ended_text(®istry, id, &slot.spec));
}
}
if r.files.fired() {
r.gate.fire(now);
}
if r.gate.due(now) {
r.schedule.request_respawn();
if registry.spec(id).live {
r.slot.kill_current();
}
log.record_respawn(id, now);
}
}
ledger.evict(now, suspicion.window);
log.evict(now);
if observing {
let panes: Vec<crate::core::trigger::PaneWindow<'_>> = registry
.ids()
.map(|id| crate::core::trigger::PaneWindow {
source: id,
trigger_respawns: log.respawns_in_window(id, now),
watched: &per_source_watched[id.0],
readers: &per_source_readers[id.0],
})
.collect();
let verdict = suspicion.evaluate(now, &ledger, &log, &panes);
if let Some(t) = trace.as_mut() {
t.record(now, &verdict);
}
badge_moved =
apply_verdict(&mut runtime, &verdict.panes, !plain, jiff::Timestamp::now());
if badge_moved {
recompose_live(
&mut live,
®istry,
&runtime,
&geom,
view.alt_time,
&palette,
profile,
);
if let Some(l) = live.as_mut() {
restamp_live(l, &runtime);
}
}
if rising_edge(&mut suspected, &verdict) {
notices.push(looping_text(®istry, &verdict.panes));
}
}
if is_tty && let (true, Some(l)) = (!notices.is_empty() || badge_moved, live.as_ref()) {
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
l,
&live_tail,
&palette,
view,
(!notices.is_empty()).then(|| notices.join(" · ")),
crossterm::terminal::size().unwrap_or((80, 24)),
session.max_height,
&faint,
profile,
&history,
)?);
}
}
if session.resize_respawn {
let measured = measure_size(is_tty, size);
let step = detect_resize(measured, &mut size, &mut geom, ®istry);
if step.geom_moved {
resize_gate.fire(Instant::now());
}
if step.size_moved
&& is_tty
&& let Some(l) = live.as_mut()
{
if step.geom_moved {
let block = compose_sources(
®istry,
&runtime,
&geom,
view.alt_time,
&palette,
profile,
);
l.lines = block.lines;
l.panes = Some(PaneLive {
marks: block.marks,
ages: chrome_ages(®istry, &runtime),
});
}
let window = usize::from(window_rows(session.max_height, size.1));
if let Some(p) = pause.as_mut() {
p.scroll = p.scroll.clamp(p.frozen.len(), window);
}
if let Some(ls) = live_scroll {
let re = ls.reanchor(l.lines.len(), window);
live_scroll = (!re.at_top()).then_some(re);
}
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
l,
&live_tail,
&palette,
view,
None,
size,
session.max_height,
&faint,
profile,
&history,
)?);
}
if resize_gate.due(Instant::now()) {
request_respawn_all(&mut runtime);
}
}
if session.once && !plain && !once_notice_sent && once_started.elapsed() >= ONCE_QUIET {
let waiting: Vec<SourceId> =
registry.ids().filter(|id| !runtime[id.0].posted).collect();
if !waiting.is_empty() {
eprintln!("{}", once_waiting_text(®istry, &waiting, ONCE_QUIET));
}
once_notice_sent = true;
}
if let Some(bound) = session.once_timeout
&& session.once
&& !plain
&& once_started.elapsed() >= bound
{
let waiting: Vec<SourceId> =
registry.ids().filter(|id| !runtime[id.0].posted).collect();
if !waiting.is_empty() {
renderer.finish().context("restoring terminal")?;
return Err(AppError::Timeout(Some(anyhow!(
"{}",
once_timeout_text(®istry, &waiting, bound)
))));
}
}
let nap = runtime
.iter()
.map(|r| r.schedule.nap(Instant::now(), SLICE))
.min()
.unwrap_or(SLICE);
if !interactive {
std::thread::sleep(nap);
continue;
}
if let Some(prev) = previous_key
&& let Some(l) = live.as_ref()
{
let want_age = displayed_age_key(
pause.as_ref(),
live_scroll,
view.alt_time,
l.changed_at,
l.panes.as_ref().map_or(&[][..], |p| &p.ages),
);
if prev.age_secs != want_age {
recompose_live(
&mut live,
®istry,
&runtime,
&geom,
view.alt_time,
&palette,
profile,
);
let l = live.as_ref().expect("checked above");
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
l,
&live_tail,
&palette,
view,
None,
crossterm::terminal::size().unwrap_or((80, 24)),
session.max_height,
&faint,
profile,
&history,
)?);
}
}
#[cfg(unix)]
if let Some(sub) = theme_sub.as_mut() {
if verify.pending && verify.in_flight_until.is_none() {
verify.pending = false;
if sub.request_colors().is_ok() {
verify.fg = None;
verify.in_flight_until = Some(Instant::now() + crate::theme::PROBE_TIMEOUT);
}
}
if verify
.in_flight_until
.is_some_and(|until| Instant::now() >= until)
{
verify.in_flight_until = None;
verify.fg = None;
}
}
#[cfg(unix)]
let events = match tap.as_ref() {
Some(tap) => {
let waited = Instant::now();
match tap.recv_timeout(nap) {
Some(TapChunk::Tty(chunk)) => scanner.feed(&chunk),
Some(TapChunk::Trigger) => scanner.idle(waited.elapsed()),
None => scanner.idle(nap),
}
}
None => crossterm_slice(nap)?,
};
#[cfg(windows)]
let events = crossterm_slice(nap)?;
for event in events {
match event {
TapEvent::Key(key) => {
match action_for(key, mode_of(pause.as_ref(), live_scroll)) {
WatchAction::Abort => {
renderer.finish().context("restoring terminal")?;
return Err(AppError::Aborted);
}
WatchAction::Quit => {
renderer.finish().context("restoring terminal")?;
return Ok(());
}
action @ (WatchAction::Page | WatchAction::Help) => {
let help;
let content: &[String] = if action == WatchAction::Help {
help = help_lines(session.help_heading, &session.help_extra);
&help
} else {
let Some(live) = live.as_ref() else { continue };
pause.as_ref().map_or(&live.lines, |p| &p.frozen)
};
#[cfg(unix)]
if let Some(sub) = theme_sub.as_mut() {
let _ = sub.suspend();
}
#[cfg(unix)]
let handed_off = tap.as_ref().is_none_or(|tap| tap.pause());
#[cfg(windows)]
let handed_off = true;
let pager_notice = if handed_off {
page_frame(content, &mut renderer)
} else {
Some(
"pager unavailable: the input reader did not yield; try again"
.to_string(),
)
};
#[cfg(unix)]
{
if let Some(tap) = tap.as_ref() {
tap.resume();
}
if let Some(sub) = theme_sub.as_mut() {
let _ = sub.resume();
}
verify = VerifyState::default();
}
if let Some(live) = live.as_ref() {
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
pager_notice,
size,
session.max_height,
&faint,
profile,
&history,
)?);
}
}
WatchAction::Scroll(step) => {
let Some(live) = live.as_ref() else { continue };
let size = crossterm::terminal::size().unwrap_or((80, 24));
let window = usize::from(window_rows(session.max_height, size.1));
if let Some(p) = pause.as_mut() {
p.scroll = p.scroll.step(step, p.frozen.len(), window);
} else if let Some(ls) = live_scroll {
let stepped = ls.step(step, live.lines.len(), window);
live_scroll = (!stepped.at_top()).then_some(stepped);
} else {
let ls = LiveScroll::start(step, live.lines.len(), window);
if ls.at_top() {
continue;
}
live_scroll = Some(ls);
}
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
None,
size,
session.max_height,
&faint,
profile,
&history,
)?);
}
WatchAction::Resume => {
let Some(live) = live.as_ref() else { continue };
if pause.take().is_some() {
request_now_all(&mut runtime);
}
live_scroll = None;
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
None,
size,
session.max_height,
&faint,
profile,
&history,
)?);
}
WatchAction::Freeze => {
let Some(live) = live.as_ref() else { continue };
let size = crossterm::terminal::size().unwrap_or((80, 24));
let window = usize::from(window_rows(session.max_height, size.1));
let offset = live_scroll.map_or(0, LiveScroll::offset);
pause.get_or_insert_with(|| PauseState {
frozen: live.lines.clone(),
scroll: ScrollState::at(offset).clamp(live.lines.len(), window),
content: live.hash,
appearance: palette.appearance,
viewed_at: jiff::Timestamp::now(),
history_seq: history.newest_seq(),
});
live_scroll = None;
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
None,
size,
session.max_height,
&faint,
profile,
&history,
)?);
}
action @ (WatchAction::ScrubBack | WatchAction::ScrubForward) => {
let Some(live) = live.as_ref() else { continue };
let anchor = pause
.as_ref()
.and_then(|p| p.history_seq)
.and_then(|seq| history.nearest(seq).map(|e| e.seq))
.or_else(|| history.newest_seq());
let Some(anchor) = anchor else { continue };
let entry = if action == WatchAction::ScrubBack {
history.prev(anchor)
} else {
history.next(anchor)
};
let Some(entry) = entry else { continue };
let size = crossterm::terminal::size().unwrap_or((80, 24));
let window = usize::from(window_rows(session.max_height, size.1));
let scroll = pause
.as_ref()
.map(|p| p.scroll)
.or_else(|| live_scroll.map(|ls| ScrollState::at(ls.offset())))
.unwrap_or_default();
pause = Some(PauseState {
frozen: entry.frame.clone(),
scroll: scroll.clamp(entry.frame.len(), window),
content: entry.sig,
appearance: pause
.as_ref()
.map_or(palette.appearance, |p| p.appearance),
viewed_at: entry.at,
history_seq: Some(entry.seq),
});
live_scroll = None;
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
None,
size,
session.max_height,
&faint,
profile,
&history,
)?);
}
action @ (WatchAction::ToggleWrap
| WatchAction::ShiftLeft
| WatchAction::ShiftRight
| WatchAction::ToggleGutter
| WatchAction::ToggleHighlight
| WatchAction::ToggleTime) => {
if live.is_none() {
continue;
}
match action {
WatchAction::ToggleWrap => view.wrap = !view.wrap,
WatchAction::ToggleGutter => view.gutter = !view.gutter,
WatchAction::ToggleHighlight => {
view.highlight = !view.highlight;
}
WatchAction::ToggleTime => {
view.alt_time = !view.alt_time;
}
WatchAction::ShiftLeft => {
view.hshift = view.hshift.saturating_sub(HSHIFT_STEP);
}
_ => view.hshift += HSHIFT_STEP,
}
if action == WatchAction::ToggleTime {
recompose_live(
&mut live,
®istry,
&runtime,
&geom,
view.alt_time,
&palette,
profile,
);
}
let Some(live) = live.as_ref() else { continue };
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
None,
size,
session.max_height,
&faint,
profile,
&history,
)?);
}
WatchAction::Snapshot => {
let Some(live) = live.as_ref() else { continue };
let text = snapshot_frame(
pause.as_ref().map_or(&live.lines, |p| &p.frozen),
session.snapshot_dir.as_deref(),
session.snapshot_ansi,
);
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
Some(text),
size,
session.max_height,
&faint,
profile,
&history,
)?);
}
WatchAction::Ignore => {}
}
}
#[cfg(unix)]
TapEvent::ThemeNotification(_) => {
verify.pending = true;
}
#[cfg(unix)]
TapEvent::OscColor(kind, color) => {
if let Some(verdict) = verify.reply(kind, color)
&& adopt(&mut palette, verdict)
{
let debug_notice = std::env::var_os("RAT_DEBUG_APPEARANCE")
.is_some()
.then(|| format!("appearance → {}", verdict.as_str()));
request_respawn_all(&mut runtime);
if let Some(live) = live.as_ref() {
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
debug_notice,
size,
session.max_height,
&faint,
profile,
&history,
)?);
}
}
}
#[cfg(windows)]
TapEvent::ThemeNotification(_) | TapEvent::OscColor(..) => {}
}
}
}
renderer.finish().context("restoring terminal")?;
Ok(())
}
#[cfg(unix)]
struct ReaderSlot {
reader: TriggerReader,
spec: TriggerSpec,
ended_seen: bool,
}
#[cfg_attr(windows, allow(dead_code))]
fn reader_key(spec: &TriggerSpec) -> crate::core::trigger::TriggerKey {
crate::core::trigger::TriggerKey(spec.to_string())
}
#[cfg(unix)]
fn fence_all(slots: &[ReaderSlot]) {
for slot in slots {
slot.reader.fence();
}
}
#[cfg(unix)]
fn drain_reader_arrivals(
slots: &[ReaderSlot],
log: &mut crate::core::trigger::WindowLog,
now: Instant,
) {
for slot in slots {
let key = reader_key(&slot.spec);
for observation in slot.reader.take_arrivals() {
log.observe_arrival(key.clone(), observation);
}
if slot.reader.overflowed() {
log.record_overflow(now);
}
}
}
struct TriggerTrace {
file: std::fs::File,
started: Instant,
last_written: Option<Instant>,
last_answer: Option<(Vec<crate::core::registry::SourceId>, bool)>,
}
impl TriggerTrace {
fn open() -> Option<TriggerTrace> {
let path = std::env::var_os("RAT_TRIGGER_TRACE")?;
Some(TriggerTrace {
file: std::fs::File::create(path).ok()?,
started: Instant::now(),
last_written: None,
last_answer: None,
})
}
fn record(&mut self, now: Instant, verdict: &crate::core::trigger::Verdict) {
let answer = (verdict.panes.clone(), verdict.abstained);
let moved = self.last_answer.as_ref() != Some(&answer);
let due = self
.last_written
.is_none_or(|at| now.duration_since(at) >= Duration::from_millis(200));
if !moved && !due {
return;
}
self.last_written = Some(now);
self.last_answer = Some(answer);
let Some(why) = verdict.why.as_deref() else {
return;
};
use std::io::Write as _;
let _ = writeln!(
self.file,
"t={:.3} {why} -> panes={:?} abstain={}",
now.duration_since(self.started).as_secs_f64(),
verdict.panes.iter().map(|s| s.0).collect::<Vec<_>>(),
u8::from(verdict.abstained),
);
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
enum FrameMode {
Live,
LiveScrolled,
Paused,
}
fn mode_of(pause: Option<&PauseState>, live_scroll: Option<LiveScroll>) -> FrameMode {
if pause.is_some() {
FrameMode::Paused
} else if live_scroll.is_some() {
FrameMode::LiveScrolled
} else {
FrameMode::Live
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
enum WatchAction {
Abort,
Quit,
Page,
Help,
Snapshot,
Resume,
Freeze,
ScrubBack,
ScrubForward,
Scroll(ScrollStep),
ToggleWrap,
ShiftLeft,
ShiftRight,
ToggleGutter,
ToggleHighlight,
ToggleTime,
Ignore,
}
fn action_for(key: Key, mode: FrameMode) -> WatchAction {
match key {
Key::CtrlC => WatchAction::Abort,
Key::Char('q') => WatchAction::Quit,
Key::Char('v') | Key::Enter => WatchAction::Page,
Key::Char('?') => WatchAction::Help,
Key::Char('S') => WatchAction::Snapshot,
Key::Char('j') | Key::Down => WatchAction::Scroll(ScrollStep::LineDown),
Key::Char('k') | Key::Up => WatchAction::Scroll(ScrollStep::LineUp),
Key::Char('d') => WatchAction::Scroll(ScrollStep::HalfDown),
Key::Char('u') => WatchAction::Scroll(ScrollStep::HalfUp),
Key::Char('f') | Key::PageDown => WatchAction::Scroll(ScrollStep::PageDown),
Key::Char('b') | Key::PageUp => WatchAction::Scroll(ScrollStep::PageUp),
Key::Char('g') | Key::Home => WatchAction::Scroll(ScrollStep::Top),
Key::Char('G') | Key::End => WatchAction::Scroll(ScrollStep::Bottom),
Key::Char('w') => WatchAction::ToggleWrap,
Key::Char('h') | Key::Left => WatchAction::ShiftLeft,
Key::Char('l') | Key::Right => WatchAction::ShiftRight,
Key::Char('D') => WatchAction::ToggleGutter,
Key::Char('c') => WatchAction::ToggleHighlight,
Key::Char('t') => WatchAction::ToggleTime,
Key::Esc | Key::Char('F') if mode != FrameMode::Live => WatchAction::Resume,
Key::Char('p') if mode != FrameMode::Paused => WatchAction::Freeze,
Key::Char('<') | Key::Char(',') => WatchAction::ScrubBack,
Key::Char('>') | Key::Char('.') if mode == FrameMode::Paused => WatchAction::ScrubForward,
_ => WatchAction::Ignore,
}
}
struct PauseState {
frozen: Vec<String>,
scroll: ScrollState,
content: u64,
appearance: Appearance,
viewed_at: jiff::Timestamp,
history_seq: Option<u64>,
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
struct ViewState {
wrap: bool,
hshift: usize,
gutter: bool,
highlight: bool,
alt_time: bool,
}
#[derive(Copy, Clone, PartialEq, Debug)]
struct PaintKey {
content: u64,
cols: u16,
rows: u16,
appearance: Appearance,
offset: usize,
paused: bool,
wrap: bool,
hshift: usize,
gutter: bool,
highlight: bool,
alt_time: bool,
age_secs: u64,
}
fn paint_key(
pause: Option<&PauseState>,
live_scroll: Option<LiveScroll>,
live_content: u64,
live_appearance: Appearance,
size: (u16, u16),
view: ViewState,
age_secs: u64,
) -> PaintKey {
let (content, appearance, offset, paused) = match pause {
Some(p) => (p.content, p.appearance, p.scroll.offset(), true),
None => (
live_content,
live_appearance,
live_scroll.map_or(0, LiveScroll::offset),
false,
),
};
PaintKey {
content,
cols: size.0,
rows: size.1,
appearance,
offset,
paused,
wrap: view.wrap,
hshift: view.hshift,
gutter: view.gutter,
highlight: view.highlight,
alt_time: view.alt_time,
age_secs,
}
}
fn age_seconds(t: jiff::Timestamp) -> u64 {
(jiff::Timestamp::now().as_second() - t.as_second()).max(0) as u64
}
fn age_text(age_secs: u64) -> String {
if age_secs < 10 {
"just now".to_string()
} else {
format!(
"{} ago",
crate::core::duration::format_long(age_secs as i64)
)
}
}
fn displayed_age(
pause: Option<&PauseState>,
live_scroll: Option<LiveScroll>,
alt_time: bool,
changed_at: jiff::Timestamp,
) -> u64 {
match (alt_time, pause, live_scroll) {
(true, Some(p), _) => age_seconds(p.viewed_at),
(true, None, None) => age_seconds(changed_at),
_ => 0,
}
}
fn live_time_segment(alt_time: bool, since: &str, live_age_secs: u64) -> String {
if alt_time {
format!("changed {}", age_text(live_age_secs))
} else {
format!("since {since}")
}
}
fn paused_time_segment(alt_time: bool, viewed_at: jiff::Timestamp, age_secs: u64) -> String {
if alt_time {
age_text(age_secs)
} else {
format!("at {}", local_hms(viewed_at))
}
}
fn resolve_interval(user: Option<&str>, triggered: bool) -> anyhow::Result<Option<Duration>> {
match (user, triggered) {
(Some(token), _) => Ok(Some(parse_interval(token)?)),
(None, false) => Ok(Some(Duration::from_secs(2))),
(None, true) => Ok(None),
}
}
fn local_hms(t: jiff::Timestamp) -> String {
t.to_zoned(jiff::tz::TimeZone::system())
.strftime("%H:%M:%S")
.to_string()
}
fn help_lines(heading: &str, extra: &[String]) -> Vec<String> {
let mut lines: Vec<String> = std::iter::once(heading.to_string())
.chain(
[
"",
" q quit",
" v, Enter view the full frame in the pager",
" ? this key reference",
" S snapshot the viewed frame to a file",
"",
" j/k, Up/Down scroll one line (opens a live window)",
" d/u scroll half a window",
" f/b, PgDn/PgUp scroll a full window",
" g, Home top — and back to the live view",
" G, End bottom — stick to the tail",
"",
" p freeze the frame in place (the command keeps running)",
" Esc, F resume the live tail",
" <, , step back through distinct frames",
" >, . step forward again",
"",
" w wrap or chop long lines",
" h/l, Left/Right shift the view horizontally",
" D toggle the change gutter",
" c toggle the change highlights",
" t time style: wall-clock stamps or counting ages",
]
.into_iter()
.map(str::to_string),
)
.collect();
lines.extend(RETENTION_HELP.iter().map(|l| (*l).to_string()));
lines.extend(extra.iter().cloned());
lines
}
const RETENTION_HELP: &[&str] = &[
"",
" dropped lines:",
" A command keeps at most 1000 lines per run. Past that, the",
" marker `1.2k lines dropped` says how many did not survive —",
" on the pane that overflowed, or on the status row of a plain",
" watch.",
"",
" Nothing is stopped or slowed to make that happen: rat reads",
" the command's output to the end and stops KEEPING, so a child",
" never blocks writing into a pipe nobody is draining. Which",
" end survives is the pane's `overflow` — the head by default,",
" the tail where declared, and the tail always for a plain",
" watch.",
];
fn trigger_help(triggers: &[TriggerSpec]) -> Vec<String> {
if triggers.is_empty() {
return Vec::new();
}
let mut lines = vec![String::new(), " refresh triggers:".to_string()];
lines.extend(triggers.iter().map(|spec| format!(" {spec}")));
lines
}
fn live_suffix(once: bool, interval: Option<&str>, triggered: bool) -> String {
if once {
return String::new();
}
match (interval, triggered) {
(Some(interval), false) => format!(" · every {interval} · ? help"),
(Some(interval), true) => format!(" · every {interval} or on trigger · ? help"),
(None, true) => " · on trigger · ? help".to_string(),
(None, false) => {
debug_assert!(false, "no interval and no trigger");
String::new()
}
}
}
fn live_notice(hidden: usize, time_seg: &str, dropped: Option<&str>) -> String {
let mut row = if hidden > 0 {
format!("… {hidden} more lines · {time_seg}")
} else {
time_seg.to_string()
};
if let Some(dropped) = dropped {
row.push_str(" · ");
row.push_str(dropped);
}
row
}
#[allow(clippy::too_many_arguments)]
fn repaint(
renderer: &mut InlineRenderer<std::io::StdoutLock<'static>>,
pause: Option<&PauseState>,
live_scroll: Option<LiveScroll>,
live: &Live,
live_tail: &str,
palette: &Palette,
view: ViewState,
notice: Option<String>,
size: (u16, u16),
max_height: Option<u16>,
faint: &StyleSpec,
profile: ColorProfile,
history: &History,
) -> anyhow::Result<PaintKey> {
let age_secs = displayed_age_key(
pause,
live_scroll,
view.alt_time,
live.changed_at,
live.panes.as_ref().map_or(&[][..], |p| &p.ages),
);
let key = paint_key(
pause,
live_scroll,
live.hash,
palette.appearance,
size,
view,
age_secs,
);
let (source, offset, mode) = match (pause, live_scroll) {
(Some(p), _) => (p.frozen.as_slice(), p.scroll.offset(), FrameMode::Paused),
(None, Some(ls)) => (live.lines.as_slice(), ls.offset(), FrameMode::LiveScrolled),
(None, None) => (live.lines.as_slice(), 0, FrameMode::Live),
};
let marks: Option<Vec<LineMark>> = (view.gutter || view.highlight).then(|| {
let anchor = match pause {
Some(p) => p
.history_seq
.and_then(|seq| history.nearest(seq).map(|e| e.seq)),
None => history.newest_seq(),
};
let prev = anchor.and_then(|seq| history.prev(seq));
paint_marks(
live.panes.is_some(),
mode,
live.panes.as_ref().map_or(&[][..], |p| &p.marks),
source,
prev.map(|e| e.frame.as_slice()),
)
});
let mark_cell = format!(
"{} ",
StyleSpec {
bold: true,
foreground: Some(palette.accent),
..StyleSpec::default()
}
.render("▌", profile)
);
let time_live = format!(
"{}{live_tail}",
live_time_segment(view.alt_time, &live.since, age_seconds(live.changed_at))
);
let time_paused = pause.map_or_else(
|| age_text(0),
|p| paused_time_segment(view.alt_time, p.viewed_at, age_seconds(p.viewed_at)),
);
paint_frame(
renderer,
source,
offset,
mode,
view,
notice,
size,
max_height,
faint,
profile,
&time_live,
&time_paused,
marks.as_deref(),
&mark_cell,
live.dropped.as_deref(),
)?;
Ok(key)
}
fn window_rows(max_height: Option<u16>, rows: u16) -> u16 {
max_height.unwrap_or_else(|| rows.saturating_sub(2))
}
fn compose_frame(
title: Option<&String>,
stdout: &[u8],
stderr: &[u8],
join_stderr: bool,
) -> Vec<String> {
let body = String::from_utf8_lossy(stdout);
let mut lines: Vec<String> = Vec::new();
if let Some(title) = title {
lines.push(title.clone());
}
lines.extend(body.trim_end_matches('\n').split('\n').map(str::to_string));
if join_stderr && !stderr.is_empty() {
let err_body = String::from_utf8_lossy(stderr);
lines.extend(
err_body
.trim_end_matches('\n')
.split('\n')
.map(str::to_string),
);
}
lines
}
#[allow(clippy::too_many_arguments)]
fn paint_frame(
renderer: &mut InlineRenderer<std::io::StdoutLock<'static>>,
lines: &[String],
offset: usize,
mode: FrameMode,
view: ViewState,
notice: Option<String>,
size: (u16, u16),
max_height: Option<u16>,
faint: &StyleSpec,
profile: ColorProfile,
time_live: &str,
time_paused: &str,
marks: Option<&[LineMark]>,
mark_cell: &str,
dropped: Option<&str>,
) -> anyhow::Result<()> {
let (cols, rows) = size;
let max_rows = window_rows(max_height, rows);
let start = match mode {
FrameMode::Live => 0,
FrameMode::LiveScrolled | FrameMode::Paused => offset.min(lines.len()),
};
let content_cols = usize::from(cols).saturating_sub(if view.gutter { GUTTER_COLS } else { 0 });
let highlight = view.highlight && profile != ColorProfile::Ascii;
let spliced = |i: usize, line: &String| -> String {
match (highlight, marks) {
(true, Some(ms)) => mark_cells(
line,
ms.get(start + i).map_or(&[][..], |m| m.cells.as_slice()),
),
_ => line.clone(),
}
};
let (mut kept, hidden) =
if !view.wrap || view.hshift > 0 || mode == FrameMode::LiveScrolled || view.gutter {
let end = (start + usize::from(max_rows)).min(lines.len());
let kept: Vec<String> = lines[start..end]
.iter()
.enumerate()
.map(|(i, line)| shift_chop(&spliced(i, line), view.hshift, content_cols))
.collect();
(kept, lines.len() - end)
} else {
truncate_to_rows(
lines[start..]
.iter()
.enumerate()
.map(|(i, line)| spliced(i, line))
.collect(),
max_rows,
cols,
)
};
if view.gutter
&& let Some(marks) = marks
{
kept = prefix_rows(kept, marks, start, mark_cell);
}
let status = match mode {
FrameMode::Paused => paused_notice(time_paused, offset, kept.len(), lines.len()),
FrameMode::LiveScrolled => scrolled_notice(offset, kept.len(), lines.len()),
FrameMode::Live => live_notice(hidden, time_live, dropped),
};
kept.push(faint.render(&status, profile));
if let Some(text) = notice {
kept.push(faint.render(&text, profile));
}
renderer.draw(&kept, cols).context("writing frame")?;
Ok(())
}
fn snapshot_frame(lines: &[String], dir: Option<&std::path::Path>, ansi: bool) -> String {
let dir = dir
.map(std::path::Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let stamp = snapshot_stamp(&jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()));
let body = snapshot_body(lines, ansi);
match write_snapshot(&dir, &stamp, &body) {
Ok(path) => format!("snapshot → {}", path.display()),
Err(err) => format!("snapshot failed ({err}) — set --snapshot-dir or RAT_SNAPSHOT_DIR"),
}
}
fn page_frame(
lines: &[String],
renderer: &mut InlineRenderer<std::io::StdoutLock<'static>>,
) -> Option<String> {
let pagers = resolve_pagers(&SystemEnv);
let mut used = pagers.first().map(|p| p.bin.clone()).unwrap_or_default();
let _ = crossterm::terminal::disable_raw_mode();
let _ = renderer.finish();
let _console_utf8 = ConsoleUtf8Guard::enable();
let result = (|| -> std::io::Result<()> {
let (bin, mut child) = spawn_first(&pagers)?;
used = bin;
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_IGN);
}
let write_result = (|| -> std::io::Result<()> {
let mut stdin = child.stdin.take().expect("stdin piped");
for line in lines {
writeln!(stdin, "{line}")?;
}
Ok(())
})();
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
match write_result {
Err(err) if err.kind() != std::io::ErrorKind::BrokenPipe => return Err(err),
_ => {}
}
child.wait()?;
Ok(())
})();
let _ = crossterm::terminal::enable_raw_mode();
renderer.resume_over_own_frame();
match result {
Ok(()) => None,
Err(err) => Some(format!(
"pager {used:?} failed ({err}) — set RAT_PAGER or install less"
)),
}
}
fn spawn_first(pagers: &[PagerCommand]) -> std::io::Result<(String, std::process::Child)> {
let mut last_err =
std::io::Error::new(std::io::ErrorKind::NotFound, "no pager candidates resolved");
for pager in pagers {
match std::process::Command::new(&pager.bin)
.args(&pager.args)
.stdin(std::process::Stdio::piped())
.spawn()
{
Ok(child) => return Ok((pager.bin.clone(), child)),
Err(err) => last_err = err,
}
}
Err(last_err)
}
fn build_source_command(
spec: &SourceSpec,
interactive: bool,
appearance: Appearance,
geom: PaneGeometry,
) -> std::process::Command {
let mut command = if spec.shell {
shell_command(&spec.command.join(" "))
} else {
let mut cmd = std::process::Command::new(&spec.command[0]);
cmd.args(&spec.command[1..]);
cmd
};
if interactive {
command.stdin(std::process::Stdio::null());
}
command.env("RAT_WIDTH", geom.inner_cols.to_string());
command.env("RAT_HEIGHT", geom.inner_rows.to_string());
command.env("RAT_APPEARANCE", appearance.as_str());
command
}
const MAX_RETAINED_LINES: usize = 1000;
fn dropped_badge(dropped: usize) -> Option<String> {
(dropped > 0).then(|| format!("{} lines dropped", compact_count(dropped)))
}
fn retention_for(registry: &Registry, id: SourceId) -> Retention {
let keep = match registry.pane(id).map(|pane| pane.overflow) {
Some(Overflow::KeepTop) => Keep::Top,
Some(Overflow::KeepBottom) | None => Keep::Bottom,
};
Retention {
max_lines: MAX_RETAINED_LINES,
keep,
}
}
fn source_command(
registry: &Registry,
id: SourceId,
interactive: bool,
appearance: Appearance,
geom: PaneGeometry,
) -> std::process::Command {
let mut command = build_source_command(registry.spec(id), interactive, appearance, geom);
if registry.pane(id).is_some() {
command.env("RAT_PANE", ®istry.spec(id).name);
}
command
}
#[cfg(unix)]
fn register_signals() -> Result<(Arc<AtomicBool>, Arc<AtomicBool>), AppError> {
let interrupted = Arc::new(AtomicBool::new(false));
let terminated = Arc::new(AtomicBool::new(false));
signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(&interrupted))
.context("registering SIGINT")?;
signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&terminated))
.context("registering SIGTERM")?;
signal_hook::flag::register(signal_hook::consts::SIGHUP, Arc::clone(&terminated))
.context("registering SIGHUP")?;
Ok((interrupted, terminated))
}
#[cfg(windows)]
fn register_signals() -> Result<(Arc<AtomicBool>, Arc<AtomicBool>), AppError> {
Ok((
Arc::new(AtomicBool::new(false)),
Arc::new(AtomicBool::new(false)),
))
}
fn crossterm_slice(nap: Duration) -> anyhow::Result<Vec<TapEvent>> {
if !crossterm::event::poll(nap).context("polling events")? {
return Ok(Vec::new());
}
let crossterm::event::Event::Key(key_event) =
crossterm::event::read().context("reading event")?
else {
return Ok(Vec::new());
};
match from_crossterm(key_event) {
Some(key) => Ok(vec![TapEvent::Key(key)]),
None => Ok(Vec::new()),
}
}
#[cfg(unix)]
#[derive(Default)]
struct VerifyState {
pending: bool,
fg: Option<xterm_color::Color>,
in_flight_until: Option<Instant>,
}
#[cfg(unix)]
impl VerifyState {
fn reply(&mut self, kind: OscColorKind, color: xterm_color::Color) -> Option<Appearance> {
self.in_flight_until?;
match kind {
OscColorKind::Foreground => {
self.fg = Some(color);
None
}
OscColorKind::Background => {
self.in_flight_until = None;
let verdict = classify_colors(self.fg.as_ref(), &color);
self.fg = None;
Some(verdict)
}
}
}
}
#[cfg_attr(windows, allow(dead_code))] fn adopt(palette: &mut Palette, reported: Appearance) -> bool {
if palette.appearance == reported {
return false;
}
*palette = Palette::builtin(reported, AppearanceSource::Notification);
true
}
fn size_fallback(
env_cols: Option<&str>,
env_rows: Option<&str>,
fallback: (u16, u16),
) -> (u16, u16) {
let parse = |v: Option<&str>| v.and_then(|v| v.parse::<u16>().ok());
(
parse(env_cols).unwrap_or(fallback.0),
parse(env_rows).unwrap_or(fallback.1),
)
}
fn measure_size(is_tty: bool, fallback: (u16, u16)) -> (u16, u16) {
let measured = crossterm::terminal::size().ok();
if is_tty {
return measured.unwrap_or(fallback);
}
let base = measured.unwrap_or(fallback);
let cols = std::env::var("RAT_WIDTH").ok();
let rows = std::env::var("RAT_HEIGHT").ok();
size_fallback(cols.as_deref(), rows.as_deref(), base)
}
fn refresh_geometry_for_spawn(
resize_respawn: bool,
measured: (u16, u16),
size: &mut (u16, u16),
geom: &mut Vec<PaneGeometry>,
registry: &Registry,
) {
if resize_respawn {
return;
}
*size = measured;
*geom = registry.geometry(measured);
}
struct ResizeStep {
size_moved: bool,
geom_moved: bool,
}
fn detect_resize(
measured: (u16, u16),
size: &mut (u16, u16),
geom: &mut Vec<PaneGeometry>,
registry: &Registry,
) -> ResizeStep {
let size_moved = measured != *size;
*size = measured;
let next = registry.geometry(measured);
let geom_moved = next != *geom;
if geom_moved {
*geom = next;
}
ResizeStep {
size_moved,
geom_moved,
}
}
fn pane_label(registry: &Registry, id: SourceId) -> String {
match registry.pane(id) {
Some(_) => format!("pane {}: ", registry.spec(id).name),
None => String::new(),
}
}
#[cfg_attr(windows, allow(dead_code))]
fn ended_text(registry: &Registry, id: SourceId, spec: &TriggerSpec) -> String {
match registry.pane(id) {
Some(_) => format!("{}: trigger ended: {spec}", registry.spec(id).name),
None => format!("trigger ended: {spec}"),
}
}
fn looping_text(registry: &Registry, panes: &[SourceId]) -> String {
let names: Vec<&str> = panes
.iter()
.filter(|id| registry.pane(**id).is_some())
.map(|id| registry.spec(*id).name.as_str())
.collect();
let mut watched: Vec<String> = Vec::new();
for spec in panes.iter().map(|id| registry.spec(*id)) {
for trigger in &spec.triggers {
let text = trigger.to_string();
if !watched.contains(&text) {
watched.push(text);
}
}
}
let who = if names.is_empty() {
String::new()
} else {
format!("{}: ", names.join(", "))
};
format!(
"{who}trigger loop suspected: {} — ? help",
watched.join(", ")
)
}
fn rising_edge(latch: &mut Vec<SourceId>, verdict: &Verdict) -> bool {
if verdict.abstained {
return false;
}
if verdict.panes.is_empty() {
latch.clear();
return false;
}
let mut grew = false;
for id in &verdict.panes {
if !latch.contains(id) {
latch.push(*id);
grew = true;
}
}
grew
}
fn pane_list(registry: &Registry, waiting: &[SourceId]) -> String {
let names = waiting
.iter()
.map(|id| format!("{:?}", registry.spec(*id).name))
.collect::<Vec<_>>()
.join(", ");
let noun = if waiting.len() == 1 { "pane" } else { "panes" };
format!("{noun} {names}")
}
fn brief_duration(d: Duration) -> String {
if d.subsec_millis() == 0 {
format!("{}s", d.as_secs())
} else {
format!("{}ms", d.as_millis())
}
}
fn once_waiting_text(registry: &Registry, waiting: &[SourceId], after: Duration) -> String {
let all_live = waiting.iter().all(|id| registry.spec(*id).live);
let state = if all_live {
if waiting.len() == 1 {
"the live child has printed nothing yet. "
} else {
"the live children have printed nothing yet. "
}
} else {
"no output, no exit. A command that follows instead of exiting \
must be declared `live=#true`; "
};
format!(
"rat dashboard: --once is still waiting on {} after {}: {state}`--once-timeout 30s` bounds the wait.",
pane_list(registry, waiting),
brief_duration(after),
)
}
fn once_timeout_text(registry: &Registry, waiting: &[SourceId], after: Duration) -> String {
let all_live = waiting.iter().all(|id| registry.spec(*id).live);
let tail = if all_live {
if waiting.len() == 1 {
"never produced its first output."
} else {
"never produced their first output."
}
} else {
"never finished. A command that follows instead of exiting must \
be declared `live=#true`."
};
format!(
"--once gave up after {}: {} {tail}",
brief_duration(after),
pane_list(registry, waiting),
)
}
fn pane_spawn_error_text(pane: &str, program: &str, err: &std::io::Error) -> String {
format!("{pane}: {err}: {program:?}")
}
fn watch_spawn_error_text(program: &str, err: &std::io::Error) -> String {
format!("watch: {program:?}: {err}")
}
fn pane_body(
stdout: &[u8],
stderr: &[u8],
spawn_error: Option<&std::io::Error>,
pane: &str,
program: &str,
) -> Vec<String> {
if let Some(err) = spawn_error {
return vec![pane_spawn_error_text(pane, program, err)];
}
output_lines(stdout, stderr)
}
fn exit_badge(status: Option<std::process::ExitStatus>) -> Option<String> {
let status = status?;
(!status.success()).then(|| match status.code() {
Some(code) => format!("exit {code}"),
None => "killed".to_string(),
})
}
fn body_signature(
lines: &[String],
failure: Option<&str>,
looping: bool,
truncated: Option<&str>,
) -> u64 {
let mut bytes = lines.join("\n").into_bytes();
if let Some(failure) = failure {
bytes.push(b'\n');
bytes.extend_from_slice(failure.as_bytes());
}
if looping {
bytes.push(b'\n');
bytes.extend_from_slice(b"looping");
}
if let Some(truncated) = truncated {
bytes.push(b'\n');
bytes.extend_from_slice(truncated.as_bytes());
}
signature(&bytes)
}
fn record_pane_body(
r: &mut SourceRuntime,
lines: Vec<String>,
failure: Option<String>,
dropped: usize,
at: jiff::Timestamp,
) -> bool {
let old_hash = r.hash;
let was_posted = r.posted;
r.failure = failure;
r.truncated = dropped_badge(dropped);
record_output(r, lines, at);
r.hash != old_hash || !was_posted
}
fn record_output(r: &mut SourceRuntime, lines: Vec<String>, at: jiff::Timestamp) {
let hash = body_signature(
&lines,
r.failure.as_deref(),
r.looping,
r.truncated.as_deref(),
);
if r.output.is_none() || r.hash != hash {
r.previous = r.output.take();
r.marks = changed_marks(r.previous.as_deref(), &lines);
r.hash = hash;
r.changed_at = at;
r.output = Some(lines);
}
}
fn apply_verdict(
runtime: &mut [SourceRuntime],
implicated: &[SourceId],
boxed: bool,
at: jiff::Timestamp,
) -> bool {
let mut moved = false;
for (i, r) in runtime.iter_mut().enumerate() {
let looping = implicated.contains(&SourceId(i));
if r.looping == looping {
continue;
}
r.looping = looping;
if let (true, Some(body)) = (boxed, r.output.clone()) {
record_output(r, body, at);
moved = true;
}
}
moved
}
fn restamp_live(live: &mut Live, runtime: &[SourceRuntime]) {
let at = runtime
.iter()
.map(|r| r.changed_at)
.max()
.unwrap_or(live.changed_at);
live.hash = combined_hash(runtime);
live.changed_at = at;
live.since = local_hms(at);
}
fn paint_marks(
panes: bool,
mode: FrameMode,
live_marks: &[LineMark],
viewed: &[String],
prev: Option<&[String]>,
) -> Vec<LineMark> {
if panes && mode != FrameMode::Paused {
return live_marks.to_vec();
}
changed_marks(prev, viewed)
}
fn displayed_age_key(
pause: Option<&PauseState>,
live_scroll: Option<LiveScroll>,
alt_time: bool,
changed_at: jiff::Timestamp,
pane_changed_at: &[jiff::Timestamp],
) -> u64 {
use std::hash::{Hash, Hasher};
let footer = displayed_age(pause, live_scroll, alt_time, changed_at);
if !alt_time || pause.is_some() || pane_changed_at.is_empty() {
return footer;
}
let mut hasher = std::hash::DefaultHasher::new();
footer.hash(&mut hasher);
for at in pane_changed_at {
age_seconds(*at).hash(&mut hasher);
}
hasher.finish()
}
fn chrome_ages(registry: &Registry, runtime: &[SourceRuntime]) -> Vec<jiff::Timestamp> {
registry
.ids()
.filter(|id| registry.pane(*id).is_some_and(|p| p.chrome))
.map(|id| runtime[id.0].changed_at)
.collect()
}
#[allow(clippy::too_many_arguments)]
fn recompose_live(
live: &mut Option<Live>,
registry: &Registry,
runtime: &[SourceRuntime],
geom: &[PaneGeometry],
alt_time: bool,
palette: &Palette,
profile: ColorProfile,
) {
let Some(l) = live.as_mut() else {
return;
};
if matches!(registry.composition(), Composition::Plain { .. }) {
return;
}
let block = compose_sources(registry, runtime, geom, alt_time, palette, profile);
l.lines = block.lines;
l.panes = Some(PaneLive {
marks: block.marks,
ages: chrome_ages(registry, runtime),
});
}
fn compose_sources(
registry: &Registry,
runtime: &[SourceRuntime],
geom: &[PaneGeometry],
alt_time: bool,
palette: &Palette,
profile: ColorProfile,
) -> PaneBlock {
let Composition::Panes {
layout,
gap,
row_gap,
title,
} = registry.composition()
else {
return PaneBlock::default();
};
let blocks: Vec<PaneBlock> = registry
.ids()
.map(|id| {
let source = &runtime[id.0];
let spec = registry.spec(id);
let pane = registry
.pane(id)
.expect("a Panes registry boxes every source");
let cadence = cadence_label(spec);
let stamp = if !source.posted {
"…".to_string()
} else if alt_time {
age_text(age_seconds(source.changed_at))
} else {
local_hms(source.changed_at)
};
let chrome = PaneChrome {
title: pane.title.as_deref().unwrap_or(&spec.name),
cadence: &cadence,
stamp: &stamp,
failure: source.failure.as_deref(),
looping: source.looping,
truncated: source.truncated.as_deref(),
};
render_pane(
source.output.as_deref().unwrap_or(&[]),
&source.marks,
pane,
geom[id.0],
&chrome,
palette,
profile,
)
})
.collect();
let mut block = compose_panes(layout, &blocks, *gap, *row_gap);
if let Some(title) = title {
let composed = block
.lines
.iter()
.map(|line| crate::core::measure::display_width(line))
.max()
.unwrap_or(0);
let text =
crate::core::measure::truncate_display(title, composed, crate::core::measure::ELLIPSIS);
block.lines.insert(
0,
StyleSpec {
bold: true,
..StyleSpec::default()
}
.render(&text, profile),
);
block.marks.insert(0, LineMark::default());
}
block
}
pub(crate) fn cadence_label(spec: &SourceSpec) -> String {
if spec.live {
return "live".to_string();
}
match (spec.interval, spec.triggers.is_empty()) {
(Some(interval), true) => format!("every {}", interval_words(interval)),
(Some(interval), false) => {
format!("every {} or on trigger", interval_words(interval))
}
(None, false) => "on trigger".to_string(),
(None, true) => "once".to_string(),
}
}
fn interval_words(interval: Duration) -> String {
let millis = interval.as_millis();
let secs = interval.as_secs();
if millis == 0 || !millis.is_multiple_of(1000) {
return format!("{millis}ms");
}
if secs.is_multiple_of(3600) {
return format!("{}h", secs / 3600);
}
if secs.is_multiple_of(60) {
return format!("{}m", secs / 60);
}
format!("{secs}s")
}
fn output_lines(stdout: &[u8], stderr: &[u8]) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
let body = String::from_utf8_lossy(stdout);
lines.extend(body.trim_end_matches('\n').split('\n').map(str::to_string));
if !stderr.is_empty() {
let err_body = String::from_utf8_lossy(stderr);
lines.extend(
err_body
.trim_end_matches('\n')
.split('\n')
.map(str::to_string),
);
}
lines
}
struct SourceRuntime {
schedule: TickSchedule,
slot: ChildSlot,
tx: std::sync::mpsc::Sender<TickEvent>,
emissions: Option<Emissions>,
output: Option<Vec<String>>,
hash: u64,
changed_at: jiff::Timestamp,
previous: Option<Vec<String>>,
marks: Vec<LineMark>,
failure: Option<String>,
truncated: Option<String>,
posted: bool,
gate: DebounceGate,
files: MtimeWatchSet,
looping: bool,
bracket: Option<BracketId>,
#[cfg(unix)]
readers: Vec<ReaderSlot>,
}
fn combined_hash(runtime: &[SourceRuntime]) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::hash::DefaultHasher::new();
for r in runtime {
r.hash.hash(&mut hasher);
}
hasher.finish()
}
fn fold_changed_at(
acc: Option<jiff::Timestamp>,
changed: bool,
at: jiff::Timestamp,
) -> Option<jiff::Timestamp> {
match (acc, changed) {
(acc, false) => acc,
(Some(best), true) => Some(best.max(at)),
(None, true) => Some(at),
}
}
fn request_now_all(runtime: &mut [SourceRuntime]) {
for r in runtime {
r.schedule.request_now();
}
}
fn request_respawn_all(runtime: &mut [SourceRuntime]) {
for r in runtime {
r.schedule.request_respawn();
}
}
#[cfg_attr(windows, allow(clippy::unnecessary_filter_map))]
fn file_paths(triggers: &[TriggerSpec]) -> Vec<std::path::PathBuf> {
triggers
.iter()
.filter_map(|trigger| match trigger {
TriggerSpec::File(path) => Some(path.clone()),
#[cfg(unix)]
_ => None,
})
.collect()
}
fn signature(bytes: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::hash::DefaultHasher::new();
bytes.hash(&mut hasher);
hasher.finish()
}
#[cfg(unix)]
fn shell_command(script: &str) -> std::process::Command {
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c").arg(script);
cmd
}
#[cfg(windows)]
fn shell_command(script: &str) -> std::process::Command {
let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd".to_string());
let mut cmd = std::process::Command::new(shell);
cmd.arg("/C").arg(script);
cmd
}
#[cfg(test)]
mod tests {
use ratatui::style::Color;
use super::*;
const ALL_MODES: [FrameMode; 3] = [FrameMode::Live, FrameMode::LiveScrolled, FrameMode::Paused];
#[test]
fn the_interval_resolves_by_the_trigger_rule() {
let secs = |n| Some(Duration::from_secs(n));
assert_eq!(resolve_interval(Some("5s"), false).unwrap(), secs(5));
assert_eq!(resolve_interval(Some("5s"), true).unwrap(), secs(5));
assert_eq!(resolve_interval(None, false).unwrap(), secs(2));
assert_eq!(resolve_interval(None, true).unwrap(), None); assert!(resolve_interval(Some("bogus"), false).is_err());
}
#[test]
fn todays_keys_mean_the_same_thing_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::CtrlC, mode), WatchAction::Abort);
assert_eq!(action_for(Key::Char('q'), mode), WatchAction::Quit);
assert_eq!(action_for(Key::Char('v'), mode), WatchAction::Page);
assert_eq!(action_for(Key::Enter, mode), WatchAction::Page);
}
}
#[test]
fn navigation_keys_scroll() {
use crate::term::scroll::ScrollStep;
for mode in ALL_MODES {
for (key, step) in [
(Key::Char('j'), ScrollStep::LineDown),
(Key::Down, ScrollStep::LineDown),
(Key::Char('k'), ScrollStep::LineUp),
(Key::Up, ScrollStep::LineUp),
(Key::Char('d'), ScrollStep::HalfDown),
(Key::Char('u'), ScrollStep::HalfUp),
(Key::Char('f'), ScrollStep::PageDown),
(Key::PageDown, ScrollStep::PageDown),
(Key::Char('b'), ScrollStep::PageUp),
(Key::PageUp, ScrollStep::PageUp),
(Key::Char('g'), ScrollStep::Top),
(Key::Home, ScrollStep::Top),
(Key::Char('G'), ScrollStep::Bottom),
(Key::End, ScrollStep::Bottom),
] {
assert_eq!(
action_for(key, mode),
WatchAction::Scroll(step),
"{key:?} mode={mode:?}"
);
}
}
}
#[test]
fn esc_only_means_something_when_not_live() {
assert_eq!(action_for(Key::Esc, FrameMode::Live), WatchAction::Ignore);
assert_eq!(
action_for(Key::Esc, FrameMode::LiveScrolled),
WatchAction::Resume
);
assert_eq!(action_for(Key::Esc, FrameMode::Paused), WatchAction::Resume);
}
#[test]
fn f_resumes_and_p_freezes() {
assert_eq!(
action_for(Key::Char('F'), FrameMode::Live),
WatchAction::Ignore
);
assert_eq!(
action_for(Key::Char('F'), FrameMode::LiveScrolled),
WatchAction::Resume
);
assert_eq!(
action_for(Key::Char('F'), FrameMode::Paused),
WatchAction::Resume
);
assert_eq!(
action_for(Key::Char('p'), FrameMode::Live),
WatchAction::Freeze
);
assert_eq!(
action_for(Key::Char('p'), FrameMode::LiveScrolled),
WatchAction::Freeze
);
assert_eq!(
action_for(Key::Char('p'), FrameMode::Paused),
WatchAction::Ignore
);
}
#[test]
fn shift_d_toggles_the_gutter_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('D'), mode), WatchAction::ToggleGutter);
}
for mode in ALL_MODES {
assert_eq!(
action_for(Key::Char('d'), mode),
WatchAction::Scroll(ScrollStep::HalfDown)
);
}
}
#[test]
fn c_toggles_the_highlight_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(
action_for(Key::Char('c'), mode),
WatchAction::ToggleHighlight
);
}
}
#[test]
fn t_toggles_the_time_display_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('t'), mode), WatchAction::ToggleTime);
}
}
#[test]
fn view_keys_are_view_actions_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('w'), mode), WatchAction::ToggleWrap);
assert_eq!(action_for(Key::Char('h'), mode), WatchAction::ShiftLeft);
assert_eq!(action_for(Key::Left, mode), WatchAction::ShiftLeft);
assert_eq!(action_for(Key::Char('l'), mode), WatchAction::ShiftRight);
assert_eq!(action_for(Key::Right, mode), WatchAction::ShiftRight);
}
}
#[test]
fn unbound_keys_are_ignored() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('x'), mode), WatchAction::Ignore);
assert_eq!(action_for(Key::Tab, mode), WatchAction::Ignore);
assert_eq!(action_for(Key::Backspace, mode), WatchAction::Ignore);
}
}
#[test]
fn scrub_keys_walk_history() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('<'), mode), WatchAction::ScrubBack);
assert_eq!(action_for(Key::Char(','), mode), WatchAction::ScrubBack);
}
assert_eq!(
action_for(Key::Char('>'), FrameMode::Paused),
WatchAction::ScrubForward
);
assert_eq!(
action_for(Key::Char('.'), FrameMode::Paused),
WatchAction::ScrubForward
);
for mode in [FrameMode::Live, FrameMode::LiveScrolled] {
assert_eq!(action_for(Key::Char('>'), mode), WatchAction::Ignore);
assert_eq!(action_for(Key::Char('.'), mode), WatchAction::Ignore);
}
}
#[test]
fn s_is_the_snapshot_key() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('S'), mode), WatchAction::Snapshot);
assert_eq!(action_for(Key::Char('s'), mode), WatchAction::Ignore);
}
}
#[test]
fn the_live_rows_carry_the_time_segment() {
assert_eq!(live_notice(0, "since 18:47:53", None), "since 18:47:53");
assert_eq!(
live_notice(8, "changed 14s ago", None),
"… 8 more lines · changed 14s ago"
);
}
#[test]
fn the_live_row_says_when_lines_were_dropped() {
assert_eq!(
live_notice(0, "since 18:47:53", Some("2.0k lines dropped")),
"since 18:47:53 · 2.0k lines dropped"
);
assert_eq!(
live_notice(8, "since 18:47:53", Some("2.0k lines dropped")),
"… 8 more lines · since 18:47:53 · 2.0k lines dropped"
);
}
#[test]
fn the_live_suffix_names_the_interval_and_help() {
assert_eq!(
live_suffix(false, Some("2s"), false),
" · every 2s · ? help"
);
assert_eq!(
live_suffix(false, Some("500ms"), false),
" · every 500ms · ? help"
);
assert_eq!(live_suffix(true, Some("2s"), false), "");
}
#[test]
fn the_live_suffix_names_the_trigger_modes() {
assert_eq!(
live_suffix(false, Some("60s"), true),
" · every 60s or on trigger · ? help"
);
assert_eq!(live_suffix(false, None, true), " · on trigger · ? help");
assert_eq!(live_suffix(true, None, true), ""); }
#[test]
fn the_help_reference_names_the_trigger_sources() {
let specs = vec![TriggerSpec::File("/tmp/state.json".into())];
let lines = help_lines("rat watch — keys", &trigger_help(&specs));
assert!(
lines
.iter()
.any(|line| line.contains("file:/tmp/state.json")),
"{lines:?}"
);
assert!(
!help_lines("rat watch — keys", &trigger_help(&[]))
.iter()
.any(|line| line.contains("trigger")),
"the untriggered reference must not mention triggers"
);
}
#[test]
fn paint_key_matches_the_live_and_paused_shapes() {
let view = ViewState {
wrap: true,
hshift: 4,
gutter: false,
highlight: false,
alt_time: false,
};
let live = paint_key(None, None, 42, Appearance::Dark, (80, 24), view, 14);
assert_eq!(
live,
PaintKey {
content: 42,
cols: 80,
rows: 24,
appearance: Appearance::Dark,
offset: 0,
paused: false,
wrap: true,
hshift: 4,
gutter: false,
highlight: false,
alt_time: false,
age_secs: 14,
}
);
let scroll = ScrollState::default().step(ScrollStep::LineDown, 50, 10);
let p = PauseState {
frozen: vec!["x".to_string()],
scroll,
content: 7,
appearance: Appearance::Light,
viewed_at: jiff::Timestamp::now(),
history_seq: None,
};
let ls = LiveScroll::start(ScrollStep::LineDown, 50, 10);
let scrolled = paint_key(None, Some(ls), 42, Appearance::Dark, (80, 24), view, 14);
assert_eq!(
scrolled,
PaintKey {
content: 42,
cols: 80,
rows: 24,
appearance: Appearance::Dark,
offset: 1,
paused: false,
wrap: true,
hshift: 4,
gutter: false,
highlight: false,
alt_time: false,
age_secs: 14,
}
);
let paused = paint_key(Some(&p), None, 42, Appearance::Dark, (80, 24), view, 14);
assert_eq!(
paused,
PaintKey {
content: 7,
cols: 80,
rows: 24,
appearance: Appearance::Light,
offset: scroll.offset(),
paused: true,
wrap: true,
hshift: 4,
gutter: false,
highlight: false,
alt_time: false,
age_secs: 14,
}
);
}
#[test]
fn the_age_reads_just_now_then_counts() {
assert_eq!(age_text(0), "just now");
assert_eq!(age_text(9), "just now");
assert_eq!(age_text(10), "10s ago");
assert_eq!(age_text(14), "14s ago");
assert_eq!(age_text(75), "1m 15s ago");
}
#[test]
fn the_displayed_age_counts_only_where_the_row_counts() {
let old = jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - 100)
.expect("timestamp");
let p = PauseState {
frozen: vec!["x".to_string()],
scroll: ScrollState::default(),
content: 7,
appearance: Appearance::Dark,
viewed_at: old,
history_seq: None,
};
let ls = LiveScroll::start(ScrollStep::LineDown, 50, 10);
assert!(
displayed_age(Some(&p), None, true, old) >= 100,
"paused flipped counts"
);
assert!(
displayed_age(None, None, true, old) >= 100,
"live flipped counts"
);
assert_eq!(
displayed_age(Some(&p), None, false, old),
0,
"paused default is a stamp"
);
assert_eq!(
displayed_age(None, None, false, old),
0,
"live default is a stamp"
);
assert_eq!(
displayed_age(None, Some(ls), true, old),
0,
"the scrolled row has no time"
);
assert_eq!(displayed_age(None, Some(ls), false, old), 0);
}
#[test]
fn the_live_segment_flips_between_stamp_and_counter() {
assert_eq!(live_time_segment(false, "18:47:53", 999), "since 18:47:53");
assert_eq!(live_time_segment(true, "18:47:53", 0), "changed just now");
assert_eq!(live_time_segment(true, "18:47:53", 14), "changed 14s ago");
assert_eq!(
live_time_segment(true, "18:47:53", 75),
"changed 1m 15s ago"
);
}
#[test]
fn the_paused_segment_stamps_by_default_and_counts_flipped() {
let t = jiff::Timestamp::from_second(1_785_067_200).expect("timestamp");
assert_eq!(
paused_time_segment(false, t, 999),
format!("at {}", local_hms(t))
);
assert_eq!(paused_time_segment(true, t, 3), "just now");
assert_eq!(paused_time_segment(true, t, 14), "14s ago");
}
#[test]
fn local_hms_is_a_wall_clock_stamp() {
let s = local_hms(jiff::Timestamp::from_second(1_785_067_200).expect("timestamp"));
let b = s.as_bytes();
assert_eq!(b.len(), 8, "HH:MM:SS: {s}");
assert!(b[2] == b':' && b[5] == b':', "{s}");
assert!(
[0, 1, 3, 4, 6, 7].iter().all(|&i| b[i].is_ascii_digit()),
"{s}"
);
}
#[test]
fn the_window_is_the_max_height_or_two_short_of_the_screen() {
assert_eq!(window_rows(None, 24), 22);
assert_eq!(window_rows(Some(5), 24), 5);
assert_eq!(window_rows(None, 1), 0);
}
#[test]
fn composing_a_frame_puts_the_title_first_and_stderr_last() {
let title = "T".to_string();
assert_eq!(
compose_frame(Some(&title), b"a\nb\n", b"boom\n", true),
vec!["T", "a", "b", "boom"]
);
assert_eq!(
compose_frame(Some(&title), b"a\nb\n", b"boom\n", false),
vec!["T", "a", "b"]
);
}
fn panes_keeping_opposite_ends() -> Registry {
use crate::core::box_model::{BorderPreset, Sides};
use crate::core::registry::{LayoutNode, Overflow, PaneBox, PaneWidth};
let spec = |name: &str| SourceSpec {
name: name.to_string(),
command: vec!["true".to_string()],
shell: false,
interval: Some(Duration::from_secs(3600)),
triggers: Vec::new(),
debounce: Duration::from_millis(250),
live: false,
};
let pane = |overflow| PaneBox {
height: 5,
width: PaneWidth::Weight(1),
overflow,
border: BorderPreset::Rounded,
padding: Sides::default(),
title: None,
chrome: true,
};
Registry::panes(
vec![spec("tail"), spec("head")],
vec![pane(Overflow::KeepBottom), pane(Overflow::KeepTop)],
LayoutNode::Column(vec![
LayoutNode::Pane(SourceId(0)),
LayoutNode::Pane(SourceId(1)),
]),
0,
0,
)
.expect("a valid two-pane registry")
}
fn once_registry(panes: &[(&str, bool)]) -> Registry {
use crate::core::box_model::{BorderPreset, Sides};
use crate::core::registry::{LayoutNode, Overflow, PaneBox, PaneWidth};
let sources = panes
.iter()
.map(|(name, live)| SourceSpec {
name: (*name).to_string(),
command: vec!["true".to_string()],
shell: false,
interval: Some(Duration::from_secs(3600)),
triggers: Vec::new(),
debounce: Duration::from_millis(250),
live: *live,
})
.collect::<Vec<_>>();
let boxes = panes
.iter()
.map(|_| PaneBox {
height: 3,
width: PaneWidth::Weight(1),
overflow: Overflow::KeepTop,
border: BorderPreset::None,
padding: Sides::default(),
title: None,
chrome: true,
})
.collect::<Vec<_>>();
let cells = (0..panes.len())
.map(|i| LayoutNode::Pane(SourceId(i)))
.collect();
Registry::panes(sources, boxes, LayoutNode::Column(cells), 0, 0).expect("a valid registry")
}
#[test]
fn the_once_notice_names_every_waiting_pane_and_the_declaration_to_write() {
let registry = once_registry(&[("logs", false), ("metrics", false)]);
assert_eq!(
once_waiting_text(®istry, &[SourceId(0)], Duration::from_secs(5)),
"rat dashboard: --once is still waiting on pane \"logs\" after 5s: no output, no exit. A command that follows instead of exiting must be declared `live=#true`; `--once-timeout 30s` bounds the wait."
);
assert_eq!(
once_waiting_text(
®istry,
&[SourceId(0), SourceId(1)],
Duration::from_secs(5)
),
"rat dashboard: --once is still waiting on panes \"logs\", \"metrics\" after 5s: no output, no exit. A command that follows instead of exiting must be declared `live=#true`; `--once-timeout 30s` bounds the wait."
);
}
#[test]
fn the_once_notice_drops_the_live_advice_when_every_waiting_pane_declared_it() {
let registry = once_registry(&[("logs", true), ("tail", true), ("build", false)]);
assert_eq!(
once_waiting_text(®istry, &[SourceId(0)], Duration::from_secs(5)),
"rat dashboard: --once is still waiting on pane \"logs\" after 5s: the live child has printed nothing yet. `--once-timeout 30s` bounds the wait."
);
assert_eq!(
once_waiting_text(
®istry,
&[SourceId(0), SourceId(1)],
Duration::from_secs(5)
),
"rat dashboard: --once is still waiting on panes \"logs\", \"tail\" after 5s: the live children have printed nothing yet. `--once-timeout 30s` bounds the wait."
);
assert_eq!(
once_waiting_text(
®istry,
&[SourceId(0), SourceId(2)],
Duration::from_secs(5)
),
"rat dashboard: --once is still waiting on panes \"logs\", \"build\" after 5s: no output, no exit. A command that follows instead of exiting must be declared `live=#true`; `--once-timeout 30s` bounds the wait."
);
}
#[test]
fn the_bound_names_the_pane_it_gave_up_on() {
let registry = once_registry(&[("logs", false), ("metrics", false)]);
assert_eq!(
once_timeout_text(®istry, &[SourceId(0)], Duration::from_secs(30)),
"--once gave up after 30s: pane \"logs\" never finished. A command that follows instead of exiting must be declared `live=#true`."
);
assert_eq!(
once_timeout_text(
®istry,
&[SourceId(0), SourceId(1)],
Duration::from_secs(30)
),
"--once gave up after 30s: panes \"logs\", \"metrics\" never finished. A command that follows instead of exiting must be declared `live=#true`."
);
}
#[test]
fn the_bound_drops_the_live_advice_when_every_waiting_pane_declared_it() {
let registry = once_registry(&[("logs", true), ("tail", true), ("build", false)]);
assert_eq!(
once_timeout_text(®istry, &[SourceId(0)], Duration::from_secs(30)),
"--once gave up after 30s: pane \"logs\" never produced its first output."
);
assert_eq!(
once_timeout_text(
®istry,
&[SourceId(0), SourceId(1)],
Duration::from_secs(30)
),
"--once gave up after 30s: panes \"logs\", \"tail\" never produced their first output."
);
assert_eq!(
once_timeout_text(
®istry,
&[SourceId(0), SourceId(2)],
Duration::from_secs(30)
),
"--once gave up after 30s: panes \"logs\", \"build\" never finished. A command that follows instead of exiting must be declared `live=#true`."
);
}
#[test]
fn a_truncated_pane_carries_a_marker_and_an_untruncated_one_does_not() {
assert_eq!(dropped_badge(0), None);
assert_eq!(dropped_badge(90).as_deref(), Some("90 lines dropped"));
assert_eq!(
dropped_badge(1_234).as_deref(),
Some("1.2k lines dropped"),
"a flood's count has to fit a chrome row"
);
assert_eq!(
dropped_badge(2_500_000).as_deref(),
Some("2.5M lines dropped")
);
}
#[test]
fn the_marker_joins_the_panes_change_signature() {
let lines = vec!["a".to_string()];
assert_ne!(
body_signature(&lines, None, false, None),
body_signature(&lines, None, false, Some("90 lines dropped")),
);
assert_ne!(
body_signature(&lines, Some("exit 3"), true, None),
body_signature(&lines, Some("exit 3"), true, Some("90 lines dropped")),
);
}
#[test]
fn a_keep_bottom_pane_retains_its_tail() {
let registry = panes_keeping_opposite_ends();
assert_eq!(retention_for(®istry, SourceId(0)).keep, Keep::Bottom);
}
#[test]
fn a_keep_top_pane_retains_its_head() {
let registry = panes_keeping_opposite_ends();
assert_eq!(retention_for(®istry, SourceId(1)).keep, Keep::Top);
}
#[test]
fn every_source_gets_a_bound_and_none_is_unbounded() {
let registry = panes_keeping_opposite_ends();
for id in registry.ids() {
assert!(retention_for(®istry, id).max_lines > 0);
}
}
#[test]
fn a_watch_session_with_no_pane_still_gets_a_policy() {
let registry = Registry::single(
SourceSpec {
name: "watch".to_string(),
command: vec!["true".to_string()],
shell: false,
interval: Some(Duration::from_secs(2)),
triggers: Vec::new(),
debounce: Duration::from_millis(250),
live: false,
},
None,
);
assert!(registry.pane(SourceId(0)).is_none());
let r = retention_for(®istry, SourceId(0));
assert_eq!(r.keep, Keep::Bottom);
assert!(r.max_lines > 0);
}
fn cadence_spec(live: bool, interval: Option<Duration>, triggered: bool) -> SourceSpec {
SourceSpec {
name: "follower".to_string(),
command: vec!["true".to_string()],
shell: false,
interval,
triggers: if triggered {
vec![TriggerSpec::File(std::path::PathBuf::from("./t"))]
} else {
Vec::new()
},
debounce: Duration::from_millis(250),
live,
}
}
#[test]
fn a_live_source_has_no_cadence_label() {
let label = cadence_label(&cadence_spec(true, Some(Duration::from_secs(2)), false));
assert!(
!label.contains("every"),
"a live pane has no cadence: {label}"
);
assert_eq!(label, "live");
}
#[test]
fn a_live_source_with_triggers_still_reads_as_live() {
assert_eq!(cadence_label(&cadence_spec(true, None, true)), "live");
assert_eq!(
cadence_label(&cadence_spec(true, Some(Duration::from_secs(2)), true)),
"live"
);
}
#[test]
fn every_batch_cadence_label_is_unchanged() {
let second = Duration::from_secs(1);
assert_eq!(
cadence_label(&cadence_spec(false, Some(second), false)),
"every 1s"
);
assert_eq!(
cadence_label(&cadence_spec(false, Some(second), true)),
"every 1s or on trigger"
);
assert_eq!(
cadence_label(&cadence_spec(false, None, true)),
"on trigger"
);
assert_eq!(cadence_label(&cadence_spec(false, None, false)), "once");
}
#[test]
fn the_dashboard_title_rides_row_zero_bold_and_only_when_declared() {
use crate::core::box_model::{BorderPreset, Sides};
use crate::core::registry::{LayoutNode, Overflow, PaneBox, PaneWidth};
let pane = || PaneBox {
height: 4,
width: PaneWidth::Weight(1),
overflow: Overflow::KeepTop,
border: BorderPreset::Rounded,
padding: Sides::default(),
title: None,
chrome: false,
};
let build = |title: Option<&str>| {
Registry::panes(
vec![cadence_spec(false, Some(Duration::from_secs(2)), false)],
vec![pane()],
LayoutNode::Pane(SourceId(0)),
0,
0,
)
.expect("a valid one-pane registry")
.with_title(title.map(str::to_string))
};
let palette = Palette::builtin(Appearance::Dark, AppearanceSource::Default);
let mut runtime = vec![SourceRuntime::for_test()];
runtime[0].output = Some(vec!["seed".to_string()]);
runtime[0].posted = true;
let registry = build(Some("Deploy status"));
let geom = registry.geometry((40, 10));
let block = compose_sources(
®istry,
&runtime,
&geom,
false,
&palette,
ColorProfile::TrueColor,
);
assert!(
block.lines[0].contains("Deploy status"),
"the title heads the frame: {:?}",
block.lines[0]
);
assert!(
block.lines[0].contains("\u{1b}[1m"),
"the title is bold, exactly as watch --title: {:?}",
block.lines[0]
);
assert_eq!(
block.lines.len(),
block.marks.len(),
"marks stay aligned to lines"
);
assert!(
!block.marks[0].changed && block.marks[0].cells.is_empty(),
"the title row carries no change mark"
);
let bare = build(None);
let bare_block = compose_sources(
&bare,
&runtime,
&geom,
false,
&palette,
ColorProfile::TrueColor,
);
assert!(
!bare_block.lines[0].contains("Deploy status"),
"undeclared means absent: {:?}",
bare_block.lines[0]
);
assert_eq!(
block.lines.len(),
bare_block.lines.len() + 1,
"the title costs exactly one row"
);
}
#[test]
fn a_long_dashboard_title_truncates_to_the_composed_width() {
use crate::core::box_model::{BorderPreset, Sides};
use crate::core::measure::display_width;
use crate::core::registry::{LayoutNode, Overflow, PaneBox, PaneWidth};
let registry = Registry::panes(
vec![cadence_spec(false, Some(Duration::from_secs(2)), false)],
vec![PaneBox {
height: 4,
width: PaneWidth::Cells(20),
overflow: Overflow::KeepTop,
border: BorderPreset::Rounded,
padding: Sides::default(),
title: None,
chrome: false,
}],
LayoutNode::Pane(SourceId(0)),
0,
0,
)
.expect("a valid one-pane registry")
.with_title(Some("a title much longer than twenty cells".to_string()));
let mut runtime = vec![SourceRuntime::for_test()];
runtime[0].output = Some(vec!["seed".to_string()]);
runtime[0].posted = true;
let geom = registry.geometry((60, 10));
let palette = Palette::builtin(Appearance::Dark, AppearanceSource::Default);
let block = compose_sources(
®istry,
&runtime,
&geom,
false,
&palette,
ColorProfile::Ascii,
);
let composed = block.lines[1..]
.iter()
.map(|line| display_width(line))
.max()
.expect("composed rows");
assert!(
display_width(&block.lines[0]) <= composed,
"the title never outgrows the composed frame: {:?}",
block.lines[0]
);
assert!(
block.lines[0].contains('…'),
"the cut is marked: {:?}",
block.lines[0]
);
}
#[test]
fn a_live_panes_chrome_row_still_carries_its_time_and_exit_badge() {
use crate::core::box_model::{BorderPreset, Sides};
use crate::core::registry::{LayoutNode, Overflow, PaneBox, PaneWidth};
let registry = Registry::panes(
vec![cadence_spec(true, Some(Duration::from_secs(2)), false)],
vec![PaneBox {
height: 6,
width: PaneWidth::Weight(1),
overflow: Overflow::KeepBottom,
border: BorderPreset::Rounded,
padding: Sides::default(),
title: None,
chrome: true,
}],
LayoutNode::Pane(SourceId(0)),
0,
0,
)
.expect("a valid one-pane registry");
let mut runtime = vec![SourceRuntime::for_test()];
runtime[0].output = Some(vec!["seed".to_string()]);
runtime[0].posted = true;
runtime[0].failure = Some("exit 1".to_string());
let at = ago(60);
runtime[0].changed_at = at;
let geom = registry.geometry((60, 8));
let palette = Palette::builtin(Appearance::Dark, AppearanceSource::Default);
let block = compose_sources(
®istry,
&runtime,
&geom,
false,
&palette,
ColorProfile::Ascii,
);
let row = block
.lines
.iter()
.find(|line| line.contains(" · "))
.expect("one row is the chrome row");
assert!(row.contains("live"), "{row}");
assert!(!row.contains("every"), "{row}");
assert!(row.contains("exit 1"), "{row}");
assert!(row.contains(&local_hms(at)), "{row}");
}
#[test]
fn empty_output_still_renders_one_empty_line() {
assert_eq!(
output_lines(&Vec::<Vec<u8>>::new().concat(), b""),
vec![String::new()]
);
}
#[test]
fn trailing_blank_lines_collapse_exactly_as_they_do_today() {
let retained = [b"a\n".to_vec(), b"\n".to_vec(), b"\n".to_vec()];
assert_eq!(output_lines(&retained.concat(), b""), vec!["a".to_string()]);
}
#[test]
fn the_plain_path_renders_a_capped_body_exactly_as_an_uncapped_one() {
let raw = b"a\n\n\n".to_vec();
let retained = [b"a\n".to_vec(), b"\n".to_vec(), b"\n".to_vec()];
assert_eq!(
compose_frame(None, &retained.concat(), b"", false),
compose_frame(None, &raw, b"", false),
);
assert_eq!(
compose_frame(None, &Vec::<Vec<u8>>::new().concat(), b"", false),
compose_frame(None, b"", b"", false),
);
}
#[test]
fn adopting_a_different_appearance_reresolves_the_palette() {
let mut palette = Palette::builtin(Appearance::Dark, AppearanceSource::Osc);
assert!(adopt(&mut palette, Appearance::Light));
assert_eq!(palette.appearance, Appearance::Light);
assert_eq!(palette.source, AppearanceSource::Notification);
assert_eq!(palette.accent, Color::Indexed(129));
}
#[test]
fn adopting_the_current_appearance_changes_nothing() {
let mut palette = Palette::builtin(Appearance::Dark, AppearanceSource::Osc);
assert!(!adopt(&mut palette, Appearance::Dark));
assert_eq!(palette.source, AppearanceSource::Osc);
assert_eq!(palette.accent, Color::Indexed(212));
}
#[test]
fn adopting_back_restores_the_original_tokens() {
let mut palette = Palette::builtin(Appearance::Dark, AppearanceSource::Osc);
assert!(adopt(&mut palette, Appearance::Light));
assert!(adopt(&mut palette, Appearance::Dark));
assert_eq!(palette.appearance, Appearance::Dark);
assert_eq!(palette.accent, Color::Indexed(212));
assert_eq!(palette.on_accent, Color::Indexed(16));
}
#[cfg(unix)]
fn white() -> xterm_color::Color {
xterm_color::Color::rgb(u16::MAX, u16::MAX, u16::MAX)
}
#[cfg(unix)]
fn black() -> xterm_color::Color {
xterm_color::Color::rgb(0, 0, 0)
}
#[cfg(unix)]
#[test]
fn a_reply_nobody_asked_for_is_ignored() {
let mut verify = VerifyState::default();
assert_eq!(verify.reply(OscColorKind::Background, black()), None);
}
#[cfg(unix)]
#[test]
fn a_background_reply_completes_the_exchange() {
use crate::theme::PROBE_TIMEOUT;
let mut verify = VerifyState {
in_flight_until: Some(Instant::now() + PROBE_TIMEOUT),
..VerifyState::default()
};
assert_eq!(verify.reply(OscColorKind::Foreground, white()), None);
assert_eq!(
verify.reply(OscColorKind::Background, black()),
Some(Appearance::Dark)
);
assert!(verify.in_flight_until.is_none());
assert_eq!(verify.reply(OscColorKind::Background, white()), None);
}
#[cfg(unix)]
#[test]
fn a_background_reply_alone_still_classifies() {
use crate::theme::PROBE_TIMEOUT;
let mut verify = VerifyState {
in_flight_until: Some(Instant::now() + PROBE_TIMEOUT),
..VerifyState::default()
};
assert_eq!(
verify.reply(OscColorKind::Background, white()),
Some(Appearance::Light)
);
}
fn stamp(secs: i64) -> jiff::Timestamp {
jiff::Timestamp::from_second(secs).expect("a representable second")
}
fn runtime_with(hash: u64) -> SourceRuntime {
let (tx, _rx) = std::sync::mpsc::channel::<TickEvent>();
SourceRuntime {
schedule: TickSchedule::new(Some(Duration::from_secs(2))),
slot: ChildSlot::default(),
tx,
emissions: None,
output: None,
hash,
changed_at: stamp(0),
previous: None,
marks: Vec::new(),
failure: None,
truncated: None,
posted: false,
looping: false,
bracket: None,
gate: DebounceGate::new(Duration::ZERO),
files: MtimeWatchSet::new(Vec::new()),
#[cfg(unix)]
readers: Vec::new(),
}
}
#[test]
fn the_combining_key_changes_when_any_source_changes() {
let base = [runtime_with(11), runtime_with(22)];
let moved = [runtime_with(11), runtime_with(23)];
let traded = [runtime_with(22), runtime_with(11)];
assert_ne!(combined_hash(&base), combined_hash(&moved));
assert_ne!(combined_hash(&base), combined_hash(&traded));
}
#[test]
fn the_combining_key_is_stable_when_no_source_changes() {
let now = [runtime_with(11), runtime_with(22)];
let again = [runtime_with(11), runtime_with(22)];
assert_eq!(combined_hash(&now), combined_hash(&again));
assert_eq!(combined_hash(&[]), combined_hash(&[]));
}
#[test]
fn changed_at_takes_the_newest_changed_source() {
let early = stamp(10);
let late = stamp(40);
assert_eq!(
fold_changed_at(fold_changed_at(None, true, early), true, late),
Some(late)
);
assert_eq!(
fold_changed_at(fold_changed_at(None, true, late), true, early),
Some(late)
);
}
#[test]
fn changed_at_ignores_an_unchanged_source_that_completed_later() {
let changed = stamp(10);
let quiet = stamp(40);
assert_eq!(
fold_changed_at(fold_changed_at(None, true, changed), false, quiet),
Some(changed)
);
assert_eq!(fold_changed_at(None, false, quiet), None);
}
#[test]
fn esc_requests_a_tick_on_every_source() {
let t = Instant::now();
let mut runtime = vec![runtime_with(1), runtime_with(2)];
for r in &mut runtime {
r.schedule.poll(t);
r.schedule.completed(t);
assert_eq!(r.schedule.poll(t), Due::Wait);
}
request_now_all(&mut runtime);
for r in &mut runtime {
assert_eq!(r.schedule.poll(t), Due::Spawn);
}
}
#[test]
fn a_theme_flip_requests_a_respawn_on_every_source() {
let t = Instant::now();
let mut runtime = vec![runtime_with(1), runtime_with(2)];
for r in &mut runtime {
assert_eq!(r.schedule.poll(t), Due::Spawn); }
request_respawn_all(&mut runtime);
for r in &mut runtime {
r.schedule.completed(t); assert_eq!(r.schedule.poll(t), Due::Spawn); }
}
#[test]
fn help_lines_carry_the_heading_and_the_extra_block() {
let extra = vec![
String::new(),
" refresh triggers:".to_string(),
" file:/tmp/state.json".to_string(),
];
let lines = help_lines("rat watch — keys", &extra);
assert_eq!(lines.first().map(String::as_str), Some("rat watch — keys"));
assert_eq!(
lines.last().map(String::as_str),
Some(" file:/tmp/state.json")
);
assert!(
lines
.iter()
.any(|line| line.contains("freeze the frame in place"))
);
let bare = help_lines("rat watch — keys", &[]);
assert!(!bare.iter().any(|line| line.contains("trigger")));
assert_eq!(lines[..bare.len()], bare[..]);
}
#[cfg(unix)]
fn exit_status(code: i32) -> std::process::ExitStatus {
std::os::unix::process::ExitStatusExt::from_raw(code << 8)
}
#[cfg(windows)]
fn exit_status(code: i32) -> std::process::ExitStatus {
std::os::windows::process::ExitStatusExt::from_raw(code as u32)
}
#[test]
fn a_spawn_error_becomes_the_panes_body_and_carries_no_exit_badge() {
let err = std::io::Error::from(std::io::ErrorKind::NotFound);
assert_eq!(
pane_body(&[], &[], Some(&err), "plan", "no-such-binary"),
vec![format!("plan: {err}: {:?}", "no-such-binary")]
);
assert_eq!(exit_badge(None), None);
}
#[test]
fn a_panes_spawn_error_leads_with_the_reason_and_ends_with_the_path() {
let err = std::io::Error::from(std::io::ErrorKind::NotFound);
let body = pane_body(&[], &[], Some(&err), "flood", "/long/path/ra");
assert_eq!(body, vec![format!("flood: {err}: {:?}", "/long/path/ra")]);
}
#[test]
fn the_shipped_watch_wording_keeps_its_bytes() {
let err = std::io::Error::from(std::io::ErrorKind::NotFound);
assert_eq!(
watch_spawn_error_text("no-such-binary", &err),
format!("watch: {:?}: {err}", "no-such-binary")
);
}
#[test]
fn a_nonzero_exit_shows_stdout_then_stderr_and_badges_the_code() {
assert_eq!(
pane_body(b"out-line\n", b"err-line\n", None, "plan", "prog"),
vec!["out-line".to_string(), "err-line".to_string()]
);
assert_eq!(exit_badge(Some(exit_status(3))).as_deref(), Some("exit 3"));
}
#[test]
fn a_successful_tick_clears_the_previous_failure() {
assert_eq!(exit_badge(Some(exit_status(0))), None);
}
#[test]
fn a_badge_that_appears_without_a_body_change_still_moves_the_hash() {
let body = vec!["steady".to_string()];
assert_ne!(
body_signature(&body, None, false, None),
body_signature(&body, Some("exit 3"), false, None)
);
}
#[test]
fn every_badge_is_in_the_change_signature_independently() {
let body = vec!["steady".to_string()];
let all: Vec<u64> = [None, Some("exit 3")]
.into_iter()
.flat_map(|failure| {
[false, true].into_iter().flat_map(move |looping| {
[None, Some("90 lines dropped")]
.into_iter()
.map(move |truncated| (failure, looping, truncated))
})
})
.map(|(failure, looping, truncated)| body_signature(&body, failure, looping, truncated))
.collect();
assert_eq!(all.len(), 8, "the whole space, not a sample");
for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] {
assert_ne!(a, b, "combination {i} collides");
}
}
assert_eq!(body_signature(&body, None, false, None), all[0]);
}
#[test]
fn only_the_fired_sources_schedule_collapses() {
let t = Instant::now();
let mut gates = [
DebounceGate::new(Duration::ZERO),
DebounceGate::new(Duration::ZERO),
];
let mut schedules = [TickSchedule::new(None), TickSchedule::new(None)];
for s in &mut schedules {
assert_eq!(s.poll(t), Due::Spawn); s.completed(t);
}
gates[0].fire(t);
for (gate, schedule) in gates.iter_mut().zip(schedules.iter_mut()) {
if gate.due(t) {
schedule.request_respawn();
}
}
assert_eq!(
schedules[0].poll(t),
Due::Spawn,
"the fired source respawns"
);
assert_eq!(
schedules[1].poll(t),
Due::Wait,
"its neighbour stays parked"
);
}
fn two_weighted_panes() -> Registry {
use crate::core::box_model::{BorderPreset, Sides};
use crate::core::registry::{LayoutNode, Overflow, PaneBox, PaneWidth};
let spec = |name: &str| SourceSpec {
name: name.to_string(),
command: vec!["true".to_string()],
shell: false,
interval: Some(Duration::from_secs(3600)),
triggers: Vec::new(),
debounce: Duration::from_millis(250),
live: false,
};
let pane = || PaneBox {
height: 5,
width: PaneWidth::Weight(1),
overflow: Overflow::KeepTop,
border: BorderPreset::Rounded,
padding: Sides::default(),
title: None,
chrome: true,
};
Registry::panes(
vec![spec("left"), spec("right")],
vec![pane(), pane()],
LayoutNode::Column(vec![LayoutNode::Row(vec![
LayoutNode::Pane(SourceId(0)),
LayoutNode::Pane(SourceId(1)),
])]),
1,
0,
)
.expect("a valid two-pane registry")
}
#[test]
fn resizes_inside_one_window_collapse_to_one_respawn() {
let t = Instant::now();
let mut gate = DebounceGate::new(RESIZE_DEBOUNCE);
gate.fire(t);
gate.fire(t + Duration::from_millis(100));
gate.fire(t + Duration::from_millis(200));
assert!(
!gate.due(t + Duration::from_millis(200)),
"window still open"
);
assert!(gate.due(t + RESIZE_DEBOUNCE), "the window closes once");
assert!(!gate.due(t + RESIZE_DEBOUNCE), "and only once");
}
#[test]
fn a_respawn_request_reaches_every_source() {
let t = Instant::now();
let mut schedules = [
TickSchedule::new(Some(Duration::from_secs(3600))),
TickSchedule::new(Some(Duration::from_secs(3600))),
];
for s in &mut schedules {
assert_eq!(s.poll(t), Due::Spawn);
s.completed(t);
assert_eq!(s.poll(t), Due::Wait, "parked for an hour");
}
for s in &mut schedules {
s.request_respawn();
}
for s in &mut schedules {
assert_eq!(s.poll(t), Due::Spawn);
}
}
#[test]
fn a_coincident_spawn_does_not_blind_the_resize_arm() {
let registry = two_weighted_panes();
let mut size = (80, 24);
let mut geom = registry.geometry(size);
let before = geom.clone();
refresh_geometry_for_spawn(true, (120, 24), &mut size, &mut geom, ®istry);
assert_eq!(size, (80, 24), "the spawn step wrote nothing under panes");
assert_eq!(geom, before);
let step = detect_resize((120, 24), &mut size, &mut geom, ®istry);
assert!(step.size_moved, "detection survived the coincident spawn");
assert!(
step.geom_moved,
"the inner widths moved, so a respawn is owed"
);
assert_ne!(geom, before, "the pair advanced exactly once, in the arm");
}
#[test]
fn plain_mode_still_measures_at_spawn() {
let registry = two_weighted_panes();
let mut size = (80, 24);
let mut geom = registry.geometry(size);
refresh_geometry_for_spawn(false, (120, 24), &mut size, &mut geom, ®istry);
assert_eq!(size, (120, 24), "plain re-measures when something spawns");
assert_eq!(geom, registry.geometry((120, 24)));
}
fn ago(secs: i64) -> jiff::Timestamp {
jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - secs)
.expect("timestamp in range")
}
impl SourceRuntime {
fn for_test() -> SourceRuntime {
let (tx, _rx) = std::sync::mpsc::channel();
SourceRuntime {
schedule: TickSchedule::new(None),
slot: ChildSlot::default(),
tx,
emissions: None,
output: None,
hash: 0,
previous: None,
marks: Vec::new(),
changed_at: jiff::Timestamp::now(),
failure: None,
truncated: None,
posted: false,
looping: false,
bracket: None,
gate: DebounceGate::new(Duration::ZERO),
files: MtimeWatchSet::new(Vec::new()),
#[cfg(unix)]
readers: Vec::new(),
}
}
}
#[test]
#[allow(clippy::single_range_in_vec_init)]
fn a_paused_frame_marks_against_history_not_pane_outputs() {
let prev = vec!["a".to_string(), "b".to_string()];
let viewed = vec!["a".to_string(), "B".to_string()];
let stale = vec![
LineMark {
changed: true,
cells: vec![0..1],
},
LineMark::default(),
];
let paused = paint_marks(true, FrameMode::Paused, &stale, &viewed, Some(&prev));
assert_eq!(paused, changed_marks(Some(&prev), &viewed));
assert!(!paused[0].changed, "row 0 did not change");
assert!(paused[1].changed, "row 1 did");
for mode in [FrameMode::Live, FrameMode::LiveScrolled] {
assert_eq!(paint_marks(true, mode, &stale, &viewed, Some(&prev)), stale);
}
assert_eq!(
paint_marks(false, FrameMode::Live, &stale, &viewed, Some(&prev)),
changed_marks(Some(&prev), &viewed)
);
}
#[test]
fn a_panes_comparand_moves_only_on_its_own_change() {
let t0 = ago(60);
let t1 = ago(30);
let mut r = SourceRuntime::for_test();
record_output(&mut r, vec!["one".to_string()], t0);
record_output(&mut r, vec!["two".to_string()], t1);
assert_eq!(r.previous.as_deref(), Some(&["one".to_string()][..]));
assert!(r.marks[0].changed);
assert_eq!(r.changed_at, t1);
record_output(&mut r, vec!["two".to_string()], ago(0));
assert_eq!(r.changed_at, t1);
assert!(r.marks[0].changed, "the mark dwells past an unchanged tick");
}
#[test]
fn a_looping_transition_re_dates_the_pane_in_both_directions() {
let t0 = ago(60);
let mut rt = vec![SourceRuntime::for_test()];
record_output(&mut rt[0], vec!["steady".to_string()], t0);
rt[0].posted = true;
let clean = rt[0].hash;
let on = ago(30);
assert!(apply_verdict(&mut rt, &[SourceId(0)], true, on));
assert!(rt[0].looping);
assert_ne!(rt[0].hash, clean, "the badge moved the pane's hash");
assert_eq!(rt[0].changed_at, on, "a displayed change re-dates");
assert_eq!(
rt[0].output.as_deref(),
Some(&["steady".to_string()][..]),
"the retained body, verbatim — nothing re-ran"
);
let off = ago(10);
assert!(apply_verdict(&mut rt, &[], true, off));
assert!(!rt[0].looping);
assert_eq!(rt[0].hash, clean, "clearing returns the un-badged hash");
assert_eq!(rt[0].changed_at, off);
assert!(!apply_verdict(&mut rt, &[], true, ago(0)));
assert_eq!(rt[0].changed_at, off);
}
#[test]
fn a_cycle_marks_every_pane_in_the_set() {
let t0 = ago(60);
let mut rt: Vec<SourceRuntime> = (0..3).map(|_| SourceRuntime::for_test()).collect();
for r in &mut rt {
record_output(r, vec!["steady".to_string()], t0);
}
let at = ago(30);
assert!(apply_verdict(
&mut rt,
&[SourceId(0), SourceId(1)],
true,
at
));
assert!(rt[0].looping && rt[1].looping);
assert!(!rt[2].looping, "a pane outside the set is untouched");
assert_eq!(rt[2].changed_at, t0, "and is not re-dated");
}
#[test]
fn a_looping_transition_takes_the_same_marks_path_as_a_failure_badge() {
let body = vec!["steady".to_string()];
let t0 = ago(60);
let at = ago(30);
let mut failing = SourceRuntime::for_test();
record_output(&mut failing, body.clone(), t0);
failing.failure = Some("exit 3".to_string());
record_output(&mut failing, body.clone(), at);
let mut rt = vec![SourceRuntime::for_test()];
record_output(&mut rt[0], body.clone(), t0);
apply_verdict(&mut rt, &[SourceId(0)], true, at);
assert_eq!(failing.changed_at, at, "the failure badge re-dates");
assert_eq!(rt[0].changed_at, at, "and so does the looping badge");
assert_eq!(rt[0].marks, failing.marks, "one marks path, not two");
assert!(
failing.marks.iter().all(|m| !m.changed),
"an unchanged body marks nothing — asserted, not assumed"
);
assert_eq!(rt[0].previous.as_deref(), Some(&body[..]));
}
fn two_triggered_panes(a: &str, b: &str) -> Registry {
use crate::core::box_model::{BorderPreset, Sides};
use crate::core::registry::{LayoutNode, Overflow, PaneBox, PaneWidth};
let spec = |name: &str, path: &str| SourceSpec {
name: name.to_string(),
command: vec!["true".to_string()],
shell: false,
interval: None,
triggers: vec![TriggerSpec::File(std::path::PathBuf::from(path))],
debounce: Duration::from_millis(250),
live: false,
};
let pane = || PaneBox {
height: 5,
width: PaneWidth::Weight(1),
overflow: Overflow::KeepTop,
border: BorderPreset::Rounded,
padding: Sides::default(),
title: None,
chrome: true,
};
Registry::panes(
vec![spec("a", a), spec("b", b)],
vec![pane(), pane()],
LayoutNode::Row(vec![
LayoutNode::Pane(SourceId(0)),
LayoutNode::Pane(SourceId(1)),
]),
1,
0,
)
.expect("a valid two-pane registry")
}
fn edge(latch: &mut Vec<SourceId>, panes: &[SourceId], abstained: bool) -> bool {
rising_edge(
latch,
&Verdict {
panes: panes.to_vec(),
ordered: None,
abstained,
why: None,
},
)
}
#[test]
fn the_notice_names_every_pane_in_the_set_and_the_watched_paths() {
let registry = two_triggered_panes("./sa", "./sb");
assert_eq!(
looping_text(®istry, &[SourceId(0), SourceId(1)]),
"a, b: trigger loop suspected: file:./sa, file:./sb — ? help"
);
}
#[test]
fn the_notice_claims_no_direction_and_never_repeats_a_shared_path() {
let registry = two_triggered_panes("./same", "./same");
let text = looping_text(®istry, &[SourceId(0), SourceId(1)]);
assert_eq!(text, "a, b: trigger loop suspected: file:./same — ? help");
assert!(!text.contains("->") && !text.contains('→'));
}
#[test]
fn a_plain_watch_names_no_pane_but_still_shows_its_evidence() {
let registry = Registry::single(
SourceSpec {
name: "watch".to_string(),
command: vec!["true".to_string()],
shell: false,
interval: None,
triggers: vec![TriggerSpec::File(std::path::PathBuf::from("./stamp"))],
debounce: Duration::from_millis(250),
live: false,
},
None,
);
assert_eq!(
looping_text(®istry, &[SourceId(0)]),
"trigger loop suspected: file:./stamp — ? help"
);
}
#[test]
fn the_notice_fires_once_per_rising_edge_and_re_arms_after_clearing() {
let mut latch = Vec::new();
assert!(edge(&mut latch, &[SourceId(0)], false), "the loop began");
assert!(!edge(&mut latch, &[SourceId(0)], false), "and it holds");
assert!(
edge(&mut latch, &[SourceId(0), SourceId(1)], false),
"a pane the last row did not name IS news — the panes of one \
cycle cross the threshold a hop apart, and a row naming half \
a loop is the failure this replaces"
);
assert!(
!edge(&mut latch, &[SourceId(0)], false),
"but a set that SHRINKS says nothing; the badges show that"
);
assert!(
!edge(&mut latch, &[SourceId(0), SourceId(1)], false),
"and a pane that drops out and comes back is not news TWICE — \
membership wobbles while a loop spins, and re-announcing it \
would be a repaint storm carrying nothing new"
);
assert!(!edge(&mut latch, &[], false), "clearing is not news either");
assert!(
edge(&mut latch, &[SourceId(0)], false),
"but a loop that returns after clearing IS a new episode"
);
}
#[test]
fn an_abstention_holds_the_latch_rather_than_re_arming_it() {
let mut latch = Vec::new();
assert!(edge(&mut latch, &[SourceId(0)], false));
assert!(!edge(&mut latch, &[], true), "abstaining says nothing");
assert!(
!edge(&mut latch, &[SourceId(0)], false),
"still the same loop"
);
let mut fresh = Vec::new();
assert!(!edge(&mut fresh, &[], true));
assert!(edge(&mut fresh, &[SourceId(0)], false));
}
#[test]
fn a_repainted_verdict_carries_the_frames_stamp_to_the_panes() {
let t0 = ago(600);
let mut rt: Vec<SourceRuntime> = (0..2).map(|_| SourceRuntime::for_test()).collect();
for r in &mut rt {
record_output(r, vec!["steady".to_string()], t0);
}
let mut live = Live {
lines: vec!["steady".to_string()],
hash: 0,
changed_at: t0,
since: local_hms(t0),
panes: None,
dropped: None,
};
let at = ago(5);
apply_verdict(&mut rt, &[SourceId(1)], true, at);
restamp_live(&mut live, &rt);
assert_eq!(live.changed_at, at, "the newest pane change dates it");
assert_eq!(live.since, local_hms(at));
assert_eq!(live.hash, combined_hash(&rt), "and the key follows");
}
#[test]
fn a_verdict_with_nothing_to_repaint_still_records_the_state() {
let mut fresh = vec![SourceRuntime::for_test()];
assert!(!apply_verdict(&mut fresh, &[SourceId(0)], true, ago(30)));
assert!(fresh[0].looping);
assert!(fresh[0].output.is_none(), "no body was invented");
let t0 = ago(60);
let mut plain = vec![SourceRuntime::for_test()];
record_output(&mut plain[0], vec!["steady".to_string()], t0);
let hash = plain[0].hash;
assert!(!apply_verdict(&mut plain, &[SourceId(0)], false, ago(30)));
assert!(plain[0].looping);
assert_eq!(plain[0].hash, hash, "a plain watch has no badge to paint");
assert_eq!(plain[0].changed_at, t0);
}
#[test]
fn absolute_stamps_keep_the_age_key_zero() {
let footer = ago(600);
let panes = [ago(600), ago(630)];
assert_eq!(displayed_age_key(None, None, false, footer, &panes), 0);
assert_eq!(displayed_age_key(None, None, false, footer, &[]), 0);
let older = [ago(600), ago(720)];
assert_ne!(
displayed_age_key(None, None, true, footer, &panes),
displayed_age_key(None, None, true, footer, &older)
);
assert_eq!(
displayed_age_key(None, None, true, footer, &[]),
displayed_age(None, None, true, footer)
);
}
#[test]
fn a_piped_frame_sizes_from_the_handed_down_geometry() {
assert_eq!(size_fallback(Some("60"), Some("20"), (80, 24)), (60, 20));
assert_eq!(size_fallback(Some("60"), None, (80, 24)), (60, 24));
assert_eq!(size_fallback(None, Some("20"), (80, 24)), (80, 20));
assert_eq!(size_fallback(None, None, (80, 24)), (80, 24));
assert_eq!(size_fallback(Some("wide"), Some("-3"), (80, 24)), (80, 24));
}
fn source_spec(command: &[&str], shell: bool) -> SourceSpec {
SourceSpec {
name: String::new(),
command: command.iter().map(|s| s.to_string()).collect(),
shell,
interval: Some(Duration::from_secs(2)),
triggers: Vec::new(),
debounce: Duration::from_millis(250),
live: false,
}
}
fn terminal_geom(cols: u16, rows: u16) -> PaneGeometry {
PaneGeometry {
cells: cols,
rows,
inner_cols: cols,
inner_rows: rows,
}
}
fn program_of(cmd: &std::process::Command) -> String {
cmd.get_program().to_string_lossy().into_owned()
}
fn argv_of(cmd: &std::process::Command) -> Vec<String> {
cmd.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
#[test]
fn every_child_is_told_the_frame_size_and_appearance() {
let spec = source_spec(&["some-tool", "--flag"], false);
let cmd = build_source_command(&spec, true, Appearance::Light, terminal_geom(100, 40));
let envs: std::collections::HashMap<String, String> = cmd
.get_envs()
.filter_map(|(k, v)| {
Some((
k.to_string_lossy().into_owned(),
v?.to_string_lossy().into_owned(),
))
})
.collect();
assert_eq!(envs.get("RAT_WIDTH").map(String::as_str), Some("100"));
assert_eq!(envs.get("RAT_HEIGHT").map(String::as_str), Some("40"));
assert_eq!(
envs.get("RAT_APPEARANCE").map(String::as_str),
Some("light")
);
}
#[test]
fn direct_mode_runs_the_command_verbatim() {
let spec = source_spec(&["some-tool", "--flag", "value"], false);
let cmd = build_source_command(&spec, false, Appearance::Dark, terminal_geom(80, 24));
assert_eq!(program_of(&cmd), "some-tool");
assert_eq!(argv_of(&cmd), ["--flag", "value"]);
}
#[test]
fn question_mark_pages_the_key_help() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('?'), mode), WatchAction::Help);
}
}
#[test]
fn the_help_names_the_key_families() {
let text = help_lines("rat watch — keys", &[]).join("\n");
for needle in [
"quit",
"pager",
"snapshot",
"freeze the frame in place",
"resume the live tail",
"step back",
"wrap",
"gutter",
"highlights",
"counting ages",
"key reference",
] {
assert!(text.contains(needle), "help must mention {needle:?}");
}
}
#[test]
fn shell_mode_goes_through_the_platform_shell() {
let spec = source_spec(&["echo hi"], true);
let cmd = build_source_command(&spec, false, Appearance::Dark, terminal_geom(80, 24));
#[cfg(unix)]
{
assert_eq!(program_of(&cmd), "sh");
assert_eq!(argv_of(&cmd), ["-c", "echo hi"]);
}
#[cfg(windows)]
{
let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd".to_string());
assert_eq!(program_of(&cmd), shell);
assert_eq!(argv_of(&cmd), ["/C", "echo hi"]);
}
}
#[cfg(unix)]
mod reader_arrivals {
use super::*;
use crate::core::trigger::{TriggerKey, WindowLog};
use crate::term::tap::TriggerReader;
fn mkfifo(path: &std::path::Path) {
let cpath =
std::ffi::CString::new(path.as_os_str().as_encoded_bytes().to_vec()).unwrap();
assert_eq!(unsafe { libc::mkfifo(cpath.as_ptr(), 0o600) }, 0, "mkfifo");
}
fn slot(dir: &std::path::Path, name: &str) -> (ReaderSlot, std::fs::File) {
let path = dir.join(name);
mkfifo(&path);
let spec = TriggerSpec::Fifo(path.clone());
let reader = TriggerReader::open(&spec, None).unwrap();
let writer = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
(
ReaderSlot {
reader,
spec,
ended_seen: false,
},
writer,
)
}
fn wait_for_empty_proof(reader: &TriggerReader) {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if reader.empty_since_for_test().is_some() {
return;
}
std::thread::sleep(Duration::from_millis(2));
}
panic!("the reader never proved its descriptor empty");
}
fn wait_for_observations(
reader: &TriggerReader,
n: usize,
) -> Vec<crate::core::trigger::Observation> {
let mut out = Vec::new();
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
out.extend(reader.take_arrivals());
if out.len() >= n {
return out;
}
std::thread::sleep(Duration::from_micros(200));
}
panic!("wanted {n} observations, saw {}", out.len());
}
fn poke(slot: &ReaderSlot, writer: &mut std::fs::File) {
use std::io::Write;
slot.reader.fired().store(false, Ordering::SeqCst);
writer.write_all(b"x").unwrap();
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
if slot.reader.fired().load(Ordering::SeqCst) {
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("the write never reached the reader");
}
#[test]
fn an_arrival_is_drained_and_resolved_against_the_bracket_log() {
let dir = tempfile::tempdir().unwrap();
let (s, mut w) = slot(dir.path(), "a.fifo");
let mut log = WindowLog::new(Duration::from_secs(30));
let key =
TriggerKey("fifo:".to_string() + &dir.path().join("a.fifo").display().to_string());
wait_for_empty_proof(&s.reader);
poke(&s, &mut w);
drain_reader_arrivals(std::slice::from_ref(&s), &mut log, Instant::now());
let idle = log.arrivals(&key);
assert_eq!(idle.len(), 1, "the arrival never reached the log");
assert!(
log.classify(&idle[0].observation).is_disjoint(),
"nothing was in flight, so this is EXOGENOUS"
);
log.open_bracket(SourceId(0), Instant::now(), Vec::new());
poke(&s, &mut w);
drain_reader_arrivals(std::slice::from_ref(&s), &mut log, Instant::now());
let all = log.arrivals(&key);
assert_eq!(all.len(), 2);
assert!(
!log.classify(&all[1].observation).is_disjoint(),
"a bracket was in flight, so this is not an outside writer"
);
}
#[test]
fn draining_arrivals_does_not_disturb_the_fired_flag() {
let dir = tempfile::tempdir().unwrap();
let (s, mut w) = slot(dir.path(), "a.fifo");
let mut log = WindowLog::new(Duration::from_secs(30));
poke(&s, &mut w);
drain_reader_arrivals(std::slice::from_ref(&s), &mut log, Instant::now());
assert!(
s.reader.fired().load(Ordering::SeqCst),
"the drain cleared the gate's flag"
);
}
#[test]
fn each_arrival_is_passed_with_its_trigger_identity() {
let dir = tempfile::tempdir().unwrap();
let (a, mut wa) = slot(dir.path(), "a.fifo");
let (b, mut wb) = slot(dir.path(), "b.fifo");
let mut log = WindowLog::new(Duration::from_secs(30));
poke(&a, &mut wa);
poke(&b, &mut wb);
poke(&b, &mut wb);
drain_reader_arrivals(&[a, b], &mut log, Instant::now());
let ka =
TriggerKey("fifo:".to_string() + &dir.path().join("a.fifo").display().to_string());
let kb =
TriggerKey("fifo:".to_string() + &dir.path().join("b.fifo").display().to_string());
assert_eq!(log.arrivals(&ka).len(), 1);
assert_eq!(log.arrivals(&kb).len(), 2);
}
#[test]
fn a_reader_overflow_makes_the_window_abstain() {
let dir = tempfile::tempdir().unwrap();
let (s, mut w) = slot(dir.path(), "a.fifo");
let mut log = WindowLog::new(Duration::from_secs(30));
let now = Instant::now();
assert!(!log.evidence_lost(now), "nothing lost yet");
for _ in 0..(crate::term::tap::ARRIVAL_CAP + 1) {
poke(&s, &mut w);
}
drain_reader_arrivals(std::slice::from_ref(&s), &mut log, now);
assert!(log.evidence_lost(now), "the overflow never reached the log");
}
#[test]
fn arrivals_are_handed_over_as_timestamps_not_pre_judged() {
let dir = tempfile::tempdir().unwrap();
let (s, mut w) = slot(dir.path(), "a.fifo");
let mut log = WindowLog::new(Duration::from_millis(50));
let key =
TriggerKey("fifo:".to_string() + &dir.path().join("a.fifo").display().to_string());
poke(&s, &mut w);
std::thread::sleep(Duration::from_millis(120));
let now = Instant::now();
drain_reader_arrivals(std::slice::from_ref(&s), &mut log, now);
assert_eq!(log.arrivals(&key).len(), 1, "the drain pre-judged it");
log.evict(now);
assert!(
log.arrivals(&key).is_empty(),
"eviction, not the drain, is what drops it"
);
}
#[test]
fn the_drain_carries_the_whole_observation_out_of_the_reader() {
let dir = tempfile::tempdir().unwrap();
let (s, mut w) = slot(dir.path(), "f.fifo");
wait_for_empty_proof(&s.reader);
poke(&s, &mut w);
let observations = s.reader.take_arrivals();
assert_eq!(observations.len(), 1);
assert!(
observations[0].empty_since.is_some(),
"the lower bound must survive — without it every observation \
is Ambiguous once 4.1 wires it up, and the route contributes \
nothing"
);
}
#[test]
fn the_window_still_records_exactly_what_it_recorded_before() {
let dir = tempfile::tempdir().unwrap();
let (s, mut w) = slot(dir.path(), "g.fifo");
wait_for_empty_proof(&s.reader);
poke(&s, &mut w);
let mut log = WindowLog::new(Duration::from_secs(30));
let slots = vec![s];
drain_reader_arrivals(&slots, &mut log, Instant::now());
let key = reader_key(&slots[0].spec);
assert_eq!(log.arrivals(&key).len(), 1);
}
#[test]
fn a_fence_between_the_bracket_and_the_spawn_makes_the_interval_covered() {
use crate::core::trigger::TemporalCoverage;
let dir = tempfile::tempdir().unwrap();
let (s, mut w) = slot(dir.path(), "fenced.fifo");
wait_for_empty_proof(&s.reader);
let mut log = WindowLog::new(Duration::from_secs(30));
let opened = log.open_bracket(SourceId(1), Instant::now(), Vec::new());
s.reader.fence();
std::thread::sleep(Duration::from_millis(2));
use std::io::Write as _;
w.write_all(b"x").unwrap();
let observation = wait_for_observations(&s.reader, 1)[0];
log.close_bracket(opened, Instant::now(), Vec::new());
match log.classify(&observation) {
TemporalCoverage::Covered(contributors) => {
assert_eq!(contributors.len(), 1);
assert_eq!(contributors[0].0, SourceId(1));
assert!(
contributors[0].1.is_some(),
"the bracket closed, so a width exists"
);
}
other => panic!(
"with the fence inside the bracket the write is attributable, got {other:?}"
),
}
}
#[test]
fn fencing_reaches_every_reader_not_just_the_spawning_source() {
let dir = tempfile::tempdir().unwrap();
let (a, _wa) = slot(dir.path(), "a-fence.fifo");
let (b, _wb) = slot(dir.path(), "b-fence.fifo");
let slots = vec![a, b];
fence_all(&slots);
assert_eq!(slots[0].reader.fences_for_test(), 1);
assert_eq!(slots[1].reader.fences_for_test(), 1);
}
}
}