config-easy 0.1.0

A small SQLite-backed interactive settings menu for command-line Rust applications
Documentation
use super::*;
use std::cell::Cell;
use std::io::Cursor;

fn connection() -> rusqlite::Connection {
    rusqlite::Connection::open_in_memory().unwrap()
}

fn create_settings(conn: &rusqlite::Connection) {
    conn.execute(
        "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
        [],
    )
    .unwrap();
}

fn insert_setting(conn: &rusqlite::Connection, key: &str, value: &str) {
    conn.execute(
        "INSERT INTO settings (key, value) VALUES (?1, ?2)",
        params![key, value],
    )
    .unwrap();
}

#[test]
fn default_builder_values_load_default_table_ordered_by_key() {
    let conn = connection();
    create_settings(&conn);
    insert_setting(&conn, "log_level", "info");
    insert_setting(&conn, "graph_client_id", "abc123");

    let rows = ConfigMenu::new(&conn).load_rows().unwrap();

    assert_eq!(rows[0].key, "graph_client_id");
    assert_eq!(rows[1].key, "log_level");
}

#[test]
fn masks_non_empty_secret_values_only() {
    let conn = connection();
    let menu = ConfigMenu::new(&conn).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 conn = connection();
    create_settings(&conn);
    insert_setting(&conn, "log_level", "info");

    ConfigMenu::new(&conn)
        .update_row("log_level", "debug")
        .unwrap();

    let value: String = conn
        .query_row(
            "SELECT value FROM settings WHERE key = 'log_level'",
            [],
            |row| row.get(0),
        )
        .unwrap();
    assert_eq!(value, "debug");
}

#[test]
fn validation_failure_prevents_update() {
    let conn = connection();
    create_settings(&conn);
    insert_setting(&conn, "log_level", "info");

    let menu = ConfigMenu::new(&conn).validator(|_, _| Err("bad value".into()));
    let error = menu.validate_value("log_level", "debug").unwrap_err();

    assert!(matches!(error, ConfigEasyError::ValidationFailed { .. }));
    let value: String = conn
        .query_row(
            "SELECT value FROM settings WHERE key = 'log_level'",
            [],
            |row| row.get(0),
        )
        .unwrap();
    assert_eq!(value, "info");
}

#[test]
fn supports_custom_table_and_columns() {
    let conn = connection();
    conn.execute(
        "CREATE TABLE app_config (name TEXT PRIMARY KEY, setting TEXT NOT NULL)",
        [],
    )
    .unwrap();
    conn.execute(
        "INSERT INTO app_config (name, setting) VALUES ('log_level', 'info')",
        [],
    )
    .unwrap();

    let rows = ConfigMenu::new(&conn)
        .table("app_config")
        .key_column("name")
        .value_column("setting")
        .order_by("name")
        .load_rows()
        .unwrap();

    assert_eq!(rows[0].key, "log_level");
    assert_eq!(rows[0].value, "info");
}

#[test]
fn supports_custom_ordering_column() {
    let conn = connection();
    conn.execute(
        "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, display_order INTEGER NOT NULL)",
        [],
    )
    .unwrap();
    conn.execute(
        "INSERT INTO settings (key, value, display_order) VALUES ('second', '2', 2), ('first', '1', 1)",
        [],
    )
    .unwrap();

    let rows = ConfigMenu::new(&conn)
        .order_by("display_order")
        .load_rows()
        .unwrap();

    assert_eq!(rows[0].key, "first");
    assert_eq!(rows[1].key, "second");
}

#[test]
fn custom_action_callback_runs() {
    let conn = connection();
    let called = Cell::new(false);
    let menu = ConfigMenu::new(&conn).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 conn = connection();
    let menu = ConfigMenu::new(&conn).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 conn = connection();
    create_settings(&conn);
    insert_setting(&conn, "log_level", "info");

    let input = b"1\ndebug\n\n";
    let mut input = Cursor::new(input);
    let mut output = Vec::new();

    ConfigMenu::new(&conn)
        .run_with(&mut input, &mut output)
        .unwrap();

    let value: String = conn
        .query_row(
            "SELECT value FROM settings WHERE key = 'log_level'",
            [],
            |row| row.get(0),
        )
        .unwrap();
    assert_eq!(value, "debug");
}

#[test]
fn invalid_action_key_fails_before_menu_loop() {
    let conn = connection();
    create_settings(&conn);

    let input = b"\n";
    let mut input = Cursor::new(input);
    let mut output = Vec::new();

    let error = ConfigMenu::new(&conn)
        .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 conn = connection();
    create_settings(&conn);

    let input = b"\n";
    let mut input = Cursor::new(input);
    let mut output = Vec::new();

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

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