upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-10 12:51:52 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-10 13:03:32 +0000
commitc40a5553c335c889390cb54f5fad85e29af7d502 (patch)
tree0dd28a5ecd566f288ac7adb564187f7c58699e3f /src
parent9d142ee7046a415bb764f626e61476ed349c98ca (diff)
feat: add non-interactive mode support to CLI interactor
Add CliError type for styled error output and cli_error() helper function. Update Interactor to support non-interactive mode with default values. Add prompt methods that respect non-interactive mode and provide better error messages when required values are missing.
Diffstat (limited to 'src')
-rw-r--r--src/lib/cli_interactor.rs148
1 files changed, 147 insertions, 1 deletions
diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs
index e944bf9..881b988 100644
--- a/src/lib/cli_interactor.rs
+++ b/src/lib/cli_interactor.rs
@@ -1,4 +1,7 @@
1use anyhow::{Context, Result}; 1use std::fmt;
2
3use anyhow::{Context, Result, bail};
4use console::Style;
2use dialoguer::{ 5use dialoguer::{
3 Confirm, Input, Password, 6 Confirm, Input, Password,
4 theme::{ColorfulTheme, Theme}, 7 theme::{ColorfulTheme, Theme},
@@ -7,9 +10,93 @@ use indicatif::TermLike;
7#[cfg(test)] 10#[cfg(test)]
8use mockall::*; 11use mockall::*;
9 12
13/// Sentinel error type indicating the error has already been printed to stderr.
14///
15/// When this propagates up to `main()`, it signals "already printed styled
16/// output to stderr, don't double-print". This is the same pattern clap uses
17/// internally.
18#[derive(Debug)]
19pub struct CliError;
20
21impl fmt::Display for CliError {
22 fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result {
23 // Empty display — the error message was already printed to stderr
24 Ok(())
25 }
26}
27
28impl std::error::Error for CliError {}
29
30/// Print a styled CLI error to stderr and return an `anyhow::Error` wrapping
31/// [`CliError`].
32///
33/// - `message`: the main error text (printed after the red `error:` prefix)
34/// - `details`: flag/description pairs shown as gray indented lines (for
35/// multiple missing fields). Descriptions are aligned to the longest flag.
36/// - `suggestions`: command suggestions shown in yellow
37///
38/// This function does NOT call `process::exit()`. It prints to stderr and
39/// returns an error that the caller should propagate with `?` or `return Err`.
40pub fn cli_error(message: &str, details: &[(&str, &str)], suggestions: &[&str]) -> anyhow::Error {
41 let dim = Style::new().for_stderr().color256(247);
42
43 eprint!(
44 "{} {}",
45 console::style("error:").for_stderr().red(),
46 message
47 );
48 if details.is_empty() {
49 eprintln!();
50 } else {
51 let max_flag_len = details
52 .iter()
53 .map(|(flag, _)| flag.len())
54 .max()
55 .unwrap_or(0);
56 eprintln!();
57 for (flag, desc) in details {
58 eprintln!(
59 " {:width$} {}",
60 dim.apply_to(flag),
61 dim.apply_to(desc),
62 width = max_flag_len
63 );
64 }
65 }
66
67 if !suggestions.is_empty() {
68 eprintln!();
69 for cmd in suggestions {
70 eprintln!(
71 "{}",
72 console::style(format!(" {cmd}")).for_stderr().yellow(),
73 );
74 }
75 }
76
77 CliError.into()
78}
79
10#[derive(Default)] 80#[derive(Default)]
11pub struct Interactor { 81pub struct Interactor {
12 theme: ColorfulTheme, 82 theme: ColorfulTheme,
83 non_interactive: bool,
84}
85
86impl Interactor {
87 pub fn new(non_interactive: bool) -> Self {
88 Self {
89 theme: ColorfulTheme::default(),
90 non_interactive,
91 }
92 }
93
94 /// Returns true if running in non-interactive mode (the default).
95 /// Interactive mode is only enabled when NGIT_INTERACTIVE_MODE env var is
96 /// set (via -i flag).
97 pub fn is_non_interactive() -> bool {
98 std::env::var("NGIT_INTERACTIVE_MODE").is_err()
99 }
13} 100}
14 101
15#[cfg_attr(test, automock)] 102#[cfg_attr(test, automock)]
@@ -22,6 +109,21 @@ pub trait InteractorPrompt {
22} 109}
23impl InteractorPrompt for Interactor { 110impl InteractorPrompt for Interactor {
24 fn input(&self, parms: PromptInputParms) -> Result<String> { 111 fn input(&self, parms: PromptInputParms) -> Result<String> {
112 if self.non_interactive || Self::is_non_interactive() {
113 if parms.optional || !parms.default.is_empty() {
114 return Ok(parms.default);
115 }
116 let flag_hint = parms
117 .flag_name
118 .as_ref()
119 .map(|f| format!(" (provide {} or use -i/-d)", f))
120 .unwrap_or_else(|| " (use -i for interactive mode or -d for defaults)".to_string());
121 bail!(
122 "interactive input required but running in non-interactive mode: {}{}",
123 parms.prompt,
124 flag_hint
125 );
126 }
25 let mut input = Input::with_theme(&self.theme) 127 let mut input = Input::with_theme(&self.theme)
26 .with_prompt(parms.prompt) 128 .with_prompt(parms.prompt)
27 .allow_empty(parms.optional) 129 .allow_empty(parms.optional)
@@ -32,6 +134,12 @@ impl InteractorPrompt for Interactor {
32 Ok(input.interact_text()?) 134 Ok(input.interact_text()?)
33 } 135 }
34 fn password(&self, parms: PromptPasswordParms) -> Result<String> { 136 fn password(&self, parms: PromptPasswordParms) -> Result<String> {
137 if self.non_interactive || Self::is_non_interactive() {
138 bail!(
139 "password input required but running in non-interactive mode: {}",
140 parms.prompt
141 );
142 }
35 let mut p = Password::with_theme(&self.theme) 143 let mut p = Password::with_theme(&self.theme)
36 .with_prompt(parms.prompt) 144 .with_prompt(parms.prompt)
37 .report(parms.report); 145 .report(parms.report);
@@ -42,6 +150,9 @@ impl InteractorPrompt for Interactor {
42 Ok(pass) 150 Ok(pass)
43 } 151 }
44 fn confirm(&self, params: PromptConfirmParms) -> Result<bool> { 152 fn confirm(&self, params: PromptConfirmParms) -> Result<bool> {
153 if self.non_interactive || Self::is_non_interactive() {
154 return Ok(params.default);
155 }
45 let confirm: bool = Confirm::with_theme(&self.theme) 156 let confirm: bool = Confirm::with_theme(&self.theme)
46 .with_prompt(params.prompt) 157 .with_prompt(params.prompt)
47 .default(params.default) 158 .default(params.default)
@@ -49,6 +160,15 @@ impl InteractorPrompt for Interactor {
49 Ok(confirm) 160 Ok(confirm)
50 } 161 }
51 fn choice(&self, parms: PromptChoiceParms) -> Result<usize> { 162 fn choice(&self, parms: PromptChoiceParms) -> Result<usize> {
163 if self.non_interactive || Self::is_non_interactive() {
164 if let Some(default) = parms.default {
165 return Ok(default);
166 }
167 bail!(
168 "interactive choice required but running in non-interactive mode: {}",
169 parms.prompt
170 );
171 }
52 let mut choice = dialoguer::Select::with_theme(&self.theme) 172 let mut choice = dialoguer::Select::with_theme(&self.theme)
53 .with_prompt(parms.prompt) 173 .with_prompt(parms.prompt)
54 .report(parms.report) 174 .report(parms.report)
@@ -61,6 +181,17 @@ impl InteractorPrompt for Interactor {
61 choice.interact().context("failed to get choice") 181 choice.interact().context("failed to get choice")
62 } 182 }
63 fn multi_choice(&self, parms: PromptMultiChoiceParms) -> Result<Vec<usize>> { 183 fn multi_choice(&self, parms: PromptMultiChoiceParms) -> Result<Vec<usize>> {
184 if self.non_interactive || Self::is_non_interactive() {
185 if let Some(defaults) = &parms.defaults {
186 return Ok(defaults
187 .iter()
188 .enumerate()
189 .filter(|(_, &selected)| selected)
190 .map(|(i, _)| i)
191 .collect());
192 }
193 return Ok(vec![]); // Empty selection if no defaults
194 }
64 // the colorful theme is not very clear so falling back to default 195 // the colorful theme is not very clear so falling back to default
65 let mut choice = dialoguer::MultiSelect::default() 196 let mut choice = dialoguer::MultiSelect::default()
66 .with_prompt(parms.prompt) 197 .with_prompt(parms.prompt)
@@ -73,11 +204,20 @@ impl InteractorPrompt for Interactor {
73 } 204 }
74} 205}
75 206
207/// Parameters for interactive input prompts.
208///
209/// Supports both interactive and non-interactive modes:
210/// - Interactive mode (NGIT_INTERACTIVE_MODE set): prompts user
211/// - Non-interactive mode (default): returns default value or errors
212///
213/// The `flag_name` field improves error messages by telling users
214/// which CLI flag would provide the missing value.
76pub struct PromptInputParms { 215pub struct PromptInputParms {
77 pub prompt: String, 216 pub prompt: String,
78 pub default: String, 217 pub default: String,
79 pub report: bool, 218 pub report: bool,
80 pub optional: bool, 219 pub optional: bool,
220 pub flag_name: Option<String>,
81} 221}
82 222
83impl Default for PromptInputParms { 223impl Default for PromptInputParms {
@@ -87,6 +227,7 @@ impl Default for PromptInputParms {
87 default: String::new(), 227 default: String::new(),
88 optional: false, 228 optional: false,
89 report: true, 229 report: true,
230 flag_name: None,
90 } 231 }
91 } 232 }
92} 233}
@@ -109,6 +250,11 @@ impl PromptInputParms {
109 self.report = false; 250 self.report = false;
110 self 251 self
111 } 252 }
253
254 pub fn with_flag_name<S: Into<String>>(mut self, flag_name: S) -> Self {
255 self.flag_name = Some(flag_name.into());
256 self
257 }
112} 258}
113 259
114pub struct PromptPasswordParms { 260pub struct PromptPasswordParms {