use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::{Duration, Instant};
use regex::Regex;
use tastty::{AbsolutePosition, Position, Screen, Terminal};
use crate::snapshot::{scrollback_snapshot_from_screen, snapshot_from_screen};
use crate::wait::condition::{WaitCondition, WaitConditionKind};
use crate::wait::error::WaitError;
use crate::wait::outcome::WaitMatch;
const MIN_SETTLE_VS_POLL: u32 = 4;
const STABLE_SETTLE_WARN_BELOW: Duration = Duration::from_secs(1);
fn validate_stable_floor(settle: Duration, poll: Duration) -> Result<(), WaitError> {
let floor = poll.saturating_mul(MIN_SETTLE_VS_POLL);
if settle < floor {
return Err(WaitError::SettleBelowPollFloor {
settle,
poll,
floor,
});
}
if settle < STABLE_SETTLE_WARN_BELOW {
tracing::warn!(
settle_ms = settle.as_millis() as u64,
poll_ms = poll.as_millis() as u64,
"stable wait settle below {}ms; monitor TUIs typically tick every 1-2 s, consider settle >= 1500 ms",
STABLE_SETTLE_WARN_BELOW.as_millis()
);
}
Ok(())
}
#[derive(Clone, Debug)]
struct CompiledPattern {
pattern: String,
regex: Regex,
}
impl PartialEq for CompiledPattern {
fn eq(&self, other: &Self) -> bool {
self.pattern == other.pattern
}
}
impl Eq for CompiledPattern {}
pub(crate) enum CompiledCondition {
Flat(FlatCondition),
AnyOf(Vec<FlatCondition>),
}
impl CompiledCondition {
pub(crate) fn compile(condition: &WaitCondition) -> Result<Self, WaitError> {
let poll = condition.poll;
match &condition.kind {
WaitConditionKind::AnyOf(subs) => {
let mut compiled = Vec::with_capacity(subs.len());
for sub in subs {
compiled.push(FlatCondition::compile(sub, poll)?);
}
Ok(Self::AnyOf(compiled))
}
other => Ok(Self::Flat(FlatCondition::compile(other, poll)?)),
}
}
pub(crate) fn is_exit_or_stable(&self) -> bool {
match self {
Self::Flat(flat) => flat.is_exit_or_stable(),
Self::AnyOf(subs) => subs.iter().any(FlatCondition::is_exit_or_stable),
}
}
#[cfg(feature = "async")]
pub(crate) fn next_stable_deadline(&self) -> Option<Instant> {
match self {
Self::Flat(flat) => flat.next_stable_deadline(),
Self::AnyOf(subs) => subs
.iter()
.filter_map(FlatCondition::next_stable_deadline)
.min(),
}
}
}
pub(crate) struct FlatCondition {
kind: WaitConditionKind<CompiledPattern>,
stable: StableState,
}
impl FlatCondition {
fn compile(kind: &WaitConditionKind<String>, poll: Duration) -> Result<Self, WaitError> {
let compile_pattern = |pattern: &String| -> Result<CompiledPattern, WaitError> {
let regex = Regex::new(pattern).map_err(|source| WaitError::InvalidRegex {
pattern: pattern.clone(),
source: source.into(),
})?;
Ok(CompiledPattern {
pattern: pattern.clone(),
regex,
})
};
let kind = match kind {
WaitConditionKind::Text(text) => WaitConditionKind::Text(text.clone()),
WaitConditionKind::Regex {
pattern,
include_scrollback,
} => WaitConditionKind::Regex {
pattern: compile_pattern(pattern)?,
include_scrollback: *include_scrollback,
},
WaitConditionKind::RowRegex { row, pattern } => WaitConditionKind::RowRegex {
row: *row,
pattern: compile_pattern(pattern)?,
},
WaitConditionKind::CellText { position, text } => WaitConditionKind::CellText {
position: *position,
text: text.clone(),
},
WaitConditionKind::Cursor(position) => WaitConditionKind::Cursor(*position),
WaitConditionKind::Exit => WaitConditionKind::Exit,
WaitConditionKind::Stable {
settle,
ignore_cursor,
ignore_style,
} => {
validate_stable_floor(*settle, poll)?;
WaitConditionKind::Stable {
settle: *settle,
ignore_cursor: *ignore_cursor,
ignore_style: *ignore_style,
}
}
WaitConditionKind::AnyOf(_) => return Err(WaitError::NestedAnyOf),
};
Ok(Self {
kind,
stable: StableState::default(),
})
}
fn is_exit_or_stable(&self) -> bool {
matches!(
self.kind,
WaitConditionKind::Exit | WaitConditionKind::Stable { .. }
)
}
#[cfg(feature = "async")]
fn next_stable_deadline(&self) -> Option<Instant> {
match (&self.kind, self.stable.stable_since) {
(WaitConditionKind::Stable { settle, .. }, Some(since)) => Some(since + *settle),
_ => None,
}
}
}
#[derive(Default)]
struct StableState {
last: Option<StableSignature>,
stable_since: Option<Instant>,
}
#[derive(Eq, PartialEq)]
struct StableSignature {
screen: u64,
cursor: Option<Position>,
}
impl StableSignature {
fn capture(screen: &Screen, ignore_cursor: bool, ignore_style: bool) -> Self {
let mut hasher = DefaultHasher::new();
screen.size().hash(&mut hasher);
if ignore_style {
for row in 0..screen.size().rows {
if let Some(text) = screen.row_text(row) {
text.hash(&mut hasher);
}
}
} else {
for (pos, cell) in screen.cells() {
pos.hash(&mut hasher);
cell.contents().hash(&mut hasher);
cell.attrs().hash(&mut hasher);
cell.is_wide().hash(&mut hasher);
cell.is_wide_continuation().hash(&mut hasher);
cell.hyperlink().hash(&mut hasher);
}
}
let cursor = if ignore_cursor {
None
} else {
Some(screen.cursor())
};
Self {
screen: hasher.finish(),
cursor,
}
}
}
pub(crate) enum Probe {
NotYet,
Matched(Option<WaitMatch>),
}
pub(crate) fn probe(
terminal: &Terminal,
screen: &Screen,
condition: &mut CompiledCondition,
) -> Result<Probe, WaitError> {
match condition {
CompiledCondition::Flat(flat) => probe_flat(terminal, screen, flat, None),
CompiledCondition::AnyOf(subs) => {
for (i, sub) in subs.iter_mut().enumerate() {
match probe_flat(terminal, screen, sub, Some(i))? {
Probe::NotYet => {}
Probe::Matched(wait_match) => return Ok(Probe::Matched(wait_match)),
}
}
Ok(Probe::NotYet)
}
}
}
fn probe_flat(
terminal: &Terminal,
screen: &Screen,
condition: &mut FlatCondition,
condition_index: Option<usize>,
) -> Result<Probe, WaitError> {
let make_match = |position: Option<AbsolutePosition>,
captures: Vec<Option<String>>,
matched_line: Option<String>,
preceding_lines: Vec<String>|
-> WaitMatch {
WaitMatch {
position,
captures,
condition_index,
matched_line,
preceding_lines,
}
};
let bare_match = || -> Option<WaitMatch> {
condition_index.map(|_| make_match(None, Vec::new(), None, Vec::new()))
};
match &condition.kind {
WaitConditionKind::Text(text) => {
let lines = screen.visible_text_rows();
for (line_idx, line) in lines.iter().enumerate() {
if let Some(byte_off) = line.find(text.as_str()) {
let col = line[..byte_off].chars().count() as u16;
let position = screen.visible_to_absolute(Position {
row: line_idx as u16,
col,
});
let preceding = lines[..line_idx].to_vec();
return Ok(Probe::Matched(Some(make_match(
position,
Vec::new(),
Some(line.clone()),
preceding,
))));
}
}
Ok(Probe::NotYet)
}
WaitConditionKind::Regex {
pattern,
include_scrollback,
} => {
let (lines, scrollback_lines): (Vec<String>, usize) = if *include_scrollback {
let s = scrollback_snapshot_from_screen(screen, None);
(s.lines, s.scrollback_lines)
} else {
(screen.visible_text_rows(), 0)
};
let text = lines.join("\n");
if let Some(captures) = pattern.regex.captures(&text) {
let located = locate_match_in_lines(&lines, captures.get(0));
let (position, matched_line, preceding_lines) = match located {
Some((local_row, col, line, preceding)) => {
let position =
local_row_to_absolute(screen, scrollback_lines, local_row, col);
(position, Some(line), preceding)
}
None => (None, None, Vec::new()),
};
Ok(Probe::Matched(Some(make_match(
position,
captures_to_strings(&captures),
matched_line,
preceding_lines,
))))
} else {
Ok(Probe::NotYet)
}
}
WaitConditionKind::RowRegex { row, pattern } => {
let Some(line) = screen.row_text(*row) else {
return Ok(Probe::NotYet);
};
if let Some(captures) = pattern.regex.captures(&line) {
let col = captures
.get(0)
.map(|m| line[..m.start()].chars().count() as u16)
.unwrap_or(0);
let preceding: Vec<String> = (0..*row)
.map(|r| screen.row_text(r).unwrap_or_default())
.collect();
let position = screen.visible_to_absolute(Position { row: *row, col });
Ok(Probe::Matched(Some(make_match(
position,
captures_to_strings(&captures),
Some(line),
preceding,
))))
} else {
Ok(Probe::NotYet)
}
}
WaitConditionKind::CellText { position, text } => {
let matched = screen
.cell(position.row, position.col)
.is_some_and(|cell| cell.contents() == text.as_str());
if matched {
let row_idx = position.row;
let matched_line = screen.row_text(row_idx);
let preceding: Vec<String> = (0..row_idx)
.map(|r| screen.row_text(r).unwrap_or_default())
.collect();
let absolute = screen.visible_to_absolute(*position);
Ok(Probe::Matched(Some(make_match(
absolute,
Vec::new(),
matched_line,
preceding,
))))
} else {
Ok(Probe::NotYet)
}
}
WaitConditionKind::Cursor(position) => {
if screen.cursor() == *position {
Ok(Probe::Matched(bare_match()))
} else {
Ok(Probe::NotYet)
}
}
WaitConditionKind::Exit => match terminal.try_wait() {
Ok(Some(_)) => Ok(Probe::Matched(bare_match())),
Ok(None) => Ok(Probe::NotYet),
Err(source) => Err(WaitError::ExitStatus {
snapshot: Box::new(snapshot_from_screen(screen)),
source,
}),
},
WaitConditionKind::Stable {
settle,
ignore_cursor,
ignore_style,
} => {
let now = Instant::now();
let signature = StableSignature::capture(screen, *ignore_cursor, *ignore_style);
match &condition.stable.last {
Some(prev) if *prev == signature => {}
_ => {
condition.stable.last = Some(signature);
condition.stable.stable_since = Some(now);
}
}
let settled = condition
.stable
.stable_since
.is_some_and(|stable_since| now.duration_since(stable_since) >= *settle);
if settled {
Ok(Probe::Matched(bare_match()))
} else {
Ok(Probe::NotYet)
}
}
WaitConditionKind::AnyOf(_) => unreachable!("FlatCondition cannot wrap AnyOf"),
}
}
fn captures_to_strings(captures: ®ex::Captures<'_>) -> Vec<Option<String>> {
captures
.iter()
.map(|opt| opt.map(|m| m.as_str().to_string()))
.collect()
}
fn locate_match_in_lines(
lines: &[String],
full_match: Option<regex::Match<'_>>,
) -> Option<(usize, u16, String, Vec<String>)> {
let m = full_match?;
let target = m.start();
let mut consumed = 0usize;
for (row_idx, line) in lines.iter().enumerate() {
let line_end = consumed + line.len();
if target <= line_end {
let col_bytes = target.saturating_sub(consumed);
let col = line
.get(..col_bytes)
.map(|s| s.chars().count())
.unwrap_or(0) as u16;
return Some((row_idx, col, line.clone(), lines[..row_idx].to_vec()));
}
consumed = line_end + 1;
}
None
}
fn local_row_to_absolute(
screen: &Screen,
scrollback_lines: usize,
local_row: usize,
col: u16,
) -> Option<AbsolutePosition> {
if local_row < scrollback_lines {
let row = screen
.scrollback_origin_row()
.saturating_add(local_row as u64);
Some(AbsolutePosition { row, col })
} else {
let viewport_row = (local_row - scrollback_lines) as u16;
screen.visible_to_absolute(Position {
row: viewport_row,
col,
})
}
}
#[cfg(test)]
mod stable_floor_tests {
use super::*;
fn compile_err(condition: WaitCondition) -> WaitError {
match CompiledCondition::compile(&condition) {
Ok(_) => panic!("expected compile to reject {condition}"),
Err(err) => err,
}
}
fn compile_ok(condition: WaitCondition) {
if let Err(err) = CompiledCondition::compile(&condition) {
panic!("expected compile to accept {condition}, got {err:?}");
}
}
#[test]
fn below_floor_rejected_with_details() {
let poll = Duration::from_millis(50);
let settle = Duration::from_millis(100);
let err = compile_err(WaitCondition::stable(settle).poll(poll).into());
let WaitError::SettleBelowPollFloor {
settle: e_settle,
poll: e_poll,
floor,
} = err
else {
panic!("expected SettleBelowPollFloor, got {err:?}");
};
assert_eq!(e_settle, settle);
assert_eq!(e_poll, poll);
assert_eq!(floor, Duration::from_millis(200));
}
#[test]
fn at_floor_accepted() {
compile_ok(
WaitCondition::stable(Duration::from_millis(200))
.poll(Duration::from_millis(50))
.into(),
);
}
#[test]
fn above_floor_below_warn_accepted() {
compile_ok(
WaitCondition::stable(Duration::from_millis(800))
.poll(Duration::from_millis(50))
.into(),
);
}
#[test]
fn floor_applied_inside_any_of_with_outer_poll() {
let subs: [WaitCondition; 2] = [
WaitCondition::regex("ready").into(),
WaitCondition::stable(Duration::from_millis(300)).into(),
];
let condition = WaitCondition::any_of(subs).poll(Duration::from_millis(100));
let err = compile_err(condition);
assert!(
matches!(err, WaitError::SettleBelowPollFloor { .. }),
"expected SettleBelowPollFloor, got {err:?}"
);
}
#[test]
fn invalid_regex_display_preserves_underlying_message() {
let err = compile_err(WaitCondition::regex("(unclosed").into());
let WaitError::InvalidRegex { pattern, source } = &err else {
panic!("expected InvalidRegex, got {err:?}");
};
assert_eq!(pattern, "(unclosed");
let source_message = source.to_string();
assert!(
!source_message.is_empty(),
"wrapper must surface a non-empty regex diagnostic"
);
let display = err.to_string();
assert!(
display.contains("(unclosed"),
"Display must include the offending pattern, got {display:?}",
);
assert!(
display.contains(&source_message),
"Display must include the wrapped diagnostic, got {display:?}",
);
}
}