diff --git a/features/acms_context_add.feature b/features/acms_context_add.feature index 0c2af4c62..bf1204e4b 100644 --- a/features/acms_context_add.feature +++ b/features/acms_context_add.feature @@ -54,6 +54,18 @@ Feature: ACMS context add command with --tag and --policy flags When I traverse the directory recursively with ChunkedFileTraverser Then the acms traversal should yield only the visible Python file + Scenario: ChunkedFileTraverser skips files larger than max_file_size + Given a temporary directory with a single Python file "large.py" + When I traverse the file with max_file_size 1 + Then the acms traversal should yield 0 entries + + Scenario: ChunkedFileTraverser skips unreadable file metadata and content + Given a temporary directory with a single Python file "broken.py" + When I read the file with a mocked stat OSError + Then the acms read result should be skipped + When I read the file with a mocked read_text OSError + Then the acms read result should be skipped + Scenario: ChunkedFileTraverser raises FileNotFoundError for missing path When I traverse a non-existent path with ChunkedFileTraverser Then an acms FileNotFoundError should be raised @@ -106,6 +118,12 @@ Feature: ACMS context add command with --tag and --policy flags When I invoke context add with directory "src/" Then the acms context add command should succeed + Scenario: add_command persists paths when traversal raises + Given a mocked context service for acms add + And a temporary file "src/main.py" exists + When I call add_command with a mocked traverser ValueError + Then the acms programmatic context add should persist the file + Scenario: context add --help shows usage examples When I invoke context add --help Then the acms help output should contain "--tag" diff --git a/features/steps/acms_context_add_steps.py b/features/steps/acms_context_add_steps.py index 68060eeea..10dcfa564 100644 --- a/features/steps/acms_context_add_steps.py +++ b/features/steps/acms_context_add_steps.py @@ -19,6 +19,7 @@ from behave.runner import Context from typer.testing import CliRunner from cleveragents.acms.index import AcmsIndexEntry, ChunkedFileTraverser +from cleveragents.cli.commands.context import add_command from cleveragents.cli.commands.context import app as context_app # --------------------------------------------------------------------------- @@ -129,6 +130,15 @@ def step_acms_traverse_dir_nonrecursive(context: Context) -> None: ) +@when("I traverse the file with max_file_size {max_file_size:d}") +def step_acms_traverse_file_with_max_size( + context: Context, max_file_size: int +) -> None: + """Traverse a single file with a low max_file_size limit.""" + traverser = ChunkedFileTraverser(max_file_size=max_file_size) + context.acms_entries = list(traverser.traverse(context.acms_target_path)) + + @when('I traverse with tags "{tag1}" and "{tag2}"') def step_acms_traverse_with_tags(context: Context, tag1: str, tag2: str) -> None: """Traverse with multiple tags.""" @@ -174,6 +184,38 @@ def step_acms_traverse_nonexistent(context: Context) -> None: context.acms_error = exc +@when("I read the file with a mocked stat OSError") +def step_acms_read_file_stat_oserror(context: Context) -> None: + """Call _read_file while stat raises OSError.""" + target = context.acms_target_path + original_stat = Path.stat + + def failing_stat(self: Path, *args: object, **kwargs: object) -> object: + if self == target: + raise OSError("stat failed") + return original_stat(self, *args, **kwargs) + + traverser = ChunkedFileTraverser() + with patch.object(Path, "stat", failing_stat): + context.acms_read_result = traverser._read_file(target) + + +@when("I read the file with a mocked read_text OSError") +def step_acms_read_file_read_text_oserror(context: Context) -> None: + """Call _read_file while read_text raises OSError after stat succeeds.""" + target = context.acms_target_path + original_read_text = Path.read_text + + def failing_read_text(self: Path, *args: object, **kwargs: object) -> str: + if self == target: + raise OSError("read failed") + return original_read_text(self, *args, **kwargs) + + traverser = ChunkedFileTraverser() + with patch.object(Path, "read_text", failing_read_text): + context.acms_read_result = traverser._read_file(target) + + @when("I create a ChunkedFileTraverser with chunk_size 0") def step_acms_invalid_chunk_size(context: Context) -> None: """Create a ChunkedFileTraverser with invalid chunk_size.""" @@ -317,6 +359,13 @@ def step_acms_only_visible_py_file(context: Context) -> None: ) +@then("the acms read result should be skipped") +def step_acms_read_result_skipped(context: Context) -> None: + assert context.acms_read_result is None, ( + f"Expected skipped read result, got {context.acms_read_result!r}" + ) + + @then("an acms FileNotFoundError should be raised") def step_acms_file_not_found(context: Context) -> None: assert isinstance(context.acms_error, FileNotFoundError), ( @@ -474,6 +523,30 @@ def step_acms_invoke_add_directory(context: Context) -> None: context.acms_result = result +@when("I call add_command with a mocked traverser ValueError") +def step_acms_call_add_command_traverser_value_error(context: Context) -> None: + """Call the programmatic wrapper while traversal raises ValueError.""" + filepath = context.acms_temp_file + container = _mock_acms_container(added_files=[filepath]) + + class FailingTraverser: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def traverse(self, *args: object, **kwargs: object) -> list[object]: + raise ValueError("not a regular file") + + with ( + patch("cleveragents.application.container.get_container", return_value=container), + patch("cleveragents.acms.index.ChunkedFileTraverser", FailingTraverser), + ): + add_command([str(filepath)], recursive=True, tags=["api"], policy="strict") + + context.acms_context_service = container.context_service.return_value + context.acms_project = container.project_service.return_value.get_current_project() + context.acms_added_path = filepath.resolve() + + @when("I invoke context add --help") def step_acms_invoke_help(context: Context) -> None: """Invoke context add --help.""" @@ -488,6 +561,15 @@ def step_acms_add_success(context: Context) -> None: ) +@then("the acms programmatic context add should persist the file") +def step_acms_programmatic_add_persisted(context: Context) -> None: + context.acms_context_service.add_to_context.assert_called_once_with( + context.acms_project, + context.acms_added_path, + recursive=True, + ) + + @then('the acms output should mention "{text}"') def step_acms_output_mentions(context: Context, text: str) -> None: assert text in context.acms_result.output, (