Skip to main content

ftui_pty/
pty_process.rs

1//! PTY process management for shell spawning and lifecycle control.
2//!
3//! `PtyProcess` provides a higher-level abstraction over `PtySession` specifically
4//! designed for spawning and managing interactive shell processes.
5//!
6//! # Invariants
7//!
8//! 1. **Single ownership**: Each `PtyProcess` owns exactly one child process.
9//! 2. **State consistency**: `is_alive()` reflects the actual process state.
10//! 3. **Clean termination**: `kill()` and `Drop` ensure no orphan processes.
11//!
12//! # Failure Modes
13//!
14//! | Failure | Cause | Behavior |
15//! |---------|-------|----------|
16//! | Shell not found | Invalid shell path | `spawn()` returns `Err` with details |
17//! | Environment error | Invalid env var | Silently ignored (shell may fail) |
18//! | Kill failure | Process already dead | `kill()` succeeds (idempotent) |
19//! | Timeout on wait | Process hung | Returns timeout error, process may linger |
20
21use std::collections::HashMap;
22use std::fmt;
23use std::io::{self, Read};
24use std::path::PathBuf;
25use std::sync::mpsc;
26use std::thread;
27use std::time::{Duration, Instant};
28
29use crate::{
30    DEFAULT_INPUT_WRITE_TIMEOUT, PtyInputWriter, deadline_after, detach_join, normalize_line_input,
31};
32use portable_pty::{CommandBuilder, ExitStatus, MasterPty, PtySize};
33
34/// Configuration for spawning a shell process.
35#[derive(Debug, Clone)]
36pub struct ShellConfig {
37    /// Path to the shell executable.
38    /// Defaults to `$SHELL` or `/bin/sh` if not set.
39    pub shell: Option<PathBuf>,
40
41    /// Arguments to pass to the shell.
42    pub args: Vec<String>,
43
44    /// Environment variables to set in the shell.
45    pub env: HashMap<String, String>,
46
47    /// Working directory for the shell.
48    pub cwd: Option<PathBuf>,
49
50    /// PTY width in columns.
51    pub cols: u16,
52
53    /// PTY height in rows.
54    pub rows: u16,
55
56    /// TERM environment variable (defaults to "xterm-256color").
57    pub term: String,
58
59    /// Enable logging of PTY events.
60    pub log_events: bool,
61
62    /// Maximum time to wait for PTY input writes before failing.
63    pub input_write_timeout: Duration,
64}
65
66impl Default for ShellConfig {
67    fn default() -> Self {
68        Self {
69            shell: None,
70            args: Vec::new(),
71            env: HashMap::new(),
72            cwd: None,
73            cols: 80,
74            rows: 24,
75            term: "xterm-256color".to_string(),
76            log_events: false,
77            input_write_timeout: DEFAULT_INPUT_WRITE_TIMEOUT,
78        }
79    }
80}
81
82impl ShellConfig {
83    /// Create a new configuration with the specified shell.
84    #[must_use]
85    pub fn with_shell(shell: impl Into<PathBuf>) -> Self {
86        Self {
87            shell: Some(shell.into()),
88            ..Default::default()
89        }
90    }
91
92    /// Set the PTY dimensions.
93    #[must_use]
94    pub fn size(mut self, cols: u16, rows: u16) -> Self {
95        self.cols = cols;
96        self.rows = rows;
97        self
98    }
99
100    /// Add a shell argument.
101    #[must_use]
102    pub fn arg(mut self, arg: impl Into<String>) -> Self {
103        self.args.push(arg.into());
104        self
105    }
106
107    /// Set an environment variable.
108    #[must_use]
109    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
110        self.env.insert(key.into(), value.into());
111        self
112    }
113
114    /// Inherit all environment variables from the parent process.
115    #[must_use]
116    pub fn inherit_env(mut self) -> Self {
117        for (key, value) in std::env::vars() {
118            self.env.entry(key).or_insert(value);
119        }
120        self
121    }
122
123    /// Set the working directory.
124    #[must_use]
125    pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
126        self.cwd = Some(path.into());
127        self
128    }
129
130    /// Set the TERM environment variable.
131    #[must_use]
132    pub fn term(mut self, term: impl Into<String>) -> Self {
133        self.term = term.into();
134        self
135    }
136
137    /// Enable or disable event logging.
138    #[must_use]
139    pub fn logging(mut self, enabled: bool) -> Self {
140        self.log_events = enabled;
141        self
142    }
143
144    /// Override the PTY input write timeout.
145    #[must_use]
146    pub fn input_write_timeout(mut self, timeout: Duration) -> Self {
147        self.input_write_timeout = timeout;
148        self
149    }
150
151    /// Resolve the shell path.
152    fn resolve_shell(&self) -> PathBuf {
153        if let Some(ref shell) = self.shell {
154            return shell.clone();
155        }
156
157        if let Some(shell) = preferred_default_shell() {
158            return shell;
159        }
160
161        // Try $SHELL environment variable
162        if let Ok(shell) = std::env::var("SHELL") {
163            return PathBuf::from(shell);
164        }
165
166        // Fall back to /bin/sh
167        PathBuf::from("/bin/sh")
168    }
169}
170
171/// Internal message type for the reader thread.
172#[derive(Debug)]
173enum ReaderMsg {
174    Data(Vec<u8>),
175    Eof,
176    Err(io::Error),
177}
178
179/// Process state tracking.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum ProcessState {
182    /// Process is running.
183    Running,
184    /// Process exited normally with the given status code.
185    Exited(u32),
186    /// Process was terminated by a signal.
187    Signaled(String),
188    /// Process state is unknown (e.g., after kill attempt).
189    Unknown,
190}
191
192impl ProcessState {
193    /// Returns `true` if the process is still running.
194    #[must_use]
195    pub fn is_alive(&self) -> bool {
196        matches!(self, ProcessState::Running)
197    }
198
199    /// Returns the exit code if the process exited normally.
200    #[must_use]
201    pub fn exit_code(&self) -> Option<u32> {
202        match self {
203            ProcessState::Exited(code) => Some(*code),
204            _ => None,
205        }
206    }
207
208    /// Returns the terminating signal description, if any.
209    #[must_use]
210    pub fn signal_name(&self) -> Option<&str> {
211        match self {
212            ProcessState::Signaled(signal) => Some(signal.as_str()),
213            _ => None,
214        }
215    }
216}
217
218/// A managed PTY process for shell interaction.
219///
220/// # Example
221///
222/// ```ignore
223/// use ftui_pty::pty_process::{PtyProcess, ShellConfig};
224/// use std::time::Duration;
225///
226/// let config = ShellConfig::default()
227///     .inherit_env()
228///     .size(80, 24);
229///
230/// let mut proc = PtyProcess::spawn(config)?;
231///
232/// // Send a command
233/// proc.write_line("echo hello")?;
234///
235/// // Read output
236/// let output = proc.read_until(b"hello", Duration::from_secs(5))?;
237///
238/// // Check if still alive
239/// assert!(proc.is_alive());
240///
241/// // Clean termination
242/// proc.kill()?;
243/// ```
244pub struct PtyProcess {
245    child: Box<dyn portable_pty::Child + Send + Sync>,
246    master: Box<dyn MasterPty + Send>,
247    input_writer: PtyInputWriter,
248    rx: mpsc::Receiver<ReaderMsg>,
249    reader_thread: Option<thread::JoinHandle<()>>,
250    captured: Vec<u8>,
251    eof: bool,
252    state: ProcessState,
253    config: ShellConfig,
254}
255
256impl fmt::Debug for PtyProcess {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        f.debug_struct("PtyProcess")
259            .field("pid", &self.child.process_id())
260            .field("state", &self.state)
261            .field("captured_len", &self.captured.len())
262            .field("eof", &self.eof)
263            .finish()
264    }
265}
266
267impl PtyProcess {
268    /// Spawn a new shell process with the given configuration.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if:
273    /// - The PTY system cannot be initialized
274    /// - The shell executable cannot be found
275    /// - The shell fails to start
276    pub fn spawn(config: ShellConfig) -> io::Result<Self> {
277        let shell_path = config.resolve_shell();
278
279        if config.log_events {
280            log_event(
281                "PTY_PROCESS_SPAWN",
282                format!("shell={}", shell_path.display()),
283            );
284        }
285
286        // Build the command
287        let mut cmd = CommandBuilder::new(&shell_path);
288
289        // Add arguments
290        for arg in &config.args {
291            cmd.arg(arg);
292        }
293
294        // Set environment
295        cmd.env("TERM", &config.term);
296        for (key, value) in &config.env {
297            cmd.env(key, value);
298        }
299
300        // Set working directory
301        if let Some(ref cwd) = config.cwd {
302            cmd.cwd(cwd);
303        }
304
305        // Create PTY
306        let pty_system = portable_pty::native_pty_system();
307        let pair = pty_system
308            .openpty(PtySize {
309                rows: config.rows,
310                cols: config.cols,
311                pixel_width: 0,
312                pixel_height: 0,
313            })
314            .map_err(|e| io::Error::other(e.to_string()))?;
315
316        // Spawn the child
317        let child = pair
318            .slave
319            .spawn_command(cmd)
320            .map_err(|e| io::Error::other(e.to_string()))?;
321
322        // Set up I/O
323        let mut reader = pair
324            .master
325            .try_clone_reader()
326            .map_err(|e| io::Error::other(e.to_string()))?;
327        let writer = pair
328            .master
329            .take_writer()
330            .map_err(|e| io::Error::other(e.to_string()))?;
331        let input_writer = PtyInputWriter::spawn(writer, "ftui-pty-process-writer")?;
332
333        // Start reader thread
334        let (tx, rx) = mpsc::channel::<ReaderMsg>();
335        let reader_thread = thread::spawn(move || {
336            let mut buf = [0u8; 8192];
337            loop {
338                match reader.read(&mut buf) {
339                    Ok(0) => {
340                        let _ = tx.send(ReaderMsg::Eof);
341                        break;
342                    }
343                    Ok(n) => {
344                        let _ = tx.send(ReaderMsg::Data(buf[..n].to_vec()));
345                    }
346                    Err(err) => {
347                        let _ = tx.send(ReaderMsg::Err(err));
348                        break;
349                    }
350                }
351            }
352        });
353
354        if config.log_events {
355            log_event(
356                "PTY_PROCESS_STARTED",
357                format!("pid={:?}", child.process_id()),
358            );
359        }
360
361        Ok(Self {
362            child,
363            master: pair.master,
364            input_writer,
365            rx,
366            reader_thread: Some(reader_thread),
367            captured: Vec::new(),
368            eof: false,
369            state: ProcessState::Running,
370            config,
371        })
372    }
373
374    /// Check if the process is still alive.
375    ///
376    /// This method polls the process state and updates internal tracking.
377    #[must_use]
378    pub fn is_alive(&mut self) -> bool {
379        self.poll_state();
380        self.state.is_alive()
381    }
382
383    /// Get the current process state.
384    #[must_use]
385    pub fn state(&mut self) -> ProcessState {
386        self.poll_state();
387        self.state.clone()
388    }
389
390    /// Get the process ID, if available.
391    #[must_use]
392    pub fn pid(&self) -> Option<u32> {
393        self.child.process_id()
394    }
395
396    /// Kill the process.
397    ///
398    /// This method is idempotent - calling it on an already-dead process succeeds.
399    ///
400    /// # Errors
401    ///
402    /// Returns an error if the kill signal cannot be sent.
403    pub fn kill(&mut self) -> io::Result<()> {
404        if !self.state.is_alive() {
405            return Ok(());
406        }
407
408        if self.config.log_events {
409            log_event(
410                "PTY_PROCESS_KILL",
411                format!("pid={:?}", self.child.process_id()),
412            );
413        }
414
415        // Attempt to kill
416        self.child.kill()?;
417        self.state = ProcessState::Unknown;
418
419        // Wait briefly for the process to actually terminate
420        match self.wait_timeout(Duration::from_millis(100)) {
421            Ok(status) => {
422                self.update_state_from_exit(&status);
423            }
424            Err(_) => {
425                // Process may still be terminating
426                self.state = ProcessState::Unknown;
427            }
428        }
429
430        Ok(())
431    }
432
433    /// Wait for the process to exit.
434    ///
435    /// This blocks until the process terminates or the timeout is reached.
436    ///
437    /// # Errors
438    ///
439    /// Returns an error if the wait fails or times out.
440    pub fn wait(&mut self) -> io::Result<ExitStatus> {
441        let status = self.child.wait()?;
442        self.update_state_from_exit(&status);
443        Ok(status)
444    }
445
446    /// Wait for the process to exit with a timeout.
447    ///
448    /// # Errors
449    ///
450    /// Returns `TimedOut` if the timeout is reached before the process exits.
451    pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result<ExitStatus> {
452        let deadline = deadline_after(timeout, "PtyProcess wait_timeout")?;
453
454        loop {
455            // Try a non-blocking wait
456            match self.child.try_wait()? {
457                Some(status) => {
458                    self.update_state_from_exit(&status);
459                    return Ok(status);
460                }
461                None => {
462                    if Instant::now() >= deadline {
463                        return Err(io::Error::new(
464                            io::ErrorKind::TimedOut,
465                            "wait_timeout: process did not exit in time",
466                        ));
467                    }
468                    thread::sleep(Duration::from_millis(10));
469                }
470            }
471        }
472    }
473
474    /// Send raw input bytes to the process.
475    ///
476    /// For interactive shell commands, prefer [`Self::write_line`] so Enter is
477    /// encoded as carriage return instead of a bare line feed.
478    ///
479    /// # Errors
480    ///
481    /// Returns an error if the write fails.
482    pub fn write_all(&mut self, data: &[u8]) -> io::Result<()> {
483        let result = self.input_writer.write_with_timeout(
484            data,
485            self.config.input_write_timeout,
486            "ftui-pty-process-write",
487            "ftui-pty-process-detached-write",
488        );
489        if matches!(
490            result.as_ref().err().map(io::Error::kind),
491            Some(io::ErrorKind::TimedOut)
492        ) {
493            let _ = self.child.kill();
494            self.state = ProcessState::Unknown;
495        }
496        result?;
497
498        if self.config.log_events {
499            log_event("PTY_PROCESS_INPUT", format!("bytes={}", data.len()));
500        }
501
502        Ok(())
503    }
504
505    /// Send a line of interactive input, normalizing Enter to carriage return.
506    pub fn write_line(&mut self, line: impl AsRef<[u8]>) -> io::Result<()> {
507        let normalized = normalize_line_input(line.as_ref());
508        self.write_all(&normalized)
509    }
510
511    /// Read any available output without blocking.
512    pub fn read_available(&mut self) -> io::Result<Vec<u8>> {
513        self.drain_channel(Duration::ZERO)?;
514        Ok(self.captured.clone())
515    }
516
517    /// Read output until a pattern is found or timeout.
518    ///
519    /// # Errors
520    ///
521    /// Returns `TimedOut` if the pattern is not found within the timeout.
522    pub fn read_until(&mut self, pattern: &[u8], timeout: Duration) -> io::Result<Vec<u8>> {
523        if pattern.is_empty() {
524            return Ok(self.captured.clone());
525        }
526
527        let deadline = deadline_after(timeout, "PtyProcess read_until")?;
528
529        loop {
530            // Check if pattern is already in captured data
531            if find_subsequence(&self.captured, pattern).is_some() {
532                return Ok(self.captured.clone());
533            }
534
535            if self.eof || Instant::now() >= deadline {
536                break;
537            }
538
539            let remaining = deadline.saturating_duration_since(Instant::now());
540            self.drain_channel(remaining)?;
541        }
542
543        Err(io::Error::new(
544            io::ErrorKind::TimedOut,
545            format!(
546                "read_until: pattern not found (captured {} bytes)",
547                self.captured.len()
548            ),
549        ))
550    }
551
552    /// Drain all remaining output until EOF or timeout.
553    pub fn drain(&mut self, timeout: Duration) -> io::Result<usize> {
554        if self.eof {
555            return Ok(0);
556        }
557
558        let start_len = self.captured.len();
559        let deadline = deadline_after(timeout, "PtyProcess drain")?;
560
561        while !self.eof && Instant::now() < deadline {
562            let remaining = deadline.saturating_duration_since(Instant::now());
563            match self.drain_channel(remaining) {
564                Ok(0) if self.eof => break,
565                Ok(_) => continue,
566                Err(e) if e.kind() == io::ErrorKind::TimedOut => break,
567                Err(e) => return Err(e),
568            }
569        }
570
571        Ok(self.captured.len() - start_len)
572    }
573
574    /// Get all captured output.
575    #[must_use]
576    pub fn output(&self) -> &[u8] {
577        &self.captured
578    }
579
580    /// Clear the captured output buffer.
581    pub fn clear_output(&mut self) {
582        self.captured.clear();
583    }
584
585    /// Resize the PTY.
586    ///
587    /// This issues TIOCSWINSZ on the master file descriptor, which
588    /// delivers SIGWINCH to the child process so it picks up the new
589    /// dimensions.
590    pub fn resize(&mut self, cols: u16, rows: u16) -> io::Result<()> {
591        if self.config.log_events {
592            log_event("PTY_PROCESS_RESIZE", format!("cols={} rows={}", cols, rows));
593        }
594        self.master
595            .resize(PtySize {
596                rows,
597                cols,
598                pixel_width: 0,
599                pixel_height: 0,
600            })
601            .map_err(|e| io::Error::other(e.to_string()))
602    }
603
604    // ── Internal Methods ──────────────────────────────────────────────
605
606    fn poll_state(&mut self) {
607        if !self.state.is_alive() {
608            return;
609        }
610
611        match self.child.try_wait() {
612            Ok(Some(status)) => {
613                self.update_state_from_exit(&status);
614            }
615            Ok(None) => {
616                // Still running
617            }
618            Err(_) => {
619                self.state = ProcessState::Unknown;
620            }
621        }
622    }
623
624    fn update_state_from_exit(&mut self, status: &ExitStatus) {
625        if let Some(signal) = status.signal() {
626            self.state = ProcessState::Signaled(signal.to_string());
627            return;
628        }
629
630        self.state = ProcessState::Exited(status.exit_code());
631    }
632
633    fn drain_channel(&mut self, timeout: Duration) -> io::Result<usize> {
634        if self.eof {
635            return Ok(0);
636        }
637
638        let mut total = 0usize;
639
640        // First receive with timeout
641        let first = if timeout.is_zero() {
642            match self.rx.try_recv() {
643                Ok(msg) => Some(msg),
644                Err(mpsc::TryRecvError::Empty) => return Ok(0),
645                Err(mpsc::TryRecvError::Disconnected) => {
646                    self.eof = true;
647                    return Ok(0);
648                }
649            }
650        } else {
651            match self.rx.recv_timeout(timeout) {
652                Ok(msg) => Some(msg),
653                Err(mpsc::RecvTimeoutError::Timeout) => return Ok(0),
654                Err(mpsc::RecvTimeoutError::Disconnected) => {
655                    self.eof = true;
656                    return Ok(0);
657                }
658            }
659        };
660
661        let mut msg = match first {
662            Some(m) => m,
663            None => return Ok(0),
664        };
665
666        loop {
667            match msg {
668                ReaderMsg::Data(bytes) => {
669                    total = total.saturating_add(bytes.len());
670                    self.captured.extend_from_slice(&bytes);
671                }
672                ReaderMsg::Eof => {
673                    self.eof = true;
674                    break;
675                }
676                ReaderMsg::Err(err) => return Err(err),
677            }
678
679            match self.rx.try_recv() {
680                Ok(next) => msg = next,
681                Err(mpsc::TryRecvError::Empty) => break,
682                Err(mpsc::TryRecvError::Disconnected) => {
683                    self.eof = true;
684                    break;
685                }
686            }
687        }
688
689        if total > 0 && self.config.log_events {
690            log_event("PTY_PROCESS_OUTPUT", format!("bytes={}", total));
691        }
692
693        Ok(total)
694    }
695}
696
697impl Drop for PtyProcess {
698    fn drop(&mut self) {
699        // Best-effort cleanup
700        let _ = self.child.kill();
701        self.input_writer.flush_best_effort();
702        self.input_writer
703            .detach_thread("ftui-pty-process-detached-writer");
704
705        if let Some(handle) = self.reader_thread.take() {
706            detach_reader_join(handle);
707        }
708
709        if self.config.log_events {
710            log_event(
711                "PTY_PROCESS_DROP",
712                format!("pid={:?}", self.child.process_id()),
713            );
714        }
715    }
716}
717
718fn detach_reader_join(handle: thread::JoinHandle<()>) {
719    detach_join(handle, "ftui-pty-process-detached-reader-join");
720}
721
722// ── Helper Functions ──────────────────────────────────────────────────
723
724fn preferred_default_shell() -> Option<PathBuf> {
725    ["/bin/bash", "/usr/bin/bash"]
726        .into_iter()
727        .map(PathBuf::from)
728        .find(|candidate| candidate.is_file())
729}
730
731fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
732    if needle.is_empty() {
733        return Some(0);
734    }
735    haystack
736        .windows(needle.len())
737        .position(|window| window == needle)
738}
739
740fn log_event(event: &str, detail: impl fmt::Display) {
741    let timestamp = time::OffsetDateTime::now_utc()
742        .format(&time::format_description::well_known::Rfc3339)
743        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
744    eprintln!("[{}] {}: {}", timestamp, event, detail);
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    // ── ShellConfig Tests ─────────────────────────────────────────────
752
753    #[test]
754    fn shell_config_defaults() {
755        let config = ShellConfig::default();
756        assert!(config.shell.is_none());
757        assert!(config.args.is_empty());
758        assert!(config.env.is_empty());
759        assert!(config.cwd.is_none());
760        assert_eq!(config.cols, 80);
761        assert_eq!(config.rows, 24);
762        assert_eq!(config.term, "xterm-256color");
763        assert!(!config.log_events);
764    }
765
766    #[test]
767    fn shell_config_with_shell() {
768        let config = ShellConfig::with_shell("/bin/bash");
769        assert_eq!(config.shell, Some(PathBuf::from("/bin/bash")));
770    }
771
772    #[test]
773    fn shell_config_builder_chain() {
774        let config = ShellConfig::default()
775            .size(120, 40)
776            .arg("-l")
777            .env("FOO", "bar")
778            .cwd("/tmp")
779            .term("dumb")
780            .logging(true);
781
782        assert_eq!(config.cols, 120);
783        assert_eq!(config.rows, 40);
784        assert_eq!(config.args, vec!["-l"]);
785        assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
786        assert_eq!(config.cwd, Some(PathBuf::from("/tmp")));
787        assert_eq!(config.term, "dumb");
788        assert!(config.log_events);
789    }
790
791    #[test]
792    fn shell_config_resolve_shell_explicit() {
793        let config = ShellConfig::with_shell("/bin/zsh");
794        assert_eq!(config.resolve_shell(), PathBuf::from("/bin/zsh"));
795    }
796
797    #[test]
798    fn shell_config_resolve_shell_prefers_bash_when_available() {
799        let config = ShellConfig::default();
800        let shell = config.resolve_shell();
801
802        if let Some(preferred) = preferred_default_shell() {
803            assert_eq!(shell, preferred);
804        } else if let Ok(env_shell) = std::env::var("SHELL") {
805            assert_eq!(shell, PathBuf::from(env_shell));
806        } else {
807            assert_eq!(shell, PathBuf::from("/bin/sh"));
808        }
809    }
810
811    // ── ProcessState Tests ────────────────────────────────────────────
812
813    #[test]
814    fn process_state_is_alive() {
815        assert!(ProcessState::Running.is_alive());
816        assert!(!ProcessState::Exited(0).is_alive());
817        assert!(!ProcessState::Signaled("SIGTERM".to_string()).is_alive());
818        assert!(!ProcessState::Unknown.is_alive());
819    }
820
821    #[test]
822    fn process_state_exit_code() {
823        assert_eq!(ProcessState::Running.exit_code(), None);
824        assert_eq!(ProcessState::Exited(0).exit_code(), Some(0));
825        assert_eq!(ProcessState::Exited(7).exit_code(), Some(7));
826        assert_eq!(
827            ProcessState::Signaled("SIGTERM".to_string()).exit_code(),
828            None
829        );
830        assert_eq!(ProcessState::Unknown.exit_code(), None);
831    }
832
833    #[test]
834    fn process_state_signal_name() {
835        assert_eq!(ProcessState::Running.signal_name(), None);
836        assert_eq!(
837            ProcessState::Signaled("Terminated".to_string()).signal_name(),
838            Some("Terminated")
839        );
840        assert_eq!(ProcessState::Exited(7).signal_name(), None);
841    }
842
843    // ── find_subsequence Tests ────────────────────────────────────────
844
845    #[test]
846    fn find_subsequence_empty_needle() {
847        assert_eq!(find_subsequence(b"anything", b""), Some(0));
848    }
849
850    #[test]
851    fn find_subsequence_found() {
852        assert_eq!(find_subsequence(b"hello world", b"world"), Some(6));
853    }
854
855    #[test]
856    fn find_subsequence_not_found() {
857        assert_eq!(find_subsequence(b"hello world", b"xyz"), None);
858    }
859
860    // ── PtyProcess Integration Tests ──────────────────────────────────
861
862    #[cfg(unix)]
863    #[test]
864    fn spawn_and_basic_io() {
865        let config = ShellConfig::default()
866            .logging(false)
867            .env("FTUI_BASIC", "hello-pty-process");
868        let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
869
870        // Should be alive
871        assert!(proc.is_alive());
872        assert!(proc.pid().is_some());
873
874        // Send a simple command
875        proc.write_line("echo $FTUI_BASIC")
876            .expect("write should succeed");
877
878        let output = proc
879            .read_until(b"hello-pty-process", Duration::from_secs(5))
880            .expect("should find output");
881        assert!(
882            output
883                .windows(b"hello-pty-process".len())
884                .any(|w| w == b"hello-pty-process"),
885            "expected to find 'hello-pty-process' in output"
886        );
887
888        // Kill the process
889        proc.kill().expect("kill should succeed");
890        assert!(!proc.is_alive());
891    }
892
893    #[cfg(unix)]
894    #[test]
895    fn spawn_with_env() {
896        let config = ShellConfig::default()
897            .logging(false)
898            .env("TEST_VAR", "test_value_123");
899
900        let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
901
902        proc.write_line("echo $TEST_VAR")
903            .expect("write should succeed");
904
905        let output = proc
906            .read_until(b"test_value_123", Duration::from_secs(5))
907            .expect("should find env var in output");
908
909        assert!(
910            output
911                .windows(b"test_value_123".len())
912                .any(|w| w == b"test_value_123"),
913            "expected to find env var value in output"
914        );
915
916        proc.kill().expect("kill should succeed");
917    }
918
919    #[cfg(unix)]
920    #[test]
921    fn exit_command_terminates() {
922        let config = ShellConfig::default().logging(false);
923        let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
924
925        proc.write_line("exit 0").expect("write should succeed");
926
927        // Wait for exit
928        let status = proc
929            .wait_timeout(Duration::from_secs(5))
930            .expect("wait should succeed");
931        assert!(status.success());
932        assert!(!proc.is_alive());
933    }
934
935    #[cfg(unix)]
936    #[test]
937    fn non_zero_exit_preserves_exit_code() {
938        let config = ShellConfig::with_shell("/bin/sh")
939            .logging(false)
940            .arg("-c")
941            .arg("exit 7");
942        let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
943
944        let status = proc
945            .wait_timeout(Duration::from_secs(5))
946            .expect("wait should succeed");
947        assert!(!status.success());
948        assert_eq!(status.exit_code(), 7);
949        assert_eq!(proc.state().exit_code(), Some(7));
950        assert_eq!(proc.state(), ProcessState::Exited(7));
951    }
952
953    #[cfg(unix)]
954    #[test]
955    fn signal_exit_preserves_signaled_state() {
956        let config = ShellConfig::with_shell("/bin/sh")
957            .logging(false)
958            .arg("-c")
959            .arg("kill -KILL $$");
960        let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
961
962        let status = proc
963            .wait_timeout(Duration::from_secs(5))
964            .expect("wait should succeed");
965        assert!(!status.success());
966        assert!(status.signal().is_some(), "expected signal exit status");
967
968        let state = proc.state();
969        assert!(matches!(state, ProcessState::Signaled(_)));
970        assert!(state.signal_name().is_some());
971    }
972
973    #[cfg(unix)]
974    #[test]
975    fn kill_is_idempotent() {
976        let config = ShellConfig::default().logging(false);
977        let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
978
979        proc.kill().expect("first kill should succeed");
980        proc.kill().expect("second kill should succeed");
981        proc.kill().expect("third kill should succeed");
982
983        assert!(!proc.is_alive());
984    }
985
986    #[cfg(unix)]
987    #[test]
988    fn drain_captures_all_output() {
989        let config = ShellConfig::default().logging(false);
990        let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
991
992        // Generate output and exit
993        proc.write_line("for i in 1 2 3 4 5; do echo line$i; done; exit 0")
994            .expect("write should succeed");
995
996        // Wait for exit
997        let _ = proc.wait_timeout(Duration::from_secs(5));
998
999        // Drain remaining
1000        let _ = proc.drain(Duration::from_secs(2));
1001
1002        let output = String::from_utf8_lossy(proc.output());
1003        for i in 1..=5 {
1004            assert!(
1005                output.contains(&format!("line{i}")),
1006                "missing line{i} in output: {output:?}"
1007            );
1008        }
1009    }
1010
1011    #[cfg(unix)]
1012    #[test]
1013    fn clear_output_works() {
1014        let config = ShellConfig::default().logging(false);
1015        let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
1016
1017        proc.write_line("echo test").expect("write should succeed");
1018        let _ = proc
1019            .read_until(b"test", Duration::from_secs(5))
1020            .expect("should capture output after sending a line");
1021
1022        assert!(!proc.output().is_empty());
1023
1024        proc.clear_output();
1025        assert!(proc.output().is_empty());
1026
1027        proc.kill().expect("kill should succeed");
1028    }
1029
1030    #[cfg(unix)]
1031    #[test]
1032    fn specific_shell_path() {
1033        let config = ShellConfig::with_shell("/bin/sh").logging(false);
1034        let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
1035
1036        assert!(proc.is_alive());
1037        proc.kill().expect("kill should succeed");
1038    }
1039
1040    #[cfg(unix)]
1041    #[test]
1042    fn invalid_shell_fails() {
1043        let config = ShellConfig::with_shell("/nonexistent/shell").logging(false);
1044        let result = PtyProcess::spawn(config);
1045
1046        assert!(result.is_err());
1047    }
1048
1049    #[cfg(unix)]
1050    #[test]
1051    fn drop_does_not_block_when_background_process_keeps_pty_open() {
1052        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1053        let (done_tx, done_rx) = mpsc::channel();
1054        let drop_thread = thread::spawn(move || {
1055            let proc = PtyProcess::spawn(
1056                ShellConfig::with_shell(shell)
1057                    .logging(false)
1058                    .arg("-c")
1059                    .arg("sleep 1 >/dev/null 2>&1 &"),
1060            )
1061            .expect("spawn should succeed");
1062            drop(proc);
1063            done_tx.send(()).expect("signal drop completion");
1064        });
1065
1066        assert!(
1067            done_rx.recv_timeout(Duration::from_millis(400)).is_ok(),
1068            "PtyProcess drop should not wait for background descendants to close the PTY"
1069        );
1070        drop_thread.join().expect("drop thread join");
1071    }
1072
1073    #[cfg(unix)]
1074    #[test]
1075    fn write_all_times_out_when_child_does_not_drain_stdin() {
1076        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1077        let config = ShellConfig::with_shell(shell)
1078            .logging(false)
1079            .input_write_timeout(Duration::from_millis(100))
1080            .arg("-c")
1081            .arg("sleep 5");
1082        let mut proc = PtyProcess::spawn(config).expect("spawn should succeed");
1083
1084        let payload = vec![b'x'; 8 * 1024 * 1024];
1085        let start = Instant::now();
1086        let err = proc
1087            .write_all(&payload)
1088            .expect_err("write_all should time out when the child never reads stdin");
1089        assert_eq!(err.kind(), io::ErrorKind::TimedOut);
1090        assert!(
1091            start.elapsed() < Duration::from_secs(2),
1092            "write_all should fail promptly instead of hanging"
1093        );
1094    }
1095}