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, Default, PartialEq, Eq)]
331pub enum LineEnding {
332    /// Unix-style line ending (LF).
333    #[default]
334    Lf,
335
336    /// Windows-style line ending (CRLF).
337    CrLf,
338
339    /// Classic Mac line ending (CR).
340    Cr,
341}
342
343impl LineEnding {
344    /// Get the line ending as a string.
345    #[must_use]
346    pub const fn as_str(self) -> &'static str {
347        match self {
348            Self::Lf => "\n",
349            Self::CrLf => "\r\n",
350            Self::Cr => "\r",
351        }
352    }
353
354    /// Get the line ending as bytes.
355    #[must_use]
356    pub const fn as_bytes(self) -> &'static [u8] {
357        match self {
358            Self::Lf => b"\n",
359            Self::CrLf => b"\r\n",
360            Self::Cr => b"\r",
361        }
362    }
363
364    /// Detect the appropriate line ending for the current platform.
365    #[must_use]
366    pub const fn platform_default() -> Self {
367        if cfg!(windows) { Self::CrLf } else { Self::Lf }
368    }
369}
370
371/// Configuration for text encoding.
372#[derive(Debug, Clone)]
373pub struct EncodingConfig {
374    /// The encoding to use (default: UTF-8).
375    pub encoding: Encoding,
376
377    /// How to handle invalid sequences.
378    pub error_handling: EncodingErrorHandling,
379
380    /// Whether to normalize line endings.
381    pub normalize_line_endings: bool,
382}
383
384impl Default for EncodingConfig {
385    fn default() -> Self {
386        Self {
387            encoding: Encoding::Utf8,
388            error_handling: EncodingErrorHandling::Replace,
389            normalize_line_endings: false,
390        }
391    }
392}
393
394impl EncodingConfig {
395    /// Create a new encoding configuration.
396    #[must_use]
397    pub fn new(encoding: Encoding) -> Self {
398        Self {
399            encoding,
400            ..Default::default()
401        }
402    }
403
404    /// Set the error handling mode.
405    #[must_use]
406    pub const fn error_handling(mut self, mode: EncodingErrorHandling) -> Self {
407        self.error_handling = mode;
408        self
409    }
410
411    /// Set whether to normalize line endings.
412    #[must_use]
413    pub const fn normalize_line_endings(mut self, normalize: bool) -> Self {
414        self.normalize_line_endings = normalize;
415        self
416    }
417}
418
419/// Supported text encodings.
420#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
421pub enum Encoding {
422    /// UTF-8 encoding.
423    #[default]
424    Utf8,
425
426    /// Raw bytes (no encoding).
427    Raw,
428
429    /// ISO-8859-1 (Latin-1).
430    #[cfg(feature = "legacy-encoding")]
431    Latin1,
432
433    /// Windows-1252.
434    #[cfg(feature = "legacy-encoding")]
435    Windows1252,
436}
437
438/// How to handle encoding errors.
439#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
440pub enum EncodingErrorHandling {
441    /// Replace invalid sequences with the replacement character.
442    #[default]
443    Replace,
444
445    /// Skip invalid sequences.
446    Skip,
447
448    /// Return an error on invalid sequences.
449    Strict,
450
451    /// Escape invalid bytes as hex.
452    Escape,
453}
454
455/// Configuration for interact mode.
456#[derive(Debug, Clone)]
457pub struct InteractConfig {
458    /// Escape character to exit interact mode.
459    pub escape_char: Option<char>,
460
461    /// Timeout for idle detection.
462    pub idle_timeout: Option<Duration>,
463
464    /// Whether to echo input.
465    pub echo: bool,
466
467    /// Output hooks.
468    pub output_hooks: Vec<InteractHook>,
469
470    /// Input hooks.
471    pub input_hooks: Vec<InteractHook>,
472}
473
474impl Default for InteractConfig {
475    fn default() -> Self {
476        Self {
477            escape_char: Some('\x1d'), // Ctrl+]
478            idle_timeout: None,
479            echo: true,
480            output_hooks: Vec::new(),
481            input_hooks: Vec::new(),
482        }
483    }
484}
485
486impl InteractConfig {
487    /// Create a new interact configuration.
488    #[must_use]
489    pub fn new() -> Self {
490        Self::default()
491    }
492
493    /// Set the escape character.
494    #[must_use]
495    pub const fn escape_char(mut self, c: char) -> Self {
496        self.escape_char = Some(c);
497        self
498    }
499
500    /// Disable the escape character.
501    #[must_use]
502    pub const fn no_escape(mut self) -> Self {
503        self.escape_char = None;
504        self
505    }
506
507    /// Set the idle timeout.
508    #[must_use]
509    pub const fn idle_timeout(mut self, timeout: Duration) -> Self {
510        self.idle_timeout = Some(timeout);
511        self
512    }
513
514    /// Set whether to echo input.
515    #[must_use]
516    pub const fn echo(mut self, enabled: bool) -> Self {
517        self.echo = enabled;
518        self
519    }
520}
521
522/// A hook for interact mode.
523#[derive(Debug, Clone)]
524pub struct InteractHook {
525    /// The pattern to match.
526    pub pattern: String,
527
528    /// Whether this is a regex pattern.
529    pub is_regex: bool,
530}
531
532impl InteractHook {
533    /// Create a new interact hook with a literal pattern.
534    #[must_use]
535    pub fn literal(pattern: impl Into<String>) -> Self {
536        Self {
537            pattern: pattern.into(),
538            is_regex: false,
539        }
540    }
541
542    /// Create a new interact hook with a regex pattern.
543    #[must_use]
544    pub fn regex(pattern: impl Into<String>) -> Self {
545        Self {
546            pattern: pattern.into(),
547            is_regex: true,
548        }
549    }
550}
551
552/// Configuration for human-like typing.
553#[derive(Debug, Clone)]
554pub struct HumanTypingConfig {
555    /// Base delay between characters.
556    pub base_delay: Duration,
557
558    /// Variance in delay (random offset from base).
559    pub variance: Duration,
560
561    /// Chance of making a typo (0.0 to 1.0).
562    pub typo_chance: f32,
563
564    /// Chance of correcting a typo (0.0 to 1.0).
565    pub correction_chance: f32,
566}
567
568impl Default for HumanTypingConfig {
569    fn default() -> Self {
570        Self {
571            base_delay: Duration::from_millis(100),
572            variance: Duration::from_millis(50),
573            typo_chance: 0.01,
574            correction_chance: 0.85,
575        }
576    }
577}
578
579impl HumanTypingConfig {
580    /// Create a new human typing configuration.
581    #[must_use]
582    pub fn new(base_delay: Duration, variance: Duration) -> Self {
583        Self {
584            base_delay,
585            variance,
586            ..Default::default()
587        }
588    }
589
590    /// Set the typo chance.
591    #[must_use]
592    pub const fn typo_chance(mut self, chance: f32) -> Self {
593        self.typo_chance = chance;
594        self
595    }
596
597    /// Set the correction chance.
598    #[must_use]
599    pub const fn correction_chance(mut self, chance: f32) -> Self {
600        self.correction_chance = chance;
601        self
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn session_config_builder() {
611        let config = SessionConfig::new("bash")
612            .args(["-l", "-i"])
613            .env("MY_VAR", "value")
614            .dimensions(120, 40)
615            .timeout(Duration::from_secs(10));
616
617        assert_eq!(config.command, "bash");
618        assert_eq!(config.args, vec!["-l", "-i"]);
619        assert_eq!(config.env.get("MY_VAR"), Some(&"value".to_string()));
620        assert_eq!(config.dimensions, (120, 40));
621        assert_eq!(config.timeout.default, Duration::from_secs(10));
622    }
623
624    #[test]
625    fn line_ending_as_str() {
626        assert_eq!(LineEnding::Lf.as_str(), "\n");
627        assert_eq!(LineEnding::CrLf.as_str(), "\r\n");
628        assert_eq!(LineEnding::Cr.as_str(), "\r");
629    }
630
631    #[test]
632    fn default_config_has_term() {
633        let config = SessionConfig::default();
634        assert_eq!(config.env.get("TERM"), Some(&"xterm-256color".to_string()));
635    }
636
637    #[test]
638    fn logging_config_builder() {
639        let config = LoggingConfig::new()
640            .log_file("/tmp/session.log")
641            .log_user(true)
642            .format(LogFormat::Ndjson)
643            .redact("password");
644
645        assert_eq!(config.log_file, Some(PathBuf::from("/tmp/session.log")));
646        assert!(config.log_user);
647        assert_eq!(config.format, LogFormat::Ndjson);
648        assert_eq!(config.redact_patterns, vec!["password"]);
649    }
650}