Skip to main content

config_easy/menu/
mod.rs

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