Feat: Added in plan and project commands and functionality

This commit is contained in:
2025-11-16 20:38:02 -05:00
parent 324b125939
commit 6d2f26bef2
45 changed files with 4998 additions and 1031 deletions
+635 -7
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import re
import tempfile
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -12,6 +14,7 @@ from typer.testing import CliRunner
from cleveragents.cli.commands import context as context_app
from cleveragents.core.exceptions import CleverAgentsError, FileSystemError
from cleveragents.domain.models.core import Context, ContextType
def strip_ansi_codes(text):
@@ -21,6 +24,21 @@ def strip_ansi_codes(text):
return ansi_escape.sub("", text)
@contextmanager
def patched_container(context, project):
"""Patch the CleverAgents container to provide mocked services."""
with patch(
"cleveragents.application.container.get_container"
) as mock_get_container:
container = MagicMock()
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = project
container.project_service.return_value = project_service
mock_get_container.return_value = container
yield container
@given("a mock context service is configured")
def step_mock_context_service(context):
"""Set up a mock context service."""
@@ -61,6 +79,24 @@ def step_mock_zero_removals(context):
context.mock_service.remove_from_context.side_effect = [0, 0]
@given("a mock context service with already tracked files")
def step_mock_already_tracked(context):
"""Set up mock that reports already tracked files."""
context.mock_service = MagicMock()
context.temp_dir = tempfile.mkdtemp()
context.already_paths = [
Path(context.temp_dir) / f"existing_{i}.txt" for i in range(12)
]
context.mock_service.add_to_context.return_value = ([], context.already_paths)
@given("a mock context service with partial removals")
def step_mock_partial_removals(context):
"""Set up mock that removes some but not all files."""
context.mock_service = MagicMock()
context.mock_service.remove_from_context.side_effect = [1, 0]
@given("a mock context service with files")
def step_mock_with_files(context):
"""Set up mock with file list."""
@@ -72,6 +108,17 @@ def step_mock_with_files(context):
context.mock_service.list_context.return_value = context.mock_files
@given("a mock context service with numeric file sizes")
def step_mock_numeric_sizes(context):
"""Set up mock with numeric size values."""
context.mock_service = MagicMock()
context.mock_files = [
{"path": "/src/data.bin", "size": 0, "added_at": "2024-02-01"},
{"path": "/src/large.bin", "size": 2048, "added_at": "2024-02-02"},
]
context.mock_service.list_context.return_value = context.mock_files
@given("a mock context service with no files")
def step_mock_no_files(context):
"""Set up mock with empty context."""
@@ -104,6 +151,32 @@ def step_mock_multiple_files(context):
context.mock_service.list_context.return_value = context.mock_files
@given("a mock context service with single file content")
def step_mock_single_file_content(context):
"""Set up mock with a single file containing content."""
context.mock_service = MagicMock()
context.single_file_entry = {
"path": "docs/report.txt",
"content": "Generated summary",
"size": 512,
"added_at": "2024-03-01",
}
context.mock_service.list_context.return_value = [context.single_file_entry]
@given("a mock context service with single file lacking content")
def step_mock_single_file_blank(context):
"""Set up mock with a single file but no stored content."""
context.mock_service = MagicMock()
context.single_file_entry = {
"path": "docs/empty.txt",
"content": None,
"size": 128,
"added_at": "2024-03-02",
}
context.mock_service.list_context.return_value = [context.single_file_entry]
@given("a mock context service with clearable files")
def step_mock_clearable(context):
"""Set up mock for clear operations."""
@@ -122,7 +195,8 @@ def step_exec_context_add_valid(context):
test_file.write_text("content")
with patch("cleveragents.application.container.get_container") as mock_container:
context.mock_service.add_to_context.return_value = [test_file]
# add_to_context should return a tuple: (added_files, already_in_context)
context.mock_service.add_to_context.return_value = ([test_file], [])
mock_container.return_value.context_service.return_value = context.mock_service
result = runner.invoke(context_app.app, ["add", str(test_file)])
@@ -141,6 +215,41 @@ def step_exec_context_add_nonexistent(context):
context.result = result
@when("the context add command is executed without an active project")
def step_exec_context_add_no_project(context):
"""Execute context add when no project is configured."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = None
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["add", "placeholder.txt"])
context.result = result
@when("the context add command processes already tracked paths")
def step_exec_context_add_already(context):
"""Execute context add when files are already tracked."""
runner = CliRunner()
test_file = Path(context.temp_dir) / "tracked.txt"
test_file.write_text("content")
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["add", str(test_file)])
context.result = result
@when("the context add command is executed and fails")
def step_exec_context_add_fails(context):
"""Execute context add that fails."""
@@ -166,7 +275,8 @@ def step_exec_context_load(context):
test_file.write_text("content")
with patch("cleveragents.application.container.get_container") as mock_container:
context.mock_service.add_to_context.return_value = [test_file]
# add_to_context should return a tuple: (added_files, already_in_context)
context.mock_service.add_to_context.return_value = ([test_file], [])
mock_container.return_value.context_service.return_value = context.mock_service
result = runner.invoke(context_app.app, ["load", str(test_file)])
@@ -179,19 +289,109 @@ def step_exec_context_remove(context):
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
mock_container.return_value.context_service.return_value = context.mock_service
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["rm", "/file1.txt", "/file2.txt"])
context.result = result
@when("the context remove command is executed without an active project")
def step_exec_context_remove_no_project(context):
"""Execute context remove when no project is configured."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = None
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["rm", "/file1.txt"])
context.result = result
@when("the context remove command is executed for relative paths")
def step_exec_context_remove_relative(context):
"""Execute context remove for relative file names."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["rm", "file1.txt", "file2.txt"])
context.result = result
@when("the context remove command is executed with service failure")
def step_exec_context_remove_failure(context):
"""Execute context remove that raises CleverAgentsError."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
context.mock_service.remove_from_context.side_effect = context.mock_error
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["rm", "/file1.txt"])
context.result = result
@when("the context list command is executed")
def step_exec_context_list(context):
"""Execute context list."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
mock_container.return_value.context_service.return_value = context.mock_service
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["list"])
context.result = result
@when("the context list command is executed without an active project")
def step_exec_context_list_no_project(context):
"""Execute context list when no project is configured."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = None
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["list"])
context.result = result
@when("the context list command is executed with service failure")
def step_exec_context_list_failure(context):
"""Execute context list when the service raises an error."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
context.mock_service.list_context.side_effect = context.mock_error
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["list"])
context.result = result
@@ -221,13 +421,50 @@ def step_exec_context_show_without_path(context):
context.result = result
@when("the context show command is executed without an active project")
def step_exec_context_show_no_project(context):
"""Execute context show when no project is configured."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = None
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["show"])
context.result = result
@when("the context show command is executed with service failure")
def step_exec_context_show_failure(context):
"""Execute context show when the service raises an error."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
context.mock_service.get_context_content.side_effect = context.mock_error
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["show", "/test.txt"])
context.result = result
@when("the context clear command is executed with yes flag")
def step_exec_context_clear_yes(context):
"""Execute context clear with --yes."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
mock_container.return_value.context_service.return_value = context.mock_service
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["clear", "--yes"])
context.result = result
@@ -239,7 +476,11 @@ def step_exec_context_clear_cancel(context):
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
mock_container.return_value.context_service.return_value = context.mock_service
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["clear"], input="n\n")
context.result = result
@@ -251,12 +492,50 @@ def step_exec_context_clear(context):
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
mock_container.return_value.context_service.return_value = context.mock_service
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["clear"])
context.result = result
@when("the context clear command is executed without an active project")
def step_exec_context_clear_no_project(context):
"""Execute context clear when no project is configured."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
project_service = MagicMock()
project_service.get_current_project.return_value = None
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["clear"])
context.result = result
@when("the context clear command is executed with service failure")
def step_exec_context_clear_failure(context):
"""Execute context clear when the service raises an error."""
runner = CliRunner()
with patch("cleveragents.application.container.get_container") as mock_container:
container = mock_container.return_value
container.context_service.return_value = context.mock_service
context.mock_service.list_context.return_value = [{"path": "pending.txt"}]
context.mock_service.clear_context.side_effect = context.mock_error
project_service = MagicMock()
project_service.get_current_project.return_value = object()
container.project_service.return_value = project_service
result = runner.invoke(context_app.app, ["clear", "--yes"])
context.result = result
@when("context command is invoked without subcommand")
def step_exec_context_no_subcommand(context):
"""Execute context without subcommand."""
@@ -294,6 +573,24 @@ def step_check_add_success(context):
)
@then("already tracked file details should be reported")
def step_check_already_tracked(context):
"""Verify output when files are already tracked."""
assert context.result.exit_code == 0, (
f"Command failed with exit code {context.result.exit_code}\n"
f"Output: {context.result.output}"
)
clean_output = strip_ansi_codes(context.result.output)
assert "File(s) already in context" in clean_output, (
f"Expected warning about already tracked files.\n"
f"Output: {context.result.output}"
)
assert "... and 2 more files" in clean_output, (
f"Expected summary of remaining already tracked files.\n"
f"Output: {context.result.output}"
)
@then("path does not exist error should be shown")
def step_check_path_error(context):
"""Check path error."""
@@ -308,6 +605,19 @@ def step_check_path_error(context):
)
@then("a no active project error should be shown")
def step_check_no_project_error(context):
"""Ensure commands abort when no project is active."""
clean_output = strip_ansi_codes(context.result.output)
assert context.result.exit_code != 0, (
f"Expected non-zero exit code when project is missing.\n"
f"Output: {context.result.output}"
)
assert "No active project" in clean_output, (
f"Expected 'No active project' message.\nOutput: {context.result.output}"
)
@then("FileSystemError should be displayed and command aborted")
def step_check_filesystem_error(context):
"""Check filesystem error."""
@@ -407,6 +717,20 @@ def step_check_no_remove(context):
)
@then("missing context files should trigger an abort")
def step_check_missing_context_abort(context):
"""Ensure missing files cause the command to abort."""
clean_output = strip_ansi_codes(context.result.output)
assert context.result.exit_code != 0, (
f"Expected abort when files are missing from context.\n"
f"Output: {context.result.output}"
)
assert "File(s) not in context" in clean_output, (
f"Expected message about missing context files.\n"
f"Output: {context.result.output}"
)
@then("a formatted table of files should be displayed")
def step_check_table_display(context):
"""Check table display."""
@@ -427,6 +751,19 @@ def step_check_table_display(context):
)
@then("numeric sizes should be rendered as bytes")
def step_check_numeric_sizes(context):
"""Ensure numeric sizes are humanized as byte strings."""
assert context.result.exit_code == 0, (
f"Command failed with exit code {context.result.exit_code}\n"
f"Output: {context.result.output}"
)
clean_output = strip_ansi_codes(context.result.output)
assert "bytes" in clean_output, (
f"Expected sizes to be formatted in bytes.\nOutput: {context.result.output}"
)
@then("empty context message should be shown with help")
def step_check_empty_message(context):
"""Check empty message."""
@@ -508,6 +845,36 @@ def step_check_summary(context):
)
@then("the show command should report an empty context")
def step_check_show_empty_context(context):
"""Verify show command informs when no files exist."""
clean_output = strip_ansi_codes(context.result.output)
assert context.result.exit_code == 0, (
f"Expected successful exit for empty context message.\n"
f"Output: {context.result.output}"
)
assert "No files in context" in clean_output, (
f"Expected 'No files in context' message.\nOutput: {context.result.output}"
)
@then("the show command should report missing content")
def step_check_show_missing_content(context):
"""Ensure show command highlights missing file content."""
clean_output = strip_ansi_codes(context.result.output)
assert context.result.exit_code == 0, (
f"Expected successful exit when content is missing.\n"
f"Output: {context.result.output}"
)
assert "No content available for" in clean_output, (
f"Expected message indicating missing content.\nOutput: {context.result.output}"
)
assert "No content available" in clean_output, (
f"Expected message indicating missing content.\nOutput: {context.result.output}"
)
@then("context should be cleared with success message")
def step_check_clear_success(context):
"""Check clear success."""
@@ -577,3 +944,264 @@ def step_check_default_list(context):
f"Raw output: '{context.result.output}'\n"
f"Clean output (no color codes): '{clean_output}'"
)
@given("a dictionary-style context entry with missing metadata")
def step_given_dict_entry(context):
"""Prepare dictionary entry missing optional fields."""
context.dict_entry = {
"path": "relative/file.txt",
"type": "NOTE",
"size": "5 KB",
}
@given("a domain Context model entry with metadata")
def step_given_context_model_entry(context):
"""Prepare a Context model instance for normalization."""
context.dict_entry = Context(
id=7,
plan_id=42,
type=ContextType.NOTE,
path="/project/docs/readme.md",
content="Example context body",
file_hash="abc123",
size=2048,
added_at=datetime(2024, 1, 5, 15, 30, 45),
)
@when("the normalization helper processes the context entry")
def step_when_normalize_entry(context):
"""Normalize dictionary-based context entry."""
context.normalized_entry = context_app._normalize_context_entry(context.dict_entry)
@then("the normalized context tuple should include placeholder defaults")
def step_then_normalized_defaults(context):
"""Verify normalization uses defaults for missing fields."""
path_text, type_label, size, added_at, content_text = context.normalized_entry
assert path_text == "relative/file.txt"
assert type_label == "NOTE"
assert size == "5 KB"
assert added_at == "Unknown"
assert content_text == ""
@then("the normalized context tuple should match the model attributes")
def step_then_normalized_model(context):
"""Verify normalization preserves Context model details."""
path_text, type_label, size, added_at, content_text = context.normalized_entry
assert path_text == "/project/docs/readme.md"
assert type_label == "note"
assert size == 2048
assert added_at == "2024-01-05 15:30:45"
assert content_text == "Example context body"
@when("the programmatic add command runs with a missing file path")
def step_when_programmatic_add_missing(context):
"""Run add_command with a path that does not exist."""
missing_path = Path(context.temp_dir) / "missing.txt"
context.programmatic_exception = None
context.mock_service.add_to_context.reset_mock()
with patched_container(context, project=object()):
try:
context_app.add_command([str(missing_path)])
except Exception as exc: # pragma: no cover - captured for assertions
context.programmatic_exception = exc
context.programmatic_result = None
@then("no files are sent to the context service")
def step_then_no_files_sent(context):
"""Ensure add_to_context is not called for missing files."""
assert context.programmatic_exception is None
context.mock_service.add_to_context.assert_not_called()
@when("the programmatic add command runs without an active project")
def step_when_programmatic_add_no_project(context):
"""Run add_command without an active project."""
test_file = Path(context.temp_dir) / "active.txt"
test_file.write_text("data")
context.programmatic_exception = None
with patched_container(context, project=None):
try:
context_app.add_command([str(test_file)])
except Exception as exc:
context.programmatic_exception = exc
@then("a CleverAgentsError is raised for programmatic add")
def step_then_add_requires_project(context):
"""Ensure add_command raises when project is missing."""
assert isinstance(context.programmatic_exception, CleverAgentsError)
@when("the programmatic list command is executed")
def step_when_programmatic_list(context):
"""Execute list_command with active project."""
context.programmatic_exception = None
with patched_container(context, project=object()):
context.programmatic_result = context_app.list_command()
@then("the programmatic list command should return those files")
def step_then_list_returns_files(context):
"""Verify list_command returns mock files."""
assert context.programmatic_result == context.mock_files
@when("the programmatic list command runs without an active project")
def step_when_programmatic_list_no_project(context):
"""Execute list_command without active project."""
context.programmatic_exception = None
with patched_container(context, project=None):
try:
context_app.list_command()
except Exception as exc:
context.programmatic_exception = exc
@then("a CleverAgentsError is raised for programmatic list")
def step_then_list_requires_project(context):
"""Ensure list_command raises when project is missing."""
assert isinstance(context.programmatic_exception, CleverAgentsError)
@when("the programmatic show command runs without an active project")
def step_when_programmatic_show_no_project(context):
"""Execute show_command without an active project configured."""
context.programmatic_exception = None
context.programmatic_result = None
with patched_container(context, project=None):
try:
context_app.show_command()
except Exception as exc:
context.programmatic_exception = exc
@when("the programmatic show command runs without a path")
def step_when_programmatic_show_summary(context):
"""Execute show_command when listing context entries."""
context.programmatic_exception = None
with patched_container(context, project=object()):
context.programmatic_result = context_app.show_command()
@then("the programmatic show command should return a summary string")
def step_then_show_returns_summary(context):
"""Verify show_command returns summary when no path provided."""
expected = f"Total files: {len(context.mock_files)}"
assert context.programmatic_result == expected
@then("a CleverAgentsError is raised for programmatic show")
def step_then_show_requires_project(context):
"""Ensure show_command raises when no project is active."""
assert isinstance(context.programmatic_exception, CleverAgentsError)
@then("the programmatic show command should return no content summary")
def step_then_show_returns_none(context):
"""Ensure show_command returns None when context has no files."""
assert context.programmatic_exception is None
assert context.programmatic_result is None
@when("the programmatic show command runs for a specific path")
def step_when_programmatic_show_specific(context):
"""Execute show_command for a single file path."""
context.programmatic_exception = None
with patched_container(context, project=object()):
context.programmatic_result = context_app.show_command("/test.txt")
@then("the programmatic show command should return that content")
def step_then_show_returns_content(context):
"""Ensure show_command returns content for requested file."""
assert context.programmatic_result == context.mock_content
@when("the programmatic remove command runs without an active project")
def step_when_programmatic_remove_no_project(context):
"""Execute rm_command without an active project configured."""
context.programmatic_exception = None
with patched_container(context, project=None):
try:
context_app.rm_command(["missing.txt"])
except Exception as exc:
context.programmatic_exception = exc
@when("the programmatic remove command runs for files")
def step_when_programmatic_remove(context):
"""Execute rm_command where files are missing."""
context.programmatic_exception = None
with patched_container(context, project=object()):
try:
context_app.rm_command(["missing.txt", "other.txt"])
except Exception as exc:
context.programmatic_exception = exc
@when("the programmatic remove command runs with removable files")
def step_when_programmatic_remove_success(context):
"""Execute rm_command when files are present in context."""
context.programmatic_exception = None
context.mock_service.remove_from_context.reset_mock()
with patched_container(context, project=object()):
try:
context_app.rm_command(["/file1.txt", "/file2.txt"])
except Exception as exc:
context.programmatic_exception = exc
@then("a CleverAgentsError is raised for programmatic remove")
def step_then_remove_requires_presence(context):
"""Ensure rm_command raises when files are not in context."""
assert isinstance(context.programmatic_exception, CleverAgentsError)
@then("the programmatic remove command should complete successfully")
def step_then_remove_success(context):
"""Ensure rm_command succeeds when files are removed."""
assert context.programmatic_exception is None
assert context.mock_service.remove_from_context.call_count == 2
@when("the programmatic clear command runs without an active project")
def step_when_programmatic_clear_no_project(context):
"""Execute clear_command without an active project."""
context.programmatic_exception = None
with patched_container(context, project=None):
try:
context_app.clear_command()
except Exception as exc:
context.programmatic_exception = exc
@then("a CleverAgentsError is raised for programmatic clear")
def step_then_clear_requires_project(context):
"""Ensure clear_command raises when project is missing."""
assert isinstance(context.programmatic_exception, CleverAgentsError)
@when("the programmatic clear command runs with an active project")
def step_when_programmatic_clear_active(context):
"""Execute clear_command with active project."""
context.programmatic_exception = None
context.mock_service.clear_context.reset_mock()
with patched_container(context, project=object()):
try:
context_app.clear_command()
except Exception as exc:
context.programmatic_exception = exc
@then("the context service clear function should be called")
def step_then_clear_calls_service(context):
"""Verify clear_command calls the context service clear method."""
assert context.programmatic_exception is None
context.mock_service.clear_context.assert_called_once()