tastty-driver 0.1.0

Terminal automation driver built on tastty
//! Session construction.

use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::thread;

use super::{Session, exit_reaper};
use crate::observer::IoObserver;
use crate::wait::exit::ExitNotifier;
use crate::wait::output::OutputNotifier;
use crate::{Error, ExitStatus, Result};
use tastty::{Builder, Terminal};

/// Driver-only spawn settings that sit alongside a [`tastty::Builder`].
///
/// These are settings the underlying `tastty` layer does not model: an
/// [`IoObserver`] push-style callback set, and a user-supplied per-tick
/// redraw hook that composes with the driver's internal output notifier
/// (the notifier wakes wait futures first, then the user hook runs).
///
/// Construct with field-update syntax from [`DriverOptions::default`]:
///
/// ```no_run
/// use std::sync::Arc;
/// use tastty_driver::{Builder, DriverOptions, IoObserver, Session};
///
/// # fn build(obs: Arc<dyn IoObserver>) -> Result<(), Box<dyn std::error::Error>> {
/// let session = Session::spawn_with(
///     Builder::command("bash"),
///     DriverOptions { observer: Some(obs), ..Default::default() },
/// )?;
/// # let _ = session;
/// # Ok(())
/// # }
/// ```
#[derive(Default, Clone)]
pub struct DriverOptions {
    /// Push-style observer for I/O and lifecycle events.
    pub observer: Option<Arc<dyn IoObserver>>,
    /// Per-parser-tick callback, run after the driver's internal
    /// output-notifier wake. The notifier fires first so wait futures
    /// observe the tick before the user callback executes. See
    /// [`tastty::SessionOptions::on_redraw`] for thread and panic
    /// contract.
    pub on_redraw: Option<Arc<dyn Fn() + Send + Sync>>,
}

impl std::fmt::Debug for DriverOptions {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DriverOptions")
            .field("observer", &self.observer.as_ref().map(|_| "<observer>"))
            .field("on_redraw", &self.on_redraw.as_ref().map(|_| "<callback>"))
            .finish()
    }
}

impl Session {
    /// Spawn a new terminal session with default driver options.
    ///
    /// # Errors
    ///
    /// - [`Error::MissingCommand`] when the [`Builder`] has no command set.
    /// - [`Error::Spawn`] when the underlying [`Terminal::spawn`] fails
    ///   (PTY allocation, child exec).
    /// - [`Error::ThreadSpawn`] when the OS denies the output-timer or
    ///   exit-reaper thread.
    pub fn spawn(builder: Builder) -> Result<Self> {
        Self::spawn_with(builder, DriverOptions::default())
    }

    /// Spawn a new terminal session with caller-supplied driver options.
    ///
    /// See [`DriverOptions`] for the available settings (observer and
    /// composed redraw callback).
    ///
    /// # Errors
    ///
    /// Same as [`Session::spawn`].
    pub fn spawn_with(builder: Builder, options: DriverOptions) -> Result<Self> {
        let DriverOptions {
            observer,
            on_redraw: user_on_redraw,
        } = options;
        let (output_notifier, output_timer) = OutputNotifier::new()?;
        let exit_notifier = Arc::new(ExitNotifier::new());
        let mut inner = builder;

        if let Some(obs) = observer.clone() {
            let on_output = Arc::clone(&obs);
            inner = inner.on_output(move |bytes| {
                on_output.on_output(bytes);
            });
            let on_input = obs;
            inner = inner.on_input(move |bytes| {
                on_input.on_input(bytes);
            });
        }

        // Always wire the output-notifier on_redraw so wait futures and
        // synchronous waiters see post-parser ticks. Compose with any
        // user callback from DriverOptions::on_redraw so the lifted
        // setter is observable too.
        {
            let notifier = Arc::clone(&output_notifier);
            inner = inner.on_redraw(move || {
                notifier.notify_tick();
                if let Some(cb) = &user_on_redraw {
                    cb();
                }
            });
        }

        let terminal = Arc::new(Terminal::spawn(inner).map_err(|err| match err {
            tastty::Error::MissingCommand => Error::MissingCommand,
            other => Error::Spawn(other),
        })?);

        let reaper_shutdown = Arc::new(AtomicBool::new(false));
        let reaper = {
            let terminal = Arc::clone(&terminal);
            let exit = Arc::clone(&exit_notifier);
            let output = Arc::clone(&output_notifier);
            let observer = observer.clone();
            let shutdown = Arc::clone(&reaper_shutdown);
            Some(
                thread::Builder::new()
                    .name("tastty-driver-exit-reaper".into())
                    .spawn(move || exit_reaper(terminal, exit, output, observer, shutdown))
                    .map_err(|source| Error::ThreadSpawn {
                        source,
                        name: "tastty-driver-exit-reaper",
                    })?,
            )
        };

        Ok(Self {
            terminal,
            observer,
            exit_notifier,
            output_notifier,
            output_timer: Some(output_timer),
            reaper_shutdown,
            reaper,
        })
    }

    /// Spawn the configured command and block until it exits.
    ///
    /// Fire-and-forget: a caller that needs to inspect the final screen,
    /// react to wait conditions, or signal the child before exit should
    /// build a [`Session`] with [`Session::spawn`] and drive it
    /// directly.
    ///
    /// # Errors
    ///
    /// [`Error::Spawn`] if spawning fails, or [`Error::ExitStatus`] if
    /// the reaper finalises without observing a clean exit.
    pub fn run_to_exit(builder: Builder) -> Result<ExitStatus> {
        Self::spawn(builder)?.wait_exit()
    }
}