Skip to main content

systemprompt_cli/
interactive.rs

1//! Interactive prompting helpers shared across commands.
2//!
3//! [`Prompter`] is the single seam over operator interaction: production code
4//! uses [`DialoguerPrompter`] (terminal prompts via `dialoguer`), tests use
5//! [`ScriptedPrompter`] with queued answers. The flag-bridging helpers
6//! ([`require_confirmation`], [`resolve_required`], …) keep their behaviour:
7//! interactive mode prompts, non-interactive mode falls back to a default or
8//! fails with a "flag required" error.
9
10use std::collections::VecDeque;
11use std::sync::Mutex;
12
13use crate::CliConfig;
14use anyhow::{Result, anyhow};
15use dialoguer::theme::ColorfulTheme;
16use dialoguer::{Confirm, Input, Password, Select};
17
18pub trait Prompter: Send + Sync {
19    fn confirm(&self, message: &str, default: bool) -> Result<bool>;
20    fn input(&self, prompt: &str) -> Result<String>;
21    fn input_with_default(&self, prompt: &str, default: &str) -> Result<String>;
22    fn select(&self, prompt: &str, items: &[String]) -> Result<usize>;
23    fn password(&self, prompt: &str) -> Result<String>;
24}
25
26#[derive(Debug, Clone, Copy, Default)]
27pub struct DialoguerPrompter;
28
29impl Prompter for DialoguerPrompter {
30    fn confirm(&self, message: &str, default: bool) -> Result<bool> {
31        Ok(Confirm::with_theme(&ColorfulTheme::default())
32            .with_prompt(message)
33            .default(default)
34            .interact()?)
35    }
36
37    fn input(&self, prompt: &str) -> Result<String> {
38        Ok(Input::<String>::with_theme(&ColorfulTheme::default())
39            .with_prompt(prompt)
40            .interact_text()?)
41    }
42
43    fn input_with_default(&self, prompt: &str, default: &str) -> Result<String> {
44        Ok(Input::<String>::with_theme(&ColorfulTheme::default())
45            .with_prompt(prompt)
46            .default(default.to_owned())
47            .interact_text()?)
48    }
49
50    fn select(&self, prompt: &str, items: &[String]) -> Result<usize> {
51        Ok(Select::with_theme(&ColorfulTheme::default())
52            .with_prompt(prompt)
53            .items(items)
54            .default(0)
55            .interact()?)
56    }
57
58    fn password(&self, prompt: &str) -> Result<String> {
59        Ok(Password::with_theme(&ColorfulTheme::default())
60            .with_prompt(prompt)
61            .interact()?)
62    }
63}
64
65#[derive(Debug, Default)]
66pub struct ScriptedPrompter {
67    answers: Mutex<VecDeque<String>>,
68}
69
70impl ScriptedPrompter {
71    #[must_use]
72    pub fn new(answers: impl IntoIterator<Item = impl Into<String>>) -> Self {
73        Self {
74            answers: Mutex::new(answers.into_iter().map(Into::into).collect()),
75        }
76    }
77
78    fn next_answer(&self, prompt: &str) -> Result<String> {
79        self.answers
80            .lock()
81            .map_err(|e| anyhow!("Scripted prompter lock poisoned: {e}"))?
82            .pop_front()
83            .ok_or_else(|| anyhow!("Scripted prompter exhausted at prompt: {prompt}"))
84    }
85}
86
87impl Prompter for ScriptedPrompter {
88    fn confirm(&self, message: &str, _default: bool) -> Result<bool> {
89        let answer = self.next_answer(message)?;
90        Ok(matches!(
91            answer.to_lowercase().as_str(),
92            "y" | "yes" | "true"
93        ))
94    }
95
96    fn input(&self, prompt: &str) -> Result<String> {
97        self.next_answer(prompt)
98    }
99
100    fn input_with_default(&self, prompt: &str, default: &str) -> Result<String> {
101        match self.next_answer(prompt) {
102            Ok(answer) if answer.is_empty() => Ok(default.to_owned()),
103            other => other,
104        }
105    }
106
107    fn select(&self, prompt: &str, items: &[String]) -> Result<usize> {
108        let answer = self.next_answer(prompt)?;
109        let idx: usize = answer
110            .parse()
111            .map_err(|e| anyhow!("Scripted select answer '{answer}' is not an index: {e}"))?;
112        if idx >= items.len() {
113            return Err(anyhow!(
114                "Scripted select index {idx} out of range for {} items",
115                items.len()
116            ));
117        }
118        Ok(idx)
119    }
120
121    fn password(&self, prompt: &str) -> Result<String> {
122        self.next_answer(prompt)
123    }
124}
125
126pub fn require_confirmation(
127    prompter: &dyn Prompter,
128    message: &str,
129    skip_confirmation: bool,
130    config: &CliConfig,
131) -> Result<()> {
132    require_confirmation_with(prompter, message, skip_confirmation, false, config)
133}
134
135pub fn require_confirmation_default_yes(
136    prompter: &dyn Prompter,
137    message: &str,
138    skip_confirmation: bool,
139    config: &CliConfig,
140) -> Result<()> {
141    require_confirmation_with(prompter, message, skip_confirmation, true, config)
142}
143
144fn require_confirmation_with(
145    prompter: &dyn Prompter,
146    message: &str,
147    skip_confirmation: bool,
148    default: bool,
149    config: &CliConfig,
150) -> Result<()> {
151    if skip_confirmation {
152        return Ok(());
153    }
154
155    if !config.is_interactive() {
156        return Err(anyhow!("--yes is required in non-interactive mode"));
157    }
158
159    if prompter.confirm(message, default)? {
160        Ok(())
161    } else {
162        Err(anyhow!("Operation cancelled"))
163    }
164}
165
166pub fn resolve_required<T, F>(
167    value: Option<T>,
168    flag_name: &str,
169    config: &CliConfig,
170    prompt_fn: F,
171) -> Result<T>
172where
173    F: FnOnce() -> Result<T>,
174{
175    match value {
176        Some(v) => Ok(v),
177        None if config.is_interactive() => prompt_fn(),
178        None => Err(anyhow!(
179            "--{} is required in non-interactive mode",
180            flag_name
181        )),
182    }
183}
184
185pub fn select_from_list<T: ToString + Clone>(
186    prompter: &dyn Prompter,
187    prompt: &str,
188    items: &[T],
189    flag_name: &str,
190    config: &CliConfig,
191) -> Result<T> {
192    if items.is_empty() {
193        return Err(anyhow!("No items available for selection"));
194    }
195
196    if !config.is_interactive() {
197        return Err(anyhow!(
198            "--{} is required in non-interactive mode",
199            flag_name
200        ));
201    }
202
203    let display: Vec<String> = items.iter().map(ToString::to_string).collect();
204    let idx = prompter.select(prompt, &display)?;
205    Ok(items[idx].clone())
206}
207
208pub fn select_index(
209    prompter: &dyn Prompter,
210    prompt: &str,
211    items: &[&str],
212    config: &CliConfig,
213) -> Result<Option<usize>> {
214    if !config.is_interactive() {
215        return Ok(None);
216    }
217
218    let display: Vec<String> = items.iter().map(|s| (*s).to_owned()).collect();
219    Ok(Some(prompter.select(prompt, &display)?))
220}
221
222pub fn prompt_input(
223    prompter: &dyn Prompter,
224    prompt: &str,
225    flag_name: &str,
226    config: &CliConfig,
227) -> Result<String> {
228    if !config.is_interactive() {
229        return Err(anyhow!(
230            "--{} is required in non-interactive mode",
231            flag_name
232        ));
233    }
234
235    prompter.input(prompt)
236}
237
238pub fn prompt_input_with_default(
239    prompter: &dyn Prompter,
240    prompt: &str,
241    default: &str,
242    config: &CliConfig,
243) -> Result<String> {
244    if !config.is_interactive() {
245        return Ok(default.to_owned());
246    }
247
248    prompter.input_with_default(prompt, default)
249}
250
251pub fn confirm_optional(
252    prompter: &dyn Prompter,
253    message: &str,
254    default: bool,
255    config: &CliConfig,
256) -> Result<bool> {
257    if !config.is_interactive() {
258        return Ok(default);
259    }
260
261    prompter.confirm(message, default)
262}