tastty-driver 0.1.0

Terminal automation driver built on tastty
use std::time::Duration;

use tastty::AbsolutePosition;

use crate::{ExitStatus, Snapshot};

/// Successful return value of [`Session::wait`](crate::Session::wait).
#[derive(Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct WaitOutcome {
    /// Snapshot captured at the moment the condition matched.
    pub snapshot: Snapshot,
    /// Wall-clock time spent polling before the match.
    pub elapsed: Duration,
    /// Match metadata for conditions that produce it
    /// ([`WaitCondition::text`], [`WaitCondition::regex`],
    /// [`WaitCondition::row_regex`], [`WaitCondition::cell_text`]).
    /// `None` for conditions that do not (cursor, exit, stable),
    /// unless they are part of a [`WaitCondition::any_of`] in which
    /// case a bare match carrying [`WaitMatch::condition_index`] is
    /// produced so the caller can identify the winning sub-condition.
    ///
    /// [`WaitCondition::text`]: crate::WaitCondition::text
    /// [`WaitCondition::regex`]: crate::WaitCondition::regex
    /// [`WaitCondition::row_regex`]: crate::WaitCondition::row_regex
    /// [`WaitCondition::cell_text`]: crate::WaitCondition::cell_text
    /// [`WaitCondition::any_of`]: crate::WaitCondition::any_of
    pub wait_match: Option<WaitMatch>,
    /// Exit status observed at the moment the wait succeeded, when the
    /// child had already terminated. `None` if the child was still
    /// running when the wait returned.
    ///
    /// Always populated for a successful [`WaitCondition::exit`]; for
    /// other conditions it is populated when the child happened to
    /// exit before or during the matching tick (a regex matched on the
    /// final flush, an [`WaitCondition::any_of`] selected a non-exit
    /// sub-condition while the child died on the same poll, ...).
    ///
    /// [`WaitCondition::exit`]: crate::WaitCondition::exit
    /// [`WaitCondition::any_of`]: crate::WaitCondition::any_of
    pub exit_status: Option<ExitStatus>,
}

impl WaitOutcome {
    /// Borrow the inner [`WaitMatch`].
    #[must_use]
    pub fn matched(&self) -> Option<&WaitMatch> {
        self.wait_match.as_ref()
    }

    /// Read one capture group from the inner [`WaitMatch`].
    ///
    /// `idx` follows the regex crate's convention: index 0 is the full
    /// match, indices 1.. are capturing groups in pattern order.
    /// Returns `None` for non-regex conditions, for indices outside the
    /// capture list, and for capturing groups that did not participate
    /// in the match.
    #[must_use]
    pub fn capture(&self, idx: usize) -> Option<&str> {
        self.wait_match.as_ref().and_then(|m| m.capture(idx))
    }

    /// Index of the winning sub-condition when the outer condition was
    /// a [`WaitCondition::any_of`]. `None` for flat conditions and for
    /// waits that produced no match at all.
    ///
    /// [`WaitCondition::any_of`]: crate::WaitCondition::any_of
    #[must_use]
    pub fn winning_index(&self) -> Option<usize> {
        self.wait_match.as_ref().and_then(|m| m.condition_index)
    }

    /// Borrow the observed [`ExitStatus`], if the child had exited.
    ///
    /// See [`Self::exit_status`] for population semantics.
    #[must_use]
    pub fn exit_status(&self) -> Option<&ExitStatus> {
        self.exit_status.as_ref()
    }
}

/// Match metadata attached to a [`WaitOutcome`].
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct WaitMatch {
    /// Buffer-stable [absolute](AbsolutePosition) position where the match
    /// starts. The row is monotonic across the entire history of the buffer,
    /// so it remains valid after scrolling, including for matches that have
    /// scrolled out of the viewport via
    /// [`RegexCondition::include_scrollback`]. The column is the character
    /// index within that row (not necessarily the cell column for wide
    /// characters), or the cell column for cell-text conditions.
    ///
    /// `None` for cursor/exit/stable bare matches reported via
    /// [`WaitCondition::any_of`], and for the rare case where the match's
    /// row could not be resolved to an [`AbsolutePosition`] (e.g. a viewport
    /// row that is outside the current viewport bounds).
    ///
    /// Map back to the current viewport coordinate when needed via
    /// [`tastty::Screen::absolute_to_visible`]; that returns `None` when
    /// the row has scrolled off-screen, which is the right semantics for a
    /// caller drawing only the visible viewport.
    ///
    /// [`RegexCondition::include_scrollback`]: crate::RegexCondition::include_scrollback
    /// [`WaitCondition::any_of`]: crate::WaitCondition::any_of
    pub position: Option<AbsolutePosition>,
    /// Capture groups, indexed in regex order. Index 0 is the full match.
    /// Empty for non-regex conditions.
    pub captures: Vec<Option<String>>,
    /// Index of the winning sub-condition when the outer condition is a
    /// [`WaitCondition::any_of`], or `None` for flat conditions. Always
    /// populated for `any_of` matches, including when the winning
    /// sub-condition would not produce a [`WaitMatch`] on its own (e.g.
    /// [`WaitCondition::exit`]).
    ///
    /// [`WaitCondition::any_of`]: crate::WaitCondition::any_of
    /// [`WaitCondition::exit`]: crate::WaitCondition::exit
    pub condition_index: Option<usize>,
    /// Full text of the line containing the match start, taken verbatim
    /// from the buffer the wait engine was scanning (visible rows, or
    /// retained scrollback rows followed by visible rows when the
    /// condition opted into scrollback via
    /// [`RegexCondition::include_scrollback`]). Trailing spaces from
    /// empty cells are preserved. `None` for cursor/exit/stable matches,
    /// which are not line-bound.
    ///
    /// [`RegexCondition::include_scrollback`]: crate::RegexCondition::include_scrollback
    pub matched_line: Option<String>,
    /// Lines preceding [`Self::matched_line`] in the scanned buffer, in
    /// display order (oldest first). For visible-only scans this starts
    /// at the topmost visible row; for `include_scrollback` regex scans
    /// it starts at the oldest retained scrollback row. Empty for
    /// matches on the first row, for cursor/exit/stable matches, and
    /// for any condition whose match position could not be resolved to
    /// a line index.
    pub preceding_lines: Vec<String>,
}

impl WaitMatch {
    /// Read one capture group by index.
    ///
    /// Index 0 is the full match; indices 1.. are capturing groups in
    /// pattern order. Returns `None` for indices outside the capture
    /// list (non-regex conditions report an empty
    /// [`Self::captures`]) and for capturing groups that did not
    /// participate in the match.
    #[must_use]
    pub fn capture(&self, idx: usize) -> Option<&str> {
        self.captures.get(idx).and_then(Option::as_deref)
    }
}