Skip to main content

scud/attractor/
interviewer.rs

1//! Human-in-the-loop interaction trait.
2
3use async_trait::async_trait;
4
5/// A question to present to a human.
6pub struct Question {
7    /// The question text.
8    pub text: String,
9    /// Available choices (may be empty for free-form).
10    pub choices: Vec<String>,
11    /// Default choice index (if any).
12    pub default: Option<usize>,
13}
14
15/// A human's answer.
16pub struct Answer {
17    /// The selected choice text.
18    pub text: String,
19    /// The index of the selected choice (if from a choice list).
20    pub index: Option<usize>,
21}
22
23/// Trait for presenting questions to humans and getting answers.
24#[async_trait]
25pub trait Interviewer: Send + Sync {
26    /// Ask a question and wait for an answer.
27    async fn ask(&self, question: Question) -> Answer;
28}
29
30/// Console-based interviewer using stdin/stdout.
31pub struct ConsoleInterviewer;
32
33#[async_trait]
34impl Interviewer for ConsoleInterviewer {
35    async fn ask(&self, question: Question) -> Answer {
36        use std::io::{self, Write};
37
38        println!("\n{}", question.text);
39        for (i, choice) in question.choices.iter().enumerate() {
40            let marker = if Some(i) == question.default {
41                "*"
42            } else {
43                " "
44            };
45            println!("  {} [{}] {}", marker, i + 1, choice);
46        }
47
48        print!("> ");
49        let _ = io::stdout().flush();
50
51        let mut input = String::new();
52        let _ = io::stdin().read_line(&mut input);
53        let input = input.trim();
54
55        // Try parsing as a number (1-indexed choice)
56        if let Ok(num) = input.parse::<usize>() {
57            if num >= 1 && num <= question.choices.len() {
58                return Answer {
59                    text: question.choices[num - 1].clone(),
60                    index: Some(num - 1),
61                };
62            }
63        }
64
65        // Free-form text answer
66        Answer {
67            text: input.to_string(),
68            index: None,
69        }
70    }
71}
72
73/// Auto-approve interviewer that always selects the first option.
74///
75/// Used for `--headless` mode where no human is available.
76pub struct AutoApproveInterviewer;
77
78#[async_trait]
79impl Interviewer for AutoApproveInterviewer {
80    async fn ask(&self, question: Question) -> Answer {
81        let (text, index) = if let Some(default) = question.default {
82            (question.choices[default].clone(), Some(default))
83        } else if !question.choices.is_empty() {
84            (question.choices[0].clone(), Some(0))
85        } else {
86            ("yes".to_string(), None)
87        };
88
89        Answer { text, index }
90    }
91}
92
93/// Queue-based interviewer with pre-filled answers (for testing).
94pub struct QueueInterviewer {
95    answers: std::sync::Mutex<Vec<Answer>>,
96}
97
98impl QueueInterviewer {
99    /// Create a new queue interviewer with pre-filled answers.
100    pub fn new(answers: Vec<Answer>) -> Self {
101        Self {
102            answers: std::sync::Mutex::new(answers),
103        }
104    }
105
106    /// Create from a list of string answers.
107    pub fn from_strings(answers: Vec<&str>) -> Self {
108        Self::new(
109            answers
110                .into_iter()
111                .map(|s| Answer {
112                    text: s.to_string(),
113                    index: None,
114                })
115                .collect(),
116        )
117    }
118}
119
120#[async_trait]
121impl Interviewer for QueueInterviewer {
122    async fn ask(&self, question: Question) -> Answer {
123        let mut queue = self.answers.lock().unwrap();
124        if queue.is_empty() {
125            // Fallback: auto-approve
126            if !question.choices.is_empty() {
127                Answer {
128                    text: question.choices[0].clone(),
129                    index: Some(0),
130                }
131            } else {
132                Answer {
133                    text: "yes".to_string(),
134                    index: None,
135                }
136            }
137        } else {
138            queue.remove(0)
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[tokio::test]
148    async fn test_auto_approve_first_choice() {
149        let interviewer = AutoApproveInterviewer;
150        let answer = interviewer
151            .ask(Question {
152                text: "Choose".into(),
153                choices: vec!["A".into(), "B".into()],
154                default: None,
155            })
156            .await;
157        assert_eq!(answer.text, "A");
158        assert_eq!(answer.index, Some(0));
159    }
160
161    #[tokio::test]
162    async fn test_auto_approve_default() {
163        let interviewer = AutoApproveInterviewer;
164        let answer = interviewer
165            .ask(Question {
166                text: "Choose".into(),
167                choices: vec!["A".into(), "B".into()],
168                default: Some(1),
169            })
170            .await;
171        assert_eq!(answer.text, "B");
172        assert_eq!(answer.index, Some(1));
173    }
174
175    #[tokio::test]
176    async fn test_queue_interviewer() {
177        let interviewer = QueueInterviewer::from_strings(vec!["first", "second"]);
178
179        let a1 = interviewer
180            .ask(Question {
181                text: "Q1?".into(),
182                choices: vec![],
183                default: None,
184            })
185            .await;
186        assert_eq!(a1.text, "first");
187
188        let a2 = interviewer
189            .ask(Question {
190                text: "Q2?".into(),
191                choices: vec![],
192                default: None,
193            })
194            .await;
195        assert_eq!(a2.text, "second");
196    }
197
198    #[tokio::test]
199    async fn test_queue_interviewer_fallback() {
200        let interviewer = QueueInterviewer::from_strings(vec![]);
201        let answer = interviewer
202            .ask(Question {
203                text: "Q?".into(),
204                choices: vec!["option".into()],
205                default: None,
206            })
207            .await;
208        assert_eq!(answer.text, "option");
209    }
210}