config-easy 0.2.1

A small interactive settings menu for command-line Rust applications
Documentation
use super::*;
use std::cell::{Cell, RefCell};
use std::io::Cursor;

#[derive(Debug, Clone, PartialEq, Eq)]
struct CapturedQuery {
    table: String,
    key_column: String,
    value_column: String,
    order_by: String,
}

#[derive(Default)]
struct FakeStore {
    rows: RefCell<Vec<SettingRow>>,
    last_query: RefCell<Option<CapturedQuery>>,
}

impl FakeStore {
    fn with_rows(rows: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
        Self {
            rows: RefCell::new(
                rows.into_iter()
                    .map(|(key, value)| SettingRow {
                        key: key.to_string(),
                        value: value.to_string(),
                    })
                    .collect(),
            ),
            last_query: RefCell::new(None),
        }
    }
}

impl SettingsStore for FakeStore {
    fn load_settings(
        &self,
        query: &SettingsQuery<'_>,
    ) -> Result<Vec<SettingRow>, Box<dyn Error + Send + Sync>> {
        *self.last_query.borrow_mut() = Some(CapturedQuery {
            table: query.table.to_string(),
            key_column: query.key_column.to_string(),
            value_column: query.value_column.to_string(),
            order_by: query.order_by.to_string(),
        });

        let mut rows = self.rows.borrow().clone();
        if query.order_by == query.key_column {
            rows.sort_by(|left, right| left.key.cmp(&right.key));
        }

        Ok(rows)
    }

    fn update_setting(
        &self,
        query: &SettingsQuery<'_>,
        key: &str,
        value: &str,
    ) -> Result<(), Box<dyn Error + Send + Sync>> {
        *self.last_query.borrow_mut() = Some(CapturedQuery {
            table: query.table.to_string(),
            key_column: query.key_column.to_string(),
            value_column: query.value_column.to_string(),
            order_by: query.order_by.to_string(),
        });

        if let Some(row) = self.rows.borrow_mut().iter_mut().find(|row| row.key == key) {
            row.value = value.to_string();
        }

        Ok(())
    }
}

#[test]
fn default_builder_values_load_default_table_ordered_by_key() {
    let store = FakeStore::with_rows([("log_level", "info"), ("graph_client_id", "abc123")]);
    let menu = ConfigMenu::new(store);

    let rows = menu.load_rows().unwrap();

    assert_eq!(rows[0].key, "graph_client_id");
    assert_eq!(rows[1].key, "log_level");
    assert_eq!(
        *menu.store.last_query.borrow(),
        Some(CapturedQuery {
            table: "settings".to_string(),
            key_column: "key".to_string(),
            value_column: "value".to_string(),
            order_by: "key".to_string(),
        })
    );
}

#[test]
fn masks_non_empty_secret_values_only() {
    let menu = ConfigMenu::new(FakeStore::default()).secret_keys(["secret"]);

    assert_eq!(
        menu.display_value(&SettingRow {
            key: "secret".to_string(),
            value: "value".to_string(),
        }),
        "********"
    );
    assert_eq!(
        menu.display_value(&SettingRow {
            key: "secret".to_string(),
            value: String::new(),
        }),
        ""
    );
    assert_eq!(
        menu.display_value(&SettingRow {
            key: "public".to_string(),
            value: "value".to_string(),
        }),
        "value"
    );
}

#[test]
fn updates_selected_setting() {
    let menu = ConfigMenu::new(FakeStore::with_rows([("log_level", "info")]));

    menu.update_row("log_level", "debug").unwrap();

    assert_eq!(menu.store.rows.borrow()[0].value, "debug");
}

#[test]
fn validation_failure_prevents_update() {
    let menu = ConfigMenu::new(FakeStore::with_rows([("log_level", "info")]))
        .validator(|_, _| Err("bad value".into()));

    let error = menu.validate_value("log_level", "debug").unwrap_err();

    assert!(matches!(error, ConfigEasyError::ValidationFailed { .. }));
    assert_eq!(menu.store.rows.borrow()[0].value, "info");
}

#[test]
fn supports_custom_table_and_columns() {
    let menu = ConfigMenu::new(FakeStore::with_rows([("log_level", "info")]))
        .table("app_config")
        .key_column("name")
        .value_column("setting")
        .order_by("name");

    let rows = menu.load_rows().unwrap();

    assert_eq!(rows[0].key, "log_level");
    assert_eq!(rows[0].value, "info");
    assert_eq!(
        *menu.store.last_query.borrow(),
        Some(CapturedQuery {
            table: "app_config".to_string(),
            key_column: "name".to_string(),
            value_column: "setting".to_string(),
            order_by: "name".to_string(),
        })
    );
}

#[test]
fn supports_custom_ordering_column() {
    let menu = ConfigMenu::new(FakeStore::with_rows([("second", "2"), ("first", "1")]))
        .order_by("display_order");

    menu.load_rows().unwrap();

    assert_eq!(
        menu.store.last_query.borrow().as_ref().unwrap().order_by,
        "display_order"
    );
}

#[test]
fn custom_action_callback_runs() {
    let called = Cell::new(false);
    let menu = ConfigMenu::new(FakeStore::default()).action("r", "reset", || {
        called.set(true);
        Ok(())
    });

    menu.run_action(&menu.actions[0]).unwrap();

    assert!(called.get());
}

#[test]
fn custom_action_errors_are_wrapped() {
    let menu = ConfigMenu::new(FakeStore::default()).action("r", "reset", || Err("failed".into()));

    let error = menu.run_action(&menu.actions[0]).unwrap_err();

    assert!(matches!(error, ConfigEasyError::ActionFailed { .. }));
}

#[test]
fn run_with_updates_then_exits() {
    let menu = ConfigMenu::new(FakeStore::with_rows([("log_level", "info")]));
    let input = b"1\ndebug\n\n";
    let mut input = Cursor::new(input);
    let mut output = Vec::new();

    menu.run_with(&mut input, &mut output).unwrap();

    assert_eq!(menu.store.rows.borrow()[0].value, "debug");
}

#[test]
fn run_with_clears_screen_before_rendering_menu() {
    let menu = ConfigMenu::new(FakeStore::with_rows([("log_level", "info")]));
    let input = b"\n";
    let mut input = Cursor::new(input);
    let mut output = Vec::new();

    menu.run_with(&mut input, &mut output).unwrap();

    let output = String::from_utf8(output).unwrap();
    assert!(output.starts_with("\x1b[2J\x1b[HSettings\n"));
}

#[test]
fn invalid_action_key_fails_before_menu_loop() {
    let input = b"\n";
    let mut input = Cursor::new(input);
    let mut output = Vec::new();

    let error = ConfigMenu::new(FakeStore::default())
        .action("1", "bad", || Ok(()))
        .run_with(&mut input, &mut output)
        .unwrap_err();

    assert!(matches!(error, ConfigEasyError::InvalidActionKey(_)));
}

#[test]
fn duplicate_action_key_fails_before_menu_loop() {
    let input = b"\n";
    let mut input = Cursor::new(input);
    let mut output = Vec::new();

    let error = ConfigMenu::new(FakeStore::default())
        .action("r", "reset", || Ok(()))
        .action("r", "refresh", || Ok(()))
        .run_with(&mut input, &mut output)
        .unwrap_err();

    assert!(matches!(error, ConfigEasyError::DuplicateActionKey(_)));
}