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