Skip to main content

ralph/commands/tutorial/
prompter.rs

1//! Testable prompt abstraction for tutorial phases.
2//!
3//! Responsibilities:
4//! - Define prompt operations needed by tutorial phases.
5//! - Provide Dialoguer implementation for interactive use.
6//! - Provide Scripted implementation for automated testing.
7//!
8//! Not handled here:
9//! - Tutorial phase logic (see phases.rs).
10
11use anyhow::{Context, Result};
12
13/// Trait for tutorial user prompts, allowing testable implementations.
14pub trait TutorialPrompter {
15    /// Pause and wait for user to press Enter to continue.
16    fn pause(&self, message: &str) -> Result<()>;
17
18    /// Ask a yes/no confirmation question.
19    fn confirm(&self, prompt: &str, default: bool) -> Result<bool>;
20
21    /// Select from a list of options.
22    fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result<usize>;
23
24    /// Display informational text (no input required).
25    fn info(&self, message: &str);
26}
27
28/// Dialoguer-based prompter for interactive terminal use.
29pub struct DialoguerTutorialPrompter;
30
31impl TutorialPrompter for DialoguerTutorialPrompter {
32    fn pause(&self, message: &str) -> Result<()> {
33        dialoguer::Confirm::new()
34            .with_prompt(message)
35            .default(true)
36            .show_default(false)
37            .interact()
38            .context("failed to get pause confirmation")?;
39        Ok(())
40    }
41
42    fn confirm(&self, prompt: &str, default: bool) -> Result<bool> {
43        dialoguer::Confirm::new()
44            .with_prompt(prompt)
45            .default(default)
46            .interact()
47            .context("failed to get confirmation")
48    }
49
50    fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result<usize> {
51        dialoguer::Select::new()
52            .with_prompt(prompt)
53            .items(items)
54            .default(default)
55            .interact()
56            .context("failed to get selection")
57    }
58
59    fn info(&self, message: &str) {
60        println!("{}", message);
61    }
62}
63
64/// Scripted prompter for testing with predetermined responses.
65#[derive(Debug)]
66pub struct ScriptedTutorialPrompter {
67    /// Queue of responses
68    pub responses: Vec<ScriptedResponse>,
69    /// Current index
70    index: std::cell::Cell<usize>,
71    /// Captured info messages
72    pub info_messages: std::cell::RefCell<Vec<String>>,
73}
74
75#[derive(Debug, Clone)]
76pub enum ScriptedResponse {
77    Pause,
78    Confirm(bool),
79    Select(usize),
80}
81
82impl ScriptedTutorialPrompter {
83    pub fn new(responses: Vec<ScriptedResponse>) -> Self {
84        Self {
85            responses,
86            index: std::cell::Cell::new(0),
87            info_messages: std::cell::RefCell::new(Vec::new()),
88        }
89    }
90
91    fn next_response(&self) -> Result<ScriptedResponse> {
92        let idx = self.index.get();
93        if idx >= self.responses.len() {
94            anyhow::bail!(
95                "Scripted prompter ran out of responses (requested #{}, have {})",
96                idx + 1,
97                self.responses.len()
98            );
99        }
100        self.index.set(idx + 1);
101        Ok(self.responses[idx].clone())
102    }
103}
104
105impl TutorialPrompter for ScriptedTutorialPrompter {
106    fn pause(&self, _message: &str) -> Result<()> {
107        match self.next_response()? {
108            ScriptedResponse::Pause => Ok(()),
109            other => anyhow::bail!("Expected Pause response, got {:?}", other),
110        }
111    }
112
113    fn confirm(&self, _prompt: &str, _default: bool) -> Result<bool> {
114        match self.next_response()? {
115            ScriptedResponse::Confirm(val) => Ok(val),
116            other => anyhow::bail!("Expected Confirm response, got {:?}", other),
117        }
118    }
119
120    fn select(&self, _prompt: &str, items: &[&str], _default: usize) -> Result<usize> {
121        match self.next_response()? {
122            ScriptedResponse::Select(idx) => {
123                if idx >= items.len() {
124                    anyhow::bail!("Select index {} out of range ({} items)", idx, items.len());
125                }
126                Ok(idx)
127            }
128            other => anyhow::bail!("Expected Select response, got {:?}", other),
129        }
130    }
131
132    fn info(&self, message: &str) {
133        self.info_messages.borrow_mut().push(message.to_string());
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn scripted_prompter_handles_pause() {
143        let prompter = ScriptedTutorialPrompter::new(vec![ScriptedResponse::Pause]);
144        assert!(prompter.pause("test").is_ok());
145    }
146
147    #[test]
148    fn scripted_prompter_handles_confirm() {
149        let prompter = ScriptedTutorialPrompter::new(vec![ScriptedResponse::Confirm(true)]);
150        assert!(prompter.confirm("test", false).unwrap());
151    }
152
153    #[test]
154    fn scripted_prompter_handles_select() {
155        let prompter = ScriptedTutorialPrompter::new(vec![ScriptedResponse::Select(1)]);
156        assert_eq!(prompter.select("test", &["a", "b", "c"], 0).unwrap(), 1);
157    }
158
159    #[test]
160    fn scripted_prompter_select_out_of_range_errors() {
161        let prompter = ScriptedTutorialPrompter::new(vec![ScriptedResponse::Select(5)]);
162        assert!(prompter.select("test", &["a", "b"], 0).is_err());
163    }
164
165    #[test]
166    fn scripted_prompter_runs_out_of_responses() {
167        let prompter = ScriptedTutorialPrompter::new(vec![]);
168        assert!(prompter.pause("test").is_err());
169    }
170
171    #[test]
172    fn scripted_prompter_captures_info_messages() {
173        let prompter = ScriptedTutorialPrompter::new(vec![]);
174        prompter.info("message 1");
175        prompter.info("message 2");
176        let messages = prompter.info_messages.borrow();
177        assert_eq!(messages.len(), 2);
178        assert_eq!(messages[0], "message 1");
179        assert_eq!(messages[1], "message 2");
180    }
181}