Skip to main content

cfgd_core/output/
prompts.rs

1//! Prompts — interaction, not output. Three invariants:
2//!   - Refuse to prompt under structured output (would deadlock pipelines).
3//!   - Refuse to prompt when stdin is not a TTY (CI runners, piped invocations).
4//!     `inquire` self-rejects this on Unix but blocks on Windows.
5//!   - Honor a test-seeded answer queue (set via
6//!     `for_test_with_prompt_responses`) so tests can drive prompt_* past the
7//!     non-interactive guard.
8
9use std::io::IsTerminal;
10
11use super::Printer;
12use super::printer::PromptAnswer;
13
14/// Build an `InquireError::Custom` for the "non-interactive context asked for
15/// an interactive prompt" case — structured output, non-TTY stdin, or a piped
16/// CI runner. Hanging on `inquire` here would deadlock scripts and silently
17/// stall CI.
18fn non_interactive_err(prompt: &str) -> inquire::InquireError {
19    inquire::InquireError::Custom(
20        format!(
21            "refusing to prompt for '{prompt}' in non-interactive/structured output \
22             mode (re-run without -o json or supply the answer via a flag / env var)"
23        )
24        .into(),
25    )
26}
27
28/// True when the current process can interact with a human — stdin is a TTY.
29/// Windows' `inquire` doesn't self-reject the non-TTY case, so the explicit
30/// gate goes here.
31fn stdin_is_tty() -> bool {
32    std::io::stdin().is_terminal()
33}
34
35impl Printer {
36    pub fn prompt_confirm(&self, message: &str) -> Result<bool, inquire::InquireError> {
37        if let Some(answer) = self.pop_prompt_answer()
38            && let PromptAnswer::Confirm(b) = answer
39        {
40            return Ok(b);
41        }
42        if self.is_structured() || !stdin_is_tty() {
43            return Err(non_interactive_err(message));
44        }
45        inquire::Confirm::new(message).with_default(false).prompt()
46    }
47
48    pub fn prompt_select<'a>(
49        &self,
50        message: &str,
51        options: &'a [String],
52    ) -> Result<&'a String, inquire::InquireError> {
53        if let Some(answer) = self.pop_prompt_answer()
54            && let PromptAnswer::Select(s) = answer
55        {
56            return options.iter().find(|o| **o == s).ok_or_else(|| {
57                inquire::InquireError::Custom(
58                    format!("test prompt response '{s}' not in option list").into(),
59                )
60            });
61        }
62        if self.is_structured() || !stdin_is_tty() {
63            return Err(non_interactive_err(message));
64        }
65        if options.is_empty() {
66            return Err(inquire::InquireError::Custom("no options available".into()));
67        }
68        let chosen = inquire::Select::new(message, options.to_vec()).prompt()?;
69        Ok(options
70            .iter()
71            .find(|o| **o == chosen)
72            .unwrap_or(&options[0]))
73    }
74
75    pub fn prompt_text(
76        &self,
77        message: &str,
78        default: &str,
79    ) -> Result<String, inquire::InquireError> {
80        if let Some(answer) = self.pop_prompt_answer()
81            && let PromptAnswer::Text(s) = answer
82        {
83            return Ok(s);
84        }
85        if self.is_structured() || !stdin_is_tty() {
86            return Err(non_interactive_err(message));
87        }
88        inquire::Text::new(message).with_default(default).prompt()
89    }
90
91    pub(crate) fn pop_prompt_answer(&self) -> Option<PromptAnswer> {
92        self.prompt_queue
93            .as_ref()?
94            .lock()
95            .unwrap_or_else(|e| e.into_inner())
96            .pop_front()
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::super::{OutputFormat, Verbosity};
103    use super::*;
104
105    #[test]
106    fn structured_mode_refuses_prompt() {
107        let p = Printer::with_format(Verbosity::Normal, None, OutputFormat::Json);
108        let r = p.prompt_confirm("really?");
109        assert!(r.is_err());
110    }
111
112    #[test]
113    fn seeded_select_returns_matching_option() {
114        let (printer, _buf) =
115            Printer::for_test_with_prompt_responses(vec![PromptAnswer::Select("yes".into())]);
116        let options = vec!["yes".to_string(), "no".to_string()];
117        let chosen = printer
118            .prompt_select("pick", &options)
119            .expect("seeded select must resolve to a listed option");
120        assert_eq!(chosen, "yes");
121    }
122
123    #[test]
124    fn seeded_select_with_unknown_response_is_custom_error() {
125        let (printer, _buf) =
126            Printer::for_test_with_prompt_responses(vec![PromptAnswer::Select("missing".into())]);
127        let options = vec!["yes".to_string(), "no".to_string()];
128        let err = printer
129            .prompt_select("pick", &options)
130            .expect_err("response not in options must Err");
131        let msg = format!("{err}");
132        assert!(msg.contains("missing"), "msg must echo unknown: {msg}");
133    }
134
135    #[test]
136    fn structured_select_refuses_when_no_seeded_answer() {
137        let p = Printer::with_format(Verbosity::Normal, None, OutputFormat::Json);
138        let options = vec!["a".to_string(), "b".to_string()];
139        let err = p
140            .prompt_select("pick", &options)
141            .expect_err("structured mode must refuse");
142        let msg = format!("{err}");
143        assert!(
144            msg.contains("non-interactive") || msg.contains("structured"),
145            "expected non-interactive refusal: {msg}"
146        );
147    }
148
149    #[test]
150    fn seeded_text_returns_value() {
151        let (printer, _buf) =
152            Printer::for_test_with_prompt_responses(vec![PromptAnswer::Text("answer".into())]);
153        let text = printer.prompt_text("name", "").expect("seeded text answer");
154        assert_eq!(text, "answer");
155    }
156
157    #[test]
158    fn structured_text_refuses_when_no_seeded_answer() {
159        let p = Printer::with_format(Verbosity::Normal, None, OutputFormat::Json);
160        let err = p.prompt_text("name", "").expect_err("structured refuse");
161        let msg = format!("{err}");
162        assert!(
163            msg.contains("non-interactive") || msg.contains("structured"),
164            "expected non-interactive refusal: {msg}"
165        );
166    }
167
168    #[test]
169    fn seeded_confirm_returns_bool() {
170        let (printer, _b1) =
171            Printer::for_test_with_prompt_responses(vec![PromptAnswer::Confirm(true)]);
172        assert!(printer.prompt_confirm("really?").expect("seeded confirm"));
173        let (printer2, _b2) =
174            Printer::for_test_with_prompt_responses(vec![PromptAnswer::Confirm(false)]);
175        assert!(
176            !printer2
177                .prompt_confirm("really?")
178                .expect("seeded confirm false")
179        );
180    }
181}