tastty 0.1.0

Embeddable pseudoterminal sessions for Rust applications
use std::ffi::OsString;
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;

use portable_pty::CommandBuilder;
use tastty_core::{
    CellPixelSize, ClipboardTarget, HostProfile, HostQuery, KeyEvent, KeyScreenState, ReplyAction,
    TerminalSize,
};

use crate::error::{Error, Result};
use crate::osc_policy::{ClipboardPolicy, OscPolicy};
use crate::session::{KeyAction, SessionOptions};

#[derive(Debug)]
enum CommandSpec {
    Command {
        program: OsString,
        args: Vec<OsString>,
    },
    Shell(String),
}

/// Spawn-time configuration for [`Terminal::spawn`](crate::Terminal::spawn).
///
/// # Example
///
/// ```no_run
/// use tastty::{Builder, Terminal, TerminalSize};
///
/// let session = Terminal::spawn(
///     Builder::shell_command("echo hello")
///         .size(TerminalSize { rows: 24, cols: 80 })
///         .scrollback(2_000),
/// ).unwrap();
/// ```
pub struct Builder {
    command: Option<CommandSpec>,
    cwd: Option<PathBuf>,
    env: Vec<(OsString, OsString)>,
    clear_env: bool,
    shell: OsString,
    controlling_tty: bool,
    opts: SessionOptions,
}

impl Default for Builder {
    fn default() -> Self {
        Self {
            command: None,
            cwd: None,
            env: Vec::new(),
            clear_env: false,
            shell: OsString::from("/bin/sh"),
            controlling_tty: true,
            opts: SessionOptions::default(),
        }
    }
}

impl fmt::Debug for Builder {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Builder")
            .field("command", &self.command)
            .field("cwd", &self.cwd)
            .field("env", &self.env)
            .field("clear_env", &self.clear_env)
            .field("shell", &self.shell)
            .field("controlling_tty", &self.controlling_tty)
            .field("opts", &self.opts)
            .finish()
    }
}

impl Builder {
    /// Build for an executable path.
    #[must_use]
    pub fn command(program: impl Into<OsString>) -> Self {
        Self::default().program(program)
    }

    /// Build for a shell command executed through a POSIX shell.
    ///
    /// Defaults to invoking `/bin/sh -c <command>`. Override the shell
    /// program with [`Builder::shell`] when the target environment ships
    /// a different POSIX shell.
    #[must_use]
    pub fn shell_command(command: impl Into<String>) -> Self {
        Self {
            command: Some(CommandSpec::Shell(command.into())),
            ..Self::default()
        }
    }

    /// Set the executable path. Resets any previously-pushed args.
    #[must_use]
    pub fn program(mut self, program: impl Into<OsString>) -> Self {
        self.command = Some(CommandSpec::Command {
            program: program.into(),
            args: Vec::new(),
        });
        self
    }

    /// Append one command argument.
    ///
    /// Promotes a previously-set [`shell_command`](Self::shell_command)
    /// builder into a [`command`](Self::command) builder when called
    /// before a [`program`](Self::program) is set, so the first `arg`
    /// becomes the program.
    #[must_use]
    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
        match self.command {
            Some(CommandSpec::Command { ref mut args, .. }) => args.push(arg.into()),
            Some(CommandSpec::Shell(_)) | None => {
                self.command = Some(CommandSpec::Command {
                    program: arg.into(),
                    args: Vec::new(),
                });
            }
        }
        self
    }

    /// Append several command arguments in order.
    #[must_use]
    pub fn args<I, S>(mut self, args: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<OsString>,
    {
        for arg in args {
            self = self.arg(arg);
        }
        self
    }

    /// Set or clear the child working directory.
    ///
    /// The `Into<Option<PathBuf>>` bound lets `Option<PathBuf>` thread
    /// through unchanged for optional-cwd configs and accepts a bare
    /// `PathBuf` for the always-set case.
    #[must_use]
    pub fn cwd(mut self, cwd: impl Into<Option<PathBuf>>) -> Self {
        self.cwd = cwd.into();
        self
    }

    /// Set one environment variable.
    #[must_use]
    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
        self.env.push((key.into(), value.into()));
        self
    }

    /// Append a batch of environment variables.
    #[must_use]
    pub fn envs<I, K, V>(mut self, vars: I) -> Self
    where
        I: IntoIterator<Item = (K, V)>,
        K: Into<OsString>,
        V: Into<OsString>,
    {
        for (key, value) in vars {
            self.env.push((key.into(), value.into()));
        }
        self
    }

    /// Clear the inherited environment so the child sees only the
    /// variables added via [`env`](Self::env) and [`envs`](Self::envs).
    #[must_use]
    pub fn clear_env(mut self) -> Self {
        self.clear_env = true;
        self
    }

    /// Override the shell program used by [`shell_command`](Self::shell_command).
    ///
    /// Defaults to `/bin/sh`. Ignored when the builder was constructed
    /// with [`command`](Self::command) or [`program`](Self::program)
    /// because those paths do not go through a shell.
    #[must_use]
    pub fn shell(mut self, program: impl Into<OsString>) -> Self {
        self.shell = program.into();
        self
    }

    /// Whether the spawned child acquires a controlling TTY. Default: `true`.
    #[must_use]
    pub fn controlling_tty(mut self, enabled: bool) -> Self {
        self.controlling_tty = enabled;
        self
    }

    /// Set the initial PTY size.
    #[must_use]
    pub fn size(mut self, size: TerminalSize) -> Self {
        self.opts.rows = size.rows;
        self.opts.cols = size.cols;
        self
    }

    /// Set retained scrollback rows.
    #[must_use]
    pub fn scrollback(mut self, rows: u32) -> Self {
        self.opts.scrollback = rows;
        self
    }

    /// Set the reported pixel size of one terminal cell.
    #[must_use]
    pub fn pixel_cell_size(mut self, size: CellPixelSize) -> Self {
        self.opts.pixel_cell_size = size;
        self
    }

    /// Set a custom [`HostProfile`] for terminal identity and default colors.
    #[must_use]
    pub fn host_profile(mut self, profile: HostProfile) -> Self {
        self.opts.host_profile = Some(profile);
        self
    }

    /// See [`SessionOptions::on_host_query`].
    #[must_use]
    pub fn on_host_query<F>(mut self, f: F) -> Self
    where
        F: Fn(&HostQuery, &HostProfile) -> ReplyAction + Send + Sync + 'static,
    {
        self.opts.host_query_callback = Arc::new(f);
        self
    }

    /// See [`SessionOptions::echo`].
    #[must_use]
    pub fn echo(mut self, enabled: bool) -> Self {
        self.opts.echo = enabled;
        self
    }

    /// See [`SessionOptions::virtual_cols`].
    #[must_use]
    pub fn virtual_cols(mut self, cols: u16) -> Self {
        self.opts.virtual_cols = Some(cols);
        self
    }

    /// See [`SessionOptions::on_redraw`].
    #[must_use]
    pub fn on_redraw<F>(mut self, f: F) -> Self
    where
        F: Fn() + Send + Sync + 'static,
    {
        self.opts.redraw_callback = Some(Arc::new(f));
        self
    }

    /// See [`SessionOptions::on_output`].
    #[must_use]
    pub fn on_output<F>(mut self, f: F) -> Self
    where
        F: Fn(&[u8]) + Send + Sync + 'static,
    {
        self.opts.output_callback = Some(Arc::new(f));
        self
    }

    /// See [`SessionOptions::on_input`].
    #[must_use]
    pub fn on_input<F>(mut self, f: F) -> Self
    where
        F: Fn(&[u8]) + Send + Sync + 'static,
    {
        self.opts.input_callback = Some(Arc::new(f));
        self
    }

    /// See [`SessionOptions::on_key`].
    #[must_use]
    pub fn on_key<F>(mut self, f: F) -> Self
    where
        F: Fn(&KeyEvent, KeyScreenState) -> KeyAction + Send + Sync + 'static,
    {
        self.opts.key_callback = Some(Arc::new(f));
        self
    }

    /// See [`SessionOptions::clipboard_policy`].
    #[must_use]
    pub fn clipboard_policy(mut self, policy: ClipboardPolicy) -> Self {
        self.opts.clipboard_policy = policy;
        self
    }

    /// See [`SessionOptions::with_clipboard_read`].
    #[must_use]
    pub fn with_clipboard_read(mut self, target: ClipboardTarget, policy: OscPolicy) -> Self {
        self.opts.clipboard_policy.read.set(target, policy);
        self
    }

    /// See [`SessionOptions::with_clipboard_write`].
    #[must_use]
    pub fn with_clipboard_write(mut self, target: ClipboardTarget, policy: OscPolicy) -> Self {
        self.opts.clipboard_policy.write.set(target, policy);
        self
    }

    pub(crate) fn into_parts(self) -> Result<(CommandBuilder, SessionOptions)> {
        let mut cmd = match self.command {
            Some(CommandSpec::Command { program, args }) => {
                let mut c = CommandBuilder::new(program);
                for arg in args {
                    c.arg(arg);
                }
                c
            }
            Some(CommandSpec::Shell(s)) => {
                let mut c = CommandBuilder::new(self.shell);
                c.arg("-c");
                c.arg(s);
                c
            }
            None => return Err(Error::MissingCommand),
        };
        if self.clear_env {
            cmd.env_clear();
        }
        // Default the child to the parent's working directory. Without an
        // explicit cwd, `portable-pty` falls back to `$HOME`, so spawning
        // fails wherever HOME is unset or points at a nonexistent path
        // (containers, CI sandboxes, services). Inheriting the parent cwd
        // matches `std::process::Command`.
        if let Some(cwd) = self.cwd.or_else(|| std::env::current_dir().ok()) {
            cmd.cwd(cwd);
        }
        for (key, value) in self.env {
            cmd.env(key, value);
        }
        cmd.set_controlling_tty(self.controlling_tty);
        Ok((cmd, self.opts))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    fn debug_includes_cwd(builder: &Builder, want: &Path) -> bool {
        format!("{builder:?}").contains(&want.display().to_string())
    }

    #[test]
    fn cwd_accepts_bare_path_buf() {
        let path = PathBuf::from("/tmp/example");
        let b = Builder::command("/bin/true").cwd(path.clone());
        assert!(debug_includes_cwd(&b, &path));
    }

    #[test]
    fn into_parts_defaults_cwd_to_current_dir_when_unset() {
        let (cmd, _) = Builder::shell_command("true")
            .into_parts()
            .expect("builder has a command");
        let current = std::env::current_dir().expect("a current dir");
        assert_eq!(cmd.get_cwd().map(PathBuf::from), Some(current));
    }

    #[test]
    fn cwd_accepts_some_path_buf() {
        let path = PathBuf::from("/tmp/example");
        let b = Builder::command("/bin/true").cwd(Some(path.clone()));
        assert!(debug_includes_cwd(&b, &path));
    }

    #[test]
    fn cwd_with_none_clears_a_previously_set_cwd() {
        let path = PathBuf::from("/tmp/example");
        let b = Builder::command("/bin/true").cwd(path).cwd(None::<PathBuf>);
        assert!(!format!("{b:?}").contains("/tmp/example"));
    }

    #[test]
    fn cwd_with_none_is_noop_on_unset_builder() {
        let b = Builder::command("/bin/true").cwd(None::<PathBuf>);
        assert!(!format!("{b:?}").contains("/tmp/example"));
    }

    #[test]
    fn cwd_threads_option_path_buf_through_unchanged() {
        let configured: Option<PathBuf> = Some(PathBuf::from("/tmp/example"));
        let unset: Option<PathBuf> = None;
        let b1 = Builder::command("/bin/true").cwd(configured.clone());
        let b2 = Builder::command("/bin/true").cwd(unset);
        assert!(debug_includes_cwd(&b1, configured.as_deref().unwrap()));
        assert!(!format!("{b2:?}").contains("/tmp/example"));
    }
}