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;