Skip to main content

synaps_cli/tools/shell/
readiness.rs

1//! Output readiness detection — determines when a shell is waiting for input.
2//!
3//! Three strategies for detecting that a shell process has finished producing
4//! output and is waiting for the next command:
5//!
6//! - **Timeout**: pure silence-based — if N ms pass with no output, we assume ready.
7//! - **Prompt**: regex-based — scan the tail of output for known prompt patterns.
8//! - **Hybrid**: try prompt detection first, fall back to silence timeout.
9//!
10//! The `check()` method is stateless and designed for polling loops — the caller
11//! owns the timers, we just evaluate the current snapshot.
12
13use std::time::Duration;
14
15use regex::Regex;
16
17use crate::tools::strip_ansi;
18
19use super::config::ShellConfig;
20
21// ---------------------------------------------------------------------------
22// Strategy & result enums
23// ---------------------------------------------------------------------------
24
25/// The strategy for detecting when a shell is ready for input.
26#[derive(Debug, Clone)]
27pub enum ReadinessStrategy {
28    /// Wait for N ms of output silence
29    Timeout,
30    /// Wait until output matches a prompt regex
31    Prompt,
32    /// Prompt detection with timeout fallback (default)
33    Hybrid,
34}
35
36impl ReadinessStrategy {
37    #[allow(clippy::should_implement_trait)]
38    pub fn from_str(s: &str) -> Self {
39        match s.to_lowercase().as_str() {
40            "timeout" => ReadinessStrategy::Timeout,
41            "prompt" => ReadinessStrategy::Prompt,
42            _ => ReadinessStrategy::Hybrid,
43        }
44    }
45}
46
47/// Result of a readiness check
48#[derive(Debug, PartialEq)]
49pub enum ReadinessResult {
50    /// Output matched a prompt pattern — shell is waiting for input
51    Ready,
52    /// Still receiving output, keep waiting
53    Waiting,
54    /// Silence timeout expired — return what we have
55    SilenceTimeout,
56    /// Maximum total wait time exceeded
57    MaxTimeout,
58}
59
60// ---------------------------------------------------------------------------
61// Detector
62// ---------------------------------------------------------------------------
63
64/// Stateless readiness evaluator — called repeatedly in a polling loop.
65pub struct ReadinessDetector {
66    strategy: ReadinessStrategy,
67    patterns: Vec<Regex>,
68    silence_timeout: Duration,
69    max_timeout: Duration,
70}
71
72/// How many chars from the tail of output to inspect for prompt matching.
73const PROMPT_TAIL_LEN: usize = 200;
74
75impl ReadinessDetector {
76    /// Build a detector from raw parts.
77    ///
78    /// Invalid regex patterns are silently skipped (with a `tracing::warn`).
79    /// If *no* patterns compile, the strategy is downgraded to `Timeout`.
80    pub fn new(
81        strategy: ReadinessStrategy,
82        patterns_str: &[String],
83        silence_timeout_ms: u64,
84        max_timeout_ms: u64,
85    ) -> Self {
86        let patterns: Vec<Regex> = patterns_str
87            .iter()
88            .filter_map(|p| match Regex::new(p) {
89                Ok(re) => Some(re),
90                Err(e) => {
91                    tracing::warn!(pattern = %p, error = %e, "skipping invalid prompt regex");
92                    None
93                }
94            })
95            .collect();
96
97        // If we wanted prompt detection but have no usable patterns, fall back.
98        let strategy = if patterns.is_empty() {
99            match strategy {
100                ReadinessStrategy::Prompt | ReadinessStrategy::Hybrid => {
101                    tracing::warn!(
102                        "no valid prompt patterns — falling back to Timeout strategy"
103                    );
104                    ReadinessStrategy::Timeout
105                }
106                other => other,
107            }
108        } else {
109            strategy
110        };
111
112        Self {
113            strategy,
114            patterns,
115            silence_timeout: Duration::from_millis(silence_timeout_ms),
116            max_timeout: Duration::from_millis(max_timeout_ms),
117        }
118    }
119
120    /// Build from the default `ShellConfig`.
121    pub fn from_config(config: &ShellConfig) -> Self {
122        let strategy = crate::tools::shell::readiness::ReadinessStrategy::from_str(&config.readiness_strategy);
123        Self::new(
124            strategy,
125            &config.prompt_patterns,
126            config.readiness_timeout_ms,
127            config.max_readiness_timeout_ms,
128        )
129    }
130
131    /// Evaluate the current output snapshot against the active strategy.
132    ///
133    /// Called in a tight poll loop — the caller tracks `silence_elapsed`
134    /// (time since last new output byte) and `total_elapsed` (wall-clock since
135    /// the command was sent).
136    pub fn check(
137        &self,
138        output: &str,
139        silence_elapsed: Duration,
140        total_elapsed: Duration,
141    ) -> ReadinessResult {
142        // Hard ceiling — always wins.
143        if total_elapsed >= self.max_timeout {
144            return ReadinessResult::MaxTimeout;
145        }
146
147        match &self.strategy {
148            ReadinessStrategy::Timeout => {
149                if silence_elapsed >= self.silence_timeout {
150                    ReadinessResult::SilenceTimeout
151                } else {
152                    ReadinessResult::Waiting
153                }
154            }
155            ReadinessStrategy::Prompt => {
156                if self.matches_prompt(output) {
157                    ReadinessResult::Ready
158                } else {
159                    ReadinessResult::Waiting
160                }
161            }
162            ReadinessStrategy::Hybrid => {
163                if self.matches_prompt(output) {
164                    ReadinessResult::Ready
165                } else if silence_elapsed >= self.silence_timeout {
166                    ReadinessResult::SilenceTimeout
167                } else {
168                    ReadinessResult::Waiting
169                }
170            }
171        }
172    }
173
174    /// Check the tail of `output` for a known prompt pattern.
175    ///
176    /// Only inspects the last ~200 chars (prompts live at the end), and strips
177    /// ANSI escapes before matching.  The match runs against the last non-empty
178    /// line.
179    pub fn matches_prompt(&self, output: &str) -> bool {
180        if output.is_empty() || self.patterns.is_empty() {
181            return false;
182        }
183
184        // Grab the tail to avoid scanning megabytes of scrollback.
185        // Walk backward to the previous UTF-8 char boundary to avoid panicking
186        // when multi-byte glyphs (●, ❯, box-drawing, emoji) straddle the cut.
187        // Walking backward (floor) instead of forward (ceil) ensures the tail
188        // is never empty when the cut lands inside the final multi-byte glyph.
189        let tail = if output.len() > PROMPT_TAIL_LEN {
190            let mut start = output.len() - PROMPT_TAIL_LEN;
191            while start > 0 && !output.is_char_boundary(start) {
192                start -= 1;
193            }
194            &output[start..]
195        } else {
196            output
197        };
198
199        let clean = strip_ansi(tail);
200
201        // Last non-empty line is where the prompt sits.
202        let last_line = clean
203            .lines()
204            .rev()
205            .find(|l| !l.trim().is_empty())
206            .unwrap_or("");
207
208        if last_line.is_empty() {
209            return false;
210        }
211
212        self.patterns.iter().any(|re| re.is_match(last_line))
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Tests
218// ---------------------------------------------------------------------------
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    /// Helper: build a Timeout detector with sensible defaults.
225    fn timeout_detector(silence_ms: u64) -> ReadinessDetector {
226        ReadinessDetector::new(ReadinessStrategy::Timeout, &[], silence_ms, 10_000)
227    }
228
229    /// Helper: build a Prompt detector with the default pattern set.
230    fn prompt_detector() -> ReadinessDetector {
231        let config = ShellConfig::default();
232        ReadinessDetector::new(
233            ReadinessStrategy::Prompt,
234            &config.prompt_patterns,
235            config.readiness_timeout_ms,
236            config.max_readiness_timeout_ms,
237        )
238    }
239
240    /// Helper: build a Hybrid detector with the default pattern set.
241    fn hybrid_detector() -> ReadinessDetector {
242        ReadinessDetector::from_config(&ShellConfig::default())
243    }
244
245    // 1. Timeout: silence exceeds threshold → SilenceTimeout
246    #[test]
247    fn timeout_strategy_silence_triggers() {
248        let det = timeout_detector(300);
249        let result = det.check(
250            "some output\n",
251            Duration::from_millis(301),
252            Duration::from_millis(500),
253        );
254        assert_eq!(result, ReadinessResult::SilenceTimeout);
255    }
256
257    // 1b. Timeout: silence below threshold → Waiting
258    #[test]
259    fn timeout_strategy_still_waiting() {
260        let det = timeout_detector(300);
261        let result = det.check(
262            "some output\n",
263            Duration::from_millis(100),
264            Duration::from_millis(200),
265        );
266        assert_eq!(result, ReadinessResult::Waiting);
267    }
268
269    // 2. Prompt: output ending with `$ ` → Ready
270    #[test]
271    fn prompt_strategy_dollar_ready() {
272        let det = prompt_detector();
273        let result = det.check(
274            "user@host:~$ ",
275            Duration::from_millis(0),
276            Duration::from_millis(50),
277        );
278        assert_eq!(result, ReadinessResult::Ready);
279    }
280
281    // 3. Prompt: output ending with `>>> ` (Python) → Ready
282    #[test]
283    fn prompt_strategy_python_ready() {
284        let det = prompt_detector();
285        let result = det.check(
286            "Python 3.11.0\n>>> ",
287            Duration::from_millis(0),
288            Duration::from_millis(50),
289        );
290        assert_eq!(result, ReadinessResult::Ready);
291    }
292
293    // 4. Prompt: output NOT ending with prompt → still waiting (no silence fallback)
294    #[test]
295    fn prompt_strategy_no_match_silence_fallback() {
296        let det = prompt_detector();
297        // No prompt at the end, silence exceeded - but prompt strategy doesn't use silence
298        let result = det.check(
299            "compiling crate...\n",
300            Duration::from_millis(500),
301            Duration::from_millis(1000),
302        );
303        assert_eq!(result, ReadinessResult::Waiting);
304    }
305
306    // 4b. Prompt: no match, silence not exceeded → Waiting
307    #[test]
308    fn prompt_strategy_no_match_waiting() {
309        let det = prompt_detector();
310        let result = det.check(
311            "compiling crate...\n",
312            Duration::from_millis(100),
313            Duration::from_millis(200),
314        );
315        assert_eq!(result, ReadinessResult::Waiting);
316    }
317
318    // 5. Hybrid: prompt match → Ready (before silence would trigger)
319    #[test]
320    fn hybrid_prompt_match_before_silence() {
321        let det = hybrid_detector();
322        let result = det.check(
323            "welcome\nuser@host:~$ ",
324            Duration::from_millis(10), // well below silence threshold
325            Duration::from_millis(50),
326        );
327        assert_eq!(result, ReadinessResult::Ready);
328    }
329
330    // 6. Hybrid: no prompt match, silence elapsed → SilenceTimeout
331    #[test]
332    fn hybrid_silence_fallback() {
333        let det = hybrid_detector();
334        let result = det.check(
335            "running long task...\n",
336            Duration::from_millis(500),
337            Duration::from_millis(1000),
338        );
339        assert_eq!(result, ReadinessResult::SilenceTimeout);
340    }
341
342    // 7. MaxTimeout always wins regardless of strategy
343    #[test]
344    fn max_timeout_always_wins() {
345        for det in [timeout_detector(300), prompt_detector(), hybrid_detector()] {
346            let result = det.check(
347                "user@host:~$ ",
348                Duration::from_millis(0),
349                Duration::from_millis(10_001),
350            );
351            assert_eq!(result, ReadinessResult::MaxTimeout);
352        }
353    }
354
355    // 8. matches_prompt against common prompts
356    #[test]
357    fn matches_prompt_common_patterns() {
358        let det = hybrid_detector();
359        let prompts = [
360            "user@host:~$ ",
361            "root@server:/var# ",
362            ">>> ",
363            "(gdb) ",
364            "Password: ",
365        ];
366        for prompt in &prompts {
367            assert!(
368                det.matches_prompt(prompt),
369                "expected match for prompt: {:?}",
370                prompt,
371            );
372        }
373    }
374
375    // 9. Invalid regex patterns are skipped — no panic
376    #[test]
377    fn invalid_regex_skipped_no_panic() {
378        let patterns = vec![
379            "[invalid(".into(), // broken regex
380            r"[$#] $".into(),   // valid
381        ];
382        let det = ReadinessDetector::new(
383            ReadinessStrategy::Hybrid,
384            &patterns,
385            300,
386            10_000,
387        );
388        // Should still work — the valid pattern compiled.
389        assert!(det.matches_prompt("user@host:~$ "));
390    }
391
392    // 9b. ALL patterns invalid → falls back to Timeout
393    #[test]
394    fn all_invalid_patterns_fallback_to_timeout() {
395        let patterns = vec!["[broken(".into(), "(also[bad".into()];
396        let det = ReadinessDetector::new(
397            ReadinessStrategy::Hybrid,
398            &patterns,
399            300,
400            10_000,
401        );
402        // Strategy downgraded — prompt won't match, silence drives the result.
403        assert!(!det.matches_prompt("user@host:~$ "));
404        let result = det.check(
405            "user@host:~$ ",
406            Duration::from_millis(301),
407            Duration::from_millis(500),
408        );
409        assert_eq!(result, ReadinessResult::SilenceTimeout);
410    }
411
412    // 10. Empty output never matches
413    #[test]
414    fn empty_output_no_match() {
415        let det = hybrid_detector();
416        assert!(!det.matches_prompt(""));
417    }
418
419    // Bonus: ANSI-laden prompt still matches after stripping
420    #[test]
421    fn ansi_stripped_before_matching() {
422        let det = hybrid_detector();
423        // Prompt with color codes wrapping it
424        let ansi_prompt = "\x1b[32muser@host\x1b[0m:\x1b[34m~\x1b[0m$ ";
425        assert!(det.matches_prompt(ansi_prompt));
426    }
427
428    // Bonus: only last line matters
429    #[test]
430    fn only_last_line_checked() {
431        let det = hybrid_detector();
432        // Prompt-like text in the middle, non-prompt at the end
433        assert!(!det.matches_prompt("user@host:~$ \nstill running..."));
434        // Prompt at the end
435        assert!(det.matches_prompt("still running...\nuser@host:~$ "));
436    }
437
438    // Regression: multi-byte UTF-8 glyphs at the tail boundary must not panic.
439    // See BUG_REPORT_synaps_ssh_crash.md — starship/powerline prompts with
440    // ●/❯/box-drawing glyphs caused SIGABRT when the byte-offset slice landed
441    // inside a multi-byte sequence.
442    #[test]
443    fn matches_prompt_handles_multibyte_glyph_at_tail_boundary() {
444        let det = hybrid_detector();
445
446        // Build output where a 3-byte char straddles the tail-window cut.
447        // prefix_len puts the start of '●' (3 bytes) exactly 1 byte before
448        // the PROMPT_TAIL_LEN boundary, so a naive slice would land inside it.
449        let prefix_len = PROMPT_TAIL_LEN - 1;
450        let mut output = "x".repeat(prefix_len);
451        output.push('●'); // 3-byte: E2 97 8F
452        output.push_str("\n~/repo on  main\nuser@host:~$ ");
453
454        // Must not panic, and should still detect the prompt.
455        assert!(det.matches_prompt(&output));
456    }
457
458    #[test]
459    fn matches_prompt_handles_4byte_emoji_at_tail_boundary() {
460        let det = hybrid_detector();
461
462        // 4-byte emoji right at the boundary edge
463        for offset in 0..4 {
464            let prefix_len = PROMPT_TAIL_LEN - offset;
465            let mut output = "a".repeat(prefix_len);
466            output.push('🔥'); // 4-byte: F0 9F 94 A5
467            output.push_str("\nuser@host:~$ ");
468            assert!(det.matches_prompt(&output), "panicked at offset {}", offset);
469        }
470    }
471}