zenops 0.20.0

Declarative system configuration management for shell config and dotfiles.
//! Test-only [`Picker`](super::Picker) impl that consumes a pre-built
//! script of keystrokes so the import flow can be driven without a real
//! terminal.

use crate::error::Error;

use super::{PickItem, PickOutcome, Picker};

/// One scripted action against the picker. Indices are 0-based.
///
/// The picker walks the script in order: [`Cycle`](Self::Cycle) advances
/// the addressed row's `choice` (modulo its `choices.len()`); the
/// terminator ([`Apply`](Self::Apply) / [`Abort`](Self::Abort)) returns
/// the outcome and any remaining steps are unread.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScriptedStep {
    /// Advance `items[row].choice` by one (modulo the row's
    /// `choices.len()`). A no-op on disabled rows (`choices.len() == 1`),
    /// matching the terminal impl's behaviour.
    Cycle {
        /// Zero-based row index to address.
        row: usize,
    },
    /// Terminator — return [`PickOutcome::Apply`].
    Apply,
    /// Terminator — return [`PickOutcome::Abort`].
    Abort,
}

/// Scripted picker: walks `script` in order on each [`Picker::pick`]
/// call and applies the cycles to `items` before returning the terminator.
///
/// Panics on misuse (script empty or doesn't end in a terminator, row
/// index out of bounds) because these are test-author errors that should
/// fail loudly rather than silently no-op.
pub struct ScriptedPicker {
    script: Vec<ScriptedStep>,
}

impl ScriptedPicker {
    /// Build a picker that will replay `script` on its next `pick` call.
    pub fn new(script: Vec<ScriptedStep>) -> Self {
        assert!(
            !script.is_empty(),
            "ScriptedPicker script must not be empty"
        );
        assert!(
            script
                .iter()
                .any(|s| matches!(s, ScriptedStep::Apply | ScriptedStep::Abort)),
            "ScriptedPicker script must contain at least one Apply or Abort terminator",
        );
        Self { script }
    }
}

impl Picker for ScriptedPicker {
    fn pick(&mut self, _title: &str, items: &mut [PickItem<'_>]) -> Result<PickOutcome, Error> {
        for step in std::mem::take(&mut self.script) {
            match step {
                ScriptedStep::Cycle { row } => {
                    let total = items.len();
                    let item = items.get_mut(row).unwrap_or_else(|| {
                        panic!(
                            "ScriptedPicker: Cycle row={row} out of bounds (items.len()={total})",
                        )
                    });
                    if item.choices.len() > 1 {
                        item.choice = (item.choice + 1) % item.choices.len();
                    }
                }
                ScriptedStep::Apply => return Ok(PickOutcome::Apply),
                ScriptedStep::Abort => return Ok(PickOutcome::Abort),
            }
        }
        unreachable!("constructor ensures script ends in a terminator")
    }
}

#[cfg(test)]
mod tests {
    use std::borrow::Cow;

    use similar_asserts::assert_eq;

    use super::*;
    use crate::picker::ChoiceLabel;

    fn two_choice() -> [ChoiceLabel; 2] {
        [
            ChoiceLabel::new("a", "first"),
            ChoiceLabel::new("b", "second"),
        ]
    }

    fn one_choice() -> [ChoiceLabel; 1] {
        [ChoiceLabel::new("only", "only")]
    }

    #[test]
    fn apply_returns_apply_and_advances_addressed_row() {
        let choices = two_choice();
        let mut items = [PickItem {
            label: Cow::Borrowed("row"),
            note: None,
            choices: &choices,
            choice: 0,
        }];
        let mut picker =
            ScriptedPicker::new(vec![ScriptedStep::Cycle { row: 0 }, ScriptedStep::Apply]);
        assert_eq!(picker.pick("t", &mut items).unwrap(), PickOutcome::Apply);
        assert_eq!(items[0].choice, 1);
    }

    #[test]
    fn cycle_wraps_modulo_choices_len() {
        let choices = two_choice();
        let mut items = [PickItem {
            label: Cow::Borrowed("row"),
            note: None,
            choices: &choices,
            choice: 0,
        }];
        let mut picker = ScriptedPicker::new(vec![
            ScriptedStep::Cycle { row: 0 },
            ScriptedStep::Cycle { row: 0 },
            ScriptedStep::Apply,
        ]);
        picker.pick("t", &mut items).unwrap();
        assert_eq!(items[0].choice, 0);
    }

    #[test]
    fn cycle_on_disabled_row_is_a_noop() {
        let only = one_choice();
        let mut items = [PickItem {
            label: Cow::Borrowed("row"),
            note: None,
            choices: &only,
            choice: 0,
        }];
        let mut picker =
            ScriptedPicker::new(vec![ScriptedStep::Cycle { row: 0 }, ScriptedStep::Apply]);
        picker.pick("t", &mut items).unwrap();
        assert_eq!(items[0].choice, 0);
    }

    #[test]
    fn abort_returns_abort_and_skips_remaining_steps() {
        let choices = two_choice();
        let mut items = [PickItem {
            label: Cow::Borrowed("row"),
            note: None,
            choices: &choices,
            choice: 0,
        }];
        let mut picker =
            ScriptedPicker::new(vec![ScriptedStep::Abort, ScriptedStep::Cycle { row: 0 }]);
        assert_eq!(picker.pick("t", &mut items).unwrap(), PickOutcome::Abort);
        assert_eq!(items[0].choice, 0);
    }

    #[test]
    #[should_panic(expected = "must not be empty")]
    fn empty_script_panics_on_construction() {
        let _ = ScriptedPicker::new(vec![]);
    }

    #[test]
    #[should_panic(expected = "must contain at least one Apply or Abort terminator")]
    fn script_without_terminator_panics_on_construction() {
        let _ = ScriptedPicker::new(vec![ScriptedStep::Cycle { row: 0 }]);
    }

    #[test]
    #[should_panic(expected = "out of bounds")]
    fn cycle_out_of_bounds_panics_on_pick() {
        let mut items: [PickItem<'_>; 0] = [];
        let mut picker =
            ScriptedPicker::new(vec![ScriptedStep::Cycle { row: 5 }, ScriptedStep::Apply]);
        let _ = picker.pick("t", &mut items);
    }
}