tastty-driver 0.1.0

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

use crate::wait::condition::WaitCondition;
use crate::{ExitStatus, Snapshot};

/// Error returned when waiting for a condition fails.
#[non_exhaustive]
#[derive(thiserror::Error)]
pub enum WaitError {
    /// The regex pattern in the condition is invalid.
    #[error("invalid regex pattern '{pattern}': {source}")]
    InvalidRegex {
        /// Invalid pattern.
        pattern: String,
        /// Diagnostic from the regex engine.
        #[source]
        source: crate::InvalidRegexSource,
    },
    /// The condition did not match before the timeout.
    #[error("timed out after {}ms waiting for {condition}", elapsed.as_millis())]
    Timeout {
        /// Unmet condition.
        condition: WaitCondition,
        /// Elapsed duration.
        elapsed: Duration,
        /// Last snapshot captured before timeout.
        snapshot: Box<Snapshot>,
    },
    /// The originating [`crate::Session`] closed before the condition
    /// matched. Async wait futures can outlive their session, but once the
    /// session is dropped there is no output notifier left to drive future
    /// re-polls.
    #[error("session closed after {}ms waiting for {condition}", elapsed.as_millis())]
    SessionClosed {
        /// Unmet condition.
        condition: WaitCondition,
        /// Elapsed duration.
        elapsed: Duration,
        /// Last snapshot captured before closure was observed.
        snapshot: Box<Snapshot>,
    },
    /// The child process exited before the condition matched. Only emitted
    /// for non-[`WaitCondition::exit`] conditions; an exit wait that sees the
    /// process exit returns success.
    #[error("process exited before matching {condition}")]
    ProcessExitedBeforeMatch {
        /// Unmet condition.
        condition: WaitCondition,
        /// Exit status reported by the child.
        exit_status: ExitStatus,
        /// Last snapshot captured before exit was observed.
        snapshot: Box<Snapshot>,
    },
    /// Polling process exit status failed.
    #[error("failed to poll exit status: {source}")]
    ExitStatus {
        /// Last snapshot captured before the failure.
        snapshot: Box<Snapshot>,
        /// Underlying tastty error.
        #[source]
        source: tastty::Error,
    },
    /// A [`WaitCondition::any_of`] was nested inside another `any_of`. Flatten
    /// the conditions into a single `any_of` call instead.
    #[error("nested any_of is not supported: flatten into a single any_of")]
    NestedAnyOf,
    /// A [`WaitCondition::stable`] settle window is too short relative to
    /// the wait's poll cadence. Below `MIN_SETTLE_VS_POLL * poll`, the
    /// poller samples the screen too few times during the settle window
    /// to distinguish a settled screen from a quiet inter-tick gap on a
    /// periodically-redrawing program. Increase `settle` or decrease
    /// `poll` so several poll rounds elapse before stability is
    /// declared.
    #[error(
        "stable settle {}ms below floor {}ms (poll {}ms): increase settle or decrease poll",
        settle.as_millis(),
        floor.as_millis(),
        poll.as_millis()
    )]
    SettleBelowPollFloor {
        /// Settle window from the [`WaitCondition::stable`] builder.
        settle: Duration,
        /// Poll cadence resolved on the enclosing [`WaitCondition`].
        poll: Duration,
        /// Minimum settle the wait engine accepts at this poll cadence.
        floor: Duration,
    },
}

const SNAPSHOT_PREVIEW_LINES: usize = 5;
const SNAPSHOT_PREVIEW_LINE_WIDTH: usize = 120;

impl fmt::Debug for WaitError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidRegex { pattern, source } => f
                .debug_struct("InvalidRegex")
                .field("pattern", pattern)
                .field("source", source)
                .finish(),
            Self::Timeout {
                condition,
                elapsed,
                snapshot,
            } => f
                .debug_struct("Timeout")
                .field("condition", condition)
                .field("elapsed", elapsed)
                .field("snapshot", &SnapshotPreview(snapshot))
                .finish(),
            Self::SessionClosed {
                condition,
                elapsed,
                snapshot,
            } => f
                .debug_struct("SessionClosed")
                .field("condition", condition)
                .field("elapsed", elapsed)
                .field("snapshot", &SnapshotPreview(snapshot))
                .finish(),
            Self::ProcessExitedBeforeMatch {
                condition,
                exit_status,
                snapshot,
            } => f
                .debug_struct("ProcessExitedBeforeMatch")
                .field("condition", condition)
                .field("exit_status", exit_status)
                .field("snapshot", &SnapshotPreview(snapshot))
                .finish(),
            Self::ExitStatus { snapshot, source } => f
                .debug_struct("ExitStatus")
                .field("snapshot", &SnapshotPreview(snapshot))
                .field("source", source)
                .finish(),
            Self::NestedAnyOf => f.debug_struct("NestedAnyOf").finish(),
            Self::SettleBelowPollFloor {
                settle,
                poll,
                floor,
            } => f
                .debug_struct("SettleBelowPollFloor")
                .field("settle", settle)
                .field("poll", poll)
                .field("floor", floor)
                .finish(),
        }
    }
}

/// Bounds the [`Snapshot`] field of [`WaitError`] in `Debug` output. The
/// derived impl on a default 80x24 grid prints roughly 400 KB; a failing
/// integration test hits that on the first unmet wait.
struct SnapshotPreview<'a>(&'a Snapshot);

impl fmt::Debug for SnapshotPreview<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let preview: Vec<String> = self
            .0
            .lines()
            .into_iter()
            .filter_map(|line| {
                let trimmed = line.trim_end();
                if trimmed.is_empty() {
                    None
                } else {
                    Some(truncate_chars(trimmed, SNAPSHOT_PREVIEW_LINE_WIDTH))
                }
            })
            .take(SNAPSHOT_PREVIEW_LINES)
            .collect();
        f.debug_struct("Snapshot")
            .field("size", &self.0.size())
            .field("cursor", &self.0.cursor())
            .field("lines", &preview)
            .finish()
    }
}

fn truncate_chars(s: &str, max_chars: usize) -> String {
    if s.chars().count() <= max_chars {
        return s.to_string();
    }
    let mut out: String = s.chars().take(max_chars.saturating_sub(3)).collect();
    out.push_str("...");
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::snapshot::snapshot_from_screen;
    use tastty::{Parser, TerminalSize};

    fn build_snapshot(rows: u16, cols: u16, fill: u8) -> Box<Snapshot> {
        let mut parser = Parser::new(TerminalSize { rows, cols }, 0);
        let payload = vec![fill; rows as usize * cols as usize];
        parser.process(&payload);
        Box::new(snapshot_from_screen(parser.screen()))
    }

    #[test]
    fn debug_for_timeout_bounds_snapshot_output() {
        let err = WaitError::Timeout {
            condition: WaitCondition::text("ready"),
            elapsed: Duration::from_millis(250),
            snapshot: build_snapshot(100, 200, b'X'),
        };
        let formatted = format!("{err:?}");
        assert!(
            formatted.len() < 4096,
            "Debug output should be under 4 KB but was {} bytes",
            formatted.len()
        );
        assert!(formatted.contains("Timeout"));
        assert!(formatted.contains("Snapshot"));
        assert!(formatted.contains("size"));
        assert!(formatted.contains("cursor"));
    }

    #[test]
    fn debug_for_nested_any_of_renders_unit_variant() {
        let formatted = format!("{:?}", WaitError::NestedAnyOf);
        assert_eq!(formatted, "NestedAnyOf");
    }

    #[test]
    fn debug_for_settle_below_poll_floor_renders_all_fields() {
        let err = WaitError::SettleBelowPollFloor {
            settle: Duration::from_millis(10),
            poll: Duration::from_millis(50),
            floor: Duration::from_millis(150),
        };
        let formatted = format!("{err:?}");
        assert!(formatted.contains("SettleBelowPollFloor"));
        assert!(formatted.contains("settle"));
        assert!(formatted.contains("poll"));
        assert!(formatted.contains("floor"));
    }

    #[test]
    fn truncate_chars_appends_ellipsis_when_over_limit() {
        let truncated = truncate_chars(&"a".repeat(200), 50);
        assert_eq!(truncated.chars().count(), 50);
        assert!(truncated.ends_with("..."));
    }

    #[test]
    fn truncate_chars_passes_through_short_input() {
        assert_eq!(truncate_chars("hello", 50), "hello");
    }
}