config_easy/menu/mod.rs
1use std::error::Error;
2use std::io::{self, BufRead, Write};
3
4use crate::ConfigEasyError;
5use crate::action::{MenuAction, MenuActionCallback, validate_action_key};
6use crate::identifier::validate_identifier;
7use crate::{SettingRow, SettingsQuery, SettingsStore};
8
9/// A callback used to validate a setting before it is saved.
10///
11/// The callback receives the setting key and the proposed new value.
12/// Return `Ok(())` if the value is valid, or an error explaining why it
13/// should be rejected.
14pub type Validator<'a> = dyn Fn(&str, &str) -> Result<(), Box<dyn Error + Send + Sync>> + 'a;
15
16/// A configurable terminal menu for viewing and editing application settings.
17///
18/// `ConfigMenu` reads key/value settings from a SQLite table, displays them as
19/// a numbered menu, and allows the selected value to be updated.
20///
21/// It can optionally mask secret values, validate proposed changes, customise
22/// ordering, and register additional user-defined menu actions.
23pub struct ConfigMenu<'a, S> {
24 store: S,
25 table: String,
26 key_column: String,
27 value_column: String,
28 order_by: Option<String>,
29 secret_keys: Vec<String>,
30 validator: Option<Box<Validator<'a>>>,
31 actions: Vec<MenuAction<'a>>,
32}
33
34impl<'a, S> ConfigMenu<'a, S>
35where
36 S: SettingsStore,
37{
38 /// Creates a new `ConfigMenu` with the given settings store and default configuration.
39 /// The default configuration assumes a table named `settings` with columns `key` and `value`, ordered by `key`.
40 ///
41 /// # Arguments
42 /// * `store` - A settings store that will be used to load and update settings.
43 ///
44 /// # Returns
45 /// A `ConfigMenu` instance with the provided database connection and default configuration.
46 pub(crate) fn new(store: S) -> Self {
47 Self {
48 store,
49 table: "settings".to_string(),
50 key_column: "key".to_string(),
51 value_column: "value".to_string(),
52 order_by: Some("key".to_string()),
53 secret_keys: Vec::new(),
54 validator: None,
55 actions: Vec::new(),
56 }
57 }
58
59 /// Sets the database table to load settings from.
60 /// The specified table must have at least two columns: one for keys and one for values.
61 ///
62 /// # Arguments
63 /// * `table` - The name of the database table to use for loading and updating settings.
64 ///
65 /// # Returns
66 /// The `ConfigMenu` instance with the updated table configuration.
67 pub fn table(mut self, table: impl Into<String>) -> Self {
68 self.table = table.into();
69 self
70 }
71
72 /// Sets the column name to use for setting keys.
73 /// The specified column must contain unique values that identify each setting.
74 ///
75 /// # Arguments
76 /// * `key_column` - The name of the column to use for setting keys.
77 ///
78 /// # Returns
79 /// The `ConfigMenu` instance with the updated key column configuration.
80 pub fn key_column(mut self, key_column: impl Into<String>) -> Self {
81 self.key_column = key_column.into();
82 self
83 }
84
85 /// Sets the column name to use for setting values.
86 /// The specified column must contain the values associated with each setting key.
87 ///
88 /// # Arguments
89 /// * `value_column` - The name of the column to use for setting values.
90 ///
91 /// # Returns
92 /// The `ConfigMenu` instance with the updated value column configuration.
93 pub fn value_column(mut self, value_column: impl Into<String>) -> Self {
94 self.value_column = value_column.into();
95 self
96 }
97
98 /// Sets the column name to use for ordering settings in the menu.
99 /// If not specified, settings will be ordered by the key column.
100 /// The specified column must contain values that can be used to determine the display order of settings.
101 ///
102 /// # Arguments
103 /// * `order_by` - The name of the column to use for ordering settings in the menu.
104 ///
105 /// # Returns
106 /// The `ConfigMenu` instance with the updated ordering configuration.
107 pub fn order_by(mut self, order_by: impl Into<String>) -> Self {
108 self.order_by = Some(order_by.into());
109 self
110 }
111
112 /// Sets the keys of settings that should be treated as secrets and have their values masked in the menu.
113 /// When a setting with a key in this list has a non-empty value, it will be displayed as "********" in the menu to indicate that it is a secret.
114 /// The actual value of the setting will still be loaded and updated in the database as normal; this only affects how the value is displayed in the menu.
115 ///
116 /// # Arguments
117 /// * `keys` - An iterable of keys that should be treated as secrets and have their values masked in the menu.
118 ///
119 /// # Returns
120 /// The `ConfigMenu` instance with the updated secret keys configuration.
121 pub fn secret_keys<I, K>(mut self, keys: I) -> Self
122 where
123 I: IntoIterator<Item = K>,
124 K: Into<String>,
125 {
126 self.secret_keys = keys.into_iter().map(Into::into).collect();
127 self
128 }
129
130 /// Sets a custom validator function that will be called to validate new values for settings before they are updated in the database.
131 /// The validator function should take the setting key and the new value as arguments and return `
132 /// Ok(())` if the value is valid or an `Err` with a message if the value is invalid.
133 /// If a validator is set and it returns an error for a new value, the menu will display the error message and prevent the update
134 /// from being applied to the database.
135 /// This allows you to enforce constraints on setting values, such as requiring a certain format or range of values.
136 ///
137 /// # Arguments
138 /// * `validator` - A function that takes a setting key and a new value.
139 ///
140 /// # Returns
141 /// The `ConfigMenu` instance with the updated validator configuration.
142 pub fn validator<F>(mut self, validator: F) -> Self
143 where
144 F: Fn(&str, &str) -> Result<(), Box<dyn Error + Send + Sync>> + 'a,
145 {
146 self.validator = Some(Box::new(validator));
147 self
148 }
149
150 /// Adds a custom action to the menu that can be triggered by entering a specific key.
151 /// The action will be displayed in the menu prompt with the specified label, and when the user enters the corresponding key,
152 /// the provided callback function will be executed.
153 /// The callback function should take a reference to the database connection as an argument and return `Ok(())` if the action was
154 /// successful or an `Err` with a message if the action failed.
155 /// This allows you to add additional functionality to the menu, such as resetting settings to default values, syncing settings with
156 /// an external service, or performing any other custom operation that can be implemented in Rust.
157 /// The action key must not be empty, must not consist solely of digits, and must not be "q" or "quit" (case-sensitive), as these
158 /// are reserved for exiting the menu.
159 /// If an invalid or reserved action key is provided, the menu will return an error when you try to run it.
160 ///
161 /// # Arguments
162 /// * `key` - The key that the user must enter to trigger the action.
163 /// * `label` - A description of the action that will be displayed in the menu prompt.
164 /// * `callback` - A function that will be called when the action is triggered. It should take a reference to the database connection and return a `Result` indicating success or failure of the action.
165 ///
166 /// # Returns
167 /// The `ConfigMenu` instance with the new action added to its configuration.
168 pub fn action<F>(
169 mut self,
170 key: impl Into<String>,
171 label: impl Into<String>,
172 callback: F,
173 ) -> Self
174 where
175 F: Fn() -> Result<(), Box<dyn Error + Send + Sync>> + 'a,
176 {
177 self.actions.push(MenuAction {
178 key: key.into(),
179 label: label.into(),
180 callback: Box::new(callback) as Box<MenuActionCallback<'a>>,
181 });
182 self
183 }
184
185 /// Runs the configuration menu, allowing the user to view and edit settings in the database according to the menu's configuration.
186 ///
187 /// # Returns
188 /// A `Result` indicating whether the menu ran successfully or if an error occurred.
189 pub fn run(&self) -> Result<(), ConfigEasyError> {
190 let stdin = io::stdin();
191 let mut stdout = io::stdout();
192 self.run_with(&mut stdin.lock(), &mut stdout)
193 }
194
195 fn run_with<R, W>(&self, input: &mut R, output: &mut W) -> Result<(), ConfigEasyError>
196 where
197 R: BufRead,
198 W: Write,
199 {
200 self.validate_config()?;
201
202 loop {
203 let rows = self.load_rows()?;
204 self.render(&rows, output)?;
205
206 let selection = read_trimmed_line(input)?;
207 if should_exit(&selection) {
208 return Ok(());
209 }
210
211 if let Some(action) = self.actions.iter().find(|action| action.key == selection) {
212 self.run_action(action)?;
213 continue;
214 }
215
216 match parse_selection(&selection, rows.len()) {
217 Ok(index) => match self.edit_row(&rows[index], input, output) {
218 Ok(()) => {}
219 Err(error @ ConfigEasyError::ValidationFailed { .. }) => {
220 writeln!(output, "{error}")?;
221 }
222 Err(error) => return Err(error),
223 },
224 Err(error) => writeln!(output, "{error}")?,
225 }
226 }
227 }
228
229 fn validate_config(&self) -> Result<(), ConfigEasyError> {
230 validate_identifier(&self.table)?;
231 validate_identifier(&self.key_column)?;
232 validate_identifier(&self.value_column)?;
233
234 if let Some(order_by) = &self.order_by {
235 validate_identifier(order_by)?;
236 }
237
238 let mut action_keys = Vec::new();
239 for action in &self.actions {
240 validate_action_key(&action.key)?;
241
242 if action_keys.iter().any(|key| key == &action.key) {
243 return Err(ConfigEasyError::DuplicateActionKey(action.key.clone()));
244 }
245
246 action_keys.push(action.key.clone());
247 }
248
249 Ok(())
250 }
251
252 fn load_rows(&self) -> Result<Vec<SettingRow>, ConfigEasyError> {
253 self.validate_config()?;
254
255 self.store
256 .load_settings(&self.query())
257 .map_err(ConfigEasyError::from)
258 }
259
260 fn render<W: Write>(&self, rows: &[SettingRow], output: &mut W) -> Result<(), ConfigEasyError> {
261 clear_write(output)?;
262 writeln!(output, "Settings")?;
263 writeln!(output)?;
264
265 let key_width = rows.iter().map(|row| row.key.len()).max().unwrap_or(0);
266
267 for (index, row) in rows.iter().enumerate() {
268 writeln!(
269 output,
270 "{}) {:key_width$} {}",
271 index + 1,
272 row.key,
273 self.display_value(row),
274 )?;
275 }
276
277 writeln!(output)?;
278 write!(output, "{}", self.prompt())?;
279 output.flush()?;
280
281 Ok(())
282 }
283
284 fn prompt(&self) -> String {
285 if self.actions.is_empty() {
286 "Enter setting number to edit, or q to quit: ".to_string()
287 } else {
288 let actions = self
289 .actions
290 .iter()
291 .map(|action| format!("{} to {}", action.key, action.label))
292 .collect::<Vec<_>>()
293 .join(", ");
294
295 format!("Enter setting number to edit, {actions}, or q to quit: ")
296 }
297 }
298
299 fn display_value(&self, row: &SettingRow) -> String {
300 if self.secret_keys.iter().any(|key| key == &row.key) && !row.value.is_empty() {
301 "********".to_string()
302 } else {
303 row.value.clone()
304 }
305 }
306
307 fn edit_row<R, W>(
308 &self,
309 row: &SettingRow,
310 input: &mut R,
311 output: &mut W,
312 ) -> Result<(), ConfigEasyError>
313 where
314 R: BufRead,
315 W: Write,
316 {
317 writeln!(output, "New value for {}:", row.key)?;
318 output.flush()?;
319
320 let new_value = read_trimmed_line(input)?;
321 self.validate_value(&row.key, &new_value)?;
322 self.update_row(&row.key, &new_value)?;
323 writeln!(output, "Updated {}.", row.key)?;
324
325 Ok(())
326 }
327
328 fn validate_value(&self, key: &str, value: &str) -> Result<(), ConfigEasyError> {
329 if let Some(validator) = &self.validator {
330 validator(key, value).map_err(|error| ConfigEasyError::ValidationFailed {
331 key: key.to_string(),
332 message: error.to_string(),
333 })?;
334 }
335
336 Ok(())
337 }
338
339 fn update_row(&self, key: &str, value: &str) -> Result<(), ConfigEasyError> {
340 self.validate_config()?;
341
342 self.store
343 .update_setting(&self.query(), key, value)
344 .map_err(ConfigEasyError::from)?;
345
346 Ok(())
347 }
348
349 fn run_action(&self, action: &MenuAction<'_>) -> Result<(), ConfigEasyError> {
350 (action.callback)().map_err(|error| ConfigEasyError::ActionFailed {
351 key: action.key.clone(),
352 message: error.to_string(),
353 })
354 }
355
356 fn query(&self) -> SettingsQuery<'_> {
357 SettingsQuery {
358 table: &self.table,
359 key_column: &self.key_column,
360 value_column: &self.value_column,
361 order_by: self.order_by.as_deref().unwrap_or(&self.key_column),
362 }
363 }
364}
365
366fn read_trimmed_line<R: BufRead>(input: &mut R) -> Result<String, ConfigEasyError> {
367 let mut line = String::new();
368 input.read_line(&mut line)?;
369 Ok(line.trim_end_matches(['\r', '\n']).trim().to_string())
370}
371
372fn clear_write<W: Write>(output: &mut W) -> Result<(), ConfigEasyError> {
373 write!(output, "\x1b[2J\x1b[H")?;
374 Ok(())
375}
376
377fn should_exit(selection: &str) -> bool {
378 selection.is_empty() || selection == "q" || selection == "quit"
379}
380
381fn parse_selection(selection: &str, row_count: usize) -> Result<usize, ConfigEasyError> {
382 let selection = selection
383 .parse::<usize>()
384 .map_err(|_| ConfigEasyError::InvalidSelection)?;
385
386 if (1..=row_count).contains(&selection) {
387 Ok(selection - 1)
388 } else {
389 Err(ConfigEasyError::InvalidSelection)
390 }
391}
392
393#[cfg(test)]
394mod tests;