config-easy 0.2.1

A small interactive settings menu for command-line Rust applications
Documentation
use std::error::Error;
use std::io::{self, BufRead, Write};

use crate::ConfigEasyError;
use crate::action::{MenuAction, MenuActionCallback, validate_action_key};
use crate::identifier::validate_identifier;
use crate::{SettingRow, SettingsQuery, SettingsStore};

/// A callback used to validate a setting before it is saved.
///
/// The callback receives the setting key and the proposed new value.
/// Return `Ok(())` if the value is valid, or an error explaining why it
/// should be rejected.
pub type Validator<'a> = dyn Fn(&str, &str) -> Result<(), Box<dyn Error + Send + Sync>> + 'a;

/// A configurable terminal menu for viewing and editing application settings.
///
/// `ConfigMenu` reads key/value settings from a SQLite table, displays them as
/// a numbered menu, and allows the selected value to be updated.
///
/// It can optionally mask secret values, validate proposed changes, customise
/// ordering, and register additional user-defined menu actions.
pub struct ConfigMenu<'a, S> {
    store: S,
    table: String,
    key_column: String,
    value_column: String,
    order_by: Option<String>,
    secret_keys: Vec<String>,
    validator: Option<Box<Validator<'a>>>,
    actions: Vec<MenuAction<'a>>,
}

impl<'a, S> ConfigMenu<'a, S>
where
    S: SettingsStore,
{
    /// Creates a new `ConfigMenu` with the given settings store and default configuration.
    /// The default configuration assumes a table named `settings` with columns `key` and `value`, ordered by `key`.
    ///
    /// # Arguments
    /// * `store` - A settings store that will be used to load and update settings.
    ///
    /// # Returns
    /// A `ConfigMenu` instance with the provided database connection and default configuration.
    pub(crate) fn new(store: S) -> Self {
        Self {
            store,
            table: "settings".to_string(),
            key_column: "key".to_string(),
            value_column: "value".to_string(),
            order_by: Some("key".to_string()),
            secret_keys: Vec::new(),
            validator: None,
            actions: Vec::new(),
        }
    }

    /// Sets the database table to load settings from.
    /// The specified table must have at least two columns: one for keys and one for values.
    ///
    /// # Arguments
    /// * `table` - The name of the database table to use for loading and updating settings.
    ///
    /// # Returns
    /// The `ConfigMenu` instance with the updated table configuration.
    pub fn table(mut self, table: impl Into<String>) -> Self {
        self.table = table.into();
        self
    }

    /// Sets the column name to use for setting keys.
    /// The specified column must contain unique values that identify each setting.
    ///
    /// # Arguments
    /// * `key_column` - The name of the column to use for setting keys.
    ///
    /// # Returns
    /// The `ConfigMenu` instance with the updated key column configuration.
    pub fn key_column(mut self, key_column: impl Into<String>) -> Self {
        self.key_column = key_column.into();
        self
    }

    /// Sets the column name to use for setting values.
    /// The specified column must contain the values associated with each setting key.
    ///
    /// # Arguments
    /// * `value_column` - The name of the column to use for setting values.
    ///
    /// # Returns
    /// The `ConfigMenu` instance with the updated value column configuration.
    pub fn value_column(mut self, value_column: impl Into<String>) -> Self {
        self.value_column = value_column.into();
        self
    }

    /// Sets the column name to use for ordering settings in the menu.
    /// If not specified, settings will be ordered by the key column.
    /// The specified column must contain values that can be used to determine the display order of settings.
    ///
    /// # Arguments
    /// * `order_by` - The name of the column to use for ordering settings in the menu.
    ///
    /// # Returns
    /// The `ConfigMenu` instance with the updated ordering configuration.
    pub fn order_by(mut self, order_by: impl Into<String>) -> Self {
        self.order_by = Some(order_by.into());
        self
    }

    /// Sets the keys of settings that should be treated as secrets and have their values masked in the menu.
    /// 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.
    /// 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.
    ///
    /// # Arguments
    /// * `keys` - An iterable of keys that should be treated as secrets and have their values masked in the menu.
    ///
    /// # Returns
    /// The `ConfigMenu` instance with the updated secret keys configuration.
    pub fn secret_keys<I, K>(mut self, keys: I) -> Self
    where
        I: IntoIterator<Item = K>,
        K: Into<String>,
    {
        self.secret_keys = keys.into_iter().map(Into::into).collect();
        self
    }

    /// Sets a custom validator function that will be called to validate new values for settings before they are updated in the database.
    /// The validator function should take the setting key and the new value as arguments and return `
    /// Ok(())` if the value is valid or an `Err` with a message if the value is invalid.
    /// 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
    /// from being applied to the database.
    /// This allows you to enforce constraints on setting values, such as requiring a certain format or range of values.
    ///
    /// # Arguments
    /// * `validator` - A function that takes a setting key and a new value.
    ///
    /// # Returns
    /// The `ConfigMenu` instance with the updated validator configuration.
    pub fn validator<F>(mut self, validator: F) -> Self
    where
        F: Fn(&str, &str) -> Result<(), Box<dyn Error + Send + Sync>> + 'a,
    {
        self.validator = Some(Box::new(validator));
        self
    }

    /// Adds a custom action to the menu that can be triggered by entering a specific key.
    /// The action will be displayed in the menu prompt with the specified label, and when the user enters the corresponding key,
    /// the provided callback function will be executed.
    /// The callback function should take a reference to the database connection as an argument and return `Ok(())` if the action was
    /// successful or an `Err` with a message if the action failed.
    /// This allows you to add additional functionality to the menu, such as resetting settings to default values, syncing settings with
    /// an external service, or performing any other custom operation that can be implemented in Rust.
    /// The action key must not be empty, must not consist solely of digits, and must not be "q" or "quit" (case-sensitive), as these
    /// are reserved for exiting the menu.
    /// If an invalid or reserved action key is provided, the menu will return an error when you try to run it.
    ///
    /// # Arguments
    /// * `key` - The key that the user must enter to trigger the action.
    /// * `label` - A description of the action that will be displayed in the menu prompt.
    /// * `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.
    ///
    /// # Returns
    /// The `ConfigMenu` instance with the new action added to its configuration.
    pub fn action<F>(
        mut self,
        key: impl Into<String>,
        label: impl Into<String>,
        callback: F,
    ) -> Self
    where
        F: Fn() -> Result<(), Box<dyn Error + Send + Sync>> + 'a,
    {
        self.actions.push(MenuAction {
            key: key.into(),
            label: label.into(),
            callback: Box::new(callback) as Box<MenuActionCallback<'a>>,
        });
        self
    }

    /// Runs the configuration menu, allowing the user to view and edit settings in the database according to the menu's configuration.
    ///
    /// # Returns
    /// A `Result` indicating whether the menu ran successfully or if an error occurred.
    pub fn run(&self) -> Result<(), ConfigEasyError> {
        let stdin = io::stdin();
        let mut stdout = io::stdout();
        self.run_with(&mut stdin.lock(), &mut stdout)
    }

    fn run_with<R, W>(&self, input: &mut R, output: &mut W) -> Result<(), ConfigEasyError>
    where
        R: BufRead,
        W: Write,
    {
        self.validate_config()?;

        loop {
            let rows = self.load_rows()?;
            self.render(&rows, output)?;

            let selection = read_trimmed_line(input)?;
            if should_exit(&selection) {
                return Ok(());
            }

            if let Some(action) = self.actions.iter().find(|action| action.key == selection) {
                self.run_action(action)?;
                continue;
            }

            match parse_selection(&selection, rows.len()) {
                Ok(index) => match self.edit_row(&rows[index], input, output) {
                    Ok(()) => {}
                    Err(error @ ConfigEasyError::ValidationFailed { .. }) => {
                        writeln!(output, "{error}")?;
                    }
                    Err(error) => return Err(error),
                },
                Err(error) => writeln!(output, "{error}")?,
            }
        }
    }

    fn validate_config(&self) -> Result<(), ConfigEasyError> {
        validate_identifier(&self.table)?;
        validate_identifier(&self.key_column)?;
        validate_identifier(&self.value_column)?;

        if let Some(order_by) = &self.order_by {
            validate_identifier(order_by)?;
        }

        let mut action_keys = Vec::new();
        for action in &self.actions {
            validate_action_key(&action.key)?;

            if action_keys.iter().any(|key| key == &action.key) {
                return Err(ConfigEasyError::DuplicateActionKey(action.key.clone()));
            }

            action_keys.push(action.key.clone());
        }

        Ok(())
    }

    fn load_rows(&self) -> Result<Vec<SettingRow>, ConfigEasyError> {
        self.validate_config()?;

        self.store
            .load_settings(&self.query())
            .map_err(ConfigEasyError::from)
    }

    fn render<W: Write>(&self, rows: &[SettingRow], output: &mut W) -> Result<(), ConfigEasyError> {
        clear_write(output)?;
        writeln!(output, "Settings")?;
        writeln!(output)?;

        let key_width = rows.iter().map(|row| row.key.len()).max().unwrap_or(0);

        for (index, row) in rows.iter().enumerate() {
            writeln!(
                output,
                "{}) {:key_width$}  {}",
                index + 1,
                row.key,
                self.display_value(row),
            )?;
        }

        writeln!(output)?;
        write!(output, "{}", self.prompt())?;
        output.flush()?;

        Ok(())
    }

    fn prompt(&self) -> String {
        if self.actions.is_empty() {
            "Enter setting number to edit, or q to quit: ".to_string()
        } else {
            let actions = self
                .actions
                .iter()
                .map(|action| format!("{} to {}", action.key, action.label))
                .collect::<Vec<_>>()
                .join(", ");

            format!("Enter setting number to edit, {actions}, or q to quit: ")
        }
    }

    fn display_value(&self, row: &SettingRow) -> String {
        if self.secret_keys.iter().any(|key| key == &row.key) && !row.value.is_empty() {
            "********".to_string()
        } else {
            row.value.clone()
        }
    }

    fn edit_row<R, W>(
        &self,
        row: &SettingRow,
        input: &mut R,
        output: &mut W,
    ) -> Result<(), ConfigEasyError>
    where
        R: BufRead,
        W: Write,
    {
        writeln!(output, "New value for {}:", row.key)?;
        output.flush()?;

        let new_value = read_trimmed_line(input)?;
        self.validate_value(&row.key, &new_value)?;
        self.update_row(&row.key, &new_value)?;
        writeln!(output, "Updated {}.", row.key)?;

        Ok(())
    }

    fn validate_value(&self, key: &str, value: &str) -> Result<(), ConfigEasyError> {
        if let Some(validator) = &self.validator {
            validator(key, value).map_err(|error| ConfigEasyError::ValidationFailed {
                key: key.to_string(),
                message: error.to_string(),
            })?;
        }

        Ok(())
    }

    fn update_row(&self, key: &str, value: &str) -> Result<(), ConfigEasyError> {
        self.validate_config()?;

        self.store
            .update_setting(&self.query(), key, value)
            .map_err(ConfigEasyError::from)?;

        Ok(())
    }

    fn run_action(&self, action: &MenuAction<'_>) -> Result<(), ConfigEasyError> {
        (action.callback)().map_err(|error| ConfigEasyError::ActionFailed {
            key: action.key.clone(),
            message: error.to_string(),
        })
    }

    fn query(&self) -> SettingsQuery<'_> {
        SettingsQuery {
            table: &self.table,
            key_column: &self.key_column,
            value_column: &self.value_column,
            order_by: self.order_by.as_deref().unwrap_or(&self.key_column),
        }
    }
}

fn read_trimmed_line<R: BufRead>(input: &mut R) -> Result<String, ConfigEasyError> {
    let mut line = String::new();
    input.read_line(&mut line)?;
    Ok(line.trim_end_matches(['\r', '\n']).trim().to_string())
}

fn clear_write<W: Write>(output: &mut W) -> Result<(), ConfigEasyError> {
    write!(output, "\x1b[2J\x1b[H")?;
    Ok(())
}

fn should_exit(selection: &str) -> bool {
    selection.is_empty() || selection == "q" || selection == "quit"
}

fn parse_selection(selection: &str, row_count: usize) -> Result<usize, ConfigEasyError> {
    let selection = selection
        .parse::<usize>()
        .map_err(|_| ConfigEasyError::InvalidSelection)?;

    if (1..=row_count).contains(&selection) {
        Ok(selection - 1)
    } else {
        Err(ConfigEasyError::InvalidSelection)
    }
}

#[cfg(test)]
mod tests;