1use std::time::Duration;
14
15use regex::Regex;
16
17use crate::tools::strip_ansi;
18
19use super::config::ShellConfig;
20
21#[derive(Debug, Clone)]
27pub enum ReadinessStrategy {
28 Timeout,
30 Prompt,
32 Hybrid,
34}
35
36impl ReadinessStrategy {
37 pub fn from_str(s: &str) -> Self {
38 match s.to_lowercase().as_str() {
39 "timeout" => ReadinessStrategy::Timeout,
40 "prompt" => ReadinessStrategy::Prompt,
41 _ => ReadinessStrategy::Hybrid,
42 }
43 }
44}
45
46#[derive(Debug, PartialEq)]
48pub enum ReadinessResult {
49 Ready,
51 Waiting,
53 SilenceTimeout,
55 MaxTimeout,
57}
58
59pub struct ReadinessDetector {
65 strategy: ReadinessStrategy,
66 patterns: Vec<Regex>,
67 silence_timeout: Duration,
68 max_timeout: Duration,
69}
70
71const PROMPT_TAIL_LEN: usize = 200;
73
74impl ReadinessDetector {
75 pub fn new(
80 strategy: ReadinessStrategy,
81 patterns_str: &[String],
82 silence_timeout_ms: u64,
83 max_timeout_ms: u64,
84 ) -> Self {
85 let patterns: Vec<Regex> = patterns_str
86 .iter()
87 .filter_map(|p| match Regex::new(p) {
88 Ok(re) => Some(re),
89 Err(e) => {
90 tracing::warn!(pattern = %p, error = %e, "skipping invalid prompt regex");
91 None
92 }
93 })
94 .collect();
95
96 let strategy = if patterns.is_empty() {
98 match strategy {
99 ReadinessStrategy::Prompt | ReadinessStrategy::Hybrid => {
100 tracing::warn!(
101 "no valid prompt patterns — falling back to Timeout strategy"
102 );
103 ReadinessStrategy::Timeout
104 }
105 other => other,
106 }
107 } else {
108 strategy
109 };
110
111 Self {
112 strategy,
113 patterns,
114 silence_timeout: Duration::from_millis(silence_timeout_ms),
115 max_timeout: Duration::from_millis(max_timeout_ms),
116 }
117 }
118
119 pub fn from_config(config: &ShellConfig) -> Self {
121 let strategy = crate::tools::shell::readiness::ReadinessStrategy::from_str(&config.readiness_strategy);
122 Self::new(
123 strategy,
124 &config.prompt_patterns,
125 config.readiness_timeout_ms,
126 config.max_readiness_timeout_ms,
127 )
128 }
129
130 pub fn check(
136 &self,
137 output: &str,
138 silence_elapsed: Duration,
139 total_elapsed: Duration,
140 ) -> ReadinessResult {
141 if total_elapsed >= self.max_timeout {
143 return ReadinessResult::MaxTimeout;
144 }
145
146 match &self.strategy {
147 ReadinessStrategy::Timeout => {
148 if silence_elapsed >= self.silence_timeout {
149 ReadinessResult::SilenceTimeout
150 } else {
151 ReadinessResult::Waiting
152 }
153 }
154 ReadinessStrategy::Prompt => {
155 if self.matches_prompt(output) {
156 ReadinessResult::Ready
157 } else {
158 ReadinessResult::Waiting
159 }
160 }
161 ReadinessStrategy::Hybrid => {
162 if self.matches_prompt(output) {
163 ReadinessResult::Ready
164 } else if silence_elapsed >= self.silence_timeout {
165 ReadinessResult::SilenceTimeout
166 } else {
167 ReadinessResult::Waiting
168 }
169 }
170 }
171 }
172
173 pub fn matches_prompt(&self, output: &str) -> bool {
179 if output.is_empty() || self.patterns.is_empty() {
180 return false;
181 }
182
183 let tail = if output.len() > PROMPT_TAIL_LEN {
189 let mut start = output.len() - PROMPT_TAIL_LEN;
190 while start > 0 && !output.is_char_boundary(start) {
191 start -= 1;
192 }
193 &output[start..]
194 } else {
195 output
196 };
197
198 let clean = strip_ansi(tail);
199
200 let last_line = clean
202 .lines()
203 .rev()
204 .find(|l| !l.trim().is_empty())
205 .unwrap_or("");
206
207 if last_line.is_empty() {
208 return false;
209 }
210
211 self.patterns.iter().any(|re| re.is_match(last_line))
212 }
213}
214
215#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn timeout_detector(silence_ms: u64) -> ReadinessDetector {
225 ReadinessDetector::new(ReadinessStrategy::Timeout, &[], silence_ms, 10_000)
226 }
227
228 fn prompt_detector() -> ReadinessDetector {
230 let config = ShellConfig::default();
231 ReadinessDetector::new(
232 ReadinessStrategy::Prompt,
233 &config.prompt_patterns,
234 config.readiness_timeout_ms,
235 config.max_readiness_timeout_ms,
236 )
237 }
238
239 fn hybrid_detector() -> ReadinessDetector {
241 ReadinessDetector::from_config(&ShellConfig::default())
242 }
243
244 #[test]
246 fn timeout_strategy_silence_triggers() {
247 let det = timeout_detector(300);
248 let result = det.check(
249 "some output\n",
250 Duration::from_millis(301),
251 Duration::from_millis(500),
252 );
253 assert_eq!(result, ReadinessResult::SilenceTimeout);
254 }
255
256 #[test]
258 fn timeout_strategy_still_waiting() {
259 let det = timeout_detector(300);
260 let result = det.check(
261 "some output\n",
262 Duration::from_millis(100),
263 Duration::from_millis(200),
264 );
265 assert_eq!(result, ReadinessResult::Waiting);
266 }
267
268 #[test]
270 fn prompt_strategy_dollar_ready() {
271 let det = prompt_detector();
272 let result = det.check(
273 "user@host:~$ ",
274 Duration::from_millis(0),
275 Duration::from_millis(50),
276 );
277 assert_eq!(result, ReadinessResult::Ready);
278 }
279
280 #[test]
282 fn prompt_strategy_python_ready() {
283 let det = prompt_detector();
284 let result = det.check(
285 "Python 3.11.0\n>>> ",
286 Duration::from_millis(0),
287 Duration::from_millis(50),
288 );
289 assert_eq!(result, ReadinessResult::Ready);
290 }
291
292 #[test]
294 fn prompt_strategy_no_match_silence_fallback() {
295 let det = prompt_detector();
296 let result = det.check(
298 "compiling crate...\n",
299 Duration::from_millis(500),
300 Duration::from_millis(1000),
301 );
302 assert_eq!(result, ReadinessResult::Waiting);
303 }
304
305 #[test]
307 fn prompt_strategy_no_match_waiting() {
308 let det = prompt_detector();
309 let result = det.check(
310 "compiling crate...\n",
311 Duration::from_millis(100),
312 Duration::from_millis(200),
313 );
314 assert_eq!(result, ReadinessResult::Waiting);
315 }
316
317 #[test]
319 fn hybrid_prompt_match_before_silence() {
320 let det = hybrid_detector();
321 let result = det.check(
322 "welcome\nuser@host:~$ ",
323 Duration::from_millis(10), Duration::from_millis(50),
325 );
326 assert_eq!(result, ReadinessResult::Ready);
327 }
328
329 #[test]
331 fn hybrid_silence_fallback() {
332 let det = hybrid_detector();
333 let result = det.check(
334 "running long task...\n",
335 Duration::from_millis(500),
336 Duration::from_millis(1000),
337 );
338 assert_eq!(result, ReadinessResult::SilenceTimeout);
339 }
340
341 #[test]
343 fn max_timeout_always_wins() {
344 for det in [timeout_detector(300), prompt_detector(), hybrid_detector()] {
345 let result = det.check(
346 "user@host:~$ ",
347 Duration::from_millis(0),
348 Duration::from_millis(10_001),
349 );
350 assert_eq!(result, ReadinessResult::MaxTimeout);
351 }
352 }
353
354 #[test]
356 fn matches_prompt_common_patterns() {
357 let det = hybrid_detector();
358 let prompts = [
359 "user@host:~$ ",
360 "root@server:/var# ",
361 ">>> ",
362 "(gdb) ",
363 "Password: ",
364 ];
365 for prompt in &prompts {
366 assert!(
367 det.matches_prompt(prompt),
368 "expected match for prompt: {:?}",
369 prompt,
370 );
371 }
372 }
373
374 #[test]
376 fn invalid_regex_skipped_no_panic() {
377 let patterns = vec![
378 "[invalid(".into(), r"[$#] $".into(), ];
381 let det = ReadinessDetector::new(
382 ReadinessStrategy::Hybrid,
383 &patterns,
384 300,
385 10_000,
386 );
387 assert!(det.matches_prompt("user@host:~$ "));
389 }
390
391 #[test]
393 fn all_invalid_patterns_fallback_to_timeout() {
394 let patterns = vec!["[broken(".into(), "(also[bad".into()];
395 let det = ReadinessDetector::new(
396 ReadinessStrategy::Hybrid,
397 &patterns,
398 300,
399 10_000,
400 );
401 assert!(!det.matches_prompt("user@host:~$ "));
403 let result = det.check(
404 "user@host:~$ ",
405 Duration::from_millis(301),
406 Duration::from_millis(500),
407 );
408 assert_eq!(result, ReadinessResult::SilenceTimeout);
409 }
410
411 #[test]
413 fn empty_output_no_match() {
414 let det = hybrid_detector();
415 assert!(!det.matches_prompt(""));
416 }
417
418 #[test]
420 fn ansi_stripped_before_matching() {
421 let det = hybrid_detector();
422 let ansi_prompt = "\x1b[32muser@host\x1b[0m:\x1b[34m~\x1b[0m$ ";
424 assert!(det.matches_prompt(ansi_prompt));
425 }
426
427 #[test]
429 fn only_last_line_checked() {
430 let det = hybrid_detector();
431 assert!(!det.matches_prompt("user@host:~$ \nstill running..."));
433 assert!(det.matches_prompt("still running...\nuser@host:~$ "));
435 }
436
437 #[test]
442 fn matches_prompt_handles_multibyte_glyph_at_tail_boundary() {
443 let det = hybrid_detector();
444
445 let prefix_len = PROMPT_TAIL_LEN - 1;
449 let mut output = "x".repeat(prefix_len);
450 output.push('●'); output.push_str("\n~/repo on main\nuser@host:~$ ");
452
453 assert!(det.matches_prompt(&output));
455 }
456
457 #[test]
458 fn matches_prompt_handles_4byte_emoji_at_tail_boundary() {
459 let det = hybrid_detector();
460
461 for offset in 0..4 {
463 let prefix_len = PROMPT_TAIL_LEN - offset;
464 let mut output = "a".repeat(prefix_len);
465 output.push('🔥'); output.push_str("\nuser@host:~$ ");
467 assert!(det.matches_prompt(&output), "panicked at offset {}", offset);
468 }
469 }
470}