Skip to main content

rust_expect/
config.rs

1//! Configuration types for rust-expect.
2//!
3//! This module defines configuration structures for sessions, timeouts,
4//! logging, and other customizable behavior.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::time::Duration;
9
10/// Default timeout duration (30 seconds).
11pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
12
13/// Default buffer size (100 MB).
14pub const DEFAULT_BUFFER_SIZE: usize = 100 * 1024 * 1024;
15
16/// Default terminal width.
17pub const DEFAULT_TERMINAL_WIDTH: u16 = 80;
18
19/// Default terminal height.
20pub const DEFAULT_TERMINAL_HEIGHT: u16 = 24;
21
22/// Default TERM environment variable value.
23pub const DEFAULT_TERM: &str = "xterm-256color";
24
25/// Default delay before send operations.
26pub const DEFAULT_DELAY_BEFORE_SEND: Duration = Duration::from_millis(50);
27
28/// Configuration for a session.
29#[derive(Debug, Clone)]
30pub struct SessionConfig {
31    /// The command to execute.
32    pub command: String,
33
34    /// Command arguments.
35    pub args: Vec<String>,
36
37    /// Environment variables to set.
38    pub env: HashMap<String, String>,
39
40    /// Whether to inherit the parent environment.
41    pub inherit_env: bool,
42
43    /// Working directory for the process.
44    pub working_dir: Option<PathBuf>,
45
46    /// Terminal dimensions (width, height).
47    pub dimensions: (u16, u16),
48
49    /// Timeout configuration.
50    pub timeout: TimeoutConfig,
51
52    /// Buffer configuration.
53    pub buffer: BufferConfig,
54
55    /// Logging configuration.
56    pub logging: LoggingConfig,
57
58    /// Line ending configuration.
59    pub line_ending: LineEnding,
60
61    /// Encoding configuration.
62    pub encoding: EncodingConfig,
63
64    /// Delay before send operations.
65    pub delay_before_send: Duration,
66}
67
68impl Default for SessionConfig {
69    fn default() -> Self {
70        let mut env = HashMap::new();
71        env.insert("TERM".to_string(), DEFAULT_TERM.to_string());
72
73        Self {
74            command: String::new(),
75            args: Vec::new(),
76            env,
77            inherit_env: true,
78            working_dir: None,
79            dimensions: (DEFAULT_TERMINAL_WIDTH, DEFAULT_TERMINAL_HEIGHT),
80            timeout: TimeoutConfig::default(),
81            buffer: BufferConfig::default(),
82            logging: LoggingConfig::default(),
83            line_ending: LineEnding::default(),
84            encoding: EncodingConfig::default(),
85            delay_before_send: DEFAULT_DELAY_BEFORE_SEND,
86        }
87    }
88}
89
90impl SessionConfig {
91    /// Create a new session configuration with the given command.
92    #[must_use]
93    pub fn new(command: impl Into<String>) -> Self {
94        Self {
95            command: command.into(),
96            ..Default::default()
97        }
98    }
99
100    /// Set the command arguments.
101    #[must_use]
102    pub fn args<I, S>(mut self, args: I) -> Self
103    where
104        I: IntoIterator<Item = S>,
105        S: Into<String>,
106    {
107        self.args = args.into_iter().map(Into::into).collect();
108        self
109    }
110
111    /// Add an environment variable.
112    #[must_use]
113    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
114        self.env.insert(key.into(), value.into());
115        self
116    }
117
118    /// Set whether to inherit the parent environment.
119    #[must_use]
120    pub const fn inherit_env(mut self, inherit: bool) -> Self {
121        self.inherit_env = inherit;
122        self
123    }
124
125    /// Set the working directory.
126    #[must_use]
127    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
128        self.working_dir = Some(path.into());
129        self
130    }
131
132    /// Set the terminal dimensions.
133    #[must_use]
134    pub const fn dimensions(mut self, width: u16, height: u16) -> Self {
135        self.dimensions = (width, height);
136        self
137    }
138
139    /// Set the default timeout.
140    #[must_use]
141    pub const fn timeout(mut self, timeout: Duration) -> Self {
142        self.timeout.default = timeout;
143        self
144    }
145
146    /// Set the line ending style.
147    #[must_use]
148    pub const fn line_ending(mut self, line_ending: LineEnding) -> Self {
149        self.line_ending = line_ending;
150        self
151    }
152
153    /// Set the delay before send operations.
154    #[must_use]
155    pub const fn delay_before_send(mut self, delay: Duration) -> Self {
156        self.delay_before_send = delay;
157        self
158    }
159}
160
161/// Configuration for timeouts.
162#[derive(Debug, Clone)]
163pub struct TimeoutConfig {
164    /// Default timeout for expect operations.
165    pub default: Duration,
166
167    /// Timeout for spawn operations.
168    pub spawn: Duration,
169
170    /// Timeout for close operations.
171    pub close: Duration,
172}
173
174impl Default for TimeoutConfig {
175    fn default() -> Self {
176        Self {
177            default: DEFAULT_TIMEOUT,
178            spawn: Duration::from_secs(60),
179            close: Duration::from_secs(10),
180        }
181    }
182}
183
184impl TimeoutConfig {
185    /// Create a new timeout configuration with the given default timeout.
186    #[must_use]
187    pub fn new(default: Duration) -> Self {
188        Self {
189            default,
190            ..Default::default()
191        }
192    }
193
194    /// Set the spawn timeout.
195    #[must_use]
196    pub const fn spawn(mut self, timeout: Duration) -> Self {
197        self.spawn = timeout;
198        self
199    }
200
201    /// Set the close timeout.
202    #[must_use]
203    pub const fn close(mut self, timeout: Duration) -> Self {
204        self.close = timeout;
205        self
206    }
207}
208
209/// Configuration for the output buffer.
210#[derive(Debug, Clone)]
211pub struct BufferConfig {
212    /// Maximum buffer size in bytes.
213    pub max_size: usize,
214
215    /// Size of the search window for pattern matching.
216    pub search_window: Option<usize>,
217
218    /// Whether to use a ring buffer (discard oldest data when full).
219    pub ring_buffer: bool,
220}
221
222impl Default for BufferConfig {
223    fn default() -> Self {
224        Self {
225            max_size: DEFAULT_BUFFER_SIZE,
226            search_window: None,
227            ring_buffer: true,
228        }
229    }
230}
231
232impl BufferConfig {
233    /// Create a new buffer configuration with the given max size.
234    #[must_use]
235    pub fn new(max_size: usize) -> Self {
236        Self {
237            max_size,
238            ..Default::default()
239        }
240    }
241
242    /// Set the search window size.
243    #[must_use]
244    pub const fn search_window(mut self, size: usize) -> Self {
245        self.search_window = Some(size);
246        self
247    }
248
249    /// Set whether to use a ring buffer.
250    #[must_use]
251    pub const fn ring_buffer(mut self, enabled: bool) -> Self {
252        self.ring_buffer = enabled;
253        self
254    }
255}
256
257/// Configuration for logging.
258#[derive(Debug, Clone, Default)]
259pub struct LoggingConfig {
260    /// Path to log file.
261    pub log_file: Option<PathBuf>,
262
263    /// Whether to echo output to stdout.
264    pub log_user: bool,
265
266    /// Log format.
267    pub format: LogFormat,
268
269    /// Whether to log sent data separately from received data.
270    pub separate_io: bool,
271
272    /// Patterns to redact from logs.
273    pub redact_patterns: Vec<String>,
274}
275
276impl LoggingConfig {
277    /// Create a new logging configuration.
278    #[must_use]
279    pub fn new() -> Self {
280        Self::default()
281    }
282
283    /// Set the log file path.
284    #[must_use]
285    pub fn log_file(mut self, path: impl Into<PathBuf>) -> Self {
286        self.log_file = Some(path.into());
287        self
288    }
289
290    /// Set whether to echo to stdout.
291    #[must_use]
292    pub const fn log_user(mut self, enabled: bool) -> Self {
293        self.log_user = enabled;
294        self
295    }
296
297    /// Set the log format.
298    #[must_use]
299    pub const fn format(mut self, format: LogFormat) -> Self {
300        self.format = format;
301        self
302    }
303
304    /// Add a pattern to redact from logs.
305    #[must_use]
306    pub fn redact(mut self, pattern: impl Into<String>) -> Self {
307        self.redact_patterns.push(pattern.into());
308        self
309    }
310}
311
312/// Log format options.
313#[derive(Debug, Clone, Default, PartialEq, Eq)]
314pub enum LogFormat {
315    /// Raw output (no formatting).
316    #[default]
317    Raw,
318
319    /// Timestamped output.
320    Timestamped,
321
322    /// Newline-delimited JSON.
323    Ndjson,
324
325    /// Asciicast v2 format (asciinema compatible).
326    Asciicast,
327}
328
329/// Line ending styles.
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub enum LineEnding {
332    /// Unix-style line ending (LF).
333    Lf,
334
335    /// Windows-style line ending (CRLF).
336    CrLf,
337
338    /// Classic Mac line ending (CR).
339    Cr,
340}
341
342/// The default is whatever the platform's terminal actually sends for ENTER, which
343/// is not the same as the platform's text-file convention.
344///
345/// On Windows this is [`LineEnding::Cr`], not `CrLf`. `ConPTY` **discards a bare LF
346/// entirely** — measured on Windows 11 26200.8893, a lone `\n` completes no line
347/// read, queues nothing, and is not even echoed — so an LF default makes
348/// [`send_line`](crate::Session::send_line) unable to submit a line at all. `\r` is
349/// the byte a terminal sends for the Enter key.
350///
351/// `CrLf` also works today, but only because conhost happens to swallow the
352/// trailing LF; that is undocumented, and against a child with `ENABLE_LINE_INPUT`
353/// disabled an LF that *did* arrive would submit a second Enter. `Cr` cannot
354/// double-submit on any build.
355impl Default for LineEnding {
356    fn default() -> Self {
357        #[cfg(windows)]
358        {
359            Self::Cr
360        }
361        #[cfg(not(windows))]
362        {
363            Self::Lf
364        }
365    }
366}
367
368impl LineEnding {
369    /// Get the line ending as a string.
370    #[must_use]
371    pub const fn as_str(self) -> &'static str {
372        match self {
373            Self::Lf => "\n",
374            Self::CrLf => "\r\n",
375            Self::Cr => "\r",
376        }
377    }
378
379    /// Get the line ending as bytes.
380    #[must_use]
381    pub const fn as_bytes(self) -> &'static [u8] {
382        match self {
383            Self::Lf => b"\n",
384            Self::CrLf => b"\r\n",
385            Self::Cr => b"\r",
386        }
387    }
388
389    /// Detect the appropriate line ending for the current platform.
390    #[must_use]
391    pub const fn platform_default() -> Self {
392        if cfg!(windows) { Self::CrLf } else { Self::Lf }
393    }
394}
395
396/// Configuration for text encoding.
397#[derive(Debug, Clone)]
398pub struct EncodingConfig {
399    /// The encoding to use (default: UTF-8).
400    pub encoding: Encoding,
401
402    /// How to handle invalid sequences.
403    pub error_handling: EncodingErrorHandling,
404
405    /// Whether to normalize line endings.
406    pub normalize_line_endings: bool,
407}
408
409impl Default for EncodingConfig {
410    fn default() -> Self {
411        Self {
412            encoding: Encoding::Utf8,
413            error_handling: EncodingErrorHandling::Replace,
414            normalize_line_endings: false,
415        }
416    }
417}
418
419impl EncodingConfig {
420    /// Create a new encoding configuration.
421    #[must_use]
422    pub fn new(encoding: Encoding) -> Self {
423        Self {
424            encoding,
425            ..Default::default()
426        }
427    }
428
429    /// Set the error handling mode.
430    #[must_use]
431    pub const fn error_handling(mut self, mode: EncodingErrorHandling) -> Self {
432        self.error_handling = mode;
433        self
434    }
435
436    /// Set whether to normalize line endings.
437    #[must_use]
438    pub const fn normalize_line_endings(mut self, normalize: bool) -> Self {
439        self.normalize_line_endings = normalize;
440        self
441    }
442}
443
444/// Supported text encodings.
445#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
446pub enum Encoding {
447    /// UTF-8 encoding.
448    #[default]
449    Utf8,
450
451    /// Raw bytes (no encoding).
452    Raw,
453
454    /// ISO-8859-1 (Latin-1).
455    #[cfg(feature = "legacy-encoding")]
456    Latin1,
457
458    /// Windows-1252.
459    #[cfg(feature = "legacy-encoding")]
460    Windows1252,
461}
462
463/// How to handle encoding errors.
464#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
465pub enum EncodingErrorHandling {
466    /// Replace invalid sequences with the replacement character.
467    #[default]
468    Replace,
469
470    /// Skip invalid sequences.
471    Skip,
472
473    /// Return an error on invalid sequences.
474    Strict,
475
476    /// Escape invalid bytes as hex.
477    Escape,
478}
479
480/// Configuration for interact mode.
481#[derive(Debug, Clone)]
482pub struct InteractConfig {
483    /// Escape character to exit interact mode.
484    pub escape_char: Option<char>,
485
486    /// Timeout for idle detection.
487    pub idle_timeout: Option<Duration>,
488
489    /// Whether to echo input.
490    pub echo: bool,
491
492    /// Output hooks.
493    pub output_hooks: Vec<InteractHook>,
494
495    /// Input hooks.
496    pub input_hooks: Vec<InteractHook>,
497}
498
499impl Default for InteractConfig {
500    fn default() -> Self {
501        Self {
502            escape_char: Some('\x1d'), // Ctrl+]
503            idle_timeout: None,
504            echo: true,
505            output_hooks: Vec::new(),
506            input_hooks: Vec::new(),
507        }
508    }
509}
510
511impl InteractConfig {
512    /// Create a new interact configuration.
513    #[must_use]
514    pub fn new() -> Self {
515        Self::default()
516    }
517
518    /// Set the escape character.
519    #[must_use]
520    pub const fn escape_char(mut self, c: char) -> Self {
521        self.escape_char = Some(c);
522        self
523    }
524
525    /// Disable the escape character.
526    #[must_use]
527    pub const fn no_escape(mut self) -> Self {
528        self.escape_char = None;
529        self
530    }
531
532    /// Set the idle timeout.
533    #[must_use]
534    pub const fn idle_timeout(mut self, timeout: Duration) -> Self {
535        self.idle_timeout = Some(timeout);
536        self
537    }
538
539    /// Set whether to echo input.
540    #[must_use]
541    pub const fn echo(mut self, enabled: bool) -> Self {
542        self.echo = enabled;
543        self
544    }
545}
546
547/// A hook for interact mode.
548#[derive(Debug, Clone)]
549pub struct InteractHook {
550    /// The pattern to match.
551    pub pattern: String,
552
553    /// Whether this is a regex pattern.
554    pub is_regex: bool,
555}
556
557impl InteractHook {
558    /// Create a new interact hook with a literal pattern.
559    #[must_use]
560    pub fn literal(pattern: impl Into<String>) -> Self {
561        Self {
562            pattern: pattern.into(),
563            is_regex: false,
564        }
565    }
566
567    /// Create a new interact hook with a regex pattern.
568    #[must_use]
569    pub fn regex(pattern: impl Into<String>) -> Self {
570        Self {
571            pattern: pattern.into(),
572            is_regex: true,
573        }
574    }
575}
576
577/// Configuration for human-like typing.
578#[derive(Debug, Clone)]
579pub struct HumanTypingConfig {
580    /// Base delay between characters.
581    pub base_delay: Duration,
582
583    /// Variance in delay (random offset from base).
584    pub variance: Duration,
585
586    /// Chance of making a typo (0.0 to 1.0).
587    pub typo_chance: f32,
588
589    /// Chance of correcting a typo (0.0 to 1.0).
590    pub correction_chance: f32,
591}
592
593impl Default for HumanTypingConfig {
594    fn default() -> Self {
595        Self {
596            base_delay: Duration::from_millis(100),
597            variance: Duration::from_millis(50),
598            typo_chance: 0.01,
599            correction_chance: 0.85,
600        }
601    }
602}
603
604impl HumanTypingConfig {
605    /// Create a new human typing configuration.
606    #[must_use]
607    pub fn new(base_delay: Duration, variance: Duration) -> Self {
608        Self {
609            base_delay,
610            variance,
611            ..Default::default()
612        }
613    }
614
615    /// Set the typo chance.
616    #[must_use]
617    pub const fn typo_chance(mut self, chance: f32) -> Self {
618        self.typo_chance = chance;
619        self
620    }
621
622    /// Set the correction chance.
623    #[must_use]
624    pub const fn correction_chance(mut self, chance: f32) -> Self {
625        self.correction_chance = chance;
626        self
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn session_config_builder() {
636        let config = SessionConfig::new("bash")
637            .args(["-l", "-i"])
638            .env("MY_VAR", "value")
639            .dimensions(120, 40)
640            .timeout(Duration::from_secs(10));
641
642        assert_eq!(config.command, "bash");
643        assert_eq!(config.args, vec!["-l", "-i"]);
644        assert_eq!(config.env.get("MY_VAR"), Some(&"value".to_string()));
645        assert_eq!(config.dimensions, (120, 40));
646        assert_eq!(config.timeout.default, Duration::from_secs(10));
647    }
648
649    #[test]
650    fn line_ending_as_str() {
651        assert_eq!(LineEnding::Lf.as_str(), "\n");
652        assert_eq!(LineEnding::CrLf.as_str(), "\r\n");
653        assert_eq!(LineEnding::Cr.as_str(), "\r");
654    }
655
656    #[test]
657    fn default_config_has_term() {
658        let config = SessionConfig::default();
659        assert_eq!(config.env.get("TERM"), Some(&"xterm-256color".to_string()));
660    }
661
662    #[test]
663    fn logging_config_builder() {
664        let config = LoggingConfig::new()
665            .log_file("/tmp/session.log")
666            .log_user(true)
667            .format(LogFormat::Ndjson)
668            .redact("password");
669
670        assert_eq!(config.log_file, Some(PathBuf::from("/tmp/session.log")));
671        assert!(config.log_user);
672        assert_eq!(config.format, LogFormat::Ndjson);
673        assert_eq!(config.redact_patterns, vec!["password"]);
674    }
675}