Skip to main content

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        writeln!(output, "Settings")?;
262        writeln!(output)?;
263
264        let key_width = rows.iter().map(|row| row.key.len()).max().unwrap_or(0);
265
266        for (index, row) in rows.iter().enumerate() {
267            writeln!(
268                output,
269                "{}) {:key_width$}  {}",
270                index + 1,
271                row.key,
272                self.display_value(row),
273            )?;
274        }
275
276        writeln!(output)?;
277        write!(output, "{}", self.prompt())?;
278        output.flush()?;
279
280        Ok(())
281    }
282
283    fn prompt(&self) -> String {
284        if self.actions.is_empty() {
285            "Enter setting number to edit, or q to quit: ".to_string()
286        } else {
287            let actions = self
288                .actions
289                .iter()
290                .map(|action| format!("{} to {}", action.key, action.label))
291                .collect::<Vec<_>>()
292                .join(", ");
293
294            format!("Enter setting number to edit, {actions}, or q to quit: ")
295        }
296    }
297
298    fn display_value(&self, row: &SettingRow) -> String {
299        if self.secret_keys.iter().any(|key| key == &row.key) && !row.value.is_empty() {
300            "********".to_string()
301        } else {
302            row.value.clone()
303        }
304    }
305
306    fn edit_row<R, W>(
307        &self,
308        row: &SettingRow,
309        input: &mut R,
310        output: &mut W,
311    ) -> Result<(), ConfigEasyError>
312    where
313        R: BufRead,
314        W: Write,
315    {
316        writeln!(output, "New value for {}:", row.key)?;
317        output.flush()?;
318
319        let new_value = read_trimmed_line(input)?;
320        self.validate_value(&row.key, &new_value)?;
321        self.update_row(&row.key, &new_value)?;
322        writeln!(output, "Updated {}.", row.key)?;
323
324        Ok(())
325    }
326
327    fn validate_value(&self, key: &str, value: &str) -> Result<(), ConfigEasyError> {
328        if let Some(validator) = &self.validator {
329            validator(key, value).map_err(|error| ConfigEasyError::ValidationFailed {
330                key: key.to_string(),
331                message: error.to_string(),
332            })?;
333        }
334
335        Ok(())
336    }
337
338    fn update_row(&self, key: &str, value: &str) -> Result<(), ConfigEasyError> {
339        self.validate_config()?;
340
341        self.store
342            .update_setting(&self.query(), key, value)
343            .map_err(ConfigEasyError::from)?;
344
345        Ok(())
346    }
347
348    fn run_action(&self, action: &MenuAction<'_>) -> Result<(), ConfigEasyError> {
349        (action.callback)().map_err(|error| ConfigEasyError::ActionFailed {
350            key: action.key.clone(),
351            message: error.to_string(),
352        })
353    }
354
355    fn query(&self) -> SettingsQuery<'_> {
356        SettingsQuery {
357            table: &self.table,
358            key_column: &self.key_column,
359            value_column: &self.value_column,
360            order_by: self.order_by.as_deref().unwrap_or(&self.key_column),
361        }
362    }
363}
364
365fn read_trimmed_line<R: BufRead>(input: &mut R) -> Result<String, ConfigEasyError> {
366    let mut line = String::new();
367    input.read_line(&mut line)?;
368    Ok(line.trim_end_matches(['\r', '\n']).trim().to_string())
369}
370
371fn should_exit(selection: &str) -> bool {
372    selection.is_empty() || selection == "q" || selection == "quit"
373}
374
375fn parse_selection(selection: &str, row_count: usize) -> Result<usize, ConfigEasyError> {
376    let selection = selection
377        .parse::<usize>()
378        .map_err(|_| ConfigEasyError::InvalidSelection)?;
379
380    if (1..=row_count).contains(&selection) {
381        Ok(selection - 1)
382    } else {
383        Err(ConfigEasyError::InvalidSelection)
384    }
385}
386
387#[cfg(test)]
388mod tests;