diff --git a/features/project_config_model.feature b/features/project_config_model.feature new file mode 100644 index 000000000..6b530faf2 --- /dev/null +++ b/features/project_config_model.feature @@ -0,0 +1,130 @@ +Feature: Project Configuration Models + As a developer + I want ValidationConfig and ContextConfig domain models + So that projects can define validation commands and context indexing behavior + + # ValidationConfig Tests (B1.5) + + # B1.5a - Model creation with defaults + + Scenario: Create a default ValidationConfig + Given a default ValidationConfig + Then the validation test_command should be none + And the validation lint_command should be none + And the validation type_check_command should be none + And the validation build_command should be none + And the validation custom_commands should be empty + And the validation timeout_seconds should be 300 + And the validation fail_on_lint_error should be true + + # B1.5b - Fields with values + + Scenario: Create a ValidationConfig with all commands + Given a ValidationConfig with test "pytest" lint "ruff check ." typecheck "pyright" build "python -m build" + Then the validation test_command should be "pytest" + And the validation lint_command should be "ruff check ." + And the validation type_check_command should be "pyright" + And the validation build_command should be "python -m build" + + Scenario: Create a ValidationConfig with custom commands + Given a ValidationConfig with custom commands "security=bandit,docs=mkdocs build" + Then the validation custom_commands should contain "security" with value "bandit" + And the validation custom_commands should contain "docs" with value "mkdocs build" + + Scenario: Create a ValidationConfig with custom timeout + Given a ValidationConfig with timeout 600 + Then the validation timeout_seconds should be 600 + + Scenario: Create a ValidationConfig with fail_on_lint_error false + Given a ValidationConfig with fail_on_lint_error false + Then the validation fail_on_lint_error should be false + + # B1.5c - Helper methods + + Scenario: get_all_commands returns all configured commands + Given a ValidationConfig with test "pytest" lint "ruff check ." typecheck "pyright" build "python -m build" + Then get_all_commands should include "test" with value "pytest" + And get_all_commands should include "lint" with value "ruff check ." + And get_all_commands should include "type_check" with value "pyright" + And get_all_commands should include "build" with value "python -m build" + + Scenario: get_all_commands includes custom commands + Given a ValidationConfig with test "pytest" and custom commands "security=bandit" + Then get_all_commands should include "test" with value "pytest" + And get_all_commands should include "security" with value "bandit" + + Scenario: get_all_commands returns empty dict when no commands configured + Given a default ValidationConfig + Then get_all_commands should be empty + + Scenario: has_any_validation is true when commands exist + Given a ValidationConfig with test "pytest" lint "ruff check ." typecheck "pyright" build "python -m build" + Then has_any_validation should be true + + Scenario: has_any_validation is false when no commands configured + Given a default ValidationConfig + Then has_any_validation should be false + + Scenario: has_any_validation is true with only custom commands + Given a ValidationConfig with custom commands "security=bandit,docs=mkdocs build" + Then has_any_validation should be true + + # ContextConfig Tests (B1.6) + + # B1.6a - Model creation with defaults + + Scenario: Create a default ContextConfig + Given a default ContextConfig + Then the context max_file_size should be 1000000 + And the context max_files should be 100000 + And the context indexing_strategy should be "full_text" + And the context chunking_policy should be "smart" + And the context chunk_size should be 1000 + And the context include_patterns should be none + + Scenario: Default ContextConfig has default ignore patterns + Given a default ContextConfig + Then the context ignore_patterns should contain ".git/" + And the context ignore_patterns should contain "node_modules/" + And the context ignore_patterns should contain "__pycache__/" + And the context ignore_patterns should contain ".venv/" + And the context ignore_patterns should contain "*.pyc" + And the context ignore_patterns should contain ".DS_Store" + + # B1.6b - Fields with values + + Scenario: Create a ContextConfig with custom ignore patterns + Given a ContextConfig with ignore patterns "dist/,build/" + Then the context ignore_patterns should contain "dist/" + And the context ignore_patterns should contain "build/" + And the context ignore_patterns should contain ".git/" + + Scenario: Create a ContextConfig with include patterns + Given a ContextConfig with include patterns "*.py,*.ts" + Then the context include_patterns should contain "*.py" + And the context include_patterns should contain "*.ts" + + Scenario: Create a ContextConfig with custom max_file_size + Given a ContextConfig with max_file_size 2000000 + Then the context max_file_size should be 2000000 + + Scenario: Create a ContextConfig with custom indexing strategy + Given a ContextConfig with indexing_strategy "semantic" + Then the context indexing_strategy should be "semantic" + + Scenario: Create a ContextConfig with custom chunking policy + Given a ContextConfig with chunking_policy "fixed" + Then the context chunking_policy should be "fixed" + + Scenario: Create a ContextConfig with custom chunk size + Given a ContextConfig with chunk_size 500 + Then the context chunk_size should be 500 + + # B1.6c - Duplicate ignore patterns are deduplicated + + Scenario: ContextConfig deduplicates ignore patterns that overlap with defaults + Given a ContextConfig with ignore patterns ".git/,node_modules/,custom/" + Then the context ignore_patterns should contain ".git/" + And the context ignore_patterns should contain "node_modules/" + And the context ignore_patterns should contain "custom/" + And the context ignore_patterns should not contain duplicate ".git/" diff --git a/features/project_model.feature b/features/project_model.feature new file mode 100644 index 000000000..49511dc2f --- /dev/null +++ b/features/project_model.feature @@ -0,0 +1,141 @@ +Feature: Project Domain Model Extensions + As a developer + I want the Project model to support ULID identifiers, namespaces, resources, and configs + So that projects can be properly categorized and managed + + # B1.1a - Import Resource, verify Project has new fields + + Scenario: Create a Project with new B1.1 fields + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "local" + Then the project project_id should be "01ARZ3NDEKTSV4RRFFQ69G5FAV" + And the project name should be "my-project" + And the project namespace should be "local" + + # B1.1b - Identity fields + + Scenario: Project has default namespace + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project" + Then the project namespace should be "local" + + Scenario: Project description defaults to none + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project" + Then the project description should be none + + Scenario: Create a Project with description + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and description "A test project" + Then the project description should be "A test project" + + # B1.1c - Categorization fields + + Scenario: Project tags default to empty list + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project" + Then the project tags should be empty + + Scenario: Create a Project with tags + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and tags "python,web,api" + Then the project tags should contain "python" + And the project tags should contain "web" + And the project tags should contain "api" + + Scenario: Project resources default to empty list + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project" + Then the project resources should be empty + + Scenario: Project validation_config defaults to none + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project" + Then the project validation_config should be none + + Scenario: Project context_config has defaults + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project" + Then the project context_config should not be none + + # B1.1d - Timestamp fields + + Scenario: Project has timestamps + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project" + Then the project created_at should be set + And the project updated_at should be set + + # B1.1e - is_remote computed property + + Scenario: Project with no resources is not remote + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project" + Then the project is_remote should be false + + Scenario: Project with all remote resources is remote + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and all remote resources + Then the project is_remote should be true + + Scenario: Project with mixed resources is not remote + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and mixed resources + Then the project is_remote should be false + + # B1.1f - Namespace validator + + Scenario: Namespace local is valid + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "local" + Then the project namespace should be "local" + + Scenario: Namespace with valid pattern is accepted + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "team_alpha" + Then the project namespace should be "team_alpha" + + Scenario: Namespace system is rejected + When I try to create a Project with namespace "system" + Then a namespace validation error should be raised + + Scenario: Namespace with uppercase is rejected + When I try to create a Project with namespace "MyNamespace" + Then a namespace validation error should be raised + + Scenario: Namespace starting with number is rejected + When I try to create a Project with namespace "1invalid" + Then a namespace validation error should be raised + + # B1.1g - Helper methods + + Scenario: namespaced_name returns correct format + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "team_alpha" + Then the project namespaced_name should be "team_alpha/my-project" + + Scenario: namespaced_name with local namespace + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "local" + Then the project namespaced_name should be "local/my-project" + + Scenario: add_resource adds a resource to the project + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project" + When I add a resource named "my-repo" to the project + Then the project should have 1 resource + And the project should have a resource named "my-repo" + + Scenario: remove_resource removes a resource from the project + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and a resource named "my-repo" + When I remove the resource named "my-repo" from the project + Then the project resources should be empty + + Scenario: get_resource returns the correct resource + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and a resource named "my-repo" + When I get the resource named "my-repo" from the project + Then the retrieved resource name should be "my-repo" + + Scenario: get_resource returns none for unknown name + Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project" + When I get the resource named "nonexistent" from the project + Then the retrieved resource should be none + + # B1.1h - parse_namespaced_name static method + + Scenario: parse_namespaced_name splits valid namespaced string + When I parse the project namespaced name "team_alpha/my-project" + Then the parsed project namespace should be "team_alpha" + And the parsed project name should be "my-project" + + Scenario: parse_namespaced_name rejects string without slash + When I try to parse the project namespaced name "no-slash-here" + Then a project namespaced name parse error should be raised + + # B1.1i - Name validation edge cases + + Scenario: Project rejects name with special characters + When I try to create a Project with invalid name "bad@name!" + Then a name validation error should be raised diff --git a/features/resource_model.feature b/features/resource_model.feature new file mode 100644 index 000000000..8b0cca7cd --- /dev/null +++ b/features/resource_model.feature @@ -0,0 +1,204 @@ +Feature: Resource Domain Model + As a developer + I want resource types and sandbox strategies defined as domain enums + So that the system can classify project resources and determine sandboxing behavior + + # ResourceType Enum Tests (B1.3) + + Scenario: ResourceType has all required values + Then the resource types should be "git_repository, filesystem, database, api_endpoint, document_corpus, cloud_infrastructure" + + Scenario: ResourceType GIT_REPOSITORY has correct value + When I access ResourceType.GIT_REPOSITORY + Then the resource type value should be "git_repository" + + Scenario: ResourceType FILESYSTEM has correct value + When I access ResourceType.FILESYSTEM + Then the resource type value should be "filesystem" + + Scenario: ResourceType DATABASE has correct value + When I access ResourceType.DATABASE + Then the resource type value should be "database" + + Scenario: ResourceType API_ENDPOINT has correct value + When I access ResourceType.API_ENDPOINT + Then the resource type value should be "api_endpoint" + + Scenario: ResourceType DOCUMENT_CORPUS has correct value + When I access ResourceType.DOCUMENT_CORPUS + Then the resource type value should be "document_corpus" + + Scenario: ResourceType CLOUD_INFRASTRUCTURE has correct value + When I access ResourceType.CLOUD_INFRASTRUCTURE + Then the resource type value should be "cloud_infrastructure" + + Scenario: ResourceType is a string enum + When I access ResourceType.GIT_REPOSITORY + Then the resource type should be a string + + Scenario: ResourceType can be created from string value + When I create a ResourceType from string "filesystem" + Then the resource type value should be "filesystem" + + Scenario: Invalid resource type string raises error + When I try to create a ResourceType from string "invalid_type" + Then a ValueError should be raised + + # SandboxStrategy Enum Tests (B1.4) + + Scenario: SandboxStrategy has all required values + Then the sandbox strategies should be "git_worktree, copy_on_write, overlay, transaction_rollback, versioning, none" + + Scenario: SandboxStrategy GIT_WORKTREE has correct value + When I access SandboxStrategy.GIT_WORKTREE + Then the sandbox strategy value should be "git_worktree" + + Scenario: SandboxStrategy COPY_ON_WRITE has correct value + When I access SandboxStrategy.COPY_ON_WRITE + Then the sandbox strategy value should be "copy_on_write" + + Scenario: SandboxStrategy OVERLAY has correct value + When I access SandboxStrategy.OVERLAY + Then the sandbox strategy value should be "overlay" + + Scenario: SandboxStrategy TRANSACTION_ROLLBACK has correct value + When I access SandboxStrategy.TRANSACTION_ROLLBACK + Then the sandbox strategy value should be "transaction_rollback" + + Scenario: SandboxStrategy VERSIONING has correct value + When I access SandboxStrategy.VERSIONING + Then the sandbox strategy value should be "versioning" + + Scenario: SandboxStrategy NONE has correct value + When I access SandboxStrategy.NONE + Then the sandbox strategy value should be "none" + + Scenario: SandboxStrategy is a string enum + When I access SandboxStrategy.GIT_WORKTREE + Then the sandbox strategy should be a string + + # SandboxStrategy.supports_rollback Tests (B1.4b) + + Scenario: GIT_WORKTREE supports rollback + Then SandboxStrategy.GIT_WORKTREE should support rollback + + Scenario: COPY_ON_WRITE supports rollback + Then SandboxStrategy.COPY_ON_WRITE should support rollback + + Scenario: OVERLAY supports rollback + Then SandboxStrategy.OVERLAY should support rollback + + Scenario: TRANSACTION_ROLLBACK supports rollback + Then SandboxStrategy.TRANSACTION_ROLLBACK should support rollback + + Scenario: VERSIONING supports rollback + Then SandboxStrategy.VERSIONING should support rollback + + Scenario: NONE does not support rollback + Then SandboxStrategy.NONE should not support rollback + + # SandboxStrategy.is_copy_based Tests (B1.4b) + + Scenario: COPY_ON_WRITE is copy based + Then SandboxStrategy.COPY_ON_WRITE should be copy based + + Scenario: OVERLAY is copy based + Then SandboxStrategy.OVERLAY should be copy based + + Scenario: GIT_WORKTREE is not copy based + Then SandboxStrategy.GIT_WORKTREE should not be copy based + + Scenario: TRANSACTION_ROLLBACK is not copy based + Then SandboxStrategy.TRANSACTION_ROLLBACK should not be copy based + + Scenario: VERSIONING is not copy based + Then SandboxStrategy.VERSIONING should not be copy based + + Scenario: NONE is not copy based + Then SandboxStrategy.NONE should not be copy based + + # Resource Pydantic Model Tests (B1.2) + + # B1.2a - Model structure (frozen=True) + + Scenario: Create a valid Resource with all required fields + Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" + Then the resource name should be "my-repo" + And the resource type should be ResourceType.GIT_REPOSITORY + And the resource location should be "/tmp/repo" + + Scenario: Resource model is frozen + Given I have a valid Resource + When I try to modify the resource name + Then a validation error should be raised + + # B1.2b - All fields present with defaults + + Scenario: Resource has correct default values + Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" + Then the resource is_remote should be false + And the resource sandbox_strategy should be SandboxStrategy.NONE + And the resource read_only should be false + And the resource metadata should be empty + And the resource created_at should be set + + Scenario: Create a remote Resource + Given a remote Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "api-svc" type "api_endpoint" location "https://api.example.com" + Then the resource is_remote should be true + + Scenario: Create a read-only Resource + Given a read-only Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "docs" type "document_corpus" location "/data/docs" + Then the resource read_only should be true + + Scenario: Create a Resource with custom sandbox strategy + Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" strategy "git_worktree" + Then the resource sandbox_strategy should be SandboxStrategy.GIT_WORKTREE + + Scenario: Create a Resource with metadata + Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" metadata "branch=main,remote=origin" + Then the resource metadata should contain key "branch" with value "main" + And the resource metadata should contain key "remote" with value "origin" + + # B1.2c - Validators + + Scenario: Reject Resource with invalid ULID + When I try to create a Resource with id "not-a-ulid" name "my-repo" type "git_repository" location "/tmp/repo" + Then a validation error should be raised + + Scenario: Reject Resource with empty name + When I try to create a Resource with an empty name + Then a validation error should be raised + + Scenario: Reject Resource with empty location + When I try to create a Resource with an empty location + Then a validation error should be raised + + Scenario: Resource name is lowercased + Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "My-Repo" type "git_repository" location "/tmp/repo" + Then the resource name should be "my-repo" + + Scenario: Reject Resource with invalid name characters + When I try to create a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my repo!!" type "git_repository" location "/tmp/repo" + Then a validation error should be raised + + # B1.2d - Properties + + Scenario: Resource with sandbox strategy supports sandbox + Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" strategy "git_worktree" + Then the resource supports_sandbox should be true + + Scenario: Resource with NONE sandbox strategy does not support sandbox + Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" + Then the resource supports_sandbox should be false + + Scenario: Writable Resource can_write is true + Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" + Then the resource can_write should be true + + Scenario: Read-only Resource can_write is false + Given a read-only Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "docs" type "document_corpus" location "/data/docs" + Then the resource can_write should be false + + Scenario: Resource get_sandbox_path returns correct path + Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" strategy "git_worktree" + Then the resource get_sandbox_path with base "/sandbox" should end with "my-repo" diff --git a/features/steps/project_config_model_steps.py b/features/steps/project_config_model_steps.py new file mode 100644 index 000000000..4af533eae --- /dev/null +++ b/features/steps/project_config_model_steps.py @@ -0,0 +1,313 @@ +"""Step definitions for project configuration model tests (B1.5, B1.6).""" + +from __future__ import annotations + +from behave import given, then +from behave.runner import Context + +from cleveragents.domain.models.core.project import ContextConfig, ValidationConfig + +# ValidationConfig Steps (B1.5) + + +@given("a default ValidationConfig") +def step_default_validation_config(context: Context) -> None: + """Create a ValidationConfig with all defaults.""" + context.validation_config = ValidationConfig() + + +@given( + 'a ValidationConfig with test "{test}" lint "{lint}" typecheck "{typecheck}" build "{build}"' +) +def step_validation_config_all_commands( + context: Context, test: str, lint: str, typecheck: str, build: str +) -> None: + """Create a ValidationConfig with all standard commands.""" + context.validation_config = ValidationConfig( + test_command=test, + lint_command=lint, + type_check_command=typecheck, + build_command=build, + ) + + +@given('a ValidationConfig with custom commands "{commands_str}"') +def step_validation_config_custom_commands(context: Context, commands_str: str) -> None: + """Create a ValidationConfig with custom commands parsed from key=value pairs.""" + custom = {} + for pair in commands_str.split(","): + key, value = pair.strip().split("=", 1) + custom[key.strip()] = value.strip() + context.validation_config = ValidationConfig(custom_commands=custom) + + +@given("a ValidationConfig with timeout {timeout:d}") +def step_validation_config_timeout(context: Context, timeout: int) -> None: + """Create a ValidationConfig with custom timeout.""" + context.validation_config = ValidationConfig(timeout_seconds=timeout) + + +@given("a ValidationConfig with fail_on_lint_error false") +def step_validation_config_no_lint_fail(context: Context) -> None: + """Create a ValidationConfig with fail_on_lint_error disabled.""" + context.validation_config = ValidationConfig(fail_on_lint_error=False) + + +@given('a ValidationConfig with test "{test}" and custom commands "{commands_str}"') +def step_validation_config_test_and_custom( + context: Context, test: str, commands_str: str +) -> None: + """Create a ValidationConfig with test command and custom commands.""" + custom = {} + for pair in commands_str.split(","): + key, value = pair.strip().split("=", 1) + custom[key.strip()] = value.strip() + context.validation_config = ValidationConfig( + test_command=test, custom_commands=custom + ) + + +# ValidationConfig field assertions + + +@then("the validation test_command should be none") +def step_check_test_command_none(context: Context) -> None: + """Verify test_command is None.""" + assert context.validation_config.test_command is None + + +@then('the validation test_command should be "{expected}"') +def step_check_test_command(context: Context, expected: str) -> None: + """Verify test_command value.""" + assert context.validation_config.test_command == expected, ( + f"Expected '{expected}', got '{context.validation_config.test_command}'" + ) + + +@then("the validation lint_command should be none") +def step_check_lint_command_none(context: Context) -> None: + """Verify lint_command is None.""" + assert context.validation_config.lint_command is None + + +@then('the validation lint_command should be "{expected}"') +def step_check_lint_command(context: Context, expected: str) -> None: + """Verify lint_command value.""" + assert context.validation_config.lint_command == expected, ( + f"Expected '{expected}', got '{context.validation_config.lint_command}'" + ) + + +@then("the validation type_check_command should be none") +def step_check_typecheck_command_none(context: Context) -> None: + """Verify type_check_command is None.""" + assert context.validation_config.type_check_command is None + + +@then('the validation type_check_command should be "{expected}"') +def step_check_typecheck_command(context: Context, expected: str) -> None: + """Verify type_check_command value.""" + assert context.validation_config.type_check_command == expected, ( + f"Expected '{expected}', got '{context.validation_config.type_check_command}'" + ) + + +@then("the validation build_command should be none") +def step_check_build_command_none(context: Context) -> None: + """Verify build_command is None.""" + assert context.validation_config.build_command is None + + +@then('the validation build_command should be "{expected}"') +def step_check_build_command(context: Context, expected: str) -> None: + """Verify build_command value.""" + assert context.validation_config.build_command == expected, ( + f"Expected '{expected}', got '{context.validation_config.build_command}'" + ) + + +@then("the validation custom_commands should be empty") +def step_check_custom_commands_empty(context: Context) -> None: + """Verify custom_commands is empty dict.""" + assert context.validation_config.custom_commands == {} + + +@then('the validation custom_commands should contain "{key}" with value "{value}"') +def step_check_custom_command(context: Context, key: str, value: str) -> None: + """Verify custom_commands contains expected key-value pair.""" + assert key in context.validation_config.custom_commands, ( + f"Expected key '{key}' in custom_commands" + ) + assert context.validation_config.custom_commands[key] == value, ( + f"Expected custom_commands['{key}'] = '{value}', " + f"got '{context.validation_config.custom_commands[key]}'" + ) + + +@then("the validation timeout_seconds should be {expected:d}") +def step_check_timeout(context: Context, expected: int) -> None: + """Verify timeout_seconds value.""" + assert context.validation_config.timeout_seconds == expected + + +@then("the validation fail_on_lint_error should be true") +def step_check_fail_on_lint_true(context: Context) -> None: + """Verify fail_on_lint_error is True.""" + assert context.validation_config.fail_on_lint_error is True + + +@then("the validation fail_on_lint_error should be false") +def step_check_fail_on_lint_false(context: Context) -> None: + """Verify fail_on_lint_error is False.""" + assert context.validation_config.fail_on_lint_error is False + + +# ValidationConfig method assertions + + +@then('get_all_commands should include "{key}" with value "{value}"') +def step_check_get_all_commands_key(context: Context, key: str, value: str) -> None: + """Verify get_all_commands includes expected key-value pair.""" + commands = context.validation_config.get_all_commands() + assert key in commands, ( + f"Expected key '{key}' in get_all_commands(), got {commands}" + ) + assert commands[key] == value, ( + f"Expected commands['{key}'] = '{value}', got '{commands[key]}'" + ) + + +@then("get_all_commands should be empty") +def step_check_get_all_commands_empty(context: Context) -> None: + """Verify get_all_commands returns empty dict.""" + commands = context.validation_config.get_all_commands() + assert commands == {}, f"Expected empty dict, got {commands}" + + +@then("has_any_validation should be true") +def step_check_has_validation_true(context: Context) -> None: + """Verify has_any_validation returns True.""" + assert context.validation_config.has_any_validation() is True + + +@then("has_any_validation should be false") +def step_check_has_validation_false(context: Context) -> None: + """Verify has_any_validation returns False.""" + assert context.validation_config.has_any_validation() is False + + +# ContextConfig Steps (B1.6) + + +@given("a default ContextConfig") +def step_default_context_config(context: Context) -> None: + """Create a ContextConfig with all defaults.""" + context.context_config = ContextConfig() + + +@given('a ContextConfig with ignore patterns "{patterns}"') +def step_context_config_ignore_patterns(context: Context, patterns: str) -> None: + """Create a ContextConfig with custom ignore patterns.""" + pattern_list = [p.strip() for p in patterns.split(",")] + context.context_config = ContextConfig(ignore_patterns=pattern_list) + + +@given('a ContextConfig with include patterns "{patterns}"') +def step_context_config_include_patterns(context: Context, patterns: str) -> None: + """Create a ContextConfig with include patterns.""" + pattern_list = [p.strip() for p in patterns.split(",")] + context.context_config = ContextConfig(include_patterns=pattern_list) + + +@given("a ContextConfig with max_file_size {size:d}") +def step_context_config_max_file_size(context: Context, size: int) -> None: + """Create a ContextConfig with custom max_file_size.""" + context.context_config = ContextConfig(max_file_size=size) + + +@given('a ContextConfig with indexing_strategy "{strategy}"') +def step_context_config_indexing_strategy(context: Context, strategy: str) -> None: + """Create a ContextConfig with custom indexing strategy.""" + context.context_config = ContextConfig(indexing_strategy=strategy) + + +@given('a ContextConfig with chunking_policy "{policy}"') +def step_context_config_chunking_policy(context: Context, policy: str) -> None: + """Create a ContextConfig with custom chunking policy.""" + context.context_config = ContextConfig(chunking_policy=policy) + + +@given("a ContextConfig with chunk_size {size:d}") +def step_context_config_chunk_size(context: Context, size: int) -> None: + """Create a ContextConfig with custom chunk size.""" + context.context_config = ContextConfig(chunk_size=size) + + +# ContextConfig field assertions + + +@then("the context max_file_size should be {expected:d}") +def step_check_max_file_size(context: Context, expected: int) -> None: + """Verify max_file_size value.""" + assert context.context_config.max_file_size == expected + + +@then("the context max_files should be {expected:d}") +def step_check_max_files(context: Context, expected: int) -> None: + """Verify max_files value.""" + assert context.context_config.max_files == expected + + +@then('the context indexing_strategy should be "{expected}"') +def step_check_indexing_strategy(context: Context, expected: str) -> None: + """Verify indexing_strategy value.""" + assert context.context_config.indexing_strategy == expected + + +@then('the context chunking_policy should be "{expected}"') +def step_check_chunking_policy(context: Context, expected: str) -> None: + """Verify chunking_policy value.""" + assert context.context_config.chunking_policy == expected + + +@then("the context chunk_size should be {expected:d}") +def step_check_chunk_size(context: Context, expected: int) -> None: + """Verify chunk_size value.""" + assert context.context_config.chunk_size == expected + + +@then("the context include_patterns should be none") +def step_check_include_patterns_none(context: Context) -> None: + """Verify include_patterns is None.""" + assert context.context_config.include_patterns is None + + +@then('the context ignore_patterns should contain "{pattern}"') +def step_check_ignore_pattern(context: Context, pattern: str) -> None: + """Verify ignore_patterns contains expected pattern.""" + assert pattern in context.context_config.ignore_patterns, ( + f"Expected '{pattern}' in ignore_patterns, " + f"got {context.context_config.ignore_patterns}" + ) + + +@then('the context include_patterns should contain "{pattern}"') +def step_check_include_pattern(context: Context, pattern: str) -> None: + """Verify include_patterns contains expected pattern.""" + assert context.context_config.include_patterns is not None, ( + "include_patterns is None" + ) + assert pattern in context.context_config.include_patterns, ( + f"Expected '{pattern}' in include_patterns, " + f"got {context.context_config.include_patterns}" + ) + + +@then('the context ignore_patterns should not contain duplicate "{pattern}"') +def step_check_no_duplicate_ignore_pattern(context: Context, pattern: str) -> None: + """Verify ignore_patterns does not contain duplicates of the given pattern.""" + count = context.context_config.ignore_patterns.count(pattern) + assert count == 1, ( + f"Expected exactly 1 occurrence of '{pattern}' in ignore_patterns, " + f"found {count}: {context.context_config.ignore_patterns}" + ) diff --git a/features/steps/project_model_steps.py b/features/steps/project_model_steps.py new file mode 100644 index 000000000..6c119fb2e --- /dev/null +++ b/features/steps/project_model_steps.py @@ -0,0 +1,361 @@ +"""Step definitions for Project domain model extension tests (B1.1).""" + +from __future__ import annotations + +from pathlib import Path + +from behave import given, then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.domain.models.core.project import Project +from cleveragents.domain.models.core.resource import ( + Resource, + ResourceType, +) + +VALID_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" +VALID_ULID_2 = "01ARZ3NDEKTSV4RRFFQ69G5FAW" +VALID_ULID_3 = "01ARZ3NDEKTSV4RRFFQ69G5FAX" + + +def _make_resource( + name: str, + is_remote: bool = False, + resource_id: str = VALID_ULID_2, +) -> Resource: + """Helper to create a test Resource.""" + return Resource( + resource_id=resource_id, + name=name, + type=ResourceType.GIT_REPOSITORY, + location="/tmp/repo" if not is_remote else "https://github.com/test", + is_remote=is_remote, + ) + + +# Project creation steps + + +@given('a Project with project_id "{pid}" name "{name}" namespace "{namespace}"') +def step_create_project_with_namespace( + context: Context, pid: str, name: str, namespace: str +) -> None: + """Create a Project with explicit namespace.""" + context.project = Project( + project_id=pid, + name=name, + namespace=namespace, + path=Path("/tmp/test"), + ) + + +@given('a Project with project_id "{pid}" and name "{name}"') +def step_create_project_minimal(context: Context, pid: str, name: str) -> None: + """Create a Project with minimal fields.""" + context.project = Project( + project_id=pid, + name=name, + path=Path("/tmp/test"), + ) + + +@given('a Project with project_id "{pid}" name "{name}" and description "{desc}"') +def step_create_project_with_description( + context: Context, pid: str, name: str, desc: str +) -> None: + """Create a Project with description.""" + context.project = Project( + project_id=pid, + name=name, + description=desc, + path=Path("/tmp/test"), + ) + + +@given('a Project with project_id "{pid}" name "{name}" and tags "{tags_str}"') +def step_create_project_with_tags( + context: Context, pid: str, name: str, tags_str: str +) -> None: + """Create a Project with tags.""" + tags = [t.strip() for t in tags_str.split(",")] + context.project = Project( + project_id=pid, + name=name, + tags=tags, + path=Path("/tmp/test"), + ) + + +@given('a Project with project_id "{pid}" name "{name}" and all remote resources') +def step_create_project_all_remote(context: Context, pid: str, name: str) -> None: + """Create a Project where all resources are remote.""" + resources = [ + _make_resource("api-svc", is_remote=True, resource_id=VALID_ULID_2), + _make_resource("cloud-db", is_remote=True, resource_id=VALID_ULID_3), + ] + context.project = Project( + project_id=pid, + name=name, + resources=resources, + path=Path("/tmp/test"), + ) + + +@given('a Project with project_id "{pid}" name "{name}" and mixed resources') +def step_create_project_mixed_resources(context: Context, pid: str, name: str) -> None: + """Create a Project with both local and remote resources.""" + resources = [ + _make_resource("local-repo", is_remote=False, resource_id=VALID_ULID_2), + _make_resource("api-svc", is_remote=True, resource_id=VALID_ULID_3), + ] + context.project = Project( + project_id=pid, + name=name, + resources=resources, + path=Path("/tmp/test"), + ) + + +@given('a Project with project_id "{pid}" name "{name}" and a resource named "{rname}"') +def step_create_project_with_resource( + context: Context, pid: str, name: str, rname: str +) -> None: + """Create a Project with one resource.""" + resource = _make_resource(rname) + context.project = Project( + project_id=pid, + name=name, + resources=[resource], + path=Path("/tmp/test"), + ) + + +# Namespace validation steps + + +@when('I try to create a Project with namespace "{namespace}"') +def step_try_create_project_bad_namespace(context: Context, namespace: str) -> None: + """Attempt to create a Project with an invalid namespace.""" + context.error = None + try: + context.project = Project( + project_id=VALID_ULID, + name="test-project", + namespace=namespace, + path=Path("/tmp/test"), + ) + except (ValidationError, ValueError) as e: + context.error = e + + +@then("a namespace validation error should be raised") +def step_check_namespace_error(context: Context) -> None: + """Verify a namespace validation error was raised.""" + assert context.error is not None, ( + "Expected a namespace validation error but none was raised" + ) + + +# Resource manipulation steps + + +@when('I add a resource named "{rname}" to the project') +def step_add_resource(context: Context, rname: str) -> None: + """Add a resource to the project.""" + resource = _make_resource(rname) + context.project = context.project.add_resource(resource) + + +@when('I remove the resource named "{rname}" from the project') +def step_remove_resource(context: Context, rname: str) -> None: + """Remove a resource from the project.""" + context.project = context.project.remove_resource(rname) + + +@when('I get the resource named "{rname}" from the project') +def step_get_resource(context: Context, rname: str) -> None: + """Get a resource by name from the project.""" + context.retrieved_resource = context.project.get_resource(rname) + + +# Project field assertions + + +@then('the project project_id should be "{expected}"') +def step_check_project_id(context: Context, expected: str) -> None: + """Verify project_id.""" + assert context.project.project_id == expected + + +# Note: "the project name should be" step is defined in service_steps.py + + +@then('the project namespace should be "{expected}"') +def step_check_project_namespace(context: Context, expected: str) -> None: + """Verify project namespace.""" + assert context.project.namespace == expected + + +@then("the project description should be none") +def step_check_project_description_none(context: Context) -> None: + """Verify description is None.""" + assert context.project.description is None + + +@then('the project description should be "{expected}"') +def step_check_project_description(context: Context, expected: str) -> None: + """Verify description value.""" + assert context.project.description == expected + + +@then("the project tags should be empty") +def step_check_project_tags_empty(context: Context) -> None: + """Verify tags is empty list.""" + assert context.project.tags == [] + + +@then('the project tags should contain "{tag}"') +def step_check_project_tag(context: Context, tag: str) -> None: + """Verify tags contains expected value.""" + assert tag in context.project.tags + + +@then("the project resources should be empty") +def step_check_project_resources_empty(context: Context) -> None: + """Verify resources is empty list.""" + assert context.project.resources == [] + + +@then("the project validation_config should be none") +def step_check_project_validation_config_none(context: Context) -> None: + """Verify validation_config is None.""" + assert context.project.validation_config is None + + +@then("the project context_config should not be none") +def step_check_project_context_config_not_none(context: Context) -> None: + """Verify context_config is set.""" + assert context.project.context_config is not None + + +@then("the project created_at should be set") +def step_check_project_created_at(context: Context) -> None: + """Verify created_at is set.""" + assert context.project.created_at is not None + + +@then("the project updated_at should be set") +def step_check_project_updated_at(context: Context) -> None: + """Verify updated_at is set.""" + assert context.project.updated_at is not None + + +@then("the project is_remote should be false") +def step_check_project_not_remote(context: Context) -> None: + """Verify project is not remote.""" + assert context.project.is_remote is False + + +@then("the project is_remote should be true") +def step_check_project_is_remote(context: Context) -> None: + """Verify project is remote.""" + assert context.project.is_remote is True + + +@then('the project namespaced_name should be "{expected}"') +def step_check_project_namespaced_name(context: Context, expected: str) -> None: + """Verify namespaced_name property.""" + assert context.project.namespaced_name == expected + + +@then("the project should have {count:d} resource") +def step_check_project_resource_count(context: Context, count: int) -> None: + """Verify resource count.""" + assert len(context.project.resources) == count + + +@then('the project should have a resource named "{rname}"') +def step_check_project_has_resource(context: Context, rname: str) -> None: + """Verify project has a resource with the given name.""" + found = context.project.get_resource(rname) + assert found is not None, f"Expected resource '{rname}' not found" + + +@then('the retrieved resource name should be "{expected}"') +def step_check_retrieved_resource_name(context: Context, expected: str) -> None: + """Verify retrieved resource name.""" + assert context.retrieved_resource is not None + assert context.retrieved_resource.name == expected + + +@then("the retrieved resource should be none") +def step_check_retrieved_resource_none(context: Context) -> None: + """Verify retrieved resource is None.""" + assert context.retrieved_resource is None + + +# parse_namespaced_name steps + + +@when('I parse the project namespaced name "{namespaced}"') +def step_parse_project_namespaced_name(context: Context, namespaced: str) -> None: + """Parse a namespaced name string via Project.parse_namespaced_name.""" + namespace, name = Project.parse_namespaced_name(namespaced) + context.parsed_project_namespace = namespace + context.parsed_project_name = name + + +@when('I try to parse the project namespaced name "{namespaced}"') +def step_try_parse_project_namespaced_name(context: Context, namespaced: str) -> None: + """Attempt to parse an invalid namespaced name string.""" + context.error = None + try: + Project.parse_namespaced_name(namespaced) + except ValueError as e: + context.error = e + + +@then('the parsed project namespace should be "{expected}"') +def step_check_parsed_project_namespace(context: Context, expected: str) -> None: + """Verify parsed project namespace.""" + assert context.parsed_project_namespace == expected + + +@then('the parsed project name should be "{expected}"') +def step_check_parsed_project_name(context: Context, expected: str) -> None: + """Verify parsed project name.""" + assert context.parsed_project_name == expected + + +@then("a project namespaced name parse error should be raised") +def step_check_project_namespaced_name_error(context: Context) -> None: + """Verify a namespaced name parse error was raised.""" + assert context.error is not None, ( + "Expected a ValueError for invalid namespaced name but none was raised" + ) + + +# Name validation steps + + +@when('I try to create a Project with invalid name "{name}"') +def step_try_create_project_bad_name(context: Context, name: str) -> None: + """Attempt to create a Project with an invalid name.""" + context.error = None + try: + context.project = Project( + project_id=VALID_ULID, + name=name, + path=Path("/tmp/test"), + ) + except (ValidationError, ValueError) as e: + context.error = e + + +@then("a name validation error should be raised") +def step_check_name_error(context: Context) -> None: + """Verify a name validation error was raised.""" + assert context.error is not None, ( + "Expected a name validation error but none was raised" + ) diff --git a/features/steps/resource_model_steps.py b/features/steps/resource_model_steps.py new file mode 100644 index 000000000..77f271a71 --- /dev/null +++ b/features/steps/resource_model_steps.py @@ -0,0 +1,558 @@ +"""Step definitions for Resource domain model tests (B1.2, B1.3, B1.4).""" + +from __future__ import annotations + +from pathlib import Path + +from behave import given, then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.domain.models.core.resource import ( + Resource, + ResourceType, + SandboxStrategy, +) + +# ResourceType Steps + + +@then('the resource types should be "{expected_order}"') +def step_check_resource_type_values(context: Context, expected_order: str) -> None: + """Verify that ResourceType has all expected values in order.""" + expected = [v.strip() for v in expected_order.split(",")] + actual = [rt.value for rt in ResourceType] + assert actual == expected, f"Expected resource types {expected}, got {actual}" + + +@when("I access ResourceType.GIT_REPOSITORY") +def step_access_git_repository(context: Context) -> None: + """Access the GIT_REPOSITORY enum member.""" + context.resource_type = ResourceType.GIT_REPOSITORY + + +@when("I access ResourceType.FILESYSTEM") +def step_access_filesystem(context: Context) -> None: + """Access the FILESYSTEM enum member.""" + context.resource_type = ResourceType.FILESYSTEM + + +@when("I access ResourceType.DATABASE") +def step_access_database(context: Context) -> None: + """Access the DATABASE enum member.""" + context.resource_type = ResourceType.DATABASE + + +@when("I access ResourceType.API_ENDPOINT") +def step_access_api_endpoint(context: Context) -> None: + """Access the API_ENDPOINT enum member.""" + context.resource_type = ResourceType.API_ENDPOINT + + +@when("I access ResourceType.DOCUMENT_CORPUS") +def step_access_document_corpus(context: Context) -> None: + """Access the DOCUMENT_CORPUS enum member.""" + context.resource_type = ResourceType.DOCUMENT_CORPUS + + +@when("I access ResourceType.CLOUD_INFRASTRUCTURE") +def step_access_cloud_infrastructure(context: Context) -> None: + """Access the CLOUD_INFRASTRUCTURE enum member.""" + context.resource_type = ResourceType.CLOUD_INFRASTRUCTURE + + +@then('the resource type value should be "{expected}"') +def step_check_resource_type_value(context: Context, expected: str) -> None: + """Check the resource type value matches expected.""" + actual = context.resource_type.value + assert actual == expected, f"Expected value '{expected}', got '{actual}'" + + +@then("the resource type should be a string") +def step_check_resource_type_is_string(context: Context) -> None: + """Verify ResourceType inherits from str.""" + assert isinstance(context.resource_type, str), ( + f"Expected ResourceType to be a string, got {type(context.resource_type)}" + ) + + +@when('I create a ResourceType from string "{value}"') +def step_create_resource_type_from_string(context: Context, value: str) -> None: + """Create a ResourceType from a string value.""" + context.error = None + try: + context.resource_type = ResourceType(value) + except ValueError as e: + context.error = e + + +@when('I try to create a ResourceType from string "{value}"') +def step_try_create_resource_type_from_string(context: Context, value: str) -> None: + """Attempt to create a ResourceType from an invalid string.""" + context.error = None + try: + context.resource_type = ResourceType(value) + except ValueError as e: + context.error = e + + +@then("a ValueError should be raised") +def step_check_value_error_raised(context: Context) -> None: + """Verify a ValueError was captured.""" + assert context.error is not None, "Expected a ValueError but none was raised" + assert isinstance(context.error, ValueError), ( + f"Expected ValueError, got {type(context.error).__name__}" + ) + + +# SandboxStrategy Steps + + +@then('the sandbox strategies should be "{expected_order}"') +def step_check_sandbox_strategy_values(context: Context, expected_order: str) -> None: + """Verify that SandboxStrategy has all expected values in order.""" + expected = [v.strip() for v in expected_order.split(",")] + actual = [ss.value for ss in SandboxStrategy] + assert actual == expected, f"Expected sandbox strategies {expected}, got {actual}" + + +@when("I access SandboxStrategy.GIT_WORKTREE") +def step_access_git_worktree(context: Context) -> None: + """Access the GIT_WORKTREE enum member.""" + context.sandbox_strategy = SandboxStrategy.GIT_WORKTREE + + +@when("I access SandboxStrategy.COPY_ON_WRITE") +def step_access_copy_on_write(context: Context) -> None: + """Access the COPY_ON_WRITE enum member.""" + context.sandbox_strategy = SandboxStrategy.COPY_ON_WRITE + + +@when("I access SandboxStrategy.OVERLAY") +def step_access_overlay(context: Context) -> None: + """Access the OVERLAY enum member.""" + context.sandbox_strategy = SandboxStrategy.OVERLAY + + +@when("I access SandboxStrategy.TRANSACTION_ROLLBACK") +def step_access_transaction_rollback(context: Context) -> None: + """Access the TRANSACTION_ROLLBACK enum member.""" + context.sandbox_strategy = SandboxStrategy.TRANSACTION_ROLLBACK + + +@when("I access SandboxStrategy.VERSIONING") +def step_access_versioning(context: Context) -> None: + """Access the VERSIONING enum member.""" + context.sandbox_strategy = SandboxStrategy.VERSIONING + + +@when("I access SandboxStrategy.NONE") +def step_access_none_strategy(context: Context) -> None: + """Access the NONE enum member.""" + context.sandbox_strategy = SandboxStrategy.NONE + + +@then('the sandbox strategy value should be "{expected}"') +def step_check_sandbox_strategy_value(context: Context, expected: str) -> None: + """Check the sandbox strategy value matches expected.""" + actual = context.sandbox_strategy.value + assert actual == expected, f"Expected value '{expected}', got '{actual}'" + + +@then("the sandbox strategy should be a string") +def step_check_sandbox_strategy_is_string(context: Context) -> None: + """Verify SandboxStrategy inherits from str.""" + assert isinstance(context.sandbox_strategy, str), ( + f"Expected SandboxStrategy to be a string, got {type(context.sandbox_strategy)}" + ) + + +# SandboxStrategy.supports_rollback Tests + + +@then("SandboxStrategy.GIT_WORKTREE should support rollback") +def step_git_worktree_supports_rollback(context: Context) -> None: + """Verify GIT_WORKTREE supports rollback.""" + assert SandboxStrategy.GIT_WORKTREE.supports_rollback is True + + +@then("SandboxStrategy.COPY_ON_WRITE should support rollback") +def step_copy_on_write_supports_rollback(context: Context) -> None: + """Verify COPY_ON_WRITE supports rollback.""" + assert SandboxStrategy.COPY_ON_WRITE.supports_rollback is True + + +@then("SandboxStrategy.OVERLAY should support rollback") +def step_overlay_supports_rollback(context: Context) -> None: + """Verify OVERLAY supports rollback.""" + assert SandboxStrategy.OVERLAY.supports_rollback is True + + +@then("SandboxStrategy.TRANSACTION_ROLLBACK should support rollback") +def step_transaction_rollback_supports_rollback(context: Context) -> None: + """Verify TRANSACTION_ROLLBACK supports rollback.""" + assert SandboxStrategy.TRANSACTION_ROLLBACK.supports_rollback is True + + +@then("SandboxStrategy.VERSIONING should support rollback") +def step_versioning_supports_rollback(context: Context) -> None: + """Verify VERSIONING supports rollback.""" + assert SandboxStrategy.VERSIONING.supports_rollback is True + + +@then("SandboxStrategy.NONE should not support rollback") +def step_none_does_not_support_rollback(context: Context) -> None: + """Verify NONE does not support rollback.""" + assert SandboxStrategy.NONE.supports_rollback is False + + +# SandboxStrategy.is_copy_based Tests + + +@then("SandboxStrategy.COPY_ON_WRITE should be copy based") +def step_copy_on_write_is_copy_based(context: Context) -> None: + """Verify COPY_ON_WRITE is copy based.""" + assert SandboxStrategy.COPY_ON_WRITE.is_copy_based is True + + +@then("SandboxStrategy.OVERLAY should be copy based") +def step_overlay_is_copy_based(context: Context) -> None: + """Verify OVERLAY is copy based.""" + assert SandboxStrategy.OVERLAY.is_copy_based is True + + +@then("SandboxStrategy.GIT_WORKTREE should not be copy based") +def step_git_worktree_not_copy_based(context: Context) -> None: + """Verify GIT_WORKTREE is not copy based.""" + assert SandboxStrategy.GIT_WORKTREE.is_copy_based is False + + +@then("SandboxStrategy.TRANSACTION_ROLLBACK should not be copy based") +def step_transaction_rollback_not_copy_based(context: Context) -> None: + """Verify TRANSACTION_ROLLBACK is not copy based.""" + assert SandboxStrategy.TRANSACTION_ROLLBACK.is_copy_based is False + + +@then("SandboxStrategy.VERSIONING should not be copy based") +def step_versioning_not_copy_based(context: Context) -> None: + """Verify VERSIONING is not copy based.""" + assert SandboxStrategy.VERSIONING.is_copy_based is False + + +@then("SandboxStrategy.NONE should not be copy based") +def step_none_not_copy_based(context: Context) -> None: + """Verify NONE is not copy based.""" + assert SandboxStrategy.NONE.is_copy_based is False + + +# Resource Pydantic Model Steps (B1.2) + +VALID_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" + + +@given( + 'a Resource with required fields id "{resource_id}" name "{name}" type "{rtype}" location "{location}"' +) +def step_create_resource( + context: Context, resource_id: str, name: str, rtype: str, location: str +) -> None: + """Create a Resource with required fields only.""" + context.error = None + try: + context.resource = Resource( + resource_id=resource_id, + name=name, + type=ResourceType(rtype), + location=location, + ) + except (ValidationError, ValueError) as e: + context.error = e + + +@given("I have a valid Resource") +def step_have_valid_resource(context: Context) -> None: + """Create a valid Resource for mutation testing.""" + context.resource = Resource( + resource_id=VALID_ULID, + name="my-repo", + type=ResourceType.GIT_REPOSITORY, + location="/tmp/repo", + ) + + +@when("I try to modify the resource name") +def step_try_modify_resource_name(context: Context) -> None: + """Attempt to modify a frozen model field.""" + context.error = None + try: + context.resource.name = "new-name" # type: ignore[misc] + except ValidationError as e: + context.error = e + + +@given( + 'a remote Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}"' +) +def step_create_resource_remote( + context: Context, resource_id: str, name: str, rtype: str, location: str +) -> None: + """Create a remote Resource.""" + context.error = None + try: + context.resource = Resource( + resource_id=resource_id, + name=name, + type=ResourceType(rtype), + location=location, + is_remote=True, + ) + except (ValidationError, ValueError) as e: + context.error = e + + +@given( + 'a read-only Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}"' +) +def step_create_resource_read_only( + context: Context, resource_id: str, name: str, rtype: str, location: str +) -> None: + """Create a read-only Resource.""" + context.error = None + try: + context.resource = Resource( + resource_id=resource_id, + name=name, + type=ResourceType(rtype), + location=location, + read_only=True, + ) + except (ValidationError, ValueError) as e: + context.error = e + + +@given( + 'a Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}" strategy "{strategy}"' +) +def step_create_resource_with_strategy( + context: Context, + resource_id: str, + name: str, + rtype: str, + location: str, + strategy: str, +) -> None: + """Create a Resource with a specific sandbox strategy.""" + context.error = None + try: + context.resource = Resource( + resource_id=resource_id, + name=name, + type=ResourceType(rtype), + location=location, + sandbox_strategy=SandboxStrategy(strategy), + ) + except (ValidationError, ValueError) as e: + context.error = e + + +@given( + 'a Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}" metadata "{meta_str}"' +) +def step_create_resource_with_metadata( + context: Context, + resource_id: str, + name: str, + rtype: str, + location: str, + meta_str: str, +) -> None: + """Create a Resource with metadata parsed from key=value pairs.""" + metadata = {} + for pair in meta_str.split(","): + key, value = pair.strip().split("=", 1) + metadata[key.strip()] = value.strip() + context.error = None + try: + context.resource = Resource( + resource_id=resource_id, + name=name, + type=ResourceType(rtype), + location=location, + metadata=metadata, + ) + except (ValidationError, ValueError) as e: + context.error = e + + +@when( + 'I try to create a Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}"' +) +def step_try_create_resource( + context: Context, resource_id: str, name: str, rtype: str, location: str +) -> None: + """Attempt to create a Resource that may fail validation.""" + context.error = None + try: + context.resource = Resource( + resource_id=resource_id, + name=name, + type=ResourceType(rtype), + location=location, + ) + except (ValidationError, ValueError) as e: + context.error = e + + +@when("I try to create a Resource with an empty name") +def step_try_create_resource_empty_name(context: Context) -> None: + """Attempt to create a Resource with empty name.""" + context.error = None + try: + context.resource = Resource( + resource_id=VALID_ULID, + name="", + type=ResourceType.GIT_REPOSITORY, + location="/tmp/repo", + ) + except (ValidationError, ValueError) as e: + context.error = e + + +@when("I try to create a Resource with an empty location") +def step_try_create_resource_empty_location(context: Context) -> None: + """Attempt to create a Resource with empty location.""" + context.error = None + try: + context.resource = Resource( + resource_id=VALID_ULID, + name="my-repo", + type=ResourceType.GIT_REPOSITORY, + location="", + ) + except (ValidationError, ValueError) as e: + context.error = e + + +# Resource field assertion steps + + +@then('the resource name should be "{expected}"') +def step_check_resource_name(context: Context, expected: str) -> None: + """Verify resource name.""" + assert context.resource.name == expected, ( + f"Expected name '{expected}', got '{context.resource.name}'" + ) + + +@then("the resource type should be ResourceType.GIT_REPOSITORY") +def step_check_resource_type_git(context: Context) -> None: + """Verify resource type is GIT_REPOSITORY.""" + assert context.resource.type == ResourceType.GIT_REPOSITORY + + +@then('the resource location should be "{expected}"') +def step_check_resource_location(context: Context, expected: str) -> None: + """Verify resource location.""" + assert context.resource.location == expected, ( + f"Expected location '{expected}', got '{context.resource.location}'" + ) + + +@then("the resource is_remote should be false") +def step_check_resource_not_remote(context: Context) -> None: + """Verify resource is not remote.""" + assert context.resource.is_remote is False + + +@then("the resource is_remote should be true") +def step_check_resource_is_remote(context: Context) -> None: + """Verify resource is remote.""" + assert context.resource.is_remote is True + + +@then("the resource sandbox_strategy should be SandboxStrategy.NONE") +def step_check_resource_strategy_none(context: Context) -> None: + """Verify default sandbox strategy is NONE.""" + assert context.resource.sandbox_strategy == SandboxStrategy.NONE + + +@then("the resource sandbox_strategy should be SandboxStrategy.GIT_WORKTREE") +def step_check_resource_strategy_worktree(context: Context) -> None: + """Verify sandbox strategy is GIT_WORKTREE.""" + assert context.resource.sandbox_strategy == SandboxStrategy.GIT_WORKTREE + + +@then("the resource read_only should be false") +def step_check_resource_not_readonly(context: Context) -> None: + """Verify resource is not read-only.""" + assert context.resource.read_only is False + + +@then("the resource read_only should be true") +def step_check_resource_is_readonly(context: Context) -> None: + """Verify resource is read-only.""" + assert context.resource.read_only is True + + +@then("the resource metadata should be empty") +def step_check_resource_metadata_empty(context: Context) -> None: + """Verify resource metadata is empty dict.""" + assert context.resource.metadata == {}, ( + f"Expected empty metadata, got {context.resource.metadata}" + ) + + +@then("the resource created_at should be set") +def step_check_resource_created_at(context: Context) -> None: + """Verify resource has a created_at timestamp.""" + assert context.resource.created_at is not None + + +@then('the resource metadata should contain key "{key}" with value "{value}"') +def step_check_resource_metadata_key(context: Context, key: str, value: str) -> None: + """Verify resource metadata contains expected key-value pair.""" + assert key in context.resource.metadata, ( + f"Expected key '{key}' in metadata, got {context.resource.metadata}" + ) + assert context.resource.metadata[key] == value, ( + f"Expected metadata['{key}'] = '{value}', got '{context.resource.metadata[key]}'" + ) + + +# Note: "a validation error should be raised" step is defined in domain_models_steps.py + + +# Resource property assertion steps + + +@then("the resource supports_sandbox should be true") +def step_check_supports_sandbox_true(context: Context) -> None: + """Verify resource supports sandbox.""" + assert context.resource.supports_sandbox is True + + +@then("the resource supports_sandbox should be false") +def step_check_supports_sandbox_false(context: Context) -> None: + """Verify resource does not support sandbox.""" + assert context.resource.supports_sandbox is False + + +@then("the resource can_write should be true") +def step_check_can_write_true(context: Context) -> None: + """Verify resource can be written to.""" + assert context.resource.can_write is True + + +@then("the resource can_write should be false") +def step_check_can_write_false(context: Context) -> None: + """Verify resource cannot be written to.""" + assert context.resource.can_write is False + + +@then('the resource get_sandbox_path with base "{base}" should end with "{suffix}"') +def step_check_sandbox_path(context: Context, base: str, suffix: str) -> None: + """Verify get_sandbox_path returns expected path.""" + result = context.resource.get_sandbox_path(Path(base)) + assert str(result).endswith(suffix), ( + f"Expected path ending with '{suffix}', got '{result}'" + ) diff --git a/src/cleveragents/domain/models/core/__init__.py b/src/cleveragents/domain/models/core/__init__.py index 7e7698695..a3ceb439f 100644 --- a/src/cleveragents/domain/models/core/__init__.py +++ b/src/cleveragents/domain/models/core/__init__.py @@ -46,9 +46,16 @@ from cleveragents.domain.models.core.plan_legacy import ( PlanStatus, ) from cleveragents.domain.models.core.project import ( + ContextConfig, Project, ProjectSettings, ProjectStats, + ValidationConfig, +) +from cleveragents.domain.models.core.resource import ( + Resource, + ResourceType, + SandboxStrategy, ) __all__ = [ @@ -58,6 +65,7 @@ __all__ = [ "ChangeSet", "CloudBillingFields", "Context", + "ContextConfig", "ContextFile", "ContextType", "ContextUpdateResult", @@ -85,7 +93,11 @@ __all__ = [ "Project", "ProjectSettings", "ProjectStats", + "Resource", + "ResourceType", + "SandboxStrategy", "SummaryForUpdateContextParams", "User", + "ValidationConfig", "can_transition", ] diff --git a/src/cleveragents/domain/models/core/project.py b/src/cleveragents/domain/models/core/project.py index 7dc1a7b8a..60c93b57a 100644 --- a/src/cleveragents/domain/models/core/project.py +++ b/src/cleveragents/domain/models/core/project.py @@ -1,26 +1,158 @@ """Project domain model for CleverAgents. -Based on Phase 0 discovery and ADR-004 (Pydantic Validation). +Includes ValidationConfig (B1.5), ContextConfig (B1.6), and the Project model (B1.1). +Based on Phase 0 discovery, implementation_plan.md, and ADR-004 (Pydantic Validation). """ +import re from datetime import datetime from pathlib import Path from pydantic import BaseModel, ConfigDict, Field, field_validator +from cleveragents.domain.models.core.resource import Resource + +ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$" +NAMESPACE_PATTERN = r"^(local|[a-z][a-z0-9_]{0,49})$" +RESERVED_NAMESPACES = frozenset({"system", "internal", "admin", "root"}) + +DEFAULT_IGNORE_PATTERNS: list[str] = [ + ".git/", + "node_modules/", + "__pycache__/", + ".venv/", + "*.pyc", + ".DS_Store", +] + + +class ValidationConfig(BaseModel): + """Configuration for project validation commands. + + Defines test, lint, type-check, and build commands along with + custom commands and execution settings. + + Implements B1.5 from the implementation plan. + """ + + test_command: str | None = Field(default=None, description="Command to run tests") + lint_command: str | None = Field(default=None, description="Command to run linting") + type_check_command: str | None = Field( + default=None, description="Command to run type checking" + ) + build_command: str | None = Field( + default=None, description="Command to build the project" + ) + custom_commands: dict[str, str] = Field( + default_factory=dict, + description="Custom named validation commands", + ) + timeout_seconds: int = Field( + default=300, description="Timeout for each validation command in seconds" + ) + fail_on_lint_error: bool = Field( + default=True, description="Whether lint errors should fail validation" + ) + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + ) + + def get_all_commands(self) -> dict[str, str]: + """Return all configured commands as a name->command dict. + + Returns: + Dictionary mapping command names to command strings. + Includes standard commands (test, lint, type_check, build) + and any custom commands. + """ + commands: dict[str, str] = {} + if self.test_command is not None: + commands["test"] = self.test_command + if self.lint_command is not None: + commands["lint"] = self.lint_command + if self.type_check_command is not None: + commands["type_check"] = self.type_check_command + if self.build_command is not None: + commands["build"] = self.build_command + commands.update(self.custom_commands) + return commands + + def has_any_validation(self) -> bool: + """Check if any validation commands are configured. + + Returns: + True if at least one command (standard or custom) is set. + """ + return len(self.get_all_commands()) > 0 + + +class ContextConfig(BaseModel): + """Configuration for project context indexing and filtering. + + Controls how project files are indexed, chunked, and filtered + for inclusion in AI context windows. + + Implements B1.6 from the implementation plan. + """ + + ignore_patterns: list[str] = Field( + default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS), + description="File patterns to ignore during indexing", + ) + include_patterns: list[str] | None = Field( + default=None, description="File patterns to include (None means include all)" + ) + max_file_size: int = Field( + default=1_000_000, description="Maximum file size in bytes (1MB default)" + ) + max_files: int = Field( + default=100_000, description="Maximum number of files to index" + ) + indexing_strategy: str = Field( + default="full_text", + description="Indexing strategy: full_text, semantic, etc.", + ) + chunking_policy: str = Field( + default="smart", description="Chunking policy: smart, fixed, etc." + ) + chunk_size: int = Field(default=1000, description="Chunk size in tokens") + + @field_validator("ignore_patterns") + @classmethod + def merge_default_ignore_patterns( + cls: "type[ContextConfig]", v: list[str] + ) -> list[str]: + """Merge user-provided ignore patterns with defaults. + + Ensures default patterns like .git/, node_modules/, etc. + are always included. + """ + merged = list(DEFAULT_IGNORE_PATTERNS) + for pattern in v: + if pattern not in merged: + merged.append(pattern) + return merged + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + ) + class ProjectSettings(BaseModel): """Project-specific settings and configuration.""" - auto_build: bool = Field(False, description="Automatically build plans") - auto_apply: bool = Field(False, description="Automatically apply changes") + auto_build: bool = Field(default=False, description="Automatically build plans") + auto_apply: bool = Field(default=False, description="Automatically apply changes") confirm_apply: bool = Field( - True, description="Require confirmation before applying" + default=True, description="Require confirmation before applying" ) max_context_size: int = Field( - 52428800, description="Maximum context size in bytes (50MB)" + default=52428800, description="Maximum context size in bytes (50MB)" ) - default_model: str = Field("mock-gpt", description="Default AI model") + default_model: str = Field(default="mock-gpt", description="Default AI model") include_paths: list[str] = Field( default_factory=list, description="Relative include globs" ) @@ -37,10 +169,10 @@ class ProjectSettings(BaseModel): class ProjectStats(BaseModel): """Statistics for a project.""" - plans: int = Field(0, ge=0) - context_files: int = Field(0, ge=0) - changes: int = Field(0, ge=0) - current_plan: str | None = Field(None) + plans: int = Field(default=0, ge=0) + context_files: int = Field(default=0, ge=0) + changes: int = Field(default=0, ge=0) + current_plan: str | None = Field(default=None) model_config = ConfigDict( str_strip_whitespace=True, @@ -52,19 +184,52 @@ class Project(BaseModel): """Domain model for a CleverAgents project. A project represents a workspace with plans, contexts, and changes. + Projects can have resources, validation configs, and context configs. + + Extends original model with B1.1 fields: project_id, namespace, + description, tags, resources, validation_config, context_config. """ - id: int | None = Field(None, description="Project ID") - name: str = Field(..., min_length=1, max_length=255) + # Legacy fields (preserved for backward compatibility) + id: int | None = Field(default=None, description="Legacy integer project ID") path: Path = Field(..., description="Project root path") + settings: ProjectSettings = Field(default_factory=lambda: ProjectSettings()) + current_plan_id: int | None = Field(default=None) + + # B1.1b - Identity fields + project_id: str | None = Field( + default=None, description="Unique ULID identifier", pattern=ULID_PATTERN + ) + name: str = Field(..., min_length=1, max_length=255) + namespace: str = Field( + default="local", description="Project namespace for grouping" + ) + description: str | None = Field( + default=None, description="Human-readable project description" + ) + + # B1.1c - Categorization fields + tags: list[str] = Field( + default_factory=list, description="Project tags for filtering" + ) + resources: list[Resource] = Field( + default_factory=list, description="Project resource references" + ) + validation_config: ValidationConfig | None = Field( + default=None, description="Validation command configuration" + ) + context_config: ContextConfig = Field( + default_factory=lambda: ContextConfig(), + description="Context indexing and filtering configuration", + ) + + # B1.1d - Timestamps created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) - settings: ProjectSettings = Field(default_factory=lambda: ProjectSettings()) # type: ignore - current_plan_id: int | None = Field(None) @field_validator("name") @classmethod - def validate_name(cls: type["Project"], v: str) -> str: + def validate_name(cls: "type[Project]", v: str) -> str: """Validate project name.""" if not v.replace("-", "").replace("_", "").replace(" ", "").isalnum(): raise ValueError( @@ -72,9 +237,31 @@ class Project(BaseModel): ) return v + @field_validator("namespace") + @classmethod + def validate_namespace(cls: "type[Project]", v: str) -> str: + """Validate namespace format and reject reserved names. + + Namespace must match pattern: 'local' or start with lowercase letter + followed by up to 49 lowercase alphanumeric/underscore chars. + Reserved names (system, internal, admin, root) are rejected. + """ + if not re.match(NAMESPACE_PATTERN, v): + raise ValueError( + f"Namespace '{v}' must match pattern: 'local' or " + "start with lowercase letter followed by lowercase " + "alphanumeric/underscore characters (max 50 chars)" + ) + if v in RESERVED_NAMESPACES: + raise ValueError( + f"Namespace '{v}' is reserved. " + f"Reserved namespaces: {', '.join(sorted(RESERVED_NAMESPACES))}" + ) + return v + @field_validator("path") @classmethod - def validate_path(cls: type["Project"], v: Path) -> Path: + def validate_path(cls: "type[Project]", v: Path) -> Path: """Ensure path is absolute.""" return v.resolve() @@ -83,3 +270,80 @@ class Project(BaseModel): validate_assignment=True, arbitrary_types_allowed=True, # Allow Path ) + + # B1.1e - Computed property + @property + def is_remote(self) -> bool: + """Check if all project resources are remote. + + A project is remote only if it has resources and ALL of them + are remotely accessible. Empty resource lists return False. + """ + if not self.resources: + return False + return all(r.is_remote for r in self.resources) + + # B1.1g - Helper methods + @property + def namespaced_name(self) -> str: + """Return the fully qualified namespace/name string.""" + return f"{self.namespace}/{self.name}" + + @staticmethod + def parse_namespaced_name(namespaced: str) -> tuple[str, str]: + """Parse a 'namespace/name' string into (namespace, name). + + Args: + namespaced: String in 'namespace/name' format. + + Returns: + Tuple of (namespace, name). + + Raises: + ValueError: If the string doesn't contain exactly one '/'. + """ + parts = namespaced.split("/", 1) + if len(parts) != 2: + raise ValueError(f"Expected 'namespace/name' format, got '{namespaced}'") + return parts[0], parts[1] + + def add_resource(self, resource: Resource) -> "Project": + """Return a new Project with the resource added. + + Since the model may be frozen or validated, this returns + a new instance with the resource appended. + + Args: + resource: The Resource to add. + + Returns: + New Project instance with the resource added. + """ + new_resources = [*list(self.resources), resource] + return self.model_copy(update={"resources": new_resources}) + + def remove_resource(self, name: str) -> "Project": + """Return a new Project with the named resource removed. + + Args: + name: Name of the resource to remove. + + Returns: + New Project instance without the named resource. + """ + new_resources = [r for r in self.resources if r.name != name] + return self.model_copy(update={"resources": new_resources}) + + def get_resource(self, name: str) -> Resource | None: + """Get a resource by name. + + Args: + name: Name of the resource to find. + + Returns: + The Resource if found, None otherwise. + """ + for r in self.resources: + if r.name == name: + return r + return None diff --git a/src/cleveragents/domain/models/core/resource.py b/src/cleveragents/domain/models/core/resource.py new file mode 100644 index 000000000..e26b1d5d4 --- /dev/null +++ b/src/cleveragents/domain/models/core/resource.py @@ -0,0 +1,139 @@ +"""Resource domain model for CleverAgents. + +Defines ResourceType enum, SandboxStrategy enum, and the Resource +Pydantic model for classifying project resources and determining +sandboxing behavior. + +Based on implementation_plan.md (B1.2, B1.3, B1.4) and ADR-004 (Pydantic Validation). +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$" + + +class ResourceType(StrEnum): + """Types of resources a project can reference. + + Each resource type maps to a category of external data source + or infrastructure that a project operates on. + """ + + GIT_REPOSITORY = "git_repository" + FILESYSTEM = "filesystem" + DATABASE = "database" + API_ENDPOINT = "api_endpoint" + DOCUMENT_CORPUS = "document_corpus" + CLOUD_INFRASTRUCTURE = "cloud_infrastructure" + + +class SandboxStrategy(StrEnum): + """Strategies for isolating resource modifications during plan execution. + + Sandboxing ensures that changes made during plan execution can be + reviewed, committed, or rolled back without affecting the original resource. + """ + + GIT_WORKTREE = "git_worktree" + COPY_ON_WRITE = "copy_on_write" + OVERLAY = "overlay" + TRANSACTION_ROLLBACK = "transaction_rollback" + VERSIONING = "versioning" + NONE = "none" + + @property + def supports_rollback(self) -> bool: + """Check if this strategy supports rolling back changes. + + All strategies except NONE support rollback, since NONE means + modifications are immediate and irreversible. + """ + return self != SandboxStrategy.NONE + + @property + def is_copy_based(self) -> bool: + """Check if this strategy creates a copy of the resource. + + COPY_ON_WRITE and OVERLAY both duplicate the resource data + to a separate location for isolation. + """ + return self in (SandboxStrategy.COPY_ON_WRITE, SandboxStrategy.OVERLAY) + + +class Resource(BaseModel): + """A project resource reference. + + Resources represent external data sources or repositories + that a project operates on. Each resource has a type, location, + and sandboxing strategy that determines how modifications are isolated. + + Implements B1.2 from the implementation plan. + """ + + resource_id: str = Field( + ..., description="Unique ULID identifier", pattern=ULID_PATTERN + ) + name: str = Field( + ..., min_length=1, max_length=255, description="Human-readable name" + ) + type: ResourceType = Field(..., description="Resource classification") + location: str = Field(..., min_length=1, description="Path or URI to the resource") + is_remote: bool = Field( + False, description="Whether the resource is remotely accessible" + ) + sandbox_strategy: SandboxStrategy = Field( + SandboxStrategy.NONE, + description="Strategy for isolating modifications", + ) + read_only: bool = Field(False, description="Whether the resource is read-only") + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Arbitrary key-value metadata for the resource", + ) + created_at: datetime = Field( + default_factory=datetime.now, + description="Timestamp when the resource was created", + ) + + @field_validator("name") + @classmethod + def validate_name(cls: type[Resource], v: str) -> str: + """Enforce naming rules: alphanumeric with hyphens/underscores, lowercased.""" + if not v.replace("-", "").replace("_", "").isalnum(): + raise ValueError("Name must be alphanumeric with hyphens/underscores only") + return v.lower() + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + use_enum_values=False, + frozen=True, + ) + + @property + def supports_sandbox(self) -> bool: + """Check if this resource has a sandbox strategy other than NONE.""" + return self.sandbox_strategy != SandboxStrategy.NONE + + @property + def can_write(self) -> bool: + """Check if this resource allows write operations.""" + return not self.read_only + + def get_sandbox_path(self, base_path: Path) -> Path: + """Get the sandbox directory path for this resource. + + Args: + base_path: The base directory for sandboxes. + + Returns: + Path combining base_path with the resource name. + """ + return base_path / self.name