diff --git a/core_lib/helpers/shell_utils.py b/core_lib/helpers/shell_utils.py index d749f2bc..e1f57b57 100644 --- a/core_lib/helpers/shell_utils.py +++ b/core_lib/helpers/shell_utils.py @@ -44,6 +44,37 @@ def _coalesce_prompt_value(raw: str, default) -> str: return '' +def _parse_toggle_indices(raw: str, max_index: int) -> list: + """Parse ``1,3,5`` / ``1 3 5`` / ``1-3,7`` into 0-based indices. + + Out-of-range and unparseable tokens are dropped silently — a typo in + one token shouldn't drop the whole edit. + """ + cleaned = raw.replace(',', ' ').strip() + if not cleaned: + return [] + out = [] + for token in cleaned.split(): + if '-' in token and not token.startswith('-'): + start_text, _, end_text = token.partition('-') + try: + start = int(start_text) + end = int(end_text) + except ValueError: + continue + for n in range(min(start, end), max(start, end) + 1): + if 1 <= n <= max_index: + out.append(n - 1) + continue + try: + n = int(token) + except ValueError: + continue + if 1 <= n <= max_index: + out.append(n - 1) + return out + + # ── string ──────────────────────────────────────────────────────────────────── @@ -244,6 +275,57 @@ def prompt_list( is_valid = True +def prompt_multi_select( + items: Sequence[ListItem], + title: str, + initial_selected: Optional[Sequence[bool]] = None, + label_for: Optional[Callable[[ListItem], str]] = None, + instructions: str = ( + 'Type indices to toggle (e.g. 1,3,5-7), ' + 'or press Enter to apply. Type "q" to cancel.' + ), + empty_message: str = '(no items)', +) -> Optional[list]: + """Toggle-list picker with ``[x]`` / ``[ ]`` checkboxes. + + Operator types comma-separated indices or ranges (``1,3,5-7``) to toggle + selection, presses Enter to apply, or types ``q`` to cancel. Returns the + final selection state as a list of bools parallel to ``items``, or + ``None`` on cancel. + + ``label_for`` formats each row's text after the checkbox; defaults to + ``str(item)``. The caller owns all formatting (alignment, suffixes, + warnings) — the picker only owns index, checkbox, and the toggle loop. + """ + items = list(items) + if initial_selected is None: + selected = [False] * len(items) + else: + selected = [bool(s) for s in initial_selected] + if len(selected) != len(items): + raise ValueError('initial_selected must match items in length') + label = label_for if label_for is not None else str + + while True: + print() + print(title) + if not items: + print(f' {empty_message}') + else: + for i, item in enumerate(items, start=1): + mark = '[x]' if selected[i - 1] else '[ ]' + print(f' {i:>3}. {mark} {label(item)}') + print() + print(instructions) + raw = _prompt('> ') + if raw.lower() in ('q', 'quit', 'exit'): + return None + if not raw: + return selected + for idx in _parse_toggle_indices(raw, len(items)): + selected[idx] = not selected[idx] + + def prompt_comma_list( message: str, default: Optional[list] = None, diff --git a/tests/test_command_line.py b/tests/test_command_line.py index ffeca903..5223be95 100644 --- a/tests/test_command_line.py +++ b/tests/test_command_line.py @@ -4,11 +4,13 @@ from core_lib.helpers import shell_utils from core_lib.helpers.shell_utils import ( + _parse_toggle_indices, prompt_bool, prompt_comma_list, prompt_enum, prompt_int, prompt_list, + prompt_multi_select, prompt_options, prompt_str, prompt_yes_no, @@ -477,3 +479,329 @@ def test_default_is_returned_as_copy_not_reference(self): def test_whitespace_only_input_treated_as_empty(self): with patch("builtins.input", side_effect=[" ", "a,b"]): self.assertEqual(prompt_comma_list("Values", allow_empty=False), ["a", "b"]) + + +# ── _parse_toggle_indices ───────────────────────────────────────────────────── + +class TestParseToggleIndices(unittest.TestCase): + def test_empty_string(self): + self.assertEqual(_parse_toggle_indices("", 5), []) + + def test_whitespace_only(self): + self.assertEqual(_parse_toggle_indices(" ", 5), []) + + def test_single_index_one_based_to_zero_based(self): + self.assertEqual(_parse_toggle_indices("1", 5), [0]) + self.assertEqual(_parse_toggle_indices("5", 5), [4]) + + def test_comma_separated(self): + self.assertEqual(_parse_toggle_indices("1,3,5", 5), [0, 2, 4]) + + def test_space_separated(self): + self.assertEqual(_parse_toggle_indices("1 3 5", 5), [0, 2, 4]) + + def test_mixed_commas_and_spaces(self): + self.assertEqual(_parse_toggle_indices("1, 3 5", 5), [0, 2, 4]) + + def test_range_inclusive(self): + self.assertEqual(_parse_toggle_indices("1-3", 5), [0, 1, 2]) + + def test_mixed_range_and_singles(self): + self.assertEqual(_parse_toggle_indices("1-3,7", 10), [0, 1, 2, 6]) + + def test_reversed_range_normalises(self): + # "3-1" should still yield 1..3 — order in input shouldn't matter + self.assertEqual(_parse_toggle_indices("3-1", 5), [0, 1, 2]) + + def test_single_element_range(self): + self.assertEqual(_parse_toggle_indices("2-2", 5), [1]) + + def test_out_of_range_single_dropped(self): + self.assertEqual(_parse_toggle_indices("99", 3), []) + + def test_out_of_range_range_partially_clamped(self): + # "1-5" with max=3 should yield 1,2,3 (not error) + self.assertEqual(_parse_toggle_indices("1-5", 3), [0, 1, 2]) + + def test_range_starting_above_max_drops(self): + self.assertEqual(_parse_toggle_indices("8-10", 5), []) + + def test_non_numeric_token_dropped(self): + self.assertEqual(_parse_toggle_indices("abc, 1", 5), [0]) + + def test_non_numeric_in_range_dropped(self): + self.assertEqual(_parse_toggle_indices("a-b", 5), []) + self.assertEqual(_parse_toggle_indices("1-abc", 5), []) + self.assertEqual(_parse_toggle_indices("abc-3", 5), []) + + def test_zero_rejected(self): + # 1-based input — 0 is invalid + self.assertEqual(_parse_toggle_indices("0", 5), []) + + def test_negative_single_rejected(self): + # int("-5") parses but fails the 1<=n<=max check + self.assertEqual(_parse_toggle_indices("-5", 5), []) + + def test_range_with_zero_clamps(self): + # "0-2" — zero clamped out, 1 and 2 kept + self.assertEqual(_parse_toggle_indices("0-2", 5), [0, 1]) + + def test_max_index_zero_drops_everything(self): + # empty items list — all input is invalid + self.assertEqual(_parse_toggle_indices("1,2,3", 0), []) + + def test_duplicates_preserved(self): + # toggle semantics rely on this — "1,1" toggles twice (no-op) + self.assertEqual(_parse_toggle_indices("1,1", 5), [0, 0]) + + def test_preserves_input_order(self): + # order matters when the consumer applies XOR semantics + self.assertEqual(_parse_toggle_indices("3,1,2", 5), [2, 0, 1]) + + def test_garbage_around_valid_tokens(self): + self.assertEqual(_parse_toggle_indices("1, foo, 2-3, bar", 5), [0, 1, 2]) + + def test_lone_dash_dropped(self): + # leading-dash branch shouldn't be treated as a range + self.assertEqual(_parse_toggle_indices("-", 5), []) + + def test_trailing_dash_dropped(self): + self.assertEqual(_parse_toggle_indices("1-", 5), []) + + +# ── prompt_multi_select ─────────────────────────────────────────────────────── + +class TestPromptMultiSelect(unittest.TestCase): + def test_empty_input_applies_initial_state_unchanged(self): + with patch("builtins.input", return_value=""), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b", "c"], "Pick", initial_selected=[True, False, True], + ) + self.assertEqual(result, [True, False, True]) + + def test_default_initial_selected_is_all_false(self): + with patch("builtins.input", return_value=""), patch("builtins.print"): + result = prompt_multi_select(["a", "b", "c"], "Pick") + self.assertEqual(result, [False, False, False]) + + def test_quit_returns_none(self): + with patch("builtins.input", return_value="q"), patch("builtins.print"): + self.assertIsNone(prompt_multi_select(["a", "b"], "Pick")) + + def test_quit_uppercase_returns_none(self): + with patch("builtins.input", return_value="Q"), patch("builtins.print"): + self.assertIsNone(prompt_multi_select(["a", "b"], "Pick")) + + def test_quit_word_returns_none(self): + for word in ("quit", "exit", "QUIT", "Exit"): + with patch("builtins.input", return_value=word), patch("builtins.print"): + self.assertIsNone(prompt_multi_select(["a"], "Pick")) + + def test_quit_does_not_apply_pending_toggles(self): + # toggle then quit — toggles must be discarded + with patch("builtins.input", side_effect=["1", "q"]), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b"], "Pick", initial_selected=[False, False], + ) + self.assertIsNone(result) + + def test_single_toggle_then_apply(self): + with patch("builtins.input", side_effect=["2", ""]), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b", "c"], "Pick", initial_selected=[False, False, False], + ) + self.assertEqual(result, [False, True, False]) + + def test_toggle_off(self): + with patch("builtins.input", side_effect=["2", ""]), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b", "c"], "Pick", initial_selected=[True, True, True], + ) + self.assertEqual(result, [True, False, True]) + + def test_multiple_toggles_in_single_input(self): + with patch("builtins.input", side_effect=["1,3", ""]), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b", "c"], "Pick", initial_selected=[False, False, False], + ) + self.assertEqual(result, [True, False, True]) + + def test_range_toggle(self): + with patch("builtins.input", side_effect=["1-3", ""]), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b", "c"], "Pick", initial_selected=[False, False, False], + ) + self.assertEqual(result, [True, True, True]) + + def test_multi_step_toggling(self): + # toggle 1, then toggle 2, then apply + with patch("builtins.input", side_effect=["1", "2", ""]), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b", "c"], "Pick", initial_selected=[False, False, False], + ) + self.assertEqual(result, [True, True, False]) + + def test_toggle_same_index_twice_in_one_input_cancels(self): + with patch("builtins.input", side_effect=["1,1", ""]), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b"], "Pick", initial_selected=[False, False], + ) + self.assertEqual(result, [False, False]) + + def test_toggle_same_index_twice_across_inputs_cancels(self): + with patch("builtins.input", side_effect=["1", "1", ""]), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b"], "Pick", initial_selected=[False, False], + ) + self.assertEqual(result, [False, False]) + + def test_out_of_range_silently_ignored_does_not_apply(self): + # input is non-empty (so not "apply"), but every token is invalid → + # nothing toggles, picker keeps prompting + with patch("builtins.input", side_effect=["99", ""]), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b"], "Pick", initial_selected=[True, False], + ) + self.assertEqual(result, [True, False]) + + def test_non_numeric_input_keeps_prompting(self): + with patch("builtins.input", side_effect=["abc", "1", ""]), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b"], "Pick", initial_selected=[False, False], + ) + self.assertEqual(result, [True, False]) + + def test_whitespace_only_treated_as_empty_apply(self): + # _prompt strips, so " " → "" → apply + with patch("builtins.input", return_value=" "), patch("builtins.print"): + result = prompt_multi_select( + ["a"], "Pick", initial_selected=[True], + ) + self.assertEqual(result, [True]) + + def test_empty_items_apply_returns_empty_list(self): + with patch("builtins.input", return_value=""), patch("builtins.print"): + result = prompt_multi_select([], "Pick") + self.assertEqual(result, []) + + def test_empty_items_quit_returns_none(self): + with patch("builtins.input", return_value="q"), patch("builtins.print"): + self.assertIsNone(prompt_multi_select([], "Pick")) + + def test_initial_selected_length_mismatch_raises(self): + with self.assertRaises(ValueError): + prompt_multi_select(["a", "b"], "Pick", initial_selected=[True]) + + def test_initial_selected_too_long_raises(self): + with self.assertRaises(ValueError): + prompt_multi_select(["a"], "Pick", initial_selected=[True, False]) + + def test_initial_selected_truthiness_normalised_to_bool(self): + # passing 1/0 should still produce a clean list[bool] + with patch("builtins.input", return_value=""), patch("builtins.print"): + result = prompt_multi_select( + ["a", "b"], "Pick", initial_selected=[1, 0], + ) + self.assertEqual(result, [True, False]) + self.assertTrue(all(isinstance(s, bool) for s in result)) + + def test_label_for_called_with_each_item(self): + seen = [] + def label(item): + seen.append(item) + return f'<{item}>' + with patch("builtins.input", return_value=""), patch("builtins.print"): + prompt_multi_select(["a", "b", "c"], "Pick", label_for=label) + self.assertEqual(seen, ["a", "b", "c"]) + + def test_label_for_default_uses_str(self): + # arbitrary objects should render via str() with no callback + class Obj(object): + def __str__(self): + return "rendered" + with patch("builtins.input", return_value=""), patch("builtins.print") as mock_print: + prompt_multi_select([Obj()], "Pick") + printed = ' '.join(str(c.args[0]) for c in mock_print.call_args_list if c.args) + self.assertIn("rendered", printed) + + def test_label_for_receives_original_item_identity(self): + # label_for must get the *same* object the caller passed, so + # the consumer can compute custom suffixes from item state + sentinel = object() + received = [] + with patch("builtins.input", return_value=""), patch("builtins.print"): + prompt_multi_select( + [sentinel], "Pick", label_for=lambda i: received.append(i) or "x", + ) + self.assertIs(received[0], sentinel) + + def test_items_accepts_any_sequence(self): + # tuple input shouldn't break — function should list() it internally + with patch("builtins.input", side_effect=["1", ""]), patch("builtins.print"): + result = prompt_multi_select(("a", "b"), "Pick") + self.assertEqual(result, [True, False]) + + def test_caller_iterable_not_mutated(self): + original = ["a", "b", "c"] + with patch("builtins.input", side_effect=["1,2,3", ""]), patch("builtins.print"): + prompt_multi_select(original, "Pick", initial_selected=[False] * 3) + self.assertEqual(original, ["a", "b", "c"]) + + def test_initial_selected_iterable_not_mutated(self): + initial = [False, False, False] + with patch("builtins.input", side_effect=["1,3", ""]), patch("builtins.print"): + prompt_multi_select(["a", "b", "c"], "Pick", initial_selected=initial) + self.assertEqual(initial, [False, False, False]) + + def test_render_shows_checkbox_marks(self): + # both [x] and [ ] should appear when state is mixed + with patch("builtins.input", return_value=""), patch("builtins.print") as mock_print: + prompt_multi_select( + ["a", "b"], "Pick", initial_selected=[True, False], + ) + printed = '\n'.join(str(c.args[0]) for c in mock_print.call_args_list if c.args) + self.assertIn("[x]", printed) + self.assertIn("[ ]", printed) + + def test_render_shows_indices_one_based(self): + with patch("builtins.input", return_value=""), patch("builtins.print") as mock_print: + prompt_multi_select(["a", "b", "c"], "Pick") + printed = '\n'.join(str(c.args[0]) for c in mock_print.call_args_list if c.args) + # 1-based numbering — must show "1." not "0." + self.assertIn("1.", printed) + self.assertIn("3.", printed) + self.assertNotIn("0.", printed) + + def test_render_shows_empty_message_when_no_items(self): + with patch("builtins.input", return_value=""), patch("builtins.print") as mock_print: + prompt_multi_select( + [], "Pick", empty_message="", + ) + printed = '\n'.join(str(c.args[0]) for c in mock_print.call_args_list if c.args) + self.assertIn("", printed) + + def test_render_shows_title_each_iteration(self): + # operator must see the updated table after every toggle round + # (not just on the first render) — that's the whole point of looping + title = "PICKER_TITLE_MARKER" + with patch("builtins.input", side_effect=["1", "2", ""]), patch("builtins.print") as mock_print: + prompt_multi_select(["a", "b"], title) + printed_lines = [str(c.args[0]) for c in mock_print.call_args_list if c.args] + title_renders = sum(1 for line in printed_lines if line == title) + # 3 input rounds = 3 renders of the title + self.assertEqual(title_renders, 3) + + def test_full_diff_workflow_matches_consumer_pattern(self): + # mirrors the REP approval-list use case: caller passes initial + # state, picker returns final state, caller diffs to compute + # add/remove operations + items = ["repo-a", "repo-b", "repo-c", "repo-d"] + initial = [True, False, True, False] + # operator: revoke repo-a (toggle 1), approve repo-d (toggle 4) + with patch("builtins.input", side_effect=["1,4", ""]), patch("builtins.print"): + final = prompt_multi_select(items, "Pick", initial_selected=initial) + self.assertEqual(final, [False, False, True, True]) + adds = [i for i, (was, now) in enumerate(zip(initial, final)) if now and not was] + removes = [i for i, (was, now) in enumerate(zip(initial, final)) if was and not now] + self.assertEqual(adds, [3]) + self.assertEqual(removes, [0])