Skip to content
30 changes: 24 additions & 6 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import logging
import os
import re
import shutil
import socket
import stat
import sys
Expand Down Expand Up @@ -251,18 +252,30 @@
if USE_COLORS:
print(f" • {Colors.BOLD}{name}{Colors.ENDC}: {rules_count} rules")
else:
print(f" - {name}: {rules_count} rules")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

print("")


def _get_progress_bar_width() -> int:
"""Calculate dynamic progress bar width based on terminal size.

Returns width clamped between 15 and 50 characters, approximately
40% of terminal width. This ensures progress bars are readable on
narrow terminals while utilizing space on wider displays.
"""
cols, _ = shutil.get_terminal_size(fallback=(80, 24))
return max(15, min(50, int(cols * 0.4)))


def countdown_timer(seconds: int, message: str = "Waiting") -> None:
"""Shows a countdown timer if strictly in a TTY, otherwise just sleeps."""
if not USE_COLORS:
time.sleep(seconds)
return

width = 15
width = _get_progress_bar_width()

for remaining in range(seconds, 0, -1):
progress = (seconds - remaining + 1) / seconds
filled = int(width * progress)
Expand All @@ -284,7 +297,8 @@
if not USE_COLORS or total == 0:
return

width = 15
width = _get_progress_bar_width()

progress = min(1.0, current / total)
filled = int(width * progress)
bar = "█" * filled + "░" * (width - filled)
Expand Down Expand Up @@ -316,10 +330,14 @@
) -> str:
"""Prompts for input until the validator returns True."""
while True:
if is_password:
value = getpass.getpass(prompt).strip()
else:
value = input(prompt).strip()
try:
if is_password:
value = getpass.getpass(prompt).strip()
else:
value = input(prompt).strip()
except (KeyboardInterrupt, EOFError):
print(f"\n{Colors.WARNING}⚠️ Input cancelled.{Colors.ENDC}")
sys.exit(130)

if not value:
print(f"{Colors.FAIL}❌ Value cannot be empty{Colors.ENDC}")
Expand Down
99 changes: 99 additions & 0 deletions test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,3 +532,102 @@
# Color codes (accessing instance Colors or m.Colors)
assert m.Colors.CYAN in combined
assert m.Colors.ENDC in combined


# Case 14: get_validated_input handles KeyboardInterrupt gracefully
def test_get_validated_input_interrupt(monkeypatch, capsys):
m = reload_main_with_env(monkeypatch)

# Mock input to raise KeyboardInterrupt
monkeypatch.setattr("builtins.input", MagicMock(side_effect=KeyboardInterrupt))
Comment on lines +537 to +542
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new cancellation handling also covers EOFError (Ctrl+D), but the tests only exercise KeyboardInterrupt. Add a test case for EOFError (or parametrize the test to cover both exceptions) so this new behavior is guarded against regressions.

Suggested change
# Case 14: get_validated_input handles KeyboardInterrupt gracefully
def test_get_validated_input_interrupt(monkeypatch, capsys):
m = reload_main_with_env(monkeypatch)
# Mock input to raise KeyboardInterrupt
monkeypatch.setattr("builtins.input", MagicMock(side_effect=KeyboardInterrupt))
# Case 14: get_validated_input handles KeyboardInterrupt and EOFError gracefully
@pytest.mark.parametrize("exc_type", [KeyboardInterrupt, EOFError])
def test_get_validated_input_interrupt(monkeypatch, capsys, exc_type):
m = reload_main_with_env(monkeypatch)
# Mock input to raise the specified interruption exception (KeyboardInterrupt or EOFError)
monkeypatch.setattr("builtins.input", MagicMock(side_effect=exc_type))

Copilot uses AI. Check for mistakes.

with pytest.raises(SystemExit) as e:
m.get_validated_input("Prompt: ", lambda x: True, "Error")

# Check exit code is 130
assert e.value.code == 130

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

# Check friendly message
captured = capsys.readouterr()
assert "Input cancelled" in captured.out
Comment thread Fixed
Comment on lines +537 to +552
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test is great for KeyboardInterrupt! However, the implementation in get_validated_input also handles EOFError and has a separate path for password inputs using getpass. To ensure full coverage of the new graceful exit logic, I'd suggest parameterizing this test to cover all combinations: KeyboardInterrupt and EOFError, for both regular and password inputs.

# Case 14: get_validated_input handles graceful exit on interrupt/EOF
@pytest.mark.parametrize("exception", [KeyboardInterrupt, EOFError])
@pytest.mark.parametrize("is_password, mock_path", [
    (False, "builtins.input"),
    (True, "getpass.getpass"),
])
def test_get_validated_input_graceful_exit(monkeypatch, capsys, exception, is_password, mock_path):
    m = reload_main_with_env(monkeypatch)

    # Mock input to raise the specified exception
    monkeypatch.setattr(mock_path, MagicMock(side_effect=exception))

    with pytest.raises(SystemExit) as e:
        m.get_validated_input("Prompt: ", lambda x: True, "Error", is_password=is_password)

    # Check exit code is 130
    assert e.value.code == 130

    # Check friendly message
    captured = capsys.readouterr()
    assert "Input cancelled" in captured.out

Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test only covers KeyboardInterrupt but not EOFError, even though the implementation catches both exceptions in the same way. Consider adding a separate test case for EOFError to ensure it also exits with code 130 and displays the cancellation message. This would provide complete test coverage for the graceful exit feature.

Suggested change
assert "Input cancelled" in captured.out
assert "Input cancelled" in captured.out
# Case 15: get_validated_input handles EOFError gracefully
def test_get_validated_input_eof(monkeypatch, capsys):
m = reload_main_with_env(monkeypatch)
# Mock input to raise EOFError to simulate end-of-input (e.g., Ctrl-D)
monkeypatch.setattr("builtins.input", MagicMock(side_effect=EOFError))
with pytest.raises(SystemExit) as e:
m.get_validated_input("Prompt: ", lambda x: True, "Error")
# Check exit code is 130, same as KeyboardInterrupt
assert e.value.code == 130
# Check the same friendly cancellation message is shown
captured = capsys.readouterr()
assert "Input cancelled" in captured.out

Copilot uses AI. Check for mistakes.

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.


# Case 15: get_validated_input handles both KeyboardInterrupt and EOFError for regular and password inputs
@pytest.mark.parametrize("exception", [KeyboardInterrupt, EOFError])
@pytest.mark.parametrize(
"is_password,mock_path",
[(False, "builtins.input"), (True, "getpass.getpass")],
)
def test_get_validated_input_graceful_exit_comprehensive(
monkeypatch, capsys, exception, is_password, mock_path
):
"""Test graceful exit on user cancellation (Ctrl+C/Ctrl+D) for both regular and password inputs."""
m = reload_main_with_env(monkeypatch)

# Mock input to raise the specified exception
monkeypatch.setattr(mock_path, MagicMock(side_effect=exception))

with pytest.raises(SystemExit) as e:
m.get_validated_input(
"Prompt: ", lambda x: True, "Error", is_password=is_password
)

# Check exit code is 130 (standard for SIGINT)
assert e.value.code == 130

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

# Check friendly cancellation message is displayed
captured = capsys.readouterr()
assert "Input cancelled" in captured.out

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.


# Case 16: _get_progress_bar_width returns correct values based on terminal size
def test_get_progress_bar_width(monkeypatch):
"""Test dynamic progress bar width calculation with various terminal sizes."""
m = reload_main_with_env(monkeypatch)

# Test very narrow terminal (30 cols) -> min clamp at 15
monkeypatch.setattr("shutil.get_terminal_size", lambda fallback: (30, 24))
width = m._get_progress_bar_width()
assert width == 15 # 30 * 0.4 = 12, clamped to min 15

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

# Test narrow terminal (50 cols) -> 40% = 20
monkeypatch.setattr("shutil.get_terminal_size", lambda fallback: (50, 24))
width = m._get_progress_bar_width()
assert width == 20 # 50 * 0.4 = 20

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

# Test standard terminal (80 cols) -> 40% = 32
monkeypatch.setattr("shutil.get_terminal_size", lambda fallback: (80, 24))
width = m._get_progress_bar_width()
assert width == 32 # 80 * 0.4 = 32

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

# Test medium terminal (100 cols) -> 40% = 40
monkeypatch.setattr("shutil.get_terminal_size", lambda fallback: (100, 24))
width = m._get_progress_bar_width()
assert width == 40 # 100 * 0.4 = 40

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

# Test wide terminal (200 cols) -> max clamp at 50
monkeypatch.setattr("shutil.get_terminal_size", lambda fallback: (200, 24))
width = m._get_progress_bar_width()
assert width == 50 # 200 * 0.4 = 80, clamped to max 50

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.


# Case 17: countdown_timer and render_progress_bar use dynamic width helper
def test_progress_functions_use_dynamic_width(monkeypatch):
"""Verify that progress functions call the width helper."""
m = reload_main_with_env(monkeypatch, no_color=None, isatty=True)
mock_stderr = MagicMock()
monkeypatch.setattr(sys, "stderr", mock_stderr)

# Mock terminal size to verify it's being used
monkeypatch.setattr("shutil.get_terminal_size", lambda fallback: (120, 24))

# Test render_progress_bar uses dynamic width
m.render_progress_bar(5, 10, "Test")
width_120 = m._get_progress_bar_width() # Should be 48 (120 * 0.4)
assert width_120 == 48

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

# Check that the progress bar output reflects the dynamic width
writes = [args[0] for args, _ in mock_stderr.write.call_args_list]
combined = "".join(writes)
# With width 48, at 50% progress we should have 24 filled chars
assert "█" * 24 in combined or len([c for c in combined if c == "█"]) == 24

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Loading