rust_expect/dialog/
executor.rs

1//! Dialog execution engine.
2
3use std::time::Duration;
4
5use tokio::io::{AsyncReadExt, AsyncWriteExt};
6
7use super::definition::{Dialog, DialogStep};
8use crate::Pattern;
9use crate::error::{ExpectError, Result};
10use crate::expect::PatternSet;
11use crate::session::Session;
12
13/// Result of executing a dialog step.
14#[derive(Debug, Clone)]
15pub struct StepResult {
16    /// Name of the step.
17    pub step_name: String,
18    /// Whether the step succeeded.
19    pub success: bool,
20    /// Output captured before the match.
21    pub output: String,
22    /// The matched text.
23    pub matched: Option<String>,
24    /// The text that was/will be sent (after variable substitution).
25    pub send: Option<String>,
26    /// Error message if failed.
27    pub error: Option<String>,
28    /// Name of the next step to execute.
29    pub next_step: Option<String>,
30}
31
32/// Result of executing a complete dialog.
33#[derive(Debug, Clone)]
34pub struct DialogResult {
35    /// Name of the dialog.
36    pub dialog_name: String,
37    /// Whether the dialog succeeded.
38    pub success: bool,
39    /// Results of each step.
40    pub steps: Vec<StepResult>,
41    /// Total output captured.
42    pub output: String,
43    /// Error message if failed.
44    pub error: Option<String>,
45}
46
47impl DialogResult {
48    /// Check if all steps succeeded.
49    #[must_use]
50    pub fn all_success(&self) -> bool {
51        self.steps.iter().all(|s| s.success)
52    }
53
54    /// Get the last step result.
55    #[must_use]
56    pub fn last_step(&self) -> Option<&StepResult> {
57        self.steps.last()
58    }
59
60    /// Get a step by name.
61    #[must_use]
62    pub fn get_step(&self, name: &str) -> Option<&StepResult> {
63        self.steps.iter().find(|s| s.step_name == name)
64    }
65}
66
67/// Dialog execution state.
68#[derive(Debug)]
69pub struct DialogExecutor {
70    /// Maximum number of steps to execute.
71    pub max_steps: usize,
72    /// Default timeout for steps without explicit timeout.
73    pub default_timeout: Duration,
74}
75
76impl Default for DialogExecutor {
77    fn default() -> Self {
78        Self {
79            max_steps: 100,
80            default_timeout: Duration::from_secs(30),
81        }
82    }
83}
84
85impl DialogExecutor {
86    /// Create a new executor.
87    #[must_use]
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Set the maximum number of steps.
93    #[must_use]
94    pub const fn max_steps(mut self, max: usize) -> Self {
95        self.max_steps = max;
96        self
97    }
98
99    /// Set the default timeout.
100    #[must_use]
101    pub const fn default_timeout(mut self, timeout: Duration) -> Self {
102        self.default_timeout = timeout;
103        self
104    }
105
106    /// Execute a single step (synchronously - for testing).
107    ///
108    /// This method prepares a step for execution by:
109    /// - Substituting variables in the send text
110    /// - Determining the next step to execute
111    ///
112    /// Note: This is a synchronous helper primarily for testing. For actual
113    /// dialog execution, use the async session-based execution methods.
114    #[must_use]
115    pub fn execute_step_sync(
116        &self,
117        step: &DialogStep,
118        dialog: &Dialog,
119        _buffer: &str,
120    ) -> StepResult {
121        let substituted_send = step.send.as_ref().map(|s| dialog.substitute(s));
122
123        StepResult {
124            step_name: step.name.clone(),
125            success: true,
126            output: String::new(),
127            matched: step.expect.clone(),
128            send: substituted_send,
129            error: None,
130            next_step: step.next.clone().or_else(|| {
131                // Find next step in sequence
132                dialog
133                    .steps
134                    .iter()
135                    .position(|s| s.name == step.name)
136                    .and_then(|i| dialog.steps.get(i + 1))
137                    .map(|s| s.name.clone())
138            }),
139        }
140    }
141
142    /// Get the pattern for a step.
143    #[must_use]
144    pub fn step_pattern(&self, step: &DialogStep, dialog: &Dialog) -> Option<Pattern> {
145        step.expect
146            .as_ref()
147            .map(|e| Pattern::literal(dialog.substitute(e)))
148    }
149
150    /// Execute a dialog on a session.
151    ///
152    /// This runs through the dialog steps, expecting patterns and sending responses.
153    ///
154    /// # Example
155    ///
156    /// ```ignore
157    /// use rust_expect::{Session, Dialog, DialogStep, DialogExecutor};
158    ///
159    /// #[tokio::main]
160    /// async fn main() -> Result<(), rust_expect::ExpectError> {
161    ///     let mut session = Session::spawn("/bin/bash", &[]).await?;
162    ///
163    ///     let dialog = Dialog::named("login")
164    ///         .step(DialogStep::new("prompt")
165    ///             .with_expect("$")
166    ///             .with_send("echo hello\n"));
167    ///
168    ///     let executor = DialogExecutor::new();
169    ///     let result = executor.execute(&mut session, &dialog).await?;
170    ///     assert!(result.success);
171    ///     Ok(())
172    /// }
173    /// ```
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if:
178    /// - A step times out without `continue_on_timeout` set
179    /// - The session closes unexpectedly
180    /// - An I/O error occurs
181    pub async fn execute<T>(
182        &self,
183        session: &mut Session<T>,
184        dialog: &Dialog,
185    ) -> Result<DialogResult>
186    where
187        T: AsyncReadExt + AsyncWriteExt + Unpin + Send,
188    {
189        if dialog.is_empty() {
190            return Ok(DialogResult {
191                dialog_name: dialog.name.clone(),
192                success: true,
193                steps: Vec::new(),
194                output: String::new(),
195                error: None,
196            });
197        }
198
199        let mut step_results = Vec::new();
200        let mut total_output = String::new();
201        let mut step_count = 0;
202
203        // Determine starting step
204        let mut current_step_idx = if let Some(ref entry) = dialog.entry {
205            dialog
206                .steps
207                .iter()
208                .position(|s| &s.name == entry)
209                .unwrap_or(0)
210        } else {
211            0
212        };
213
214        loop {
215            // Prevent infinite loops
216            step_count += 1;
217            if step_count > self.max_steps {
218                return Ok(DialogResult {
219                    dialog_name: dialog.name.clone(),
220                    success: false,
221                    steps: step_results,
222                    output: total_output,
223                    error: Some(format!("Exceeded maximum steps ({})", self.max_steps)),
224                });
225            }
226
227            // Get current step
228            let Some(step) = dialog.steps.get(current_step_idx) else {
229                break; // No more steps
230            };
231
232            // Execute the step
233            let step_result = self.execute_step(session, step, dialog).await?;
234            let success = step_result.success;
235            total_output.push_str(&step_result.output);
236
237            // Determine next step
238            let next_step = step_result.next_step.clone();
239            step_results.push(step_result);
240
241            if !success {
242                return Ok(DialogResult {
243                    dialog_name: dialog.name.clone(),
244                    success: false,
245                    steps: step_results,
246                    output: total_output,
247                    error: Some(format!("Step '{}' failed", step.name)),
248                });
249            }
250
251            // Move to next step
252            if let Some(next_name) = next_step {
253                if let Some(idx) = dialog.steps.iter().position(|s| s.name == next_name) {
254                    current_step_idx = idx;
255                } else {
256                    // Next step not found, end dialog
257                    break;
258                }
259            } else {
260                // No explicit next, try sequential
261                current_step_idx += 1;
262                if current_step_idx >= dialog.steps.len() {
263                    break;
264                }
265            }
266        }
267
268        Ok(DialogResult {
269            dialog_name: dialog.name.clone(),
270            success: true,
271            steps: step_results,
272            output: total_output,
273            error: None,
274        })
275    }
276
277    /// Execute a single dialog step on a session.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if an I/O error occurs (timeouts are handled per-step).
282    pub async fn execute_step<T>(
283        &self,
284        session: &mut Session<T>,
285        step: &DialogStep,
286        dialog: &Dialog,
287    ) -> Result<StepResult>
288    where
289        T: AsyncReadExt + AsyncWriteExt + Unpin + Send,
290    {
291        let timeout = step.timeout.unwrap_or(self.default_timeout);
292        let mut output = String::new();
293        let mut matched_text = None;
294
295        // Handle expect pattern if present
296        if let Some(ref expect_pattern) = step.expect {
297            let pattern = Pattern::literal(dialog.substitute(expect_pattern));
298            let mut patterns = PatternSet::new();
299            patterns.add(pattern).add(Pattern::timeout(timeout));
300
301            match session.expect_any(&patterns).await {
302                Ok(m) => {
303                    output.clone_from(&m.before);
304                    matched_text = Some(m.matched);
305                }
306                Err(ExpectError::Timeout { buffer, .. }) => {
307                    if step.continue_on_timeout {
308                        output = buffer;
309                    } else {
310                        return Ok(StepResult {
311                            step_name: step.name.clone(),
312                            success: false,
313                            output: buffer,
314                            matched: None,
315                            send: None,
316                            error: Some(format!(
317                                "Timeout waiting for pattern '{expect_pattern}' after {timeout:?}"
318                            )),
319                            next_step: None,
320                        });
321                    }
322                }
323                Err(e) => return Err(e),
324            }
325        }
326
327        // Check for branch conditions based on matched text
328        let mut next_step = step.next.clone();
329        if let Some(ref matched) = matched_text {
330            for (branch_pattern, branch_target) in &step.branches {
331                if matched.contains(branch_pattern) {
332                    next_step = Some(branch_target.clone());
333                    break;
334                }
335            }
336        }
337
338        // Handle send if present (text or control character)
339        let substituted_send = if let Some(ref send_text) = step.send {
340            let substituted = dialog.substitute(send_text);
341            session.send_str(&substituted).await?;
342            Some(substituted)
343        } else if let Some(ctrl) = step.send_control {
344            session.send_control(ctrl).await?;
345            Some(format!("<{ctrl:?}>"))
346        } else {
347            None
348        };
349
350        // Determine next step if not set
351        if next_step.is_none() {
352            next_step = dialog
353                .steps
354                .iter()
355                .position(|s| s.name == step.name)
356                .and_then(|i| dialog.steps.get(i + 1))
357                .map(|s| s.name.clone());
358        }
359
360        Ok(StepResult {
361            step_name: step.name.clone(),
362            success: true,
363            output,
364            matched: matched_text,
365            send: substituted_send,
366            error: None,
367            next_step,
368        })
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn executor_default() {
378        let executor = DialogExecutor::new();
379        assert_eq!(executor.max_steps, 100);
380    }
381
382    #[test]
383    fn step_result_success() {
384        let result = StepResult {
385            step_name: "test".to_string(),
386            success: true,
387            output: "output".to_string(),
388            matched: Some("match".to_string()),
389            send: Some("hello".to_string()),
390            error: None,
391            next_step: None,
392        };
393        assert!(result.success);
394        assert_eq!(result.send, Some("hello".to_string()));
395    }
396
397    #[test]
398    fn step_result_with_substitution() {
399        use super::super::definition::{Dialog, DialogStep};
400
401        let dialog = Dialog::named("test_dialog")
402            .variable("username", "admin")
403            .step(
404                DialogStep::new("login")
405                    .with_expect("Username:")
406                    .with_send("${username}"),
407            );
408
409        let executor = DialogExecutor::new();
410        let step = &dialog.steps[0];
411        let result = executor.execute_step_sync(step, &dialog, "");
412
413        assert_eq!(result.step_name, "login");
414        assert_eq!(result.send, Some("admin".to_string()));
415    }
416}