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