tastty-driver 0.1.0

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

use tastty::Position;

/// Default cadence at which [`Session::wait`](crate::Session::wait) polls
/// snapshots when a [`WaitCondition`] does not override it via
/// [`WaitCondition::poll`].
pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(50);

/// Condition used by [`Session::wait`](crate::Session::wait).
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WaitCondition {
    pub(super) kind: WaitConditionKind<String>,
    pub(super) poll: Duration,
}

// Generic so the source form (P = String) and the compiled form
// (P = CompiledPattern) share one variant list. A new wait kind
// only requires editing this enum and the regex/non-regex split
// inside FlatCondition::compile.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum WaitConditionKind<P> {
    Text(String),
    Regex {
        pattern: P,
        include_scrollback: bool,
    },
    RowRegex {
        row: u16,
        pattern: P,
    },
    CellText {
        position: Position,
        text: String,
    },
    Cursor(Position),
    Exit,
    Stable {
        settle: Duration,
        ignore_cursor: bool,
        ignore_style: bool,
    },
    AnyOf(Vec<WaitConditionKind<P>>),
}

impl WaitCondition {
    /// Build a text-substring wait condition.
    #[must_use]
    pub fn text(text: impl Into<String>) -> Self {
        Self::with_kind(WaitConditionKind::Text(text.into()))
    }

    /// Build a regex wait condition.
    ///
    /// Returns a [`RegexCondition`] so regex-only options like
    /// [`RegexCondition::include_scrollback`] are reachable on the typed
    /// builder while remaining unavailable on other variants.
    #[must_use]
    pub fn regex(pattern: impl Into<String>) -> RegexCondition {
        RegexCondition {
            pattern: pattern.into(),
            include_scrollback: false,
            poll: DEFAULT_POLL_INTERVAL,
        }
    }

    /// Build a row regex wait condition.
    #[must_use]
    pub fn row_regex(row: u16, pattern: impl Into<String>) -> Self {
        Self::with_kind(WaitConditionKind::RowRegex {
            row,
            pattern: pattern.into(),
        })
    }

    /// Build a cell text wait condition.
    #[must_use]
    pub fn cell_text(position: Position, text: impl Into<String>) -> Self {
        Self::with_kind(WaitConditionKind::CellText {
            position,
            text: text.into(),
        })
    }

    /// Build a cursor-position wait condition.
    #[must_use]
    pub fn cursor(position: Position) -> Self {
        Self::with_kind(WaitConditionKind::Cursor(position))
    }

    /// Build an exit wait condition.
    #[must_use]
    pub fn exit() -> Self {
        Self::with_kind(WaitConditionKind::Exit)
    }

    /// Build a composite wait condition that matches when any sub-condition
    /// matches.
    ///
    /// Sub-conditions are evaluated in the order given on every poll tick;
    /// the first one to match wins. The winning sub-condition's index in
    /// `conditions` is reported on the resulting [`WaitMatch`] via
    /// [`WaitMatch::condition_index`], including for sub-conditions whose
    /// flat form does not normally produce a [`WaitMatch`] (e.g.
    /// [`WaitCondition::exit`]).
    ///
    /// The poll cadence is taken from the outer [`WaitCondition`] (override
    /// with [`WaitCondition::poll`]). Each sub-condition's own `poll` value
    /// is ignored, since `any_of` runs every sub-condition on the same
    /// cadence.
    ///
    /// Accepts any iterator whose item converts into a [`WaitCondition`], so
    /// typed builders ([`RegexCondition`], [`StableCondition`]) flow without
    /// an explicit `.into()` when the iterator is homogeneous:
    ///
    /// ```no_run
    /// # use tastty_driver::WaitCondition;
    /// let condition = WaitCondition::any_of([
    ///     WaitCondition::regex("hello"),
    ///     WaitCondition::regex("world"),
    /// ]);
    /// # let _ = condition;
    /// ```
    ///
    /// Express EOF-as-match by composing a regex with [`WaitCondition::exit`].
    /// Heterogeneous element types still need a per-item `.into()` to land
    /// the array on a common type, which the iterator then maps through:
    ///
    /// ```no_run
    /// # use tastty_driver::WaitCondition;
    /// let condition = WaitCondition::any_of([
    ///     WaitCondition::regex("ready").into(),
    ///     WaitCondition::exit(),
    /// ]);
    /// # let _ = condition;
    /// ```
    ///
    /// [`WaitMatch`]: crate::WaitMatch
    /// [`WaitMatch::condition_index`]: crate::WaitMatch#structfield.condition_index
    #[must_use]
    pub fn any_of<I, C>(conditions: I) -> Self
    where
        I: IntoIterator<Item = C>,
        C: Into<WaitCondition>,
    {
        Self::with_kind(WaitConditionKind::AnyOf(
            conditions
                .into_iter()
                .map(|condition| condition.into().kind)
                .collect(),
        ))
    }

    /// Build a stability wait condition.
    ///
    /// Returns a [`StableCondition`] so stable-only options
    /// ([`StableCondition::ignore_cursor`], [`StableCondition::ignore_style`])
    /// are reachable on the typed builder while remaining unavailable on
    /// other variants. By default, the screen is considered stable only
    /// when both visible cells (text and styles) and the cursor position
    /// remain unchanged for `settle`.
    #[must_use]
    pub fn stable(settle: Duration) -> StableCondition {
        StableCondition {
            settle,
            ignore_cursor: false,
            ignore_style: false,
            poll: DEFAULT_POLL_INTERVAL,
        }
    }

    /// Override the poll cadence used by [`Session::wait`](crate::Session::wait)
    /// when checking this condition. Defaults to [`DEFAULT_POLL_INTERVAL`].
    #[must_use]
    pub fn poll(mut self, poll: Duration) -> Self {
        self.poll = poll;
        self
    }

    fn with_kind(kind: WaitConditionKind<String>) -> Self {
        Self {
            kind,
            poll: DEFAULT_POLL_INTERVAL,
        }
    }
}

/// Typed builder produced by [`WaitCondition::regex`].
///
/// Owns the regex-variant fields directly; [`From<RegexCondition>`]
/// for [`WaitCondition`] assembles the `kind` at conversion time, so
/// the "this wrapper is a regex variant" invariant is structural
/// rather than something a reader has to verify by tracing
/// constructors. [`Session::wait`](crate::Session::wait) accepts
/// `impl Into<WaitCondition>`, so the conversion is implicit at the
/// call site.
///
/// Stable-only options are a compile error rather than a runtime
/// panic:
///
/// ```compile_fail
/// # use tastty_driver::WaitCondition;
/// // ignore_cursor only exists on StableCondition.
/// let _ = WaitCondition::regex("x").ignore_cursor();
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RegexCondition {
    pattern: String,
    include_scrollback: bool,
    poll: Duration,
}

impl RegexCondition {
    /// Override the poll cadence used by [`Session::wait`](crate::Session::wait)
    /// when checking this condition. Defaults to [`DEFAULT_POLL_INTERVAL`].
    #[must_use]
    pub fn poll(mut self, poll: Duration) -> Self {
        self.poll = poll;
        self
    }

    /// Search retained scrollback rows in addition to the visible screen.
    #[must_use]
    pub fn include_scrollback(mut self) -> Self {
        self.include_scrollback = true;
        self
    }
}

impl From<RegexCondition> for WaitCondition {
    fn from(condition: RegexCondition) -> Self {
        WaitCondition {
            kind: WaitConditionKind::Regex {
                pattern: condition.pattern,
                include_scrollback: condition.include_scrollback,
            },
            poll: condition.poll,
        }
    }
}

/// Typed builder produced by [`WaitCondition::stable`].
///
/// Owns the stable-variant fields directly; [`From<StableCondition>`]
/// for [`WaitCondition`] assembles the `kind` at conversion time, so
/// the "this wrapper is a stable variant" invariant is structural
/// rather than something a reader has to verify by tracing
/// constructors. [`Session::wait`](crate::Session::wait) accepts
/// `impl Into<WaitCondition>`, so the conversion is implicit at the
/// call site.
///
/// Regex-only options are a compile error rather than a runtime
/// panic, both on this type and on the plain [`WaitCondition`]
/// returned by [`WaitCondition::row_regex`]:
///
/// ```compile_fail
/// # use tastty_driver::WaitCondition;
/// # use std::time::Duration;
/// // include_scrollback only exists on RegexCondition.
/// let _ = WaitCondition::stable(Duration::from_millis(100)).include_scrollback();
/// ```
///
/// ```compile_fail
/// # use tastty_driver::WaitCondition;
/// // include_scrollback does not exist on plain WaitCondition either.
/// let _ = WaitCondition::row_regex(0, "x").include_scrollback();
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StableCondition {
    settle: Duration,
    ignore_cursor: bool,
    ignore_style: bool,
    poll: Duration,
}

impl StableCondition {
    /// Override the poll cadence used by [`Session::wait`](crate::Session::wait)
    /// when checking this condition. Defaults to [`DEFAULT_POLL_INTERVAL`].
    #[must_use]
    pub fn poll(mut self, poll: Duration) -> Self {
        self.poll = poll;
        self
    }

    /// Treat cursor-only changes as stable.
    #[must_use]
    pub fn ignore_cursor(mut self) -> Self {
        self.ignore_cursor = true;
        self
    }

    /// Treat style-only changes (foreground/background color, attributes)
    /// as stable.
    #[must_use]
    pub fn ignore_style(mut self) -> Self {
        self.ignore_style = true;
        self
    }
}

impl From<StableCondition> for WaitCondition {
    fn from(condition: StableCondition) -> Self {
        WaitCondition {
            kind: WaitConditionKind::Stable {
                settle: condition.settle,
                ignore_cursor: condition.ignore_cursor,
                ignore_style: condition.ignore_style,
            },
            poll: condition.poll,
        }
    }
}

impl fmt::Display for WaitCondition {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Display::fmt(&self.kind, f)
    }
}

impl fmt::Display for WaitConditionKind<String> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            WaitConditionKind::Text(text) => write!(f, "text containing {text:?}"),
            WaitConditionKind::Regex {
                pattern,
                include_scrollback,
            } => {
                write!(f, "regex /{pattern}/")?;
                if *include_scrollback {
                    f.write_str(" (with scrollback)")?;
                }
                Ok(())
            }
            WaitConditionKind::RowRegex { row, pattern } => {
                write!(f, "row {row} matching /{pattern}/")
            }
            WaitConditionKind::CellText { position, text } => {
                write!(
                    f,
                    "cell ({}, {}) containing {text:?}",
                    position.row, position.col
                )
            }
            WaitConditionKind::Cursor(position) => {
                write!(f, "cursor at ({}, {})", position.row, position.col)
            }
            WaitConditionKind::Exit => f.write_str("process exit"),
            WaitConditionKind::Stable {
                settle,
                ignore_cursor,
                ignore_style,
            } => {
                write!(f, "stable for {}ms", settle.as_millis())?;
                if *ignore_cursor || *ignore_style {
                    f.write_str(" (")?;
                    let mut first = true;
                    if *ignore_cursor {
                        f.write_str("ignore cursor")?;
                        first = false;
                    }
                    if *ignore_style {
                        if !first {
                            f.write_str(", ")?;
                        }
                        f.write_str("ignore style")?;
                    }
                    f.write_str(")")?;
                }
                Ok(())
            }
            WaitConditionKind::AnyOf(kinds) => {
                f.write_str("any of [")?;
                for (i, kind) in kinds.iter().enumerate() {
                    if i > 0 {
                        f.write_str(", ")?;
                    }
                    fmt::Display::fmt(kind, f)?;
                }
                f.write_str("]")
            }
        }
    }
}