Skip to main content

socket_patch_cli/
output.rs

1use std::io::{self, IsTerminal, Write};
2
3/// Check if stdout is a terminal (for ANSI color output).
4pub fn stdout_is_tty() -> bool {
5    std::io::stdout().is_terminal()
6}
7
8/// Check if stderr is a terminal (for progress output).
9pub fn stderr_is_tty() -> bool {
10    std::io::stderr().is_terminal()
11}
12
13/// Check if stdin is a terminal (for interactive prompts).
14pub fn stdin_is_tty() -> bool {
15    std::io::stdin().is_terminal()
16}
17
18/// Format a severity string with optional ANSI colors.
19pub fn format_severity(s: &str, use_color: bool) -> String {
20    if !use_color {
21        return s.to_string();
22    }
23    match s.to_lowercase().as_str() {
24        "critical" => format!("\x1b[31m{s}\x1b[0m"),
25        "high" => format!("\x1b[91m{s}\x1b[0m"),
26        "medium" => format!("\x1b[33m{s}\x1b[0m"),
27        "low" => format!("\x1b[36m{s}\x1b[0m"),
28        _ => s.to_string(),
29    }
30}
31
32/// Wrap text in ANSI color codes if use_color is true.
33pub fn color(text: &str, code: &str, use_color: bool) -> String {
34    if use_color {
35        format!("\x1b[{code}m{text}\x1b[0m")
36    } else {
37        text.to_string()
38    }
39}
40
41/// Error type for interactive selection.
42pub enum SelectError {
43    /// User cancelled the selection.
44    Cancelled,
45    /// JSON mode requires explicit selection (e.g. via --id).
46    JsonModeNeedsExplicit,
47}
48
49/// Prompt the user for a yes/no confirmation.
50///
51/// - `skip_prompt` (from `-y` flag) or `is_json`: return `default_yes` immediately.
52/// - Non-TTY stdin: return `default_yes` with a stderr warning.
53/// - Interactive: print prompt to stderr, read line; empty = `default_yes`.
54pub fn confirm(prompt: &str, default_yes: bool, skip_prompt: bool, is_json: bool) -> bool {
55    if skip_prompt || is_json {
56        return default_yes;
57    }
58    if !stdin_is_tty() {
59        eprintln!("Non-interactive mode detected, proceeding with default.");
60        return default_yes;
61    }
62    let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
63    eprint!("{prompt} {hint} ");
64    io::stderr().flush().unwrap();
65    let mut answer = String::new();
66    io::stdin().read_line(&mut answer).unwrap();
67    let answer = answer.trim().to_lowercase();
68    if answer.is_empty() {
69        return default_yes;
70    }
71    answer == "y" || answer == "yes"
72}
73
74/// Prompt the user to select one option from a list using dialoguer.
75///
76/// - `is_json`: return `Err(SelectError::JsonModeNeedsExplicit)`.
77/// - Non-TTY: auto-select first option with stderr warning.
78/// - Interactive: use `dialoguer::Select` on stderr.
79pub fn select_one(prompt: &str, options: &[String], is_json: bool) -> Result<usize, SelectError> {
80    if is_json {
81        return Err(SelectError::JsonModeNeedsExplicit);
82    }
83    if !stdin_is_tty() {
84        eprintln!("Non-interactive mode: auto-selecting first option.");
85        return Ok(0);
86    }
87    let selection = dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
88        .with_prompt(prompt)
89        .items(options)
90        .default(0)
91        .interact_opt()
92        .map_err(|_| SelectError::Cancelled)?;
93    match selection {
94        Some(idx) => Ok(idx),
95        None => Err(SelectError::Cancelled),
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    // ---- format_severity ----
104
105    #[test]
106    fn format_severity_critical_with_color() {
107        let out = format_severity("critical", true);
108        assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}");
109        assert!(out.contains("critical"), "expected input verbatim: {out:?}");
110        assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}");
111        assert!(out.contains("31"), "expected red code 31: {out:?}");
112    }
113
114    #[test]
115    fn format_severity_high_with_color() {
116        let out = format_severity("high", true);
117        assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}");
118        assert!(out.contains("high"), "expected input verbatim: {out:?}");
119        assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}");
120        assert!(out.contains("91"), "expected bright-red code 91: {out:?}");
121    }
122
123    #[test]
124    fn format_severity_medium_with_color() {
125        let out = format_severity("medium", true);
126        assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}");
127        assert!(out.contains("medium"), "expected input verbatim: {out:?}");
128        assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}");
129        assert!(out.contains("33"), "expected yellow code 33: {out:?}");
130    }
131
132    #[test]
133    fn format_severity_low_with_color() {
134        let out = format_severity("low", true);
135        assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}");
136        assert!(out.contains("low"), "expected input verbatim: {out:?}");
137        assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}");
138        assert!(out.contains("36"), "expected cyan code 36: {out:?}");
139    }
140
141    #[test]
142    fn format_severity_case_insensitive_critical_uppercase() {
143        let out = format_severity("CRITICAL", true);
144        assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}");
145        assert!(out.contains("CRITICAL"), "expected input verbatim: {out:?}");
146        assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}");
147        assert!(out.contains("31"), "expected red code 31: {out:?}");
148    }
149
150    #[test]
151    fn format_severity_case_insensitive_critical_titlecase() {
152        let out = format_severity("Critical", true);
153        assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}");
154        assert!(out.contains("Critical"), "expected input verbatim: {out:?}");
155        assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}");
156        assert!(out.contains("31"), "expected red code 31: {out:?}");
157    }
158
159    #[test]
160    fn format_severity_case_insensitive_high_lowercase() {
161        let out = format_severity("high", true);
162        assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}");
163        assert!(out.contains("high"), "expected input verbatim: {out:?}");
164        assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}");
165    }
166
167    #[test]
168    fn format_severity_case_insensitive_high_uppercase() {
169        let out = format_severity("HIGH", true);
170        assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}");
171        assert!(out.contains("HIGH"), "expected input verbatim: {out:?}");
172        assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}");
173        assert!(out.contains("91"), "expected bright-red code 91: {out:?}");
174    }
175
176    #[test]
177    fn format_severity_unknown_passes_through_with_color() {
178        let out = format_severity("unknown", true);
179        assert_eq!(out, "unknown");
180    }
181
182    #[test]
183    fn format_severity_critical_no_color() {
184        assert_eq!(format_severity("critical", false), "critical");
185    }
186
187    #[test]
188    fn format_severity_high_no_color() {
189        assert_eq!(format_severity("high", false), "high");
190    }
191
192    #[test]
193    fn format_severity_medium_no_color() {
194        assert_eq!(format_severity("medium", false), "medium");
195    }
196
197    #[test]
198    fn format_severity_low_no_color() {
199        assert_eq!(format_severity("low", false), "low");
200    }
201
202    #[test]
203    fn format_severity_unknown_no_color() {
204        assert_eq!(format_severity("unknown", false), "unknown");
205    }
206
207    #[test]
208    fn format_severity_empty_with_color_passes_through() {
209        let out = format_severity("", true);
210        assert_eq!(out, "");
211    }
212
213    // ---- color ----
214
215    #[test]
216    fn color_with_color_on() {
217        assert_eq!(color("hi", "31", true), "\x1b[31mhi\x1b[0m");
218    }
219
220    #[test]
221    fn color_with_color_off() {
222        assert_eq!(color("hi", "31", false), "hi");
223    }
224
225    #[test]
226    fn color_with_empty_text_and_color_on() {
227        assert_eq!(color("", "1;32", true), "\x1b[1;32m\x1b[0m");
228    }
229
230    // ---- confirm ----
231
232    #[test]
233    fn confirm_skip_prompt_returns_default_yes_true() {
234        assert!(confirm("?", true, true, false));
235    }
236
237    #[test]
238    fn confirm_skip_prompt_returns_default_yes_false() {
239        assert!(!confirm("?", false, true, false));
240    }
241
242    #[test]
243    fn confirm_is_json_returns_default_yes_true() {
244        assert!(confirm("?", true, false, true));
245    }
246
247    #[test]
248    fn confirm_is_json_returns_default_yes_false() {
249        assert!(!confirm("?", false, false, true));
250    }
251
252    #[test]
253    fn confirm_skip_prompt_and_is_json_both_set_returns_default_yes() {
254        assert!(confirm("?", true, true, true));
255    }
256
257    // ---- select_one ----
258    //
259    // Only the `is_json` branch is exercised here: it returns before reading
260    // stdin, so it is deterministic regardless of whether the test runs under
261    // a TTY. The non-TTY auto-select (`Ok(0)`) and the interactive
262    // `dialoguer` branches both depend on / consume the real stdin and would
263    // hang or vary by environment, so they are intentionally left to the e2e
264    // suite (see get.rs `select_patches` coverage).
265
266    #[test]
267    fn select_one_json_mode_requires_explicit_selection() {
268        let opts = vec!["first".to_string(), "second".to_string()];
269        match select_one("pick one", &opts, true) {
270            Err(SelectError::JsonModeNeedsExplicit) => {}
271            Err(SelectError::Cancelled) => panic!("json mode must not report Cancelled"),
272            Ok(idx) => panic!("json mode must not auto-select (got index {idx})"),
273        }
274    }
275
276    #[test]
277    fn select_one_json_mode_ignores_options_contents() {
278        // Even with a single option, JSON mode must defer to an explicit
279        // `--id` rather than silently picking it.
280        let opts = vec!["only".to_string()];
281        assert!(matches!(
282            select_one("pick", &opts, true),
283            Err(SelectError::JsonModeNeedsExplicit)
284        ));
285    }
286}