ailloli_ui_terminal_pty/
lib.rs

1//! PTY runtime contracts and backends for Ailloli UI terminals.
2//!
3//! This crate is intentionally separate from `ailloli_ui_terminal_core`: it owns
4//! system I/O only. It does not parse ANSI, render widgets, or integrate with
5//! application-specific UI. The default feature set provides models, batching,
6//! a handle abstraction, and an in-memory mock without opening OS resources.
7//! Feature `portable` adds the native `PortablePtyBackend` with detached worker
8//! threads and unbounded queues documented on that feature-gated type.
9//!
10//! # Examples
11//!
12//! ```
13//! use ailloli_ui_terminal_pty::{MockPtyBackend, PtyBackend, PtyEvent, PtySpawnConfig};
14//! let backend = MockPtyBackend::default();
15//! backend.push_event(PtyEvent::Output(b"ready".to_vec()));
16//! let handle = backend.spawn(PtySpawnConfig::default()).unwrap();
17//! assert_eq!(handle.drain_events(), vec![PtyEvent::Output(b"ready".to_vec())]);
18//! ```
19
20mod batch;
21mod error;
22mod handle;
23mod mock;
24mod model;
25
26#[cfg(feature = "portable")]
27mod portable;
28
29pub use batch::{PtyBatchConfig, PtyOutputBatcher};
30pub use error::PtyError;
31pub use handle::{PtyBackend, PtyHandle};
32pub use mock::MockPtyBackend;
33pub use model::{PtyEvent, PtyExitStatus, PtySize, PtySpawnConfig};
34
35#[cfg(feature = "portable")]
36pub use portable::PortablePtyBackend;
37
38#[cfg(test)]
39/// Cross-module unit tests for mock, batching, dimensions, and native smoke behavior.
40mod tests {
41    use std::thread;
42    use std::time::Duration;
43
44    use super::*;
45
46    #[test]
47    fn mock_spawn_records_config_and_drains_events_in_order() {
48        let backend = MockPtyBackend::default();
49        backend.push_event(PtyEvent::Output(b"hello".to_vec()));
50        backend.push_event(PtyEvent::Exit(PtyExitStatus::success(0)));
51
52        let config = PtySpawnConfig::default();
53        let handle = backend.spawn(config.clone()).expect("spawn");
54
55        assert_eq!(backend.spawned_configs(), vec![config]);
56        assert_eq!(
57            handle.drain_events(),
58            vec![
59                PtyEvent::Output(b"hello".to_vec()),
60                PtyEvent::Exit(PtyExitStatus::success(0)),
61            ]
62        );
63        assert!(handle.drain_events().is_empty());
64    }
65
66    #[test]
67    fn mock_spawn_error_is_deterministic() {
68        let backend = MockPtyBackend::default().with_spawn_error(PtyError::Spawn("boom".into()));
69
70        assert_eq!(
71            backend.spawn(PtySpawnConfig::default()).expect_err("error"),
72            PtyError::Spawn("boom".into())
73        );
74    }
75
76    #[test]
77    fn mock_write_and_resize_are_recorded() {
78        let backend = MockPtyBackend::default();
79        let handle = backend.spawn(PtySpawnConfig::default()).expect("spawn");
80        let size = PtySize::new(40, 100, 800, 600);
81
82        handle.write(b"ls\r").expect("write");
83        handle.resize(size).expect("resize");
84
85        assert_eq!(backend.writes(), vec![b"ls\r".to_vec()]);
86        assert_eq!(backend.resizes(), vec![size]);
87    }
88
89    #[test]
90    fn mock_shutdown_is_idempotent_and_write_after_shutdown_is_closed() {
91        let backend = MockPtyBackend::default();
92        let handle = backend.spawn(PtySpawnConfig::default()).expect("spawn");
93
94        assert!(!handle.is_shutdown());
95        handle.shutdown().expect("shutdown");
96        handle.shutdown().expect("shutdown twice");
97        assert!(handle.is_shutdown());
98        assert_eq!(handle.write(b"nope").expect_err("closed"), PtyError::Closed);
99    }
100
101    #[test]
102    fn batcher_flushes_by_size_newline_timeout_and_preserves_order() {
103        let mut batcher = PtyOutputBatcher::with_config(PtyBatchConfig {
104            max_bytes: 4,
105            flush_timeout: Duration::from_millis(5),
106        });
107
108        assert!(batcher.push(b"ab").is_empty());
109        assert_eq!(
110            batcher.push(b"cd"),
111            vec![PtyEvent::Output(b"abcd".to_vec())]
112        );
113        assert_eq!(
114            batcher.push(b"e\n"),
115            vec![PtyEvent::Output(b"e\n".to_vec())]
116        );
117
118        assert!(batcher.push(b"fg").is_empty());
119        thread::sleep(Duration::from_millis(20));
120        assert_eq!(batcher.tick(), Some(PtyEvent::Output(b"fg".to_vec())));
121    }
122
123    #[test]
124    fn pty_size_clamps_rows_and_cols() {
125        let size = PtySize::new(0, 0, 20, 30);
126        assert_eq!(size.rows, 1);
127        assert_eq!(size.cols, 1);
128        assert_eq!(size.pixel_width, 20);
129        assert_eq!(size.pixel_height, 30);
130    }
131
132    #[cfg(feature = "portable")]
133    #[test]
134    #[ignore]
135    fn portable_backend_can_spawn_echo_resize_and_shutdown() {
136        let backend = PortablePtyBackend::new();
137        let mut config = PtySpawnConfig {
138            size: PtySize::new(12, 80, 0, 0),
139            ..PtySpawnConfig::default()
140        };
141        #[cfg(windows)]
142        {
143            config.program = Some("cmd".into());
144            config.args = vec![
145                "/C".into(),
146                "echo ailloli_ui-pty && ping -n 5 127.0.0.1 >NUL".into(),
147            ];
148        }
149        #[cfg(not(windows))]
150        {
151            config.program = Some("sh".into());
152            config.args = vec!["-lc".into(), "printf ailloli_ui-pty; sleep 5".into()];
153        }
154
155        let handle = backend.spawn(config).expect("spawn");
156        let deadline = std::time::Instant::now() + Duration::from_secs(2);
157        let mut output = Vec::new();
158        while std::time::Instant::now() < deadline {
159            for event in handle.drain_events() {
160                if let PtyEvent::Output(bytes) = event {
161                    output.extend(bytes);
162                }
163            }
164            if output
165                .windows(b"ailloli_ui-pty".len())
166                .any(|w| w == b"ailloli_ui-pty")
167            {
168                break;
169            }
170            thread::sleep(Duration::from_millis(20));
171        }
172        handle.resize(PtySize::new(24, 100, 0, 0)).expect("resize");
173        handle.shutdown().expect("shutdown");
174        assert!(
175            output
176                .windows(b"ailloli_ui-pty".len())
177                .any(|w| w == b"ailloli_ui-pty"),
178            "output was {:?}",
179            String::from_utf8_lossy(&output)
180        );
181    }
182}