diff --git a/.gitignore b/.gitignore index de67b75..82cefe8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .agents/ .claude/ .codex/ +.forge/ .kilo/ .serena/ .specify/ diff --git a/packages/n-framework-core-template b/packages/n-framework-core-template index 739835b..ca7e23c 160000 --- a/packages/n-framework-core-template +++ b/packages/n-framework-core-template @@ -1 +1 @@ -Subproject commit 739835b5f862992c67d28d098a565f324d8068b5 +Subproject commit ca7e23cbe4761859111a9b2202ef9a311a08e999 diff --git a/specs/013-gen-endpoint-command/clarifications.md b/specs/013-gen-endpoint-command/clarifications.md new file mode 100644 index 0000000..7ea5e49 --- /dev/null +++ b/specs/013-gen-endpoint-command/clarifications.md @@ -0,0 +1,3 @@ +# Clarifications + +No critical ambiguities detected worth formal clarification. The specification and plan provide sufficient detail. No action needed at this stage, so you can proceed. diff --git a/specs/013-gen-endpoint-command/data-model.md b/specs/013-gen-endpoint-command/data-model.md new file mode 100644 index 0000000..958c030 --- /dev/null +++ b/specs/013-gen-endpoint-command/data-model.md @@ -0,0 +1,24 @@ +# Data Model: Gen Endpoint Command + +## Data Entities + +### 1. Endpoint Generation Arguments (`GenEndpointArgs`) + +- **Fields**: + - `operation_type`: Enum string representing HTTP verb (GET, POST, PUT, DELETE) + - `name`: Target command or query name + - `feature`: Target feature folder in the Application layer + - `service`: Optional target service (if multiple exist in workspace) +- **Validation Rules**: + - MUST match existing standard HTTP verbs (Get, Post, Put, Delete). + - MUST find the file `Application/Features/{feature}/{name}.cs` that represents the request. + +### 2. Generator Template Payload + +- **Fields sent to Template Engine**: + - `operation_type`: (String) e.g. "Get" + - `name`: (String) e.g. "GetInventoryItem" + - `feature`: (String) e.g. "Inventory" + - `service_name`: (String) e.g. "Catalog" + - `request_type`: (String) Evaluated from the name (Command vs Query) to map HTTP return types intuitively if needed. + - `route_path`: (String) Evaluated from the feature and name (e.g. "/api/inventory/getinventoryitem"). diff --git a/specs/013-gen-endpoint-command/plan.md b/specs/013-gen-endpoint-command/plan.md new file mode 100644 index 0000000..7c1730f --- /dev/null +++ b/specs/013-gen-endpoint-command/plan.md @@ -0,0 +1,61 @@ +# Implementation Plan: Gen Endpoint Command + +**Branch**: `013-gen-endpoint-command` | **Date**: 2026-05-07 | **Spec**: `src/nfw/specs/013-gen-endpoint-command/spec.md` +**Input**: Feature specification from `specs/013-gen-endpoint-command/spec.md` + +## Summary + +Implement `nfw gen endpoint` command to generate an HTTP Minimal API endpoint for an existing MediatR command or query. The generated file should use attribute routing based on the HTTP operation type (GET/POST/PUT/DELETE) and include OpenAPI annotations, completing in under 3 seconds. + +## Technical Context + +**Language/Version**: Rust 1.85+ (2024 edition) +**Primary Dependencies**: clap (CLI), serde/serde_yaml (templates), NFramework templates store +**Storage**: File system (templates and generated C# code) +**Testing**: cargo test (unit, integration) +**Target Platform**: CLI binary (Linux/macOS/Windows) +**Project Type**: CLI Tool +**Performance Goals**: < 3 seconds execution time per endpoint generation +**Constraints**: Must validate C# Application layer structure before generation +**Scale/Scope**: Generates single C# file in Api layer per invocation + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **I. Library-First (N/A)**: Command implementation is an extension of existing CLI application codebase +- **II. CLI Interface (PASS)**: Built entirely as a CLI command via `clap`, reading args format mapping +- **III. Test-First (PASS)**: Integration tests planned covering failure validation and successful boilerplate generation +- **IV. Integration Testing (PASS)**: Tests will invoke CLI to analyze scaffolded file structures +- **V. Observability (PASS)**: Uses existing nfw CLI error handling structured tracing + +## Project Structure + +### Documentation (this feature) + +```text +specs/013-gen-endpoint-command/ +├── spec.md # Feature specification +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +└── quickstart.md # Phase 1 output +``` + +### Source Code (repository root) + +```text +src/nfw/src/commands/gen/ +├── mod.rs # (Update to register endpoint command) +└── endpoint.rs # (New command implementation) + +src/nfw/tests/integration/ +└── gen_endpoint_tests.rs # (New test file) + +src/nfw-templates/src/dotnet-service/ +├── template.yaml # (Update to register endpoint generator) +└── endpoint/ + └── Endpoint.cs.tera # (New template) +``` + +**Structure Decision**: Extending existing CLI command logic. Adding `commands/gen/endpoint.rs` and the generator definition in `nfw-templates/src/dotnet-service/`. diff --git a/specs/013-gen-endpoint-command/quickstart.md b/specs/013-gen-endpoint-command/quickstart.md new file mode 100644 index 0000000..fd11809 --- /dev/null +++ b/specs/013-gen-endpoint-command/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart: Gen Endpoint Command + +## Usage + +Generate an endpoint by referencing an existing feature and command/query name: + +```bash +# General syntax +nfw gen endpoint + +# Examples +nfw gen endpoint GET GetInventoryItem Inventory +nfw gen endpoint POST CreateProduct Catalog +nfw gen endpoint PUT UpdateOrder Orders +nfw gen endpoint DELETE DeleteUser Management +``` + +## Generated Artifacts + +This will generate an endpoint file like `Api/Endpoints/Inventory/GetInventoryItemEndpoint.cs` containing: + +```csharp +namespace Catalog.Presentation.WebApi.Endpoints.Inventory; + +public static class GetInventoryItemEndpoint +{ + public static void MapGetInventoryItemEndpoint(this IEndpointRouteBuilder app) + { + app.MapGet("/api/inventory/getinventoryitem", async ([AsParameters] GetInventoryItem request, ISender sender) => + { + var result = await sender.Send(request); + return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(); + }) + .WithName("GetInventoryItem") + .WithTags("Inventory") + .Produces(StatusCodes.Status200OK); + } +} +``` diff --git a/specs/013-gen-endpoint-command/research.md b/specs/013-gen-endpoint-command/research.md new file mode 100644 index 0000000..d5029be --- /dev/null +++ b/specs/013-gen-endpoint-command/research.md @@ -0,0 +1,11 @@ +# Research: Gen Endpoint Command + +## Needs Clarification Resolution + +All technical details are well known. No required clarification. + +## Best Practices + +- Standard Minimal API configurations using `Map{Verb}`. +- Include typical OpenAPI descriptors like `.WithName(...)`, `.WithTags(...)`, and `.Produces(...)`. +- Ensure mapping between MediatR request and HTTP endpoint aligns cleanly. diff --git a/specs/013-gen-endpoint-command/spec.md b/specs/013-gen-endpoint-command/spec.md new file mode 100644 index 0000000..e271867 --- /dev/null +++ b/specs/013-gen-endpoint-command/spec.md @@ -0,0 +1,80 @@ +# Feature Specification: Gen Endpoint Command + +## User Scenarios & Testing + +### User Story 1 - Generate Endpoint for Existing Command/Query (Priority: P1) + +As a .NET developer, I want to generate a Minimal API HTTP endpoint for an existing MediatR command or query so I can easily expose application logic over HTTP without writing boilerplate routing and mapping code. + +**Why this priority**: Exposing application features over HTTP is a core and frequent workflow. + +**Independent Test**: Can be tested by running `nfw gen endpoint GET GetInventoryItem Inventory` in a workspace containing the specified MediatR query, and verifying that the endpoint file is created and the route is registered. + +**Acceptance Scenarios**: + +1. **Given** a service with an existing query `GetInventoryItem` in feature `Inventory`, **When** I run `nfw gen endpoint GET GetInventoryItem Inventory`, **Then** a GET endpoint file is created in the API layer. +2. **Given** a service with an existing command `CreateInventoryItem` in feature `Inventory`, **When** I run `nfw gen endpoint POST CreateInventoryItem Inventory`, **Then** a POST endpoint file is created in the API layer. + +--- + +### User Story 2 - Validation of Referenced Command/Query (Priority: P1) + +As a developer, I want the CLI to validate that the referenced command or query actually exists before generating the endpoint so I don't scaffold broken code that references missing application logic. + +**Why this priority**: Generating code that doesn't compile due to missing references degrades the developer experience. + +**Independent Test**: Can be tested by running the command for a nonexistent feature or command and asserting that it fails with a validation error. + +**Acceptance Scenarios**: + +1. **Given** a service without the feature `NonExistentFeature`, **When** I run `nfw gen endpoint GET GetItem NonExistentFeature`, **Then** the command fails with a "Feature not found" error. +2. **Given** a service with feature `Inventory` but missing the `UpdateItem` command, **When** I run `nfw gen endpoint PUT UpdateItem Inventory`, **Then** the command fails with a "Command/Query not found" error. + +--- + +### Edge Cases + +- **Operation Type Validation**: Specifying an invalid operation type (e.g., `PATCH` if not supported, or `FETCH`) should fail argument validation. +- **Service Selection**: If multiple services exist and no service is specified, it should prompt the user to select one. +- **Endpoint Already Exists**: If the endpoint file already exists, the command should fail or warn instead of overwriting custom code. +- **Missing API Layer**: If the target service does not have an API layer (WebAPI module), the command should fail with a helpful error suggesting `nfw add webapi`. + +## Requirements + +### Functional Requirements + +- **FR-001**: The CLI MUST provide an `nfw gen endpoint ` command to generate HTTP endpoint boilerplate. +- **FR-002**: The `` MUST support and validate standard HTTP verbs: GET, POST, PUT, DELETE. +- **FR-003**: The command MUST discover and validate that the `` references an existing Command or Query in the target `` folder. +- **FR-004**: The `` MUST reference an existing feature folder in the application layer. +- **FR-005**: The generated endpoint MUST use Minimal API route definitions with proper attribute routing (e.g., `app.MapGet(...)`). The default route pattern MUST be `"/api/{feature.ToLower()}/{name.ToLower()}"`, avoiding assumptions about specific path parameters. +- **FR-006**: The generated endpoint MUST include OpenAPI/Swagger documentation annotations (e.g., `Produces`, `WithTags`, `WithName`). +- **FR-007**: The generated files MUST be placed in the API layer following project conventions (e.g., `Api/Endpoints/{Feature}/{Name}Endpoint.cs` or similar). +- **FR-008**: The command MUST complete in under 3 seconds. +- **FR-009**: The command MUST include integration tests covering the generation and validation behavior. + +### Key Entities + +- **Endpoint Artifacts**: Minimal API endpoint files, route registrations, and request/response mappers if necessary. +- **Application Command/Query**: The existing MediatR requests that the endpoints will map HTTP requests to. + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: The command completes the entire generation process in under 3 seconds. +- **SC-002**: 100% of integration tests pass correctly. +- **SC-003**: A generated endpoint compiles immediately and clearly calls `ISender.Send(...)` with the mapped request. +- **SC-004**: Operation type correctly dictates the generated HTTP verb (GET -> `MapGet`, POST -> `MapPost`, etc.). + +## Assumptions + +- The target service has an API layer (WebAPI) using Minimal APIs (e.g., native endpoint groups). +- The Application layer uses MediatR for commands and queries, organized by Feature folders. +- Template rendering engine is available to handle endpoint file generation. + +## Non-Goals + +- Refactoring or altering the existing Command/Query. +- Implementing the actual mapping logic properties within the endpoint (only the boilerplate is generated). +- Supporting GraphQL or gRPC endpoints. diff --git a/specs/013-gen-endpoint-command/tasks.md b/specs/013-gen-endpoint-command/tasks.md new file mode 100644 index 0000000..eb4df63 --- /dev/null +++ b/specs/013-gen-endpoint-command/tasks.md @@ -0,0 +1,102 @@ +# Tasks: Gen Endpoint Command + +**Input**: Design documents from `/specs/013-gen-endpoint-command/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, quickstart.md + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2) +- Include exact file paths in descriptions + +## Path Conventions + +- Paths shown below assume single project repository logic in `src/nfw/` and template directory `src/nfw-templates/` + +--- + +## Phase 1: Setup & Foundational (Shared Infrastructure) + +**Purpose**: Command registration, project setup + +- [x] T001 Initialize endpoint generator configuration `src/nfw-templates/src/dotnet-service/endpoint/template.yaml` +- [x] T002 In `src/nfw-templates/src/dotnet-service/template.yaml`, register the new generator under `generators: endpoint: ./endpoint/` +- [x] T003 Create Tera template for the endpoint in `src/nfw-templates/src/dotnet-service/endpoint/Endpoint.cs.tera` (including Map{OperationType}, ISender, OpenAPI mapping) + +**Checkpoint**: Foundation ready - template and config scaffolded natively. + +--- + +## Phase 2: User Story 1 - Generate Endpoint for Existing Command/Query (Priority: P1) 🎯 MVP + +**Goal**: As a .NET developer, I want to generate a Minimal API HTTP endpoint for an existing MediatR command or query so I can easily expose application logic over HTTP without writing boilerplate routing and mapping code. + +**Independent Test**: Can be tested by running `nfw gen endpoint GET GetInventoryItem Inventory` in a workspace containing the specified MediatR query, and verifying that the endpoint file is created and the route is registered. + +### Implementation for User Story 1 + +- [x] T004 [US1] Create command handler logic via clean architecture layout: Application layer command `GenEndpointCommand` and `GenEndpointCommandHandler` +- [x] T005 [US1] Create presentation endpoint in `src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/endpoint/` via `GenEndpointCliCommand` to capture operation bounds over GET/POST/PUT/DELETE +- [x] T006 [US1] Hook `endpoint` submodule to `src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/registration.rs` Command mapping +- [x] T007 [US1] Integration tests for happy path GET, POST, PUT, DELETE in test directories + +**Checkpoint**: At this point, User Story 1 should be fully functional provided the application is structured normally. + +--- + +## Phase 3: User Story 2 - Validation of Referenced Command/Query (Priority: P1) + +**Goal**: As a developer, I want the CLI to validate that the referenced command or query actually exists before generating the endpoint so I don't scaffold broken code that references missing application logic. + +**Independent Test**: Can be tested by running the command for a nonexistent feature or command and asserting that it fails with a validation error. + +### Implementation for User Story 2 + +- [x] T008 [US2] In `GenEndpointCommandHandler`, implement validation checking that target application handles Minimal APIs +- [x] T009 [US2] In `GenEndpointCommandHandler`, implement validation checking that `Application/Features/{feature}` directory exists using Workspace utilities +- [x] T010 [US2] In `GenEndpointCommandHandler`, implement validation checking that specified command/query actually exists in the target feature directory +- [x] T011 [US2] Add integration test for nonexistent feature error case +- [x] T012 [US2] Add integration test for nonexistent command/query error case +- [x] T013 [US2] Add edge case validation to block overwriting an existing endpoint file +- [x] T014 [US2] Implement interactive service selection prompt if multiple services exist and no service is specified + +**Checkpoint**: At this point, User Story 2 validation constraints will safeguard CLI users. + +--- + +## Expected Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 1)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 2+)**: All depend on Foundational phase completion + - Sequential in priority order (US1 → US2) + +### Within Each User Story + +- Arg structures before business logic +- Business logic implementation before testing + +### Parallel Opportunities + +- Within Phase 1: T002 and T003 can be built essentially decoupled from Rust code (T001 is a base) +- Within User Story 2: Validation features (T008, T009, T010) are sequentially independent logic chunks in the `handler` func. + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: User Story 1 +3. **STOP and VALIDATE**: Test User Story 1 independently against existing templates + +### Incremental Delivery + +1. Verify User Story 1 MVP works +2. Add validations for User Story 2 +3. Each validation block narrows down scaffold edge-case failures diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/entity_generation/commands/add_entity_command_handler.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/entity_generation/commands/add_entity_command_handler.rs index c7ac283..cdd5a0c 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/entity_generation/commands/add_entity_command_handler.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/entity_generation/commands/add_entity_command_handler.rs @@ -62,7 +62,6 @@ where workspace: &WorkspaceContext, service: &ServiceInfo, ) -> Result<(EntitySchema, PathBuf), EntityGenerationError> { - self.validate_feature(command, workspace, service)?; self.validate_persistence_module(service)?; self.validate_id_type(command)?; @@ -143,55 +142,6 @@ where self.artifact_service.list_features(workspace, service) } - fn validate_feature( - &self, - command: &AddEntityCommand, - _workspace: &WorkspaceContext, - service: &ServiceInfo, - ) -> Result<(), EntityGenerationError> { - let feature_name = command.feature(); - let service_root = service.path(); - - // N-Framework services typically follow one of several project structure conventions. - // We attempt to find the feature directory by checking these common locations in order - // of likelihood: - // 1. {ServiceRoot}/src/core/Features/{Feature} - Standard vertical slice in core - // 2. {ServiceRoot}/src/Application/Features/{Feature} - Application layer in Hexagonal/Clean - // 3. {ServiceRoot}/src/Features/{Feature} - Simplified feature folders - // 4. {ServiceRoot}/Features/{Feature} - Flat feature folders - let possible_feature_roots = vec![ - service_root.join("src").join("core").join("Features"), - service_root - .join("src") - .join("Application") - .join("Features"), - service_root.join("src").join("Features"), - service_root.join("Features"), - ]; - - let mut feature_path = None; - for root in possible_feature_roots { - let candidate = root.join(feature_name); - tracing::debug!("Checking for feature directory at: {}", candidate.display()); - if candidate.is_dir() { - tracing::info!("Found feature directory at: {}", candidate.display()); - feature_path = Some(candidate); - break; - } - } - - if feature_path.is_none() { - tracing::info!( - "Feature directory '{}' not found in service '{}' ({}), it will be created during generation.", - feature_name, - service.name(), - service_root.display() - ); - } - - Ok(()) - } - fn validate_persistence_module( &self, service: &ServiceInfo, diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/entity_generation/commands/add_entity_command_handler.tests.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/entity_generation/commands/add_entity_command_handler.tests.rs index d8714b0..66778e8 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/entity_generation/commands/add_entity_command_handler.tests.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/entity_generation/commands/add_entity_command_handler.tests.rs @@ -190,40 +190,6 @@ fn validate_persistence_module_checks_service() { } } -#[test] -fn validate_feature_allows_missing_feature_directory() { - let temp = tempfile::tempdir().unwrap(); - let service_root = temp.path().join("src/Catalog"); - std::fs::create_dir_all(&service_root).unwrap(); - - let handler = setup_handler(false); - let command = AddEntityCommand::try_new( - "Product".to_owned(), - vec![PropertyDefinition::new( - "Name".to_owned(), - GeneralType::String, - false, - )], - GeneralType::Uuid, - EntityType::Entity, - EntityGenerationOptions::new(None, "NonExistentFeature".to_owned(), false, None, true), - ) - .unwrap(); - let workspace = WorkspaceContext::new(PathBuf::from("/"), vec![]); - let service = ServiceInfo::new( - "Catalog".to_owned(), - service_root, - vec!["persistence".to_owned()], - ); - - let result = handler.validate_feature(&command, &workspace, &service); - assert!( - result.is_ok(), - "Expected Ok for missing feature directory, but got {:?}", - result - ); -} - #[test] fn handle_from_schema_fails_if_schema_not_found() { let handler = setup_handler(false); // schema_exists returns false diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_mediator/add_mediator_command_handler.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_mediator/add_mediator_command_handler.rs index 71de3c0..d5b0e4f 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_mediator/add_mediator_command_handler.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_mediator/add_mediator_command_handler.rs @@ -72,8 +72,8 @@ where self.service .engine() .execute( - &context.config, - &context.template_root, + context.config(), + context.template_root(), &output_root, ¶meters, ) @@ -125,4 +125,14 @@ where ) -> Result, AddArtifactError> { self.service.extract_services(workspace) } + + /// Lists the presentation layer names available for the given service by reading the `webapi` + /// generator template's step destinations — no hardcoded paths or naming conventions. + pub fn list_presentation_layers( + &self, + workspace: &WorkspaceContext, + service: &ServiceInfo, + ) -> Result, AddArtifactError> { + self.service.list_presentation_layers(workspace, service) + } } diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command.tests.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command.tests.rs index f799129..60fa88a 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command.tests.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command.tests.rs @@ -16,7 +16,8 @@ fn can_create_valid_command() { let ctx = WorkspaceContext::new(PathBuf::from("/"), nfw_yaml, PreservedComments::default()); let svc = ServiceInfo::new("Svc".to_string(), "path".to_string(), "t1".to_string()).unwrap(); - let cmd = AddPersistenceCommand::new(svc.clone(), ctx.clone(), "WebApi".to_string()).unwrap(); + let cmd = AddPersistenceCommand::new(svc.clone(), ctx.clone().unwrap(), "WebApi".to_string()) + .unwrap(); assert_eq!(cmd.service_info().name(), "Svc"); } @@ -25,7 +26,7 @@ fn add_persistence_command_enforces_rules() { let nfw_yaml = serde_yaml::from_str("workspace: {}").unwrap(); let _ctx = WorkspaceContext::new(PathBuf::from("."), nfw_yaml, PreservedComments::default()); let svc = ServiceInfo::new("Svc".to_string(), "path".to_string(), "t1".to_string()).unwrap(); - let cmd = AddPersistenceCommand::new(svc, _ctx, "WebApi".to_string()).unwrap(); + let cmd = AddPersistenceCommand::new(svc, _ctx.unwrap(), "WebApi".to_string()).unwrap(); assert_eq!(cmd.service_info().name(), "Svc"); } diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command_handler.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command_handler.rs index bbb46a1..bba4270 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command_handler.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command_handler.rs @@ -77,8 +77,8 @@ where self.service .engine() .execute( - &context.config, - &context.template_root, + context.config(), + context.template_root(), &output_root, ¶meters, ) @@ -130,6 +130,16 @@ where ) -> Result, AddArtifactError> { self.service.extract_services(workspace) } + + /// Lists the presentation layer names available for the given service by reading the `webapi` + /// generator template's step destinations — no hardcoded paths or naming conventions. + pub fn list_presentation_layers( + &self, + workspace: &WorkspaceContext, + service: &ServiceInfo, + ) -> Result, AddArtifactError> { + self.service.list_presentation_layers(workspace, service) + } } #[cfg(test)] diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command_handler.tests.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command_handler.tests.rs index 34d073e..c6db6e5 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command_handler.tests.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_persistence/add_persistence_command_handler.tests.rs @@ -83,7 +83,8 @@ generators: sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), "WebApi".to_string(), ) .unwrap(); diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_webapi/add_webapi_command_handler.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_webapi/add_webapi_command_handler.rs index c4c5189..05eb1f7 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_webapi/add_webapi_command_handler.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_webapi/add_webapi_command_handler.rs @@ -42,7 +42,7 @@ where )?; self.service.validate_required_modules( - &context.config, + context.config(), workspace.nfw_yaml(), Path::new(cmd.service_info().path()), )?; @@ -95,8 +95,8 @@ where self.service .engine() .execute( - &context.config, - &context.template_root, + context.config(), + context.template_root(), &output_root, ¶meters, ) diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_webapi/add_webapi_command_handler.tests.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_webapi/add_webapi_command_handler.tests.rs index 1d57a48..50455d6 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_webapi/add_webapi_command_handler.tests.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/add_webapi/add_webapi_command_handler.tests.rs @@ -124,7 +124,8 @@ fn handle_error_on_engine_failure() { sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), WebApiConfig::default(), ); @@ -157,7 +158,8 @@ fn handle_error_on_missing_namespace() { sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), WebApiConfig::default(), ); @@ -197,7 +199,8 @@ fn handle_error_on_existing_module() { sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), WebApiConfig::default(), ); @@ -244,7 +247,8 @@ fn handle_error_on_resolver_failure() { sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), WebApiConfig::default(), ); @@ -282,7 +286,8 @@ fn handle_error_on_missing_yaml() { sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), WebApiConfig::default(), ); @@ -322,7 +327,8 @@ fn handle_error_on_missing_sub_template() { sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), WebApiConfig::default(), ); @@ -357,7 +363,8 @@ fn handle_error_on_readonly_yaml() { sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), WebApiConfig::default(), ); @@ -398,7 +405,8 @@ fn handle_captures_config_parameters() { sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), config, ); @@ -440,7 +448,8 @@ fn handle_captures_all_config_flags() { sandbox.path().to_path_buf(), nfw_yaml.clone(), n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), config_all_true, ); handler.handle(&cmd1).unwrap(); @@ -476,7 +485,8 @@ fn handle_captures_all_config_flags() { sandbox.path().to_path_buf(), nfw_yaml_all_false, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), config_all_false, ); // handler.handle(&cmd1).unwrap_err(); // This was causing issues due to file system state mismatch @@ -515,7 +525,8 @@ fn handle_success_full_flow() { sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), WebApiConfig::default(), ); @@ -573,7 +584,8 @@ generators: sandbox.path().to_path_buf(), nfw_yaml, n_framework_nfw_infrastructure_workspace_metadata::PreservedComments::default(), - ), + ) + .unwrap(), WebApiConfig::default(), ); diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/gen_endpoint_command.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/gen_endpoint_command.rs new file mode 100644 index 0000000..38ad5a6 --- /dev/null +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/gen_endpoint_command.rs @@ -0,0 +1,113 @@ +use crate::features::template_management::models::errors::add_artifact_error::AddArtifactError; +use crate::features::template_management::services::artifact_generation_service::AddArtifactContext; +use serde_json::Value; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HttpMethod { + Get, + Post, + Put, + Delete, + Patch, +} + +impl FromStr for HttpMethod { + type Err = AddArtifactError; + + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_str() { + "GET" => Ok(HttpMethod::Get), + "POST" => Ok(HttpMethod::Post), + "PUT" => Ok(HttpMethod::Put), + "DELETE" => Ok(HttpMethod::Delete), + "PATCH" => Ok(HttpMethod::Patch), + _ => Err(AddArtifactError::InvalidParameter(format!( + "Invalid HTTP method '{}'. Must be GET, POST, PUT, DELETE, or PATCH.", + s + ))), + } + } +} + +impl fmt::Display for HttpMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HttpMethod::Get => write!(f, "Get"), + HttpMethod::Post => write!(f, "Post"), + HttpMethod::Put => write!(f, "Put"), + HttpMethod::Delete => write!(f, "Delete"), + HttpMethod::Patch => write!(f, "Patch"), + } + } +} + +#[derive(Debug, Clone)] +pub struct GenEndpointCommand { + name: String, + feature: Option, + operation_type: HttpMethod, + params: Option, + context: AddArtifactContext, + /// When true the handler verifies that the named Command/Query artifact already + /// exists in the Application layer before generating the endpoint. Set to false + /// when the user explicitly chose NOT to attach to an existing mediator artifact + /// (free-form endpoint name) so that the existence check is skipped. + attach_to_mediator: bool, +} + +impl GenEndpointCommand { + pub fn new( + name: impl Into, + feature: Option, + operation_type: HttpMethod, + params: Option, + context: AddArtifactContext, + attach_to_mediator: bool, + ) -> Result { + let name_str = name.into(); + if name_str.trim().is_empty() { + return Err(AddArtifactError::InvalidIdentifier( + "Endpoint name cannot be empty".to_string(), + )); + } + + Ok(Self { + name: name_str, + feature, + operation_type, + params, + context, + attach_to_mediator, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn feature(&self) -> Option<&str> { + self.feature.as_deref() + } + + pub fn operation_type(&self) -> &HttpMethod { + &self.operation_type + } + + pub fn params(&self) -> Option<&Value> { + self.params.as_ref() + } + + pub fn into_params(self) -> Option { + self.params + } + + pub fn context(&self) -> &AddArtifactContext { + &self.context + } + + pub fn attach_to_mediator(&self) -> bool { + self.attach_to_mediator + } +} diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/gen_endpoint_command_handler.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/gen_endpoint_command_handler.rs new file mode 100644 index 0000000..f0f6fdb --- /dev/null +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/gen_endpoint_command_handler.rs @@ -0,0 +1,405 @@ +use crate::features::template_management::models::errors::add_artifact_error::AddArtifactError; +use crate::features::template_management::services::abstractions::template_root_resolver::TemplateRootResolver; +use crate::features::template_management::services::artifact_generation_service::{ + AddArtifactContext, ArtifactGenerationService, +}; +use crate::features::template_management::services::template_engine::TemplateEngine; +use crate::features::workspace_management::services::abstractions::working_directory_provider::WorkingDirectoryProvider; +use tracing::info; + +use crate::features::template_management::models::template_error::TemplateError; + +use super::gen_endpoint_command::GenEndpointCommand; +use crate::features::template_management::constants::generation::DEFAULT_FEATURE_NAME; + +#[derive(Debug, Clone)] +pub struct GenEndpointCommandHandler { + pub(crate) service: ArtifactGenerationService, +} + +impl GenEndpointCommandHandler +where + W: WorkingDirectoryProvider, + R: TemplateRootResolver, + E: TemplateEngine, +{ + pub fn new(working_dir_provider: W, root_resolver: R, engine: E) -> Self { + Self { + service: ArtifactGenerationService::new(working_dir_provider, root_resolver, engine), + } + } + + pub fn handle(&self, command: GenEndpointCommand) -> Result { + info!("Handling GenEndpointCommand for name: {}", command.name()); + + let workspace_context = command.context().workspace.clone(); + + let services = self.service.extract_services(&workspace_context)?; + let service = services + .into_iter() + .find(|s| s.path() == command.context().service_path().to_str().unwrap_or("")) + .ok_or_else(|| { + AddArtifactError::ConfigError(format!( + "Service not found for path: {}", + command.context().service_path().display() + )) + })?; + + // Validate required modules + self.service.validate_required_modules( + &command.context().config, + command.context().workspace.nfw_yaml(), + &command.context().service_path, + )?; + + let feature_name = command.feature().unwrap_or(DEFAULT_FEATURE_NAME); + + let mediator_sources = command.context().config().mediator_sources().to_vec(); + + if !mediator_sources.is_empty() && command.attach_to_mediator() { + // Validate that the mediator artifact (Command or Query) exists for the given name, + // deriving the expected location from the command/query template step destinations. + let mediator_ok = self.mediator_artifact_exists( + &workspace_context, + &service, + feature_name, + command.name(), + &mediator_sources, + )?; + if !mediator_ok { + return Err(AddArtifactError::ExecutionFailed(Box::new( + TemplateError::io( + format!( + "No Command or Query artifact found for '{}' in feature '{}'. \ + Generate the command or query first.", + command.name(), + feature_name + ), + std::path::PathBuf::new(), + ), + ))); + } + } + + // Validate that the endpoint output file does not already exist, deriving the expected + // destination from the endpoint template step. + self.assert_endpoint_not_exists(&command, &service)?; + + let context = AddArtifactContext { + workspace: workspace_context.clone(), + template_root: command.context().template_root().clone(), + config: command.context().config().clone(), + service_name: command.context().service_name().to_string(), + service_path: command.context().service_path().clone(), + }; + + let mut final_params = command + .params() + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + if let Some(obj) = final_params.as_object_mut() { + obj.insert( + "OperationType".to_string(), + serde_json::json!(command.operation_type().to_string()), + ); + obj.insert( + "AttachToMediator".to_string(), + serde_json::json!(command.attach_to_mediator()), + ); + } + + self.service.execute_generation( + command.name(), + command.feature(), + &Some(final_params), + &context, + )?; + + Ok(format!( + "Successfully generated endpoint {}", + command.name() + )) + } + + /// Checks whether a Command or Query artifact already exists for the given name and feature by + /// reading the mediator source generator types declared in the endpoint template config and + /// probing each one's step destinations — no hardcoded paths or generator type names. + fn mediator_artifact_exists( + &self, + workspace_context: &crate::features::template_management::services::artifact_generation_service::WorkspaceContext, + service: &crate::features::template_management::services::artifact_generation_service::ServiceInfo, + feature_name: &str, + artifact_name: &str, + mediator_sources: &[String], + ) -> Result { + for generator_type in mediator_sources { + match self.service.resolve_mediator_artifact_root( + workspace_context, + service, + generator_type, + ) { + Ok(Some((features_root, sub_dirs, file_suffix))) => { + // Path: features_root / feature / sub_dirs... / {{ Name }}{{ suffix }} + let mut candidate = features_root.join(feature_name); + for seg in &sub_dirs { + candidate = candidate.join(seg); + } + // The template nests the artifact under a {{ Name }} sub-directory as well. + candidate = candidate + .join(artifact_name) + .join(format!("{}{}", artifact_name, file_suffix)); + if candidate.is_file() { + return Ok(true); + } + } + Ok(None) => { + tracing::debug!( + "Template step destination does not resolve properly for generator '{}'", + generator_type + ); + } + Err(e) => { + tracing::error!( + "Template resolution failure for mediator artifact '{}': {}", + generator_type, + e + ); + return Err(e); + } + } + } + Ok(false) + } + + /// Asserts that the endpoint output file does not already exist, resolving its path from the + /// endpoint template's step destination pattern instead of a hardcoded path. + fn assert_endpoint_not_exists( + &self, + command: &GenEndpointCommand, + service: &crate::features::template_management::services::artifact_generation_service::ServiceInfo, + ) -> Result<(), AddArtifactError> { + use n_framework_nfw_core_domain::features::template_management::template_config::TemplateStepAction; + + let feature_name = command.feature().unwrap_or(DEFAULT_FEATURE_NAME); + + for step in command.context().config().steps() { + let destination = match &step.action { + TemplateStepAction::Render { destination, .. } => destination, + _ => continue, + }; + + let namespace = command + .context() + .workspace + .nfw_yaml() + .get("workspace") + .and_then(|w| w.get("namespace")) + .and_then(|v| v.as_str()) + .unwrap_or(&service.name); + + let resolved = destination + .replace("{{ Service }}", &service.name) + .replace("{{Service}}", &service.name) + .replace("{{ Namespace }}", namespace) + .replace("{{Namespace}}", namespace) + .replace("{{ Feature }}", feature_name) + .replace("{{Feature}}", feature_name) + .replace("{{ Name }}", command.name()) + .replace("{{Name}}", command.name()); + + let endpoint_file = command + .context() + .workspace_root() + .join(command.context().service_path()) + .join(&resolved); + + if endpoint_file.is_file() { + return Err(AddArtifactError::ExecutionFailed(Box::new( + TemplateError::io( + format!( + "Target endpoint file already exists: {}", + endpoint_file.display() + ), + std::path::PathBuf::new(), + ), + ))); + } + } + + Ok(()) + } + + pub fn get_workspace_context(&self) -> Result{ + self.service.get_workspace_context() + } + + pub fn has_service_module( + &self, + workspace_context: &crate::features::template_management::services::artifact_generation_service::WorkspaceContext, + service: &crate::features::template_management::services::artifact_generation_service::ServiceInfo, + module_name: &str, + ) -> Result { + self.service.has_service_module( + workspace_context.workspace_root(), + &service.name, + module_name, + ) + } + + /// Lists mediator artifacts (Commands or Queries) in the given feature folder, deriving the + /// search directory and file suffix from the template step destinations — no hardcoded paths. + pub fn get_mediator_items( + &self, + workspace_context: &crate::features::template_management::services::artifact_generation_service::WorkspaceContext, + service: &crate::features::template_management::services::artifact_generation_service::ServiceInfo, + feature: &str, + is_query: bool, + ) -> Result, AddArtifactError> { + let generator_type = if is_query { "query" } else { "command" }; + + let Some((features_root, sub_dirs, file_suffix)) = self + .service + .resolve_mediator_artifact_root(workspace_context, service, generator_type)? + else { + return Ok(Vec::new()); + }; + + // Build the concrete scan directory: features_root / feature / sub_dirs... + let mut scan_dir = features_root.join(feature); + for seg in &sub_dirs { + scan_dir = scan_dir.join(seg); + } + + if !scan_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut items = Vec::new(); + // The template nests each artifact under its own {{ Name }} sub-directory, so scan one + // level of sub-directories and look for a file matching the suffix inside each one. + let entries = std::fs::read_dir(&scan_dir).map_err(|e| { + AddArtifactError::WorkspaceError(format!( + "failed to read directory {}: {}", + scan_dir.display(), + e + )) + })?; + + for entry in entries { + let entry = entry.map_err(|e| { + AddArtifactError::WorkspaceError(format!("failed to read directory entry: {}", e)) + })?; + + let entry_path = entry.path(); + if entry_path.is_dir() { + // Sub-directory named after the artifact (e.g. `CreateProduct/`) + let inner = std::fs::read_dir(&entry_path).map_err(|e| { + AddArtifactError::WorkspaceError(format!( + "failed to read sub-directory {}: {}", + entry_path.display(), + e + )) + })?; + + for inner_entry in inner { + let inner_entry = inner_entry.map_err(|e| { + AddArtifactError::WorkspaceError(format!( + "failed to read sub-directory entry: {}", + e + )) + })?; + + if let Some(name) = inner_entry.file_name().to_str() + && name.ends_with(&file_suffix) + { + let class_name = &name[..name.len() - file_suffix.len()]; + if !class_name.is_empty() { + items.push(class_name.to_string()); + } + } + } + } else if let Some(name) = entry.file_name().to_str() { + // Flat layout — file is directly in the scan dir + if name.ends_with(&file_suffix) { + let class_name = &name[..name.len() - file_suffix.len()]; + if !class_name.is_empty() { + items.push(class_name.to_string()); + } + } + } + } + Ok(items) + } + + pub fn generate_mediator_item( + &self, + name: &str, + feature: Option<&str>, + params: &Option, + context: &AddArtifactContext, + ) -> Result<(), AddArtifactError> { + self.service + .execute_generation(name, feature, params, context) + } + + pub fn extract_services( + &self, + workspace_context: &crate::features::template_management::services::artifact_generation_service::WorkspaceContext, + ) -> Result, AddArtifactError>{ + self.service.extract_services(workspace_context) + } + + pub fn load_template_context( + &self, + template_id: &str, + service: &crate::features::template_management::services::artifact_generation_service::ServiceInfo, + workspace_context: &crate::features::template_management::services::artifact_generation_service::WorkspaceContext, + ) -> Result { + self.service + .load_template_context(workspace_context.clone(), service, template_id) + } + + pub fn validate_required_modules( + &self, + context: &AddArtifactContext, + ) -> Result<(), AddArtifactError> { + self.service.validate_required_modules( + context.config(), + context.nfw_yaml(), + context.service_path(), + ) + } + + pub fn list_features( + &self, + workspace_context: &crate::features::template_management::services::artifact_generation_service::WorkspaceContext, + service: &crate::features::template_management::services::artifact_generation_service::ServiceInfo, + ) -> Result, AddArtifactError> { + self.service.list_features(workspace_context, service) + } + + /// Returns true if the service has at least one mediator source available — determined by + /// loading each source generator's template config and checking whether the service satisfies + /// its `required_modules`. This replaces any hardcoded module-name check in the CLI layer. + pub fn has_mediator_sources( + &self, + workspace_context: &crate::features::template_management::services::artifact_generation_service::WorkspaceContext, + service: &crate::features::template_management::services::artifact_generation_service::ServiceInfo, + mediator_sources: &[String], + ) -> bool { + for generator_type in mediator_sources { + if let Ok(ctx) = self.service.load_template_context( + workspace_context.clone(), + service, + generator_type, + ) && self + .service + .validate_required_modules(ctx.config(), ctx.nfw_yaml(), ctx.service_path()) + .is_ok() + { + return true; + } + } + false + } +} diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/gen_endpoint_command_handler.tests.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/gen_endpoint_command_handler.tests.rs new file mode 100644 index 0000000..8b6b28b --- /dev/null +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/gen_endpoint_command_handler.tests.rs @@ -0,0 +1,563 @@ +#[cfg(test)] +mod tests { + use super::super::gen_endpoint_command_handler::GenEndpointCommandHandler; + use crate::features::template_management::commands::gen_endpoint::gen_endpoint_command::{ + GenEndpointCommand, HttpMethod, + }; + use crate::features::template_management::models::errors::add_artifact_error::AddArtifactError; + use crate::features::template_management::models::template_error::TemplateError; + use crate::features::template_management::services::abstractions::template_root_resolver::TemplateRootResolver; + use crate::features::template_management::services::artifact_generation_service::{ + AddArtifactContext, WorkspaceContext, + }; + use crate::features::template_management::services::template_engine::TemplateEngine; + use crate::features::workspace_management::services::abstractions::working_directory_provider::WorkingDirectoryProvider; + use n_framework_nfw_core_domain::features::template_management::{ + template_config::TemplateConfig, template_parameters::TemplateParameters, + }; + use n_framework_nfw_infrastructure_workspace_metadata::PreservedComments; + use std::path::{Path, PathBuf}; + + // Need mocks since we're in core-application and infrastructure is not available + struct MockWorkingDir {} + impl WorkingDirectoryProvider for MockWorkingDir { + fn current_dir(&self) -> Result { + Ok(PathBuf::from("")) + } + } + + struct MockTemplateEngine {} + impl TemplateEngine for MockTemplateEngine { + fn execute( + &self, + _config: &TemplateConfig, + _template_root: &Path, + _output_root: &Path, + _parameters: &TemplateParameters, + ) -> Result<(), TemplateError> { + Ok(()) + } + } + + fn test_valid_yaml() -> serde_yaml::Value { + serde_yaml::from_str( + r#" +workspace: + namespace: SmokeTest +workspaces: + local: + namespace: SmokeTest + type: custom +services: + TestService: + description: A test service + path: src/TestService + template: + id: "test" + modules: [] +"#, + ) + .unwrap() + } + + /// Builds a TemplateConfig for the endpoint template that declares `mediator_sources` so the + /// handler performs the mediator-artifact existence check and a single render step so the + /// duplicate-endpoint check has a destination to probe. + fn test_endpoint_config() -> TemplateConfig { + serde_yaml::from_str( + r#" +id: test-endpoint +mediator_sources: + - command + - query +steps: + - action: render + source: Endpoint.cs.tera + destination: 'src/presentation/{{ Service }}.Presentation.WebApi/Endpoints/{{ Feature }}/{{ Name }}Endpoint.cs' +"#, + ) + .unwrap() + } + + /// A MockTemplateRoot that resolves to a given base directory. + struct FixedMockTemplateRoot { + base: PathBuf, + } + + impl TemplateRootResolver for FixedMockTemplateRoot { + fn resolve( + &self, + _nfw_yaml: &serde_yaml::Value, + _template_id: &str, + _workspace_root: &std::path::Path, + ) -> Result { + Ok(self.base.clone()) + } + } + + /// Writes minimal command and query template configs under `base/command/` and `base/query/` + /// so `resolve_mediator_artifact_root` can parse their step destinations. + fn write_mediator_template_configs(base: &Path) { + let cmd_dir = base.join("command"); + std::fs::create_dir_all(&cmd_dir).unwrap(); + std::fs::write( + cmd_dir.join("template.yaml"), + "id: test/command\nname: Command\nsteps:\n - action: render\n source: Command.cs.tera\n destination: 'src/core/{{ Service }}.Core.Application/Features/{{ Feature }}/Commands/{{ Name }}/{{ Name }}Command.cs'\n", + ) + .unwrap(); + + let qry_dir = base.join("query"); + std::fs::create_dir_all(&qry_dir).unwrap(); + std::fs::write( + qry_dir.join("template.yaml"), + "id: test/query\nname: Query\nsteps:\n - action: render\n source: Query.cs.tera\n destination: 'src/core/{{ Service }}.Core.Application/Features/{{ Feature }}/Queries/{{ Name }}/{{ Name }}Query.cs'\n", + ) + .unwrap(); + + // Root template.yaml must declare the generators map + std::fs::write( + base.join("template.yaml"), + "id: test\nname: Test\nversion: 1.0.0\ngenerators:\n command: command\n query: query\n", + ) + .unwrap(); + } + + #[test] + fn test_missing_feature_directory_returns_error() { + let root = tempfile::tempdir().unwrap(); + let tpl_base = root.path().join("templates").join("test"); + std::fs::create_dir_all(&tpl_base).unwrap(); + write_mediator_template_configs(&tpl_base); + + let config = test_endpoint_config(); + let nfw_yaml = test_valid_yaml(); + + let context = AddArtifactContext { + template_root: root.path().to_path_buf(), + service_path: std::path::PathBuf::from("src/TestService"), + service_name: "TestService".to_string(), + config, + workspace: WorkspaceContext::new( + root.path().to_path_buf(), + nfw_yaml.clone(), + PreservedComments::default(), + ) + .unwrap(), + }; + + let command = GenEndpointCommand::new( + "GetProduct".to_string(), + Some("NonExistentFeature".to_string()), + HttpMethod::Get, + None, + context, + true, + ) + .unwrap(); + + let handler = GenEndpointCommandHandler::new( + MockWorkingDir {}, + FixedMockTemplateRoot { base: tpl_base }, + MockTemplateEngine {}, + ); + + let result = handler.handle(command); + + assert!(result.is_err()); + match result.unwrap_err() { + AddArtifactError::ExecutionFailed(err) => { + let msg = err.to_string(); + if !msg.contains("No Command or Query artifact found") { + panic!("Actual error: {}", msg); + } + } + e => panic!("Expected ExecutionFailed, got: {:?}", e), + } + } + + #[test] + fn test_missing_command_query_file_returns_error() { + let root = tempfile::tempdir().unwrap(); + let tpl_base = root.path().join("templates").join("test"); + std::fs::create_dir_all(&tpl_base).unwrap(); + write_mediator_template_configs(&tpl_base); + + // Feature dir exists but no Command/Query file inside it + let feature_dir = root.path().join( + "src/TestService/src/core/TestService.Core.Application/Features/Inventory/Commands", + ); + std::fs::create_dir_all(&feature_dir).unwrap(); + + let config = test_endpoint_config(); + let nfw_yaml = test_valid_yaml(); + + let context = AddArtifactContext { + template_root: root.path().to_path_buf(), + service_path: std::path::PathBuf::from("src/TestService"), + service_name: "TestService".to_string(), + config, + workspace: WorkspaceContext::new( + root.path().to_path_buf(), + nfw_yaml.clone(), + PreservedComments::default(), + ) + .unwrap(), + }; + + let command = GenEndpointCommand::new( + "GetProduct".to_string(), + Some("Inventory".to_string()), + HttpMethod::Get, + None, + context, + true, + ) + .unwrap(); + + let handler = GenEndpointCommandHandler::new( + MockWorkingDir {}, + FixedMockTemplateRoot { base: tpl_base }, + MockTemplateEngine {}, + ); + + let result = handler.handle(command); + + assert!(result.is_err()); + match result.unwrap_err() { + AddArtifactError::ExecutionFailed(err) => { + let msg = err.to_string(); + if !msg.contains("No Command or Query artifact found") { + panic!("Actual error: {}", msg); + } + } + e => panic!("Expected ExecutionFailed, got: {:?}", e), + } + } + + #[test] + fn test_existing_endpoint_file_returns_error() { + let root = tempfile::tempdir().unwrap(); + let tpl_base = root.path().join("templates").join("test"); + std::fs::create_dir_all(&tpl_base).unwrap(); + write_mediator_template_configs(&tpl_base); + + // Create the GetProductQuery.cs in the nested template path: + // features_root / feature / Commands / GetProduct / GetProductQuery.cs + let query_dir = root + .path() + .join("src/TestService/src/core/TestService.Core.Application/Features/Inventory/Queries/GetProduct"); + std::fs::create_dir_all(&query_dir).unwrap(); + std::fs::write(query_dir.join("GetProductQuery.cs"), "dummy").unwrap(); + + // Create the endpoint file that should trigger the duplicate check + let endpoint_dir = root.path().join( + "src/TestService/src/presentation/TestService.Presentation.WebApi/Endpoints/Inventory", + ); + std::fs::create_dir_all(&endpoint_dir).unwrap(); + std::fs::write(endpoint_dir.join("GetProductEndpoint.cs"), "dummy").unwrap(); + + let config = test_endpoint_config(); + let nfw_yaml = test_valid_yaml(); + + let context = AddArtifactContext { + template_root: root.path().to_path_buf(), + service_path: std::path::PathBuf::from("src/TestService"), + service_name: "TestService".to_string(), + config, + workspace: WorkspaceContext::new( + root.path().to_path_buf(), + nfw_yaml.clone(), + PreservedComments::default(), + ) + .unwrap(), + }; + + let command = GenEndpointCommand::new( + "GetProduct".to_string(), + Some("Inventory".to_string()), + HttpMethod::Get, + None, + context, + true, + ) + .unwrap(); + + let handler = GenEndpointCommandHandler::new( + MockWorkingDir {}, + FixedMockTemplateRoot { base: tpl_base }, + MockTemplateEngine {}, + ); + + let result = handler.handle(command); + + assert!(result.is_err()); + match result.unwrap_err() { + AddArtifactError::ExecutionFailed(err) => { + let msg = err.to_string(); + if !msg.contains("Target endpoint file already exists") { + panic!("Actual error: {}", msg); + } + } + e => panic!("Expected ExecutionFailed, got: {:?}", e), + } + } + + #[test] + fn test_parameter_injection_collision() { + let root = tempfile::tempdir().unwrap(); + let tpl_base = root.path().join("templates").join("test"); + std::fs::create_dir_all(&tpl_base).unwrap(); + write_mediator_template_configs(&tpl_base); + + let query_dir = root + .path() + .join("src/TestService/src/core/TestService.Core.Application/Features/Inventory/Queries/GetProduct"); + std::fs::create_dir_all(&query_dir).unwrap(); + std::fs::write(query_dir.join("GetProductQuery.cs"), "dummy").unwrap(); + + let config = test_endpoint_config(); + let nfw_yaml = test_valid_yaml(); + + let context = AddArtifactContext::new( + WorkspaceContext::new( + root.path().to_path_buf(), + nfw_yaml.clone(), + PreservedComments::default(), + ) + .unwrap(), + root.path().to_path_buf(), + config, + "TestService".to_string(), + std::path::PathBuf::from("src/TestService"), + ) + .unwrap(); + + // Pass a JSON parameter containing `OperationType` to verify the system correctly overrides it + let custom_params = serde_json::json!({ + "OperationType": "CUSTOM", + "AttachToMediator": false, + "CustomKey": "CustomValue" + }); + + let command = GenEndpointCommand::new( + "GetProduct".to_string(), + Some("Inventory".to_string()), + HttpMethod::Get, + Some(custom_params), + context, + true, + ) + .unwrap(); + + let handler = GenEndpointCommandHandler::new( + MockWorkingDir {}, + FixedMockTemplateRoot { base: tpl_base }, + MockTemplateEngine {}, + ); + + let result = handler.handle(command); + assert!( + result.is_ok(), + "Expected success with overridden parameter injection" + ); + } + + #[test] + fn test_attach_to_mediator_false_bypasses_check() { + let root = tempfile::tempdir().unwrap(); + let tpl_base = root.path().join("templates").join("test"); + std::fs::create_dir_all(&tpl_base).unwrap(); + write_mediator_template_configs(&tpl_base); + + // DO NOT create the query file. It should still succeed because attach_to_mediator = false + + let config = test_endpoint_config(); + let nfw_yaml = test_valid_yaml(); + + let context = AddArtifactContext::new( + WorkspaceContext::new( + root.path().to_path_buf(), + nfw_yaml.clone(), + PreservedComments::default(), + ) + .unwrap(), + root.path().to_path_buf(), + config, + "TestService".to_string(), + std::path::PathBuf::from("src/TestService"), + ) + .unwrap(); + + let command = GenEndpointCommand::new( + "GetProduct".to_string(), + Some("Inventory".to_string()), + HttpMethod::Get, + None, + context, + false, // Bypass check! + ) + .unwrap(); + + let handler = GenEndpointCommandHandler::new( + MockWorkingDir {}, + FixedMockTemplateRoot { base: tpl_base }, + MockTemplateEngine {}, + ); + + let result = handler.handle(command); + assert!( + result.is_ok(), + "Expected success when attach_to_mediator is false, even if artifact is missing" + ); + } + + #[test] + fn test_get_mediator_items_success() { + let root = tempfile::tempdir().unwrap(); + let tpl_base = root.path().join("templates").join("test"); + std::fs::create_dir_all(&tpl_base).unwrap(); + write_mediator_template_configs(&tpl_base); + + // Create some mediator commands + let feature_dir = root.path().join( + "src/TestService/src/core/TestService.Core.Application/Features/Inventory/Commands", + ); + + let cmd1_dir = feature_dir.join("CreateProduct"); + std::fs::create_dir_all(&cmd1_dir).unwrap(); + std::fs::write(cmd1_dir.join("CreateProductCommand.cs"), "dummy").unwrap(); + + let cmd2_dir = feature_dir.join("UpdateProduct"); + std::fs::create_dir_all(&cmd2_dir).unwrap(); + std::fs::write(cmd2_dir.join("UpdateProductCommand.cs"), "dummy").unwrap(); + + let handler = GenEndpointCommandHandler::new( + MockWorkingDir {}, + FixedMockTemplateRoot { base: tpl_base }, + MockTemplateEngine {}, + ); + + let workspace_context = WorkspaceContext::new( + root.path().to_path_buf(), + test_valid_yaml(), + PreservedComments::default(), + ) + .unwrap(); + + let service_info = handler + .service + .extract_services(&workspace_context) + .unwrap() + .into_iter() + .find(|s| s.name() == "TestService") + .unwrap(); + + let items = handler + .get_mediator_items(&workspace_context, &service_info, "Inventory", false) + .unwrap(); + + assert_eq!(items.len(), 2); + assert!(items.contains(&"CreateProduct".to_string())); + assert!(items.contains(&"UpdateProduct".to_string())); + } + + #[test] + fn test_has_mediator_sources_success() { + let root = tempfile::tempdir().unwrap(); + let tpl_base = root.path().join("templates").join("test"); + std::fs::create_dir_all(&tpl_base).unwrap(); + write_mediator_template_configs(&tpl_base); + + let handler = GenEndpointCommandHandler::new( + MockWorkingDir {}, + FixedMockTemplateRoot { base: tpl_base }, + MockTemplateEngine {}, + ); + + let nfw_yaml = r#" +services: + TestService: + path: src/TestService + template: + id: "test" + modules: ["mediator"] +"#; + let workspace_context = WorkspaceContext::new( + root.path().to_path_buf(), + serde_yaml::from_str(nfw_yaml).unwrap(), + PreservedComments::default(), + ) + .unwrap(); + + let service_info = handler + .service + .extract_services(&workspace_context) + .unwrap() + .into_iter() + .find(|s| s.name() == "TestService") + .unwrap(); + + let sources = vec!["command".to_string(), "query".to_string()]; + + // Mocking template context load: we need the command/query templates to NOT have required_modules + // or have modules that the service has. Our write_mediator_template_configs creates them without required_modules. + + assert!(handler.has_mediator_sources(&workspace_context, &service_info, &sources)); + } + + #[test] + fn test_has_mediator_sources_fails_on_missing_modules() { + let root = tempfile::tempdir().unwrap(); + let tpl_base = root.path().join("templates").join("test"); + std::fs::create_dir_all(&tpl_base).unwrap(); + + // Write templates that REQUIRE "mediator" module + let cmd_dir = tpl_base.join("command"); + std::fs::create_dir_all(&cmd_dir).unwrap(); + std::fs::write( + cmd_dir.join("template.yaml"), + "id: test/command\nrequired_modules: [\"mediator\"]\nsteps: []\n", + ) + .unwrap(); + + std::fs::write( + tpl_base.join("template.yaml"), + "id: test\ngenerators:\n command: command\n", + ) + .unwrap(); + + let handler = GenEndpointCommandHandler::new( + MockWorkingDir {}, + FixedMockTemplateRoot { base: tpl_base }, + MockTemplateEngine {}, + ); + + // Service with NO modules + let nfw_yaml = r#" +services: + TestService: + path: src/TestService + template: + id: "test" + modules: [] +"#; + let workspace_context = WorkspaceContext::new( + root.path().to_path_buf(), + serde_yaml::from_str(nfw_yaml).unwrap(), + PreservedComments::default(), + ) + .unwrap(); + + let service_info = handler + .service + .extract_services(&workspace_context) + .unwrap() + .into_iter() + .find(|s| s.name() == "TestService") + .unwrap(); + + let sources = vec!["command".to_string()]; + + assert!(!handler.has_mediator_sources(&workspace_context, &service_info, &sources)); + } +} diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/mod.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/mod.rs new file mode 100644 index 0000000..697440a --- /dev/null +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_endpoint/mod.rs @@ -0,0 +1,6 @@ +pub mod gen_endpoint_command; +pub mod gen_endpoint_command_handler; + +#[path = "gen_endpoint_command_handler.tests.rs"] +#[cfg(test)] +mod gen_endpoint_command_handler_tests; diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_repository/gen_repository_command_handler.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_repository/gen_repository_command_handler.rs index 25c3145..ac62973 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_repository/gen_repository_command_handler.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_repository/gen_repository_command_handler.rs @@ -35,7 +35,6 @@ where pub fn handle(&self, command: &GenRepositoryCommand) -> Result<(), AddArtifactError> { let entity_name = command.entity_name(); - // Validate entity name let re = Regex::new(r"^[a-zA-Z][a-zA-Z0-9]*$").unwrap(); if !re.is_match(entity_name) { return Err(AddArtifactError::InvalidIdentifier(format!( @@ -45,175 +44,133 @@ where } let context = command.context(); - let service_name = &context.service_name; - let namespace = self.service.extract_namespace(&context.nfw_yaml)?; - let service_root = context.workspace_root.join(&context.service_path); - - let possible_roots = vec![ - service_root - .join("src") - .join("core") - .join(format!("{}.Core.Domain", namespace)) - .join("Features"), - service_root.join("src").join("Domain").join("Features"), - service_root.join("src").join("Features"), - service_root.join("Features"), - ]; - - let mut features_dir_opt = None; - for root in possible_roots { - if root.is_dir() { - features_dir_opt = Some(root); - break; - } - } - - let features_dir = match features_dir_opt { - Some(dir) => dir, - None => { - return Err(AddArtifactError::InvalidIdentifier(format!( - "Could not find Features directory in domain layer for service '{}'", - service_name - ))); - } + let service_name = &context.service_name(); + + // Build the WorkspaceContext so we can use template-driven path resolution. + let workspace = WorkspaceContext::new( + context.workspace_root().clone(), + context.nfw_yaml().clone(), + Default::default(), + )?; + let service = crate::features::template_management::services::artifact_generation_service::ServiceInfo { + name: service_name.to_string(), + path: context.service_path().to_string_lossy().to_string(), + template_id: context + .nfw_yaml() + .get("services") + .and_then(|s| s.get(service_name)) + .and_then(|s| s.get("template")) + .and_then(|t| t.as_mapping()) + .and_then(|t| t.get("id")) + .and_then(|id| id.as_str()) + .unwrap_or_default() + .to_string(), }; - // Auto-detect feature if not explicitly provided + // Auto-detect or validate feature using the entity template's step destinations — no + // hardcoded path candidates. let target_feature = if let Some(f) = command.feature() { f.to_string() } else { - let features_containing_entity = - self.auto_detect_features(&features_dir, entity_name)?; + let matching = self.service.find_artifact_in_features( + &workspace, + &service, + "entity", + entity_name, + )?; - if features_containing_entity.is_empty() { + if matching.is_empty() { return Err(AddArtifactError::InvalidIdentifier(format!( - "Entity '{}' not found in any feature in {:?}", - entity_name, features_dir + "Entity '{}' not found in any feature. Generate the entity first.", + entity_name ))); - } else if features_containing_entity.len() > 1 { + } else if matching.len() > 1 { return Err(AddArtifactError::InvalidIdentifier(format!( "Entity '{}' found in multiple features ({:?}). Please specify --feature.", - entity_name, features_containing_entity + entity_name, matching ))); } - - features_containing_entity[0].clone() + matching.into_iter().next().unwrap() }; - // 1. Verify Entity Exists - let entities_dir = features_dir.join(&target_feature).join("Entities"); - - if !entities_dir.exists() { - return Err(AddArtifactError::InvalidIdentifier(format!( - "Feature '{}' does not contain an Entities folder at {:?}", - target_feature, entities_dir - ))); - } - - let mut entity_found = false; - let entries = std::fs::read_dir(&entities_dir).map_err(|e| { - AddArtifactError::FileReadError(format!( - "Failed to read entities directory {:?}: {}", - entities_dir, e - )) - })?; - - for entry_result in entries { - let entry = entry_result.map_err(|e| { - AddArtifactError::FileReadError(format!( - "Error reading directory entry in {:?}: {}", - entities_dir, e - )) - })?; - - if let Some(file_name) = entry.file_name().to_str() { - if file_name.starts_with(entity_name) && file_name.ends_with(".cs") { - entity_found = true; - break; - } - } else { - tracing::warn!( - "Failed to convert file name to string in {:?}", - entities_dir - ); - } - } - - if !entity_found { - return Err(AddArtifactError::InvalidIdentifier(format!( - "Entity '{}' not found in feature '{}' at {:?}", - entity_name, target_feature, entities_dir - ))); - } - - // Check if repository already exists - let possible_repo_roots = vec![ - service_root - .join("src") - .join("infrastructure") - .join(format!("{}.Infrastructure.Persistence", namespace)) - .join("Features") - .join(&target_feature) - .join("Repositories"), - service_root - .join("src") - .join("Persistence") - .join("Features") - .join(&target_feature) - .join("Repositories"), - service_root - .join("src") - .join("Features") - .join(&target_feature) - .join("Persistence") - .join("Repositories"), - features_dir - .join(&target_feature) - .join("Persistence") - .join("Repositories"), - ]; - - let mut repo_exists = false; - let mut existing_repo_path = None; - for root in possible_repo_roots { - let repo_file = root.join(format!("{}Repository.cs", entity_name)); - if repo_file.exists() { - repo_exists = true; - existing_repo_path = Some(repo_file); - break; + // Validate entity exists in the specified feature using the entity template's paths. + let entity_subdir = self.service.resolve_artifact_subdir( + &workspace, + &service, + "entity", + &target_feature, + )?; + if let Some(dir) = &entity_subdir { + if !dir.is_dir() { + return Err(AddArtifactError::InvalidIdentifier(format!( + "Feature '{}' does not contain an entity artifacts directory at {}", + target_feature, + dir.display() + ))); } - - let i_repo_file = root.join(format!("I{}Repository.cs", entity_name)); - if i_repo_file.exists() { - repo_exists = true; - existing_repo_path = Some(i_repo_file); - break; + // Check that a file starting with entity_name exists in that directory. + let entity_exists = std::fs::read_dir(dir) + .ok() + .map(|entries| { + entries.flatten().any(|e| { + e.file_name() + .to_str() + .map(|n| n.starts_with(entity_name)) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + if !entity_exists { + return Err(AddArtifactError::InvalidIdentifier(format!( + "Entity '{}' not found in feature '{}'", + entity_name, target_feature + ))); } } - if repo_exists { - return Err(AddArtifactError::ArtifactAlreadyExists(format!( - "Repository for entity '{}' already exists at {:?}", - entity_name, - existing_repo_path.unwrap() - ))); - } - - // 2. Validate Persistence is Configured - let nfw_yaml = &context.nfw_yaml; - - let mut has_persistence = false; - - if let Some(services) = nfw_yaml.get("services") - && let Some(service_info) = services.get(service_name) - && let Some(modules) = service_info.get("modules") - && let Some(modules_seq) = modules.as_sequence() + // Check if repository already exists by probing the path derived from the repository + // template's step destination — no hardcoded `Repositories/` or `.cs` suffix. + let repo_subdir = self.service.resolve_artifact_subdir( + &workspace, + &service, + "repository", + &target_feature, + )?; + if let Some(dir) = repo_subdir + && dir.is_dir() { - has_persistence = modules_seq - .iter() - .any(|m| m.as_str() == Some("persistence")); + let repo_exists = std::fs::read_dir(&dir) + .ok() + .map(|entries| { + entries.flatten().any(|e| { + e.file_name() + .to_str() + .map(|n| { + n.starts_with(entity_name) + || n.starts_with(&format!("I{}", entity_name)) + }) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + if repo_exists { + return Err(AddArtifactError::ArtifactAlreadyExists(format!( + "Repository for entity '{}' already exists in feature '{}'", + entity_name, target_feature + ))); + } } + // Validate persistence module is configured. + let has_persistence = context + .nfw_yaml() + .get("services") + .and_then(|s| s.get(service_name)) + .and_then(|d| d.get("modules")) + .and_then(|m| m.as_sequence()) + .map(|seq| seq.iter().any(|m| m.as_str() == Some("persistence"))) + .unwrap_or(false); + if !has_persistence { return Err(AddArtifactError::MissingRequiredModule(format!( "Service '{}' does not have 'persistence' module configured. Run 'nfw add persistence' first.", @@ -221,102 +178,17 @@ where ))); } - // 3. Add Entity to Params let mut params_map = serde_json::Map::new(); params_map.insert( "Entity".to_string(), serde_json::Value::String(entity_name.to_string()), ); - let params = Some(serde_json::Value::Object(params_map)); - // 4. Execute generation self.service .execute_generation(entity_name, Some(&target_feature), ¶ms, context) } - /// Auto-detects the feature containing the given entity. - /// - /// This method performs a recursive filesystem search within the features directory - /// to locate the `Entities` folder and find the `[Entity].cs` file. - /// - /// The algorithm follows these steps: - /// 1. Iterates through all subdirectories in the `features_dir`. - /// 2. For each directory, it looks for an `Entities` child folder. - /// 3. If found, it reads all files in that `Entities` folder. - /// 4. Matches any file that starts with the `entity_name` and has a `.cs` extension. - /// 5. Collects all matching feature names to return. - fn auto_detect_features( - &self, - features_dir: &std::path::Path, - entity_name: &str, - ) -> Result, AddArtifactError> { - let mut features_containing_entity = vec![]; - if !features_dir.exists() { - tracing::warn!( - "Features directory does not exist: {}", - features_dir.display() - ); - return Ok(features_containing_entity); - } - - let entries = std::fs::read_dir(features_dir).map_err(|e| { - AddArtifactError::FileReadError(format!( - "Failed to read features directory {}: {}", - features_dir.display(), - e - )) - })?; - - for entry_result in entries { - let entry = entry_result.map_err(|e| { - AddArtifactError::FileReadError(format!( - "Failed to read features directory entry in {}: {}", - features_dir.display(), - e - )) - })?; - - if !entry.path().is_dir() { - continue; - } - - if let Some(feature_name) = entry.file_name().to_str() { - let entities_dir = entry.path().join("Entities"); - if !entities_dir.exists() { - continue; - } - - let entity_entries = std::fs::read_dir(&entities_dir).map_err(|e| { - AddArtifactError::FileReadError(format!( - "Failed to read entities directory {}: {}", - entities_dir.display(), - e - )) - })?; - - for entity_entry_result in entity_entries { - let entity_entry = entity_entry_result.map_err(|e| { - AddArtifactError::FileReadError(format!( - "Failed to read entities entry in {}: {}", - entities_dir.display(), - e - )) - })?; - - if let Some(file_name) = entity_entry.file_name().to_str() - && file_name.starts_with(entity_name) - && file_name.ends_with(".cs") - { - features_containing_entity.push(feature_name.to_string()); - } - } - } - } - - Ok(features_containing_entity) - } - pub fn get_workspace_context(&self) -> Result { self.service.get_workspace_context() } diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_repository/gen_repository_command_handler.tests.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_repository/gen_repository_command_handler.tests.rs index 626f3b0..b42eaa7 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_repository/gen_repository_command_handler.tests.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/gen_repository/gen_repository_command_handler.tests.rs @@ -28,17 +28,20 @@ mod tests { } } + /// Template root resolver that points to a caller-supplied base directory. #[derive(Clone)] - struct MockTemplateRootResolver; + struct FixedTemplateRootResolver { + base: PathBuf, + } - impl TemplateRootResolver for MockTemplateRootResolver { + impl TemplateRootResolver for FixedTemplateRootResolver { fn resolve( &self, - _nfw_yaml: &YamlValue, + _nfw_yaml: &serde_yaml::Value, _template_id: &str, - _workspace_root: &Path, + _workspace_root: &std::path::Path, ) -> Result { - Ok(PathBuf::from("/mock/templates")) + Ok(self.base.clone()) } } @@ -58,41 +61,108 @@ mod tests { } } - fn create_test_context(workspace_root: PathBuf) -> AddArtifactContext { + /// Writes the minimal template configs under `base/` so that + /// `find_artifact_in_features` and `resolve_artifact_subdir` can derive + /// feature paths from the template step destinations. + fn write_template_configs(base: &Path) { + // Root template: declares generators + fs::write( + base.join("template.yaml"), + "id: test\nname: Test\nversion: 1.0.0\ngenerators:\n entity: entity\n repository: repository\n", + ) + .unwrap(); + + // entity sub-template + let entity_dir = base.join("entity"); + fs::create_dir_all(&entity_dir).unwrap(); + fs::write( + entity_dir.join("template.yaml"), + "id: test/entity\nname: Entity\nsteps:\n - action: render\n source: Entity.cs.tera\n destination: 'src/core/{{ Service }}.Core.Application/Features/{{ Feature }}/Entities/{{ Name }}.cs'\n", + ) + .unwrap(); + + // repository sub-template + let repo_dir = base.join("repository"); + fs::create_dir_all(&repo_dir).unwrap(); + fs::write( + repo_dir.join("template.yaml"), + "id: test/repository\nname: Repository\nsteps:\n - action: render\n source: Repository.cs.tera\n destination: 'src/core/{{ Service }}.Core.Application/Features/{{ Feature }}/Persistence/Repositories/{{ Name }}Repository.cs'\n", + ) + .unwrap(); + } + + fn create_test_context(workspace_root: PathBuf, template_root: PathBuf) -> AddArtifactContext { let yaml_content = r#" workspace: namespace: TestApp services: TestService: + path: src/TestService + template: + id: test modules: - persistence "#; let nfw_yaml: YamlValue = serde_yaml::from_str(yaml_content).unwrap(); AddArtifactContext { - workspace_root, - nfw_yaml, - preserved_comments: PreservedComments::default(), - template_root: PathBuf::from("/mock/templates"), - config: TemplateConfig::new(Some("test".to_string()), vec![], vec![]).unwrap(), + workspace: crate::features::template_management::services::artifact_generation_service::WorkspaceContext::new(workspace_root.clone(), nfw_yaml.clone(), PreservedComments::default()).unwrap(), + template_root, + config: TemplateConfig::new( + Some("test".to_string()), + vec![n_framework_nfw_core_domain::features::template_management::template_config::TemplateStepConfig { + condition: None, + action: n_framework_nfw_core_domain::features::template_management::template_config::TemplateStepAction::RunCommand { + command: "echo done".to_string(), + working_directory: None, + }, + }], + vec![], + ) + .unwrap(), service_name: "TestService".to_string(), service_path: PathBuf::from("src/TestService"), } } + // Template-derived paths for TestService with namespace TestApp: + // entity: src/TestService/src/core/TestApp.Core.Application/Features/{Feature}/Entities/{Name}.cs + // repository: src/TestService/src/core/TestApp.Core.Application/Features/{Feature}/Persistence/Repositories/{Name}Repository.cs + // NOTE: The `{{ Service }}` placeholder is substituted with the service *name* ("TestService"), + // and `{{ Namespace }}` with the workspace namespace. Our template uses `{{ Service }}` so the + // path prefix becomes `TestService.Core.Application`. + + fn entity_features_root(workspace_root: &Path) -> PathBuf { + workspace_root.join("src/TestService/src/core/TestService.Core.Application/Features") + } + + fn repo_dir_for(workspace_root: &Path, feature: &str) -> PathBuf { + entity_features_root(workspace_root) + .join(feature) + .join("Persistence") + .join("Repositories") + } + + fn entity_dir_for(workspace_root: &Path, feature: &str) -> PathBuf { + entity_features_root(workspace_root) + .join(feature) + .join("Entities") + } + #[test] fn test_entity_in_multiple_features() { let temp_dir = TempDir::new().unwrap(); - let service_dir = temp_dir.path().join("src").join("TestService"); - let features_dir = service_dir.join("src").join("Features"); + let tpl_base = temp_dir.path().join("templates").join("test"); + fs::create_dir_all(&tpl_base).unwrap(); + write_template_configs(&tpl_base); - // Create Feature1 with Entity - let f1_entities = features_dir.join("Feature1").join("Entities"); + // Create Feature1 with User entity + let f1_entities = entity_dir_for(temp_dir.path(), "Feature1"); fs::create_dir_all(&f1_entities).unwrap(); fs::write(f1_entities.join("User.cs"), "public class User {}").unwrap(); - // Create Feature2 with Entity - let f2_entities = features_dir.join("Feature2").join("Entities"); + // Create Feature2 with the same User entity + let f2_entities = entity_dir_for(temp_dir.path(), "Feature2"); fs::create_dir_all(&f2_entities).unwrap(); fs::write(f2_entities.join("User.cs"), "public class User {}").unwrap(); @@ -101,34 +171,46 @@ services: }; let handler = GenRepositoryCommandHandler::new( provider, - MockTemplateRootResolver, + FixedTemplateRootResolver { + base: tpl_base.clone(), + }, MockTemplateEngine, ); let command = GenRepositoryCommand::new( "User".to_string(), None, - create_test_context(temp_dir.path().to_path_buf()), + create_test_context(temp_dir.path().to_path_buf(), tpl_base), ); let result = handler.handle(&command); assert!(result.is_err()); if let Err(AddArtifactError::InvalidIdentifier(msg)) = result { - assert!(msg.contains("found in multiple features")); + assert!( + msg.contains("found in multiple features"), + "Actual error: {}", + msg + ); } else { - panic!("Expected InvalidIdentifier error"); + panic!("Expected InvalidIdentifier error, got: {:?}", result); } } #[test] fn test_invalid_entity_names() { let temp_dir = TempDir::new().unwrap(); + let tpl_base = temp_dir.path().join("templates").join("test"); + fs::create_dir_all(&tpl_base).unwrap(); + write_template_configs(&tpl_base); + let provider = MockWorkingDirectoryProvider { dir: temp_dir.path().to_path_buf(), }; let handler = GenRepositoryCommandHandler::new( provider, - MockTemplateRootResolver, + FixedTemplateRootResolver { + base: tpl_base.clone(), + }, MockTemplateEngine, ); @@ -138,13 +220,18 @@ services: let command = GenRepositoryCommand::new( name.to_string(), None, - create_test_context(temp_dir.path().to_path_buf()), + create_test_context(temp_dir.path().to_path_buf(), tpl_base.clone()), ); let result = handler.handle(&command); assert!(result.is_err()); if let Err(AddArtifactError::InvalidIdentifier(msg)) = result { - assert!(msg.contains("Invalid entity name")); + assert!( + msg.contains("Invalid entity name"), + "Actual error for '{}': {}", + name, + msg + ); } else { panic!("Expected InvalidIdentifier error for name: {}", name); } @@ -154,18 +241,19 @@ services: #[test] fn test_repository_already_exists() { let temp_dir = TempDir::new().unwrap(); - let service_dir = temp_dir.path().join("src").join("TestService"); - let features_dir = service_dir.join("src").join("Features"); + let tpl_base = temp_dir.path().join("templates").join("test"); + fs::create_dir_all(&tpl_base).unwrap(); + write_template_configs(&tpl_base); let target_feature = "UserManagement"; - let f_entities = features_dir.join(target_feature).join("Entities"); + + // Entity must exist so the handler proceeds past entity validation. + let f_entities = entity_dir_for(temp_dir.path(), target_feature); fs::create_dir_all(&f_entities).unwrap(); fs::write(f_entities.join("User.cs"), "public class User {}").unwrap(); - let repo_dir = features_dir - .join(target_feature) - .join("Persistence") - .join("Repositories"); + // Repository file that triggers the "already exists" guard. + let repo_dir = repo_dir_for(temp_dir.path(), target_feature); fs::create_dir_all(&repo_dir).unwrap(); fs::write( repo_dir.join("UserRepository.cs"), @@ -178,21 +266,23 @@ services: }; let handler = GenRepositoryCommandHandler::new( provider, - MockTemplateRootResolver, + FixedTemplateRootResolver { + base: tpl_base.clone(), + }, MockTemplateEngine, ); let command = GenRepositoryCommand::new( "User".to_string(), Some(target_feature.to_string()), - create_test_context(temp_dir.path().to_path_buf()), + create_test_context(temp_dir.path().to_path_buf(), tpl_base), ); let result = handler.handle(&command); assert!(result.is_err()); match result { Err(AddArtifactError::ArtifactAlreadyExists(msg)) => { - assert!(msg.contains("already exists")); + assert!(msg.contains("already exists"), "Actual msg: {}", msg); } Err(e) => panic!("Expected ArtifactAlreadyExists, got {:?}", e), Ok(_) => panic!("Expected error, but succeeded"), diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/mod.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/mod.rs index 5c55e1a..e795cdf 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/mod.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/commands/mod.rs @@ -3,6 +3,7 @@ pub mod add_persistence; pub mod add_template_source; pub mod add_webapi; pub mod ensure_default_source; +pub mod gen_endpoint; pub mod gen_mediator_command; pub mod gen_mediator_query; pub mod gen_repository; diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/constants.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/constants.rs index fc6ff29..a455b43 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/constants.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/constants.rs @@ -24,6 +24,9 @@ pub mod source { /// Constants related to generation features pub mod generation { + /// Default feature name when none is provided + pub const DEFAULT_FEATURE_NAME: &str = "Common"; + /// Identifier for the PresentationLayer feature pub const PRESENTATION_LAYER: &str = "PresentationLayer"; diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/services/artifact_generation_service.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/services/artifact_generation_service.rs index e45f921..508e7f7 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/services/artifact_generation_service.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/services/artifact_generation_service.rs @@ -13,31 +13,42 @@ use crate::features::template_management::services::abstractions::template_root_ use crate::features::template_management::services::template_engine::TemplateEngine; use crate::features::workspace_management::services::abstractions::working_directory_provider::WorkingDirectoryProvider; +pub const GEN_TYPE_COMMAND: &str = "command"; +pub const GEN_TYPE_QUERY: &str = "query"; +pub const GEN_TYPE_WEBAPI: &str = "webapi"; + #[derive(Debug, Clone)] pub struct WorkspaceContext { workspace_root: PathBuf, - nfw_yaml: YamlValue, + nfw_yaml: serde_yaml::Value, preserved_comments: PreservedComments, } impl WorkspaceContext { pub fn new( workspace_root: PathBuf, - nfw_yaml: YamlValue, + nfw_yaml: serde_yaml::Value, preserved_comments: PreservedComments, - ) -> Self { - Self { + ) -> Result { + if !workspace_root.is_dir() { + return Err(AddArtifactError::WorkspaceError(format!( + "Workspace root is not a valid directory: {}", + workspace_root.display() + ))); + } + + Ok(Self { workspace_root, nfw_yaml, preserved_comments, - } + }) } - pub fn workspace_root(&self) -> &Path { + pub fn workspace_root(&self) -> &PathBuf { &self.workspace_root } - pub fn nfw_yaml(&self) -> &YamlValue { + pub fn nfw_yaml(&self) -> &serde_yaml::Value { &self.nfw_yaml } @@ -48,9 +59,9 @@ impl WorkspaceContext { #[derive(Debug, Clone)] pub struct ServiceInfo { - name: String, - path: String, - template_id: String, + pub name: String, + pub path: String, + pub template_id: String, } impl ServiceInfo { @@ -86,15 +97,70 @@ impl ServiceInfo { #[derive(Debug, Clone)] pub struct AddArtifactContext { - pub workspace_root: PathBuf, - pub nfw_yaml: YamlValue, - pub preserved_comments: PreservedComments, + pub workspace: WorkspaceContext, pub template_root: PathBuf, pub config: TemplateConfig, pub service_name: String, pub service_path: PathBuf, } +impl AddArtifactContext { + pub fn new( + workspace: WorkspaceContext, + template_root: PathBuf, + config: TemplateConfig, + service_name: String, + service_path: PathBuf, + ) -> Result { + if service_name.is_empty() { + return Err(AddArtifactError::InvalidParameter( + "service_name cannot be empty".to_string(), + )); + } + if service_path.as_os_str().is_empty() { + return Err(AddArtifactError::InvalidParameter( + "service_path cannot be empty".to_string(), + )); + } + + Ok(Self { + workspace, + template_root, + config, + service_name, + service_path, + }) + } + + pub fn workspace_root(&self) -> &PathBuf { + &self.workspace.workspace_root + } + + pub fn nfw_yaml(&self) -> &YamlValue { + &self.workspace.nfw_yaml + } + + pub fn preserved_comments(&self) -> &PreservedComments { + &self.workspace.preserved_comments + } + + pub fn template_root(&self) -> &PathBuf { + &self.template_root + } + + pub fn config(&self) -> &TemplateConfig { + &self.config + } + + pub fn service_name(&self) -> &str { + &self.service_name + } + + pub fn service_path(&self) -> &PathBuf { + &self.service_path + } +} + #[derive(Debug, Clone)] pub struct ArtifactGenerationService { working_dir_provider: W, @@ -102,6 +168,9 @@ pub struct ArtifactGenerationService { engine: E, } +const PRIORITY_APP_LAYER: u8 = 0; +const PRIORITY_PRESENTATION_LAYER: u8 = 1; + impl ArtifactGenerationService where W: WorkingDirectoryProvider, @@ -129,16 +198,20 @@ where context: &AddArtifactContext, ) -> Result<(), AddArtifactError> { self.validate_identifiers(command_name, command_feature)?; - self.validate_required_modules(&context.config, &context.nfw_yaml, &context.service_path)?; + self.validate_required_modules( + &context.config, + &context.workspace.nfw_yaml, + &context.service_path, + )?; let parameters = self.build_parameters( - &context.nfw_yaml, + &context.workspace.nfw_yaml, command_name, &context.service_name, command_feature, command_params, )?; - let output_root = context.workspace_root.join(&context.service_path); + let output_root = context.workspace.workspace_root.join(&context.service_path); self.engine .execute( @@ -236,11 +309,7 @@ where })?; let (nfw_yaml, preserved_comments) = self.read_nfw_yaml(&workspace_root)?; - Ok(WorkspaceContext { - workspace_root, - nfw_yaml, - preserved_comments, - }) + WorkspaceContext::new(workspace_root, nfw_yaml, preserved_comments) } /// Extracts all services defined in the workspace. @@ -300,83 +369,198 @@ where } /// Lists all features available in the workspace. - /// - /// This method searches for the `Features` directory in the service's src folder, - /// traversing the logical namespace path if necessary. pub fn list_features( &self, workspace: &WorkspaceContext, service: &ServiceInfo, ) -> Result, AddArtifactError> { - let namespace = self.extract_namespace(&workspace.nfw_yaml)?; - let service_root = workspace.workspace_root.join(&service.path); - - let possible_roots = vec![ - service_root - .join("src") - .join("core") - .join(format!("{}.Core.Application", namespace)) - .join("Features"), - service_root - .join("src") - .join("core") - .join(format!("{}.Core.Domain", namespace)) - .join("Features"), - service_root - .join("src") - .join("Application") - .join("Features"), - service_root.join("src").join("Features"), - service_root.join("Features"), - service_root.join("specs").join("features"), - ]; - - let mut features_root = None; - for root in possible_roots { - tracing::debug!("Checking for Features root at: {}", root.display()); - if root.is_dir() { - tracing::info!("Found Features root at: {}", root.display()); - features_root = Some(root); - break; + let features_roots = self.derive_all_features_roots(workspace, service)?; + + let mut features = std::collections::HashSet::new(); + + for features_root in features_roots { + if !features_root.is_dir() { + continue; + } + + let entries = std::fs::read_dir(&features_root).map_err(|e| { + AddArtifactError::WorkspaceError(format!( + "failed to read features directory {}: {}", + features_root.display(), + e + )) + })?; + + for entry in entries { + let entry = entry.map_err(|e| { + AddArtifactError::WorkspaceError(format!( + "failed to read directory entry in {}: {}", + features_root.display(), + e + )) + })?; + + match entry.file_type() { + Ok(ft) if ft.is_dir() => { + if let Some(name) = entry.file_name().to_str() { + features.insert(name.to_string()); + } + } + Ok(_) => {} // skip files + Err(e) => { + tracing::warn!( + "Failed to get file type for entry {}: {}", + entry.path().display(), + e + ); + } + } } } - let Some(features_root) = features_root else { + let mut features_vec: Vec = features.into_iter().collect(); + features_vec.sort(); + Ok(features_vec) + } + + pub fn derive_features_root( + &self, + workspace: &WorkspaceContext, + service: &ServiceInfo, + ) -> Result, AddArtifactError> { + let roots = self.derive_all_features_roots(workspace, service)?; + if let Some(root) = roots.first() { + // Log it just to maintain existing behavior parity, though not strictly required + tracing::info!("Derived features root: {}", root.display()); + Ok(Some(root.clone())) + } else { tracing::warn!( - "No Features directory found in service '{}' ({})", - service.name, - service.path + "No generator template for service '{}' declares a '{{{{ Feature }}}}' destination — features root cannot be derived.", + service.name ); - return Ok(Vec::new()); - }; + Ok(None) + } + } - let mut features = Vec::new(); - match fs::read_dir(&features_root) { - Ok(entries) => { - for entry in entries.flatten() { - let _path = entry.path(); - if let Ok(file_type) = entry.file_type() - && file_type.is_dir() - && let Some(name) = entry.file_name().to_str() - { - features.push(name.to_string()); - } - } + /// Finds all root directories where features are stored for a given service. + /// + /// This method derives the features roots from the service's own template step + /// destinations. It loads the base template config, iterates every generator type declared in + /// the `generators` map, and for each one looks for a `Render` step whose destination + /// contains a `{{ Feature }}` placeholder. The path prefix up to (but not including) that + /// placeholder segment — with `{{ Service }}` substituted — is the features root. + /// + /// Returns a list of all distinct candidate directories across all layers. + pub fn derive_all_features_roots( + &self, + workspace: &WorkspaceContext, + service: &ServiceInfo, + ) -> Result, AddArtifactError> { + use n_framework_nfw_core_domain::features::template_management::template_config::TemplateStepAction; + + let base_root = self + .root_resolver + .resolve( + &workspace.nfw_yaml, + &service.template_id, + &workspace.workspace_root, + ) + .map_err(|e| { + AddArtifactError::TemplateNotFound(format!( + "Could not resolve base template '{}' for service '{}': {}", + service.template_id, service.name, e + )) + })?; + + let base_config = self.load_and_validate_template_config(&base_root)?; + + let generators = base_config.generators().ok_or_else(|| { + AddArtifactError::ConfigError(format!( + "No generators declared in template '{}' for service '{}' — cannot derive features root.", + service.template_id, + service.name + )) + })?; + + let mut candidates: Vec<(String, std::path::PathBuf, u8)> = Vec::new(); + + for sub_folder in generators.values() { + let template_root = base_root.join(sub_folder.as_str()); + let config = match self.load_and_validate_template_config(&template_root) { + Ok(c) => c, + Err(_) => continue, + }; + + for step in config.steps() { + let destination = match &step.action { + TemplateStepAction::Render { destination, .. } => destination, + _ => continue, + }; + + let segments: Vec<&str> = destination.split('/').collect(); + + let feature_idx = segments + .iter() + .position(|s| s.contains("{{ Feature }}") || s.contains("{{Feature}}")); + + let Some(fi) = feature_idx else { continue }; + + let namespace = workspace + .nfw_yaml + .get("workspace") + .and_then(|w| w.get("namespace")) + .and_then(|v| v.as_str()) + .unwrap_or(&service.name); + + let prefix_segments: Vec = segments[..fi] + .iter() + .map(|s| { + s.replace("{{ Service }}", &service.name) + .replace("{{Service}}", &service.name) + .replace("{{ Namespace }}", namespace) + .replace("{{Namespace}}", namespace) + }) + .collect(); + + let base = workspace.workspace_root.join(&service.path); + let features_root: PathBuf = + prefix_segments.iter().fold(base, |acc, seg| acc.join(seg)); + + // Prefer Application/core layer over Presentation layer + // by ranking generators: command/query/entity = 0 (highest priority), endpoint/webapi = 1 + let is_application_layer = sub_folder.as_str().contains("command") + || sub_folder.as_str().contains("query") + || sub_folder.as_str().contains("entity"); + let priority = if is_application_layer { + PRIORITY_APP_LAYER + } else { + PRIORITY_PRESENTATION_LAYER + }; + + candidates.push((sub_folder.as_str().to_string(), features_root, priority)); } - Err(e) => { - tracing::error!( - "Failed to read directory {}: {}", - features_root.display(), - e - ); - return Err(AddArtifactError::WorkspaceError(format!( - "failed to read features directory {}: {}", - features_root.display(), - e - ))); + } + + // Sort by priority (application layer first), then by whether the directory exists + candidates.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + // Secondary sort: prefer directories that exist and have content + let a_has_content = + a.1.is_dir() && a.1.read_dir().is_ok_and(|mut d| d.next().is_some()); + let b_has_content = + b.1.is_dir() && b.1.read_dir().is_ok_and(|mut d| d.next().is_some()); + b_has_content.cmp(&a_has_content) + }) + }); + + let mut roots = Vec::new(); + for (_, features_root, _) in candidates { + if !roots.contains(&features_root) { + roots.push(features_root.clone()); } } - Ok(features) + + Ok(roots) } /// Extracts the namespace from the workspace configuration. @@ -440,9 +624,7 @@ where let config = self.load_and_validate_template_config(&template_root)?; Ok(AddArtifactContext { - workspace_root: workspace.workspace_root, - nfw_yaml: workspace.nfw_yaml, - preserved_comments: workspace.preserved_comments, + workspace, template_root, config, service_name: service.name.clone(), @@ -706,6 +888,429 @@ where Ok(false) } + + /// Resolves the concrete directory that holds mediator artifacts (Commands or Queries) for a + /// specific feature, along with the file suffix used to identify them (e.g. `"Command.cs"`). + /// + /// Instead of relying on hardcoded paths, this method reads the `command` or `query` template's + /// step destinations to discover where the framework places those files for the given service. + /// + /// # Returns + /// `Ok(Some((feature_dir, file_suffix)))` when the template provides a usable destination + /// pattern, `Ok(None)` when no `Render` step with a `{{ Feature }}` placeholder is found. + pub fn resolve_mediator_artifact_root( + &self, + workspace: &WorkspaceContext, + service: &ServiceInfo, + generator_type: &str, + ) -> Result, String)>, AddArtifactError> { + use n_framework_nfw_core_domain::features::template_management::template_config::TemplateStepAction; + + let context = self.load_template_context(workspace.clone(), service, generator_type)?; + + for step in context.config.steps() { + let destination = match &step.action { + TemplateStepAction::Render { destination, .. } => destination, + _ => continue, + }; + + let segments: Vec<&str> = destination.split('/').collect(); + + let feature_idx = segments + .iter() + .position(|s| s.contains("{{ Feature }}") || s.contains("{{Feature}}")); + + let Some(fi) = feature_idx else { continue }; + + // Locate the first {{ Name }} segment after {{ Feature }}. + let name_idx = segments[fi + 1..] + .iter() + .position(|s| s.contains("{{ Name }}") || s.contains("{{Name}}")) + .map(|rel| fi + 1 + rel); + + // Build the features root from everything up to (not including) {{ Feature }}, with + // {{ Service }} and {{ Namespace }} substituted for the actual values. + let namespace = workspace + .nfw_yaml + .get("workspace") + .and_then(|w| w.get("namespace")) + .and_then(|v| v.as_str()) + .unwrap_or(&service.name); + + let prefix_segments: Vec = segments[..fi] + .iter() + .map(|s| { + s.replace("{{ Service }}", &service.name) + .replace("{{Service}}", &service.name) + .replace("{{ Namespace }}", namespace) + .replace("{{Namespace}}", namespace) + }) + .collect(); + + let base = workspace.workspace_root.join(&service.path); + let features_root: PathBuf = + prefix_segments.iter().fold(base, |acc, seg| acc.join(seg)); + + // Collect static sub-directory segments that sit between {{ Feature }} and {{ Name }} + // (e.g. `Commands`, `Queries`). These are returned separately so callers can append + // them after the feature name: `features_root / feature / sub_dirs`. + let sub_dirs: Vec = if let Some(ni) = name_idx { + segments[fi + 1..ni] + .iter() + .filter(|s| !s.contains("{{")) + .map(|s| s.to_string()) + .collect() + } else { + Vec::new() + }; + + // Derive the artifact file suffix from the filename segment (the last segment). + // The filename looks like `{{ Name }}Command.cs` — strip the `{{ Name }}` prefix. + let file_suffix = segments + .last() + .map(|s| { + s.strip_prefix("{{ Name }}") + .or_else(|| s.strip_prefix("{{Name}}")) + .unwrap_or(s) + .to_string() + }) + .unwrap_or_default(); + + return Ok(Some((features_root, sub_dirs, file_suffix))); + } + + tracing::warn!( + "No Render step with {{ Feature }} placeholder found for generator '{}' in service '{}'", + generator_type, + service.name + ); + Ok(None) + } + + /// Lists the names of the presentation layers available for the given service by reading the + /// `webapi` generator template's step destinations — no hardcoded paths or naming conventions. + /// + /// The algorithm finds the first step that contains both `{{ Service }}` and a `{{ Service }}` + /// sub-segment followed by `.` (e.g. `{{ Service }}.Presentation.WebApi`), derives the root + /// directory that sits before that segment, and then enumerates real subdirectories there whose + /// names start with the `.` prefix and strips it to return the layer name. + /// + /// Returns an empty `Vec` when the `webapi` generator is not declared in the base template or + /// when the presentation root does not exist on disk. + pub fn list_presentation_layers( + &self, + workspace: &WorkspaceContext, + service: &ServiceInfo, + ) -> Result, AddArtifactError> { + use n_framework_nfw_core_domain::features::template_management::template_config::TemplateStepAction; + + // Try to load the webapi generator template. If the template does not declare a + // "webapi" generator, return an empty list gracefully. + let context = self + .load_template_context(workspace.clone(), service, GEN_TYPE_WEBAPI) + .map_err(|e| { + AddArtifactError::ConfigError(format!( + "Failed to load context for webapi generator: {}", + e + )) + })?; + + for step in context.config.steps() { + // Collect any path-like string from the step that contains {{ Service }} or {{ Namespace }} + let path_str: Option<&str> = match &step.action { + TemplateStepAction::Render { destination, .. } => Some(destination), + TemplateStepAction::RenderIfAbsent { destination, .. } => Some(destination), + TemplateStepAction::Inject { destination, .. } => Some(destination), + TemplateStepAction::RunCommand { command, .. } => Some(command), + TemplateStepAction::RenderFolder { destination, .. } => Some(destination), + }; + + let Some(raw) = path_str else { continue }; + + let namespace = workspace + .nfw_yaml + .get("workspace") + .and_then(|w| w.get("namespace")) + .and_then(|v| v.as_str()) + .unwrap_or(&service.name); + + // We need a segment that looks like `{{ Service }}.Something` or `{{ Namespace }}.Something` + // to derive the naming convention. + let segments: Vec<&str> = raw.split('/').collect(); + + let (service_dot_idx, effective_name) = { + let idx_svc = segments.iter().position(|s| { + (s.contains("{{ Service }}.") || s.contains("{{Service}}.")) + && s.len() > "{{ Service }}.".len() + }); + let idx_ns = segments.iter().position(|s| { + (s.contains("{{ Namespace }}.") || s.contains("{{Namespace}}.")) + && s.len() > "{{ Namespace }}.".len() + }); + match (idx_svc, idx_ns) { + (Some(i), _) => (i, service.name.as_str()), + (None, Some(i)) => (i, namespace), + _ => continue, + } + }; + + // Everything before this segment is the presentation root directory. + let prefix_segs = &segments[..service_dot_idx]; + // The segment itself tells us what follows the service/namespace name + let seg = segments[service_dot_idx]; + // Strip the placeholder to get the suffix, e.g. `.Presentation.WebApi` + let dot_suffix = seg + .strip_prefix("{{ Service }}") + .or_else(|| seg.strip_prefix("{{Service}}")) + .or_else(|| seg.strip_prefix("{{ Namespace }}")) + .or_else(|| seg.strip_prefix("{{Namespace}}")) + .unwrap_or(""); + let prefix_to_strip = if let Some(last_dot) = dot_suffix.rfind('.') { + format!("{}{}", effective_name, &dot_suffix[..=last_dot]) + } else { + format!("{}{}", effective_name, dot_suffix) + }; + let base = workspace.workspace_root.join(&service.path); + let pres_root: std::path::PathBuf = + prefix_segs.iter().fold(base, |acc, seg| acc.join(seg)); + + if !pres_root.is_dir() { + return Ok(Vec::new()); + } + + let mut layers = Vec::new(); + let entries = std::fs::read_dir(&pres_root).map_err(|e| { + tracing::warn!( + "Failed to read presentation root directory {}: {}", + pres_root.display(), + e + ); + AddArtifactError::WorkspaceError(format!( + "Failed to read presentation root directory: {}", + e + )) + })?; + for entry in entries { + let entry = entry.map_err(|e| { + AddArtifactError::WorkspaceError(format!( + "failed to read directory entry in {}: {}", + pres_root.display(), + e + )) + })?; + + if !entry.path().is_dir() { + continue; + } + if let Some(name) = entry.file_name().to_str() + && name.starts_with(&prefix_to_strip) + { + let layer = name[prefix_to_strip.len()..].to_string(); + if !layer.is_empty() { + layers.push(layer); + } + } + } + return Ok(layers); + } + + Ok(Vec::new()) + } + + /// Resolves the concrete path to a named sub-directory within the feature folder for a given + /// generator type, by reading that generator's template step destinations. + /// + /// For example, given the `entity` template whose destination is: + /// `src/core/{{ Service }}.Core.Domain/Features/{{ Feature }}/Entities/{{ Name }}.cs` + /// this method returns the path to `Features//Entities` under the service root. + /// + /// Returns `Ok(None)` when the template cannot be found or declares no suitable step. + pub fn resolve_artifact_subdir( + &self, + workspace: &WorkspaceContext, + service: &ServiceInfo, + generator_type: &str, + feature: &str, + ) -> Result, AddArtifactError> { + let Some((features_root, sub_dirs, _file_suffix)) = + self.resolve_mediator_artifact_root(workspace, service, generator_type)? + else { + return Ok(None); + }; + + let mut dir = features_root.join(feature); + for seg in &sub_dirs { + dir = dir.join(seg); + } + Ok(Some(dir)) + } + + /// Searches features for one that contains a file matching `.` inside the + /// sub-directory derived from `generator_type`'s template step destinations. + /// + /// Returns all feature names whose derived sub-directory contains a file whose name starts with + /// `artifact_name` and ends with `file_suffix` (both derived from the template when not + /// provided — pass `None` to auto-derive from the template step destination). + /// + /// Used by `gen_repository_command_handler` to auto-detect which feature holds a given entity + /// without hardcoding `"Entities"` or `.cs`. + pub fn find_artifact_in_features( + &self, + workspace: &WorkspaceContext, + service: &ServiceInfo, + generator_type: &str, + artifact_name: &str, + ) -> Result, AddArtifactError> { + use n_framework_nfw_core_domain::features::template_management::template_config::TemplateStepAction; + + let context = self + .load_template_context(workspace.clone(), service, generator_type) + .map_err(|e| { + AddArtifactError::ConfigError(format!( + "Failed to load context for generator type {}: {}", + generator_type, e + )) + })?; + + // Find the first Render step with {{ Feature }} to derive the features root and sub-dirs. + let mut features_root_opt: Option<(std::path::PathBuf, Vec, String)> = None; + for step in context.config.steps() { + let destination = match &step.action { + TemplateStepAction::Render { destination, .. } => destination, + _ => continue, + }; + let segments: Vec<&str> = destination.split('/').collect(); + let Some(fi) = segments + .iter() + .position(|s| s.contains("{{ Feature }}") || s.contains("{{Feature}}")) + else { + continue; + }; + let ni = segments[fi + 1..] + .iter() + .position(|s| s.contains("{{ Name }}") || s.contains("{{Name}}")) + .map(|rel| fi + 1 + rel); + + let namespace = workspace + .nfw_yaml + .get("workspace") + .and_then(|w| w.get("namespace")) + .and_then(|v| v.as_str()) + .unwrap_or(&service.name); + + let prefix: std::path::PathBuf = segments[..fi].iter().fold( + workspace.workspace_root.join(&service.path), + |acc, s| { + acc.join( + s.replace("{{ Service }}", &service.name) + .replace("{{Service}}", &service.name) + .replace("{{ Namespace }}", namespace) + .replace("{{Namespace}}", namespace), + ) + }, + ); + + let sub_dirs: Vec = if let Some(ni) = ni { + segments[fi + 1..ni] + .iter() + .filter(|s| !s.contains("{{")) + .map(|s| s.to_string()) + .collect() + } else { + Vec::new() + }; + + let file_suffix = segments + .last() + .map(|s| { + s.strip_prefix("{{ Name }}") + .or_else(|| s.strip_prefix("{{Name}}")) + .unwrap_or(s) + .to_string() + }) + .unwrap_or_default(); + + features_root_opt = Some((prefix, sub_dirs, file_suffix)); + break; + } + + let Some((features_root, sub_dirs, file_suffix)) = features_root_opt else { + tracing::warn!( + "Could not determine features root from template steps for artifact check in service '{}'", + service.name + ); + return Ok(Vec::new()); + }; + + if !features_root.is_dir() { + return Ok(Vec::new()); + } + + let mut matched_features = Vec::new(); + let entries = std::fs::read_dir(&features_root).map_err(|e| { + tracing::warn!( + "Failed to read features root directory {}: {}", + features_root.display(), + e + ); + AddArtifactError::WorkspaceError(format!("Failed to read features root: {e}")) + })?; + + for entry in entries { + let entry = entry.map_err(|e| { + AddArtifactError::WorkspaceError(format!( + "failed to read directory entry in {}: {}", + features_root.display(), + e + )) + })?; + + if !entry.path().is_dir() { + continue; + } + let feature_name = match entry.file_name().into_string() { + Ok(n) => n, + Err(_) => continue, + }; + let mut scan_dir = entry.path(); + for seg in &sub_dirs { + scan_dir = scan_dir.join(seg); + } + + if !scan_dir.is_dir() { + continue; + } + let inner_entries = std::fs::read_dir(&scan_dir).map_err(|e| { + tracing::warn!( + "Failed to read artifact sub-directory {}: {}", + scan_dir.display(), + e + ); + AddArtifactError::WorkspaceError(format!( + "Failed to read artifact sub-directory: {e}" + )) + })?; + + for inner_entry in inner_entries { + let inner_entry = inner_entry.map_err(|e| { + AddArtifactError::WorkspaceError(format!( + "failed to read directory entry in {}: {}", + scan_dir.display(), + e + )) + })?; + + if let Some(fname) = inner_entry.file_name().to_str() + && fname.starts_with(artifact_name) + && fname.ends_with(&file_suffix) + { + matched_features.push(feature_name.clone()); + break; + } + } + } + Ok(matched_features) + } } fn get_identifier_regex() -> &'static regex::Regex { diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/services/artifact_generation_service.tests.rs b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/services/artifact_generation_service.tests.rs index 5b0b960..83aab10 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/services/artifact_generation_service.tests.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-application/src/features/template_management/services/artifact_generation_service.tests.rs @@ -325,7 +325,7 @@ services: // We need to simulate required_modules. Since TemplateConfig fields are private, // we use a yaml string to deserialize it. - let config_yaml = "id: test\nrequired_modules: [\"persistence\"]"; + let config_yaml = "id: test\nrequired_modules: [\"persistence\"]\nsteps:\n - action: run_command\n command: echo"; let config: TemplateConfig = serde_yaml::from_str(config_yaml).unwrap(); let result = service.validate_required_modules(&config, &nfw_yaml, Path::new("src/MyService")); diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-domain/src/features/template_management/template_config.rs b/src/n-framework-nfw/core/n-framework-nfw-core-domain/src/features/template_management/template_config.rs index fde1412..311b0bd 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-domain/src/features/template_management/template_config.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-domain/src/features/template_management/template_config.rs @@ -10,7 +10,7 @@ pub struct TemplateConfig { id: Option, /// The sequence of rendering steps to perform. #[serde(default)] - steps: Vec, + steps: Vec, /// The input parameters accepted by this template. #[serde(default)] inputs: Vec, @@ -20,19 +20,26 @@ pub struct TemplateConfig { /// Explicit paths for nested generators. #[serde(default)] generators: Option>, + /// Generator types whose step destinations are scanned when attaching this template output to + /// an existing mediator artifact (command or query). Each entry is a key in the parent + /// template's `generators` map (e.g. `"command"`, `"query"`). + #[serde(default)] + mediator_sources: Vec, } #[derive(Deserialize)] struct TemplateConfigShadow { id: Option, #[serde(default)] - steps: Vec, + steps: Vec, #[serde(default)] inputs: Vec, #[serde(default)] required_modules: Vec, #[serde(default)] generators: Option>, + #[serde(default)] + mediator_sources: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -92,6 +99,7 @@ impl TryFrom for TemplateConfig { inputs: shadow.inputs, required_modules: shadow.required_modules, generators: shadow.generators, + mediator_sources: shadow.mediator_sources, }; config.validate()?; Ok(config) @@ -137,7 +145,7 @@ impl TemplateConfig { /// Returns an error if the configuration is invalid. pub fn new( id: Option, - steps: Vec, + steps: Vec, inputs: Vec, ) -> Result { let config = Self { @@ -146,23 +154,34 @@ impl TemplateConfig { inputs, required_modules: Vec::new(), generators: None, + mediator_sources: Vec::new(), }; config.validate()?; Ok(config) } /// Validates the configuration state. - /// - /// Note: Allows empty steps for backward compatibility with legacy templates - /// that might use an empty template.yaml and rely on default content/ rendering. pub fn validate(&self) -> Result<(), TemplateConfigError> { if let Some(id) = &self.id { validate_id_format(id)?; } + // Allow steps to be empty if the template defines generators (acting as a parent template) + if self.steps.is_empty() && self.generators.as_ref().is_none_or(|g| g.is_empty()) { + return Err(TemplateConfigError::InvalidStep { + index: 0, + message: "A template must define at least one step or specify child generators" + .to_string(), + }); + } + for (i, step) in self.steps.iter().enumerate() { - match step { - TemplateStep::Render { + match &step.action { + TemplateStepAction::Render { + source, + destination, + } + | TemplateStepAction::RenderIfAbsent { source, destination, } => { @@ -179,7 +198,7 @@ impl TemplateConfig { }); } } - TemplateStep::RenderFolder { + TemplateStepAction::RenderFolder { source, destination, } => { @@ -202,10 +221,10 @@ impl TemplateConfig { }); } } - TemplateStep::Inject { + TemplateStepAction::Inject { source, destination, - .. + injection_target: _, } => { if source.trim().is_empty() { return Err(TemplateConfigError::InvalidStep { @@ -220,7 +239,7 @@ impl TemplateConfig { }); } } - TemplateStep::RunCommand { command, .. } => { + TemplateStepAction::RunCommand { command, .. } => { if command.trim().is_empty() { return Err(TemplateConfigError::InvalidStep { index: i, @@ -381,7 +400,7 @@ impl TemplateConfig { } /// Returns the list of rendering steps. - pub fn steps(&self) -> &[TemplateStep] { + pub fn steps(&self) -> &[TemplateStepConfig] { &self.steps } @@ -403,6 +422,15 @@ impl TemplateConfig { pub fn generators(&self) -> Option<&HashMap> { self.generators.as_ref() } + + /// Returns the generator types whose step destinations are scanned when attaching this + /// template's output to an existing mediator artifact. + /// + /// Each entry corresponds to a key in the parent template's `generators` map + /// (e.g. `"command"`, `"query"`). An empty slice means no mediator attachment is supported. + pub fn mediator_sources(&self) -> &[String] { + &self.mediator_sources + } } fn validate_id_format(id: &str) -> Result<(), TemplateConfigError> { @@ -426,8 +454,20 @@ fn validate_id_format(id: &str) -> Result<(), TemplateConfigError> { /// A single step in the template rendering process. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TemplateStepConfig { + /// Optional condition (Tera expression). If it evaluates to false, the step is skipped. + #[serde(rename = "if", default, skip_serializing_if = "Option::is_none")] + pub condition: Option, + + /// The action to perform for this step. + #[serde(flatten)] + pub action: TemplateStepAction, +} + +/// The action of a template step. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "action", rename_all = "snake_case")] -pub enum TemplateStep { +pub enum TemplateStepAction { /// Renders a single template file. Render { /// Path to the source template file (relative to template root). @@ -435,6 +475,16 @@ pub enum TemplateStep { /// Path to the destination file (relative to output root). destination: String, }, + /// Renders a single template file only if the destination does not already exist. + /// Use this for hand-editable scaffolding files that should be created once but never + /// overwritten by subsequent generator runs (e.g. partial class declarations, + /// feature grouping registration stubs). + RenderIfAbsent { + /// Path to the source template file (relative to template root). + source: String, + /// Path to the destination file (relative to output root). + destination: String, + }, /// Renders an entire folder of templates. RenderFolder { /// Path to the source folder (relative to template root). diff --git a/src/n-framework-nfw/core/n-framework-nfw-core-domain/src/features/template_management/template_config.tests.rs b/src/n-framework-nfw/core/n-framework-nfw-core-domain/src/features/template_management/template_config.tests.rs index 1210898..9100587 100644 --- a/src/n-framework-nfw/core/n-framework-nfw-core-domain/src/features/template_management/template_config.tests.rs +++ b/src/n-framework-nfw/core/n-framework-nfw-core-domain/src/features/template_management/template_config.tests.rs @@ -1,19 +1,25 @@ use super::*; #[test] -fn template_config_validate_succeeds_on_empty_steps() { +fn template_config_validate_fails_on_empty_steps() { let config = TemplateConfig::new(None, vec![], vec![]); - // Empty steps are allowed for legacy template support - assert!(config.is_ok()); + assert!(config.is_err()); + assert!(matches!( + config.unwrap_err(), + TemplateConfigError::InvalidStep { .. } + )); } #[test] fn template_config_validate_fails_on_invalid_step() { let config = TemplateConfig::new( None, - vec![TemplateStep::Render { - source: "".to_string(), // Invalid: empty source - destination: "dest".to_string(), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Render { + source: "".to_string(), // Invalid: empty source + destination: "dest".to_string(), + }, }], vec![], ); @@ -28,9 +34,12 @@ fn template_config_validate_fails_on_invalid_step() { fn template_config_validate_succeeds_with_valid_steps() { let config = TemplateConfig::new( None, - vec![TemplateStep::Render { - source: "src".to_string(), - destination: "dest".to_string(), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Render { + source: "src".to_string(), + destination: "dest".to_string(), + }, }], vec![], ); @@ -49,7 +58,17 @@ fn template_config_validate_fails_on_invalid_id() { #[test] fn template_config_validate_succeeds_with_namespaced_id() { - let config = TemplateConfig::new(Some("official/dotnet-service".to_string()), vec![], vec![]); + let config = TemplateConfig::new( + Some("official/dotnet-service".to_string()), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::RunCommand { + command: "echo test".to_string(), + working_directory: None, + }, + }], + vec![], + ); assert!(config.is_ok()); } @@ -57,9 +76,12 @@ fn template_config_validate_succeeds_with_namespaced_id() { fn template_config_validate_fails_on_empty_run_command() { let config = TemplateConfig::new( None, - vec![TemplateStep::RunCommand { - command: " ".to_string(), - working_directory: None, + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::RunCommand { + command: " ".to_string(), + working_directory: None, + }, }], vec![], ); @@ -74,9 +96,12 @@ fn template_config_validate_fails_on_empty_run_command() { fn template_config_validate_succeeds_with_valid_run_command() { let config = TemplateConfig::new( None, - vec![TemplateStep::RunCommand { - command: "dotnet add package Mediator".to_string(), - working_directory: Some("src/core".to_string()), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::RunCommand { + command: "dotnet add package Mediator".to_string(), + working_directory: Some("src/core".to_string()), + }, }], vec![], ); @@ -87,6 +112,13 @@ fn template_config_validate_succeeds_with_valid_run_command() { fn template_config_parses_generators() { let json = r#"{ "id": "t1", +"steps": [ + { + "action": "run_command", + "command": "echo" + } +], +"inputs": [], "generators": { "persistence": "sub/persistence", "mediator": "sub/mediator" @@ -97,3 +129,23 @@ fn template_config_parses_generators() { assert_eq!(generators.get("persistence").unwrap(), "sub/persistence"); assert_eq!(generators.get("mediator").unwrap(), "sub/mediator"); } + +#[test] +fn template_config_parses_step_conditions() { + let json = r#"{ +"id": "t1", +"steps": [ + { + "action": "render", + "source": "src", + "destination": "dest", + "if": "{{ not no_api }}" + } +], +"inputs": [] +}"#; + let config: TemplateConfig = serde_json::from_str(json).expect("should parse condition"); + let steps = config.steps(); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].condition.as_deref(), Some("{{ not no_api }}")); +} diff --git a/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-filesystem/src/features/template_management/template_engine.rs b/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-filesystem/src/features/template_management/template_engine.rs index f4097a9..5210813 100644 --- a/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-filesystem/src/features/template_management/template_engine.rs +++ b/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-filesystem/src/features/template_management/template_engine.rs @@ -7,7 +7,7 @@ use n_framework_nfw_core_application::features::template_management::models::tem }; use n_framework_nfw_core_application::features::template_management::services::template_engine::TemplateEngine; use n_framework_nfw_core_domain::features::template_management::template_config::{ - InjectionTarget, TemplateConfig, TemplateStep, + InjectionTarget, TemplateConfig, TemplateStepAction, }; use n_framework_nfw_core_domain::features::template_management::template_parameters::TemplateParameters; @@ -34,7 +34,7 @@ impl FileSystemTemplateEngine { pub fn new() -> Self { Self { generator: TeraFileGenerator::default(), - renderer: TeraTemplateRenderer, + renderer: TeraTemplateRenderer::new(), } } @@ -185,8 +185,51 @@ impl TemplateEngine for FileSystemTemplateEngine { }; for (i, step) in config.steps().iter().enumerate() { - match step { - TemplateStep::Render { + // Evaluate condition first if provided + if let Some(condition_expr) = &step.condition { + let eval_result = self + .renderer + .render_content(condition_expr, ctx.core_context) + .map_err(|e| TemplateError::TemplateRenderError { + message: format!( + "failed to evaluate condition '{}': {}", + condition_expr, e + ), + step_index: Some(i), + template_id: ctx.template_id.clone(), + file_path: None, + source: None, + })?; + + let is_true = match eval_result.trim().to_lowercase().as_str() { + "true" | "1" => true, + "false" | "0" | "" => false, + _ => { + return Err(TemplateError::TemplateRenderError { + message: format!( + "Invalid boolean evaluation for condition expression '{}'. Evaluated to '{}'", + condition_expr, eval_result + ), + step_index: Some(i), + template_id: ctx.template_id.clone(), + file_path: None, + source: None, + }); + } + }; + + if !is_true { + tracing::debug!( + "Skipping step [action: {:?}] due to false condition: {}", + step.action, + condition_expr + ); + continue; + } + } + + match &step.action { + TemplateStepAction::Render { source, destination, } => { @@ -227,7 +270,53 @@ impl TemplateEngine for FileSystemTemplateEngine { TemplateError::io(format!("failed to write generated file: {e}"), dest_path) })?; } - TemplateStep::RenderFolder { + TemplateStepAction::RenderIfAbsent { + source, + destination, + } => { + let (source_path, dest_path) = + self.resolve_paths(source, destination, &ctx, i)?; + + // Skip if the destination already exists — this step is idempotent. + if dest_path.exists() { + continue; + } + + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + TemplateError::io( + format!( + "failed to create parent directory for {}: {e}", + dest_path.display() + ), + parent, + ) + })?; + } + + let content = fs::read_to_string(&source_path).map_err(|e| { + TemplateError::io( + format!("failed to read template source: {e}"), + source_path, + ) + })?; + let rendered_content = self + .renderer + .render_content(&content, ctx.core_context) + .map_err(|e| { + Self::map_error( + e, + i, + template_id.clone(), + Some(dest_path.display().to_string()), + ) + })?; + + fs::write(&dest_path, rendered_content).map_err(|e| { + TemplateError::io(format!("failed to write generated file: {e}"), dest_path) + })?; + } + TemplateStepAction::RenderFolder { source, destination, } => { @@ -245,7 +334,7 @@ impl TemplateEngine for FileSystemTemplateEngine { ) })?; } - TemplateStep::Inject { + TemplateStepAction::Inject { source, destination, injection_target, @@ -318,7 +407,17 @@ impl TemplateEngine for FileSystemTemplateEngine { .collect(); let indented_content = indent_lines(&rendered_inject_content, &indent); - file_content.insert_str(insert_pos, &indented_content); + + // Skip insert if the trimmed content already exists in the region. + let region_slice = &file_content[start_pos..absolute_end_pos]; + let trimmed_new = rendered_inject_content.trim(); + if !trimmed_new.is_empty() + && region_slice.lines().any(|l| l.trim() == trimmed_new) + { + // Content already present — no-op. + } else { + file_content.insert_str(insert_pos, &indented_content); + } } else { let snippet = get_snippet(&file_content, start_pos); return Err(TemplateError::TemplateInjectionError { @@ -350,7 +449,7 @@ impl TemplateEngine for FileSystemTemplateEngine { TemplateError::io(format!("failed to write injected file: {e}"), dest_path) })?; } - TemplateStep::RunCommand { + TemplateStepAction::RunCommand { command, working_directory, } => { diff --git a/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-filesystem/src/features/template_management/template_engine.tests.rs b/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-filesystem/src/features/template_management/template_engine.tests.rs index 8a166bd..5b75e70 100644 --- a/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-filesystem/src/features/template_management/template_engine.tests.rs +++ b/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-filesystem/src/features/template_management/template_engine.tests.rs @@ -1,4 +1,7 @@ use super::*; +use n_framework_nfw_core_domain::features::template_management::template_config::{ + InjectionTarget, TemplateConfig, TemplateStepAction, TemplateStepConfig, +}; use std::fs; use tempfile::TempDir; @@ -19,9 +22,12 @@ fn execute_render_step_creates_file() { let config = TemplateConfig::new( None, - vec![TemplateStep::Render { - source: "hello.txt.tera".to_string(), - destination: "hello.txt".to_string(), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Render { + source: "hello.txt.tera".to_string(), + destination: "hello.txt".to_string(), + }, }], vec![], ) @@ -53,9 +59,12 @@ fn prevents_path_traversal_in_render_destination() { let config = TemplateConfig::new( None, - vec![TemplateStep::Render { - source: "leak.txt".to_string(), - destination: "../outside.txt".to_string(), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Render { + source: "leak.txt".to_string(), + destination: "../outside.txt".to_string(), + }, }], vec![], ) @@ -86,9 +95,12 @@ fn prevents_absolute_path_traversal() { let config = TemplateConfig::new( None, - vec![TemplateStep::Render { - source: "leak.txt".to_string(), - destination: "/tmp/evil.txt".to_string(), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Render { + source: "leak.txt".to_string(), + destination: "/tmp/evil.txt".to_string(), + }, }], vec![], ) @@ -122,10 +134,13 @@ fn inject_at_end_works() { let config = TemplateConfig::new( None, - vec![TemplateStep::Inject { - source: "inject.txt".to_string(), - destination: "app.txt".to_string(), - injection_target: InjectionTarget::AtEnd, + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Inject { + source: "inject.txt".to_string(), + destination: "app.txt".to_string(), + injection_target: InjectionTarget::AtEnd, + }, }], vec![], ) @@ -164,10 +179,13 @@ fn inject_region_works() { let config = TemplateConfig::new( None, - vec![TemplateStep::Inject { - source: "inject.txt".to_string(), - destination: "app.txt".to_string(), - injection_target: InjectionTarget::Region("deps".to_string()), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Inject { + source: "inject.txt".to_string(), + destination: "app.txt".to_string(), + injection_target: InjectionTarget::Region("deps".to_string()), + }, }], vec![], ) @@ -205,10 +223,13 @@ fn inject_region_fails_when_start_marker_is_missing() { let config = TemplateConfig::new( None, - vec![TemplateStep::Inject { - source: "inject.txt".to_string(), - destination: "app.txt".to_string(), - injection_target: InjectionTarget::Region("deps".to_string()), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Inject { + source: "inject.txt".to_string(), + destination: "app.txt".to_string(), + injection_target: InjectionTarget::Region("deps".to_string()), + }, }], vec![], ) @@ -244,10 +265,13 @@ fn inject_region_fails_when_end_marker_is_missing() { let config = TemplateConfig::new( None, - vec![TemplateStep::Inject { - source: "inject.txt".to_string(), - destination: "app.txt".to_string(), - injection_target: InjectionTarget::Region("deps".to_string()), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Inject { + source: "inject.txt".to_string(), + destination: "app.txt".to_string(), + injection_target: InjectionTarget::Region("deps".to_string()), + }, }], vec![], ) @@ -287,10 +311,13 @@ fn inject_region_auto_indents_content() { let config = TemplateConfig::new( None, - vec![TemplateStep::Inject { - source: "inject.txt".to_string(), - destination: "app.cs".to_string(), - injection_target: InjectionTarget::Region("deps".to_string()), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Inject { + source: "inject.txt".to_string(), + destination: "app.cs".to_string(), + injection_target: InjectionTarget::Region("deps".to_string()), + }, }], vec![], ) @@ -327,9 +354,12 @@ fn render_folder_step_copies_and_renders_directory_tree() { let config = TemplateConfig::new( None, - vec![TemplateStep::RenderFolder { - source: "components".to_string(), - destination: "out_components".to_string(), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::RenderFolder { + source: "components".to_string(), + destination: "out_components".to_string(), + }, }], vec![], ) @@ -362,9 +392,12 @@ fn render_step_fails_when_source_file_missing() { let config = TemplateConfig::new( None, - vec![TemplateStep::Render { - source: "does_not_exist.txt".to_string(), - destination: "out.txt".to_string(), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Render { + source: "does_not_exist.txt".to_string(), + destination: "out.txt".to_string(), + }, }], vec![], ) @@ -400,10 +433,13 @@ fn inject_fails_when_destination_file_missing() { let config = TemplateConfig::new( None, - vec![TemplateStep::Inject { - source: "inject.txt".to_string(), - destination: "missing_target.txt".to_string(), - injection_target: InjectionTarget::AtEnd, + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Inject { + source: "inject.txt".to_string(), + destination: "missing_target.txt".to_string(), + injection_target: InjectionTarget::AtEnd, + }, }], vec![], ) @@ -444,9 +480,12 @@ fn execute_fails_on_parent_directory_creation_failure() { // Destination requires creating a directory where 'obstacle' already is let config = TemplateConfig::new( None, - vec![TemplateStep::Render { - source: "hello.txt".to_string(), - destination: "file_blocking_dir/nested/hello.txt".to_string(), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Render { + source: "hello.txt".to_string(), + destination: "file_blocking_dir/nested/hello.txt".to_string(), + }, }], vec![], ) @@ -482,9 +521,12 @@ fn execute_fails_on_template_syntax_error() { let config = TemplateConfig::new( None, - vec![TemplateStep::Render { - source: "bad.txt.tera".to_string(), - destination: "out.txt".to_string(), + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::Render { + source: "bad.txt.tera".to_string(), + destination: "out.txt".to_string(), + }, }], vec![], ) @@ -517,9 +559,12 @@ fn run_command_step_executes_successfully() { let config = TemplateConfig::new( None, - vec![TemplateStep::RunCommand { - command: "echo hello > result.txt".to_string(), - working_directory: None, + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::RunCommand { + command: "echo hello > result.txt".to_string(), + working_directory: None, + }, }], vec![], ) @@ -550,9 +595,12 @@ fn run_command_step_fails_on_nonzero_exit() { let config = TemplateConfig::new( None, - vec![TemplateStep::RunCommand { - command: "exit 1".to_string(), - working_directory: None, + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::RunCommand { + command: "exit 1".to_string(), + working_directory: None, + }, }], vec![], ) @@ -585,9 +633,12 @@ fn run_command_step_renders_tera_placeholders() { let config = TemplateConfig::new( None, - vec![TemplateStep::RunCommand { - command: "echo {{ Name }} > greeting.txt".to_string(), - working_directory: None, + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::RunCommand { + command: "echo {{ Name }} > greeting.txt".to_string(), + working_directory: None, + }, }], vec![], ) @@ -619,9 +670,12 @@ fn run_command_step_blocks_dangerous_patterns() { let config = TemplateConfig::new( None, - vec![TemplateStep::RunCommand { - command: "echo {{ name }}".to_string(), - working_directory: None, + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::RunCommand { + command: "echo {{ name }}".to_string(), + working_directory: None, + }, }], vec![], ) @@ -661,9 +715,12 @@ fn run_command_error_includes_context() { let config = TemplateConfig::new( None, - vec![TemplateStep::RunCommand { - command: "ls /non_existent_path_xyz_123_q42".to_string(), - working_directory: None, + vec![TemplateStepConfig { + condition: None, + action: TemplateStepAction::RunCommand { + command: "ls /non_existent_path_xyz_123_q42".to_string(), + working_directory: None, + }, }], vec![], ) diff --git a/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-yaml/src/features/template_management/services/serde_yaml_parser.tests.rs b/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-yaml/src/features/template_management/services/serde_yaml_parser.tests.rs index 6b7b191..aa60607 100644 --- a/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-yaml/src/features/template_management/services/serde_yaml_parser.tests.rs +++ b/src/n-framework-nfw/infrastructure/n-framework-nfw-infrastructure-yaml/src/features/template_management/services/serde_yaml_parser.tests.rs @@ -1,6 +1,6 @@ use super::*; use n_framework_nfw_core_domain::features::template_management::template_config::{ - InjectionTarget, TemplateConfig, TemplateStep, + InjectionTarget, TemplateConfig, TemplateStepAction, }; #[test] @@ -32,8 +32,8 @@ steps: let config = result.unwrap(); assert_eq!(config.steps().len(), 2); - match &config.steps()[1] { - TemplateStep::Inject { + match &config.steps()[1].action { + TemplateStepAction::Inject { injection_target, .. } => { assert!(matches!(injection_target, InjectionTarget::Region(_))); diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/Cargo.toml b/src/n-framework-nfw/presentation/n-framework-nfw-cli/Cargo.toml index 0db2356..25b7b20 100644 --- a/src/n-framework-nfw/presentation/n-framework-nfw-cli/Cargo.toml +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/Cargo.toml @@ -168,3 +168,7 @@ path = "../../../../tests/integration/n-framework-nfw/features/entity_generation name = "entity_generation_schema_test" path = "../../../../tests/integration/n-framework-nfw/features/entity_generation/schema_tests.rs" +[[test]] +name = "gen_endpoint_test" +path = "../../../../tests/integration/n-framework-nfw/features/template_management/commands/gen_endpoint/gen_endpoint_test.rs" + diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/add/mediator/handler.rs b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/add/mediator/handler.rs index 6ed94dc..2700375 100644 --- a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/add/mediator/handler.rs +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/add/mediator/handler.rs @@ -1,6 +1,5 @@ use n_framework_core_cli_abstractions::{InteractivePrompt, Logger, SelectOption}; use crate::cli_error::CliError; -use crate::commands::add::utils::find_presentation_layers; use crate::startup::cli_service_collection_factory::CliServiceCollection; use n_framework_nfw_core_application::features::cli::exit_codes::ExitCodes; use n_framework_nfw_core_application::features::template_management::commands::add_mediator::add_mediator_command::AddMediatorCommand; @@ -84,11 +83,9 @@ where })? }; - let layers = find_presentation_layers( - workspace_context.workspace_root(), - selected_service.path(), - selected_service.name(), - )?; + let layers = self + .handler + .list_presentation_layers(&workspace_context, &selected_service)?; if layers.is_empty() { return Err(CliError::internal( diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/add/persistence/handler.rs b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/add/persistence/handler.rs index ea77bb3..45f086b 100644 --- a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/add/persistence/handler.rs +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/add/persistence/handler.rs @@ -1,6 +1,5 @@ use n_framework_core_cli_abstractions::{InteractivePrompt, Logger, SelectOption}; use crate::cli_error::CliError; -use crate::commands::add::utils::find_presentation_layers; use crate::startup::cli_service_collection_factory::CliServiceCollection; use n_framework_nfw_core_application::features::cli::exit_codes::ExitCodes; use n_framework_nfw_core_application::features::template_management::commands::add_persistence::add_persistence_command::AddPersistenceCommand; @@ -84,11 +83,9 @@ where })? }; - let layers = find_presentation_layers( - workspace_context.workspace_root(), - selected_service.path(), - selected_service.name(), - )?; + let layers = self + .handler + .list_presentation_layers(&workspace_context, &selected_service)?; if layers.is_empty() { return Err(CliError::internal( diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/command/handler.rs b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/command/handler.rs index a119288..c5bbd8b 100644 --- a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/command/handler.rs +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/command/handler.rs @@ -86,7 +86,7 @@ where let feature = self.resolve_feature(&request, existing_features)?; let name = self.resolve_name(&request)?; - let resolved_params = self.resolve_params(&request, context.config.inputs())?; + let resolved_params = self.resolve_params(&request, context.config().inputs())?; let params_opt = if resolved_params.as_object().is_none_or(|o| o.is_empty()) { None @@ -141,7 +141,7 @@ where } self.prompt - .text("Enter command name (e.g. ApproveOrderCommand):", None) + .text("Enter command name (e.g. ApproveOrder):", None) .map_err(|e| AddArtifactError::WorkspaceError(e.to_string())) } @@ -158,19 +158,6 @@ where return Ok(None); } - if existing_features.is_empty() { - let feature = self - .prompt - .text("Enter feature name (e.g. Catalog):", None) - .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; - - if feature.trim().is_empty() { - return Ok(None); - } else { - return Ok(Some(feature.trim().to_string())); - } - } - let mut options: Vec = existing_features .iter() .map(|f| SelectOption::new(f, f)) @@ -187,7 +174,7 @@ where if selected.value() == CREATE_NEW { let feature = self .prompt - .text("Enter new feature name (e.g. Catalog):", None) + .text("Enter feature name (e.g. Catalog):", None) .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; if feature.trim().is_empty() { diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/endpoint/handler.rs b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/endpoint/handler.rs new file mode 100644 index 0000000..1509678 --- /dev/null +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/endpoint/handler.rs @@ -0,0 +1,639 @@ +use n_framework_core_cli_abstractions::{InteractivePrompt, Logger, SelectOption}; +use crate::cli_error::CliError; +use crate::startup::cli_service_collection_factory::CliServiceCollection; +use n_framework_nfw_core_application::features::cli::exit_codes::ExitCodes; +use n_framework_nfw_core_application::features::template_management::commands::gen_endpoint::gen_endpoint_command::{GenEndpointCommand, HttpMethod}; +use n_framework_nfw_core_application::features::template_management::commands::gen_endpoint::gen_endpoint_command_handler::GenEndpointCommandHandler; +pub use n_framework_nfw_core_application::features::template_management::models::errors::add_artifact_error::AddArtifactError; +use n_framework_nfw_core_application::features::workspace_management::services::abstractions::working_directory_provider::WorkingDirectoryProvider; +use n_framework_nfw_core_application::features::template_management::services::abstractions::template_root_resolver::TemplateRootResolver; +use n_framework_nfw_core_application::features::template_management::services::template_engine::TemplateEngine; +use n_framework_nfw_core_domain::features::template_management::template_config::{TemplateInput, TemplateInputType}; +use std::str::FromStr; + +#[derive(Debug, Clone)] +pub struct GenEndpointCliCommand { + handler: GenEndpointCommandHandler, + prompt: P, +} + +pub struct GenEndpointRequest<'a> { + pub operation_type: Option<&'a str>, + pub name: Option<&'a str>, + pub feature: Option<&'a str>, + pub params: Option<&'a str>, + pub param_json: Option<&'a str>, + pub no_input: bool, + pub is_interactive_terminal: bool, +} + +impl GenEndpointCliCommand +where + W: WorkingDirectoryProvider, + R: TemplateRootResolver, + E: TemplateEngine, + P: InteractivePrompt + Logger, +{ + pub fn new(handler: GenEndpointCommandHandler, prompt: P) -> Self { + Self { handler, prompt } + } + + pub fn execute(&self, request: GenEndpointRequest) -> Result<(), CliError> { + self.prompt + .intro("Generate Endpoint") + .map_err(|e| CliError::internal(e.to_string()))?; + + let workspace_context = self.handler.get_workspace_context()?; + let services = self.handler.extract_services(&workspace_context)?; + + if services.is_empty() { + return Err(AddArtifactError::WorkspaceError( + "No services found in workspace. Add a service first.".to_string(), + ) + .into()); + } + + let selected_service = + if (request.no_input || !request.is_interactive_terminal) && services.len() == 1 { + services.into_iter().next().ok_or_else(|| { + AddArtifactError::WorkspaceError( + "Expected at least one service, but found none.".to_string(), + ) + })? + } else { + let options: Vec = services + .iter() + .map(|s| SelectOption::new(s.name(), s.name())) + .collect(); + let selected = self + .prompt + .select("Select target service:", &options, Some(0)) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + + services + .into_iter() + .find(|s| s.name() == selected.value()) + .ok_or_else(|| { + AddArtifactError::WorkspaceError( + "No service found matching the selection".to_string(), + ) + })? + }; + + let context = self.handler.load_template_context( + "endpoint", + &selected_service, + &workspace_context, + )?; + + // Validate required modules immediately after service selection, before any + // further prompts. This gives the user an early, actionable error instead of + // a late failure after they have answered all the interactive questions. + self.handler + .validate_required_modules(&context) + .map_err(|e| CliError::silent( + n_framework_nfw_core_application::features::cli::exit_codes::ExitCodes::from_add_artifact_error(&e) as i32, + e.to_string(), + ))?; + + let existing_features = self + .handler + .list_features(&workspace_context, &selected_service)?; + let feature = self.resolve_feature(&request, existing_features)?; + let op_type_str = self.resolve_operation_type(&request)?; + let op_type = HttpMethod::from_str(&op_type_str) + .map_err(|e| AddArtifactError::InvalidParameter(e.to_string()))?; + let mediator_sources = context.config().mediator_sources().to_vec(); + let (name, attach_to_mediator) = self.resolve_name( + &request, + &workspace_context, + &selected_service, + feature.as_deref(), + &op_type_str, + &mediator_sources, + )?; + + let resolved_params = self.resolve_params(&request, context.config().inputs())?; + + let params_opt = if resolved_params.as_object().is_none_or(|o| o.is_empty()) { + None + } else { + Some(resolved_params) + }; + + let command = GenEndpointCommand::new( + name.to_string(), + feature, + op_type, + params_opt, + context, + attach_to_mediator, + )?; + + let spinner = self + .prompt + .spinner(&format!("Generating endpoint '{}'...", name)) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + + let res = self.handler.handle(command).map_err(|e| { + spinner.error(&format!("Failed to generate endpoint: {}", e)); + e + }); + + if let Err(e) = res { + return Err(CliError::silent( + ExitCodes::from_add_artifact_error(&e) as i32, + e.to_string(), + )); + } + + spinner.success(&format!("Endpoint '{}' generated successfully", name)); + + self.prompt + .outro(&format!("Successfully generated endpoint '{}'.", name)) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + + Ok(()) + } + + fn resolve_operation_type( + &self, + request: &GenEndpointRequest, + ) -> Result { + if let Some(op_type_str) = request.operation_type { + let normalized = match op_type_str.to_uppercase().as_str() { + "GET" => "Get", + "POST" => "Post", + "PUT" => "Put", + "DELETE" => "Delete", + _ => { + return Err(AddArtifactError::InvalidParameter(format!( + "Invalid operation type '{}'. Must be GET, POST, PUT, or DELETE.", + op_type_str + ))); + } + }; + return Ok(normalized.to_string()); + } + + if request.no_input || !request.is_interactive_terminal { + return Err(AddArtifactError::InvalidIdentifier( + "operation-type is required. Provide it as a positional argument (GET, POST, PUT, DELETE) or run interactively." + .to_string(), + )); + } + + let options = vec![ + SelectOption::new("GET", "Get"), + SelectOption::new("POST", "Post"), + SelectOption::new("PUT", "Put"), + SelectOption::new("DELETE", "Delete"), + ]; + + let selected = self + .prompt + .select("Select HTTP operation type:", &options, Some(0)) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + + Ok(selected.value().to_string()) + } + + /// Resolves the endpoint name interactively and returns `(name, attach_to_mediator)`. + /// + /// `attach_to_mediator` is `true` only when the user confirmed they want to attach to an + /// existing Command/Query. When `false` the application handler will skip the mediator + /// artifact existence check, allowing free-form endpoint names. + fn resolve_name( + &self, + request: &GenEndpointRequest, + workspace_context: &n_framework_nfw_core_application::features::template_management::services::artifact_generation_service::WorkspaceContext, + selected_service: &n_framework_nfw_core_application::features::template_management::services::artifact_generation_service::ServiceInfo, + feature: Option<&str>, + op_type: &str, + mediator_sources: &[String], + ) -> Result<(String, bool), AddArtifactError> { + if let Some(name) = request.name { + if name.is_empty() { + return Err(AddArtifactError::InvalidIdentifier( + "name cannot be empty".to_string(), + )); + } + + // Check if name corresponds to an existing command or query if mediator_sources are defined + let is_query = op_type.eq_ignore_ascii_case("Get"); + let mut attach = false; + + if !mediator_sources.is_empty() + && let Some(feature_name) = feature + { + let items = self + .handler + .get_mediator_items(workspace_context, selected_service, feature_name, is_query) + .unwrap_or_default(); + + attach = items.contains(&name.to_string()); + } + + return Ok((name.to_owned(), attach)); + } + + if request.no_input || !request.is_interactive_terminal { + return Err(AddArtifactError::InvalidIdentifier( + "name is required. Provide it as a positional argument or run interactively." + .to_string(), + )); + } + + let has_mediator = !mediator_sources.is_empty() + && self.handler.has_mediator_sources( + workspace_context, + selected_service, + mediator_sources, + ); + + if has_mediator { + let is_query = op_type.eq_ignore_ascii_case("GET"); + + let items = if let Some(feature_name) = feature { + self.handler + .get_mediator_items(workspace_context, selected_service, feature_name, is_query) + .unwrap_or_default() + } else { + Vec::new() + }; + + let attach = self + .prompt + .confirm( + &format!( + "Do you want to attach to an existing {}?", + if is_query { "Query" } else { "Command" } + ), + true, + ) + .unwrap_or(false); + + if attach { + let kind_label = if is_query { "query" } else { "command" }; + let kind_example = if is_query { "Get" } else { "Create" }; + + let mut options: Vec = + items.iter().map(|i| SelectOption::new(i, i)).collect(); + options.push(SelectOption::new( + format!("[Generate new {}]", kind_label), + "__create_new__", + )); + + let selected = self + .prompt + .select(&format!("Select {}:", kind_label), &options, Some(0)) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + + if selected.value() == "__create_new__" { + let new_name = self + .prompt + .text( + &format!( + "Enter new {} name (e.g. {}Product):", + kind_label, kind_example + ), + None, + ) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + + let template_context = self.handler.load_template_context( + kind_label, + selected_service, + workspace_context, + )?; + + let mut map = serde_json::Map::new(); + for input in template_context.config().inputs() { + let value = self.prompt_for_input(input)?; + map.insert(input.id().to_string(), value); + } + let params = if map.is_empty() { + None + } else { + Some(serde_json::Value::Object(map)) + }; + + self.handler.generate_mediator_item( + &new_name, + feature, + ¶ms, + &template_context, + )?; + + let _ = self.prompt.log_success(&format!( + "Generated Mediator {} '{}'.", + if is_query { "Query" } else { "Command" }, + new_name + )); + + // The artifact was just created — attach_to_mediator=true so the handler + // validates it exists (which it now does). + return Ok((new_name, true)); + } else { + // User selected an existing artifact from the list — attach. + return Ok((selected.value().to_string(), true)); + } + } + // User said No → free-form name, skip mediator check. + } + + let name = self + .prompt + .text( + "Enter command or query name to map (e.g. GetProduct):", + None, + ) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + Ok((name, false)) + } + + fn resolve_feature( + &self, + request: &GenEndpointRequest, + existing_features: Vec, + ) -> Result, AddArtifactError> { + if let Some(feature) = request.feature { + if feature.is_empty() { + return Ok(None); + } + return Ok(Some(feature.to_owned())); + } + + if request.no_input || !request.is_interactive_terminal { + return Ok(None); + } + + let mut options: Vec = existing_features + .iter() + .map(|f| SelectOption::new(f, f)) + .collect(); + + const CREATE_NEW: &str = "__create_new__"; + options.push(SelectOption::new("[Create new feature]", CREATE_NEW)); + + let selected = self + .prompt + .select("Select feature:", &options, Some(0)) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + + if selected.value() == CREATE_NEW { + let feature = self + .prompt + .text("Enter feature name (e.g. Catalog):", None) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + + if feature.trim().is_empty() { + Ok(None) + } else { + Ok(Some(feature.trim().to_string())) + } + } else { + Ok(Some(selected.value().to_string())) + } + } + + fn resolve_params( + &self, + request: &GenEndpointRequest, + inputs: &[TemplateInput], + ) -> Result { + let mut map = serde_json::Map::new(); + + if let Some(params_str) = request.params { + for (key, value) in self.parse_param_pairs(params_str)? { + map.insert(key, serde_json::Value::String(value)); + } + } + + if let Some(json_str) = request.param_json { + let json_val: serde_json::Value = serde_json::from_str(json_str).map_err(|e| { + AddArtifactError::InvalidParameter(format!("invalid JSON in --param-json: {e}")) + })?; + + if let serde_json::Value::Object(json_map) = json_val { + for (k, v) in json_map { + if map.contains_key(&k) { + return Err(AddArtifactError::InvalidParameter(format!( + "parameter conflict: '{}' is defined in both --param and --param-json. Use one or the other for each key.", + k + ))); + } + map.insert(k, v); + } + } else { + return Err(AddArtifactError::InvalidParameter( + "--param-json must be a JSON object (e.g., --param-json '{\"key\": \"value\"}')" + .to_string(), + )); + } + } + + if !request.no_input && request.is_interactive_terminal { + for input in inputs { + let id = input.id(); + if map.contains_key(id) { + continue; + } + let value = self.prompt_for_input(input)?; + map.insert(id.to_string(), value); + } + } + + for input in inputs { + let id = input.id(); + if !map.contains_key(id) { + return Err(AddArtifactError::InvalidParameter(format!( + "required parameter '{}' was not provided", + id + ))); + } + } + + Ok(serde_json::Value::Object(map)) + } + + pub fn prompt_for_input( + &self, + input: &TemplateInput, + ) -> Result { + let mut prompt_message = input.prompt().to_string(); + if let Some(description) = input.description() { + prompt_message = format!("{}\n {}", prompt_message, description); + } + + match input.input_type() { + TemplateInputType::Text => self + .prompt + .text(&prompt_message, None) + .map(serde_json::Value::String) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string())), + TemplateInputType::Password => self + .prompt + .password(&prompt_message) + .map(serde_json::Value::String) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string())), + TemplateInputType::Confirm => { + let default_bool = input.default().and_then(|v| v.as_bool()).unwrap_or(false); + let result = self + .prompt + .confirm(&prompt_message, default_bool) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + Ok(serde_json::Value::Bool(result)) + } + TemplateInputType::Select => { + let options = input.options().ok_or_else(|| { + AddArtifactError::InvalidParameter(format!( + "select input '{}' has no options defined", + input.id() + )) + })?; + let select_options: Vec = + options.iter().map(|s| SelectOption::new(s, s)).collect(); + let default_idx = input + .default() + .and_then(|v| v.as_str()) + .and_then(|d| options.iter().position(|o| o == d)); + let selected = self + .prompt + .select(&prompt_message, &select_options, default_idx) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + Ok(serde_json::Value::String(selected.value().to_string())) + } + TemplateInputType::Multiselect => { + let options = input.options().ok_or_else(|| { + AddArtifactError::InvalidParameter(format!( + "multiselect input '{}' has no options defined", + input.id() + )) + })?; + let select_options: Vec = + options.iter().map(|s| SelectOption::new(s, s)).collect(); + + let defaults = input.default().and_then(|v| v.as_array()); + let default_indices: Vec = if let Some(arr) = defaults { + arr.iter() + .filter_map(|v| v.as_str()) + .filter_map(|d| options.iter().position(|o| o == d)) + .collect() + } else { + Vec::new() + }; + + let selected = self + .prompt + .multiselect(&prompt_message, &select_options, &default_indices) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + + let selected_values: Vec = selected + .iter() + .map(|s| serde_json::Value::String(s.value().to_string())) + .collect(); + Ok(serde_json::Value::Array(selected_values)) + } + TemplateInputType::Object => { + let mut obj_map = serde_json::Map::new(); + let props = input.properties().ok_or_else(|| { + AddArtifactError::InvalidParameter(format!( + "object input '{}' has no properties defined", + input.id() + )) + })?; + for prop in props { + let id = prop.id(); + let value = self.prompt_for_input(prop)?; + obj_map.insert(id.to_string(), value); + } + Ok(serde_json::Value::Object(obj_map)) + } + TemplateInputType::List => { + let mut list = Vec::new(); + if let Some(item_schema) = input.items() { + loop { + let add_more = self + .prompt + .confirm(&format!("Add an item to {}?", input.prompt()), false) + .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; + if !add_more { + break; + } + let item_value = self.prompt_for_input(item_schema)?; + list.push(item_value); + } + } + Ok(serde_json::Value::Array(list)) + } + } + } + + fn parse_param_pairs(&self, input: &str) -> Result, AddArtifactError> { + let mut pairs = Vec::new(); + let mut current_key = String::new(); + let mut current_value = String::new(); + let mut in_quotes = false; + let mut in_key = true; + + for c in input.chars() { + match c { + '=' if in_key && !in_quotes => { + in_key = false; + } + ',' if !in_key && !in_quotes => { + pairs.push(( + current_key.trim().to_string(), + current_value.trim().to_string(), + )); + current_key.clear(); + current_value.clear(); + in_key = true; + } + '"' => in_quotes = !in_quotes, + _ => { + if in_key { + current_key.push(c); + } else { + current_value.push(c); + } + } + } + } + + if !current_key.trim().is_empty() { + pairs.push(( + current_key.trim().to_string(), + current_value.trim().to_string(), + )); + } + + Ok(pairs) + } +} + +impl GenEndpointCliCommand<(), (), (), n_framework_core_cli_cliclack::CliclackPromptService> { + pub fn handle( + command: &dyn n_framework_core_cli_abstractions::Command, + context: &CliServiceCollection, + ) -> Result<(), String> { + use std::io::{self, IsTerminal}; + let is_interactive_terminal = io::stdin().is_terminal() && io::stdout().is_terminal(); + + GenEndpointCliCommand::new( + context.gen_endpoint_command_handler.clone(), + n_framework_core_cli_cliclack::CliclackPromptService::new(), + ) + .execute(GenEndpointRequest { + operation_type: command.option("operation-type"), + name: command.option("name"), + feature: command.option("feature"), + params: command.option("param"), + param_json: command.option("param-json"), + no_input: command.option("no-input").is_some(), + is_interactive_terminal, + }) + .map_err(|error| error.to_string()) + } +} diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/endpoint/mod.rs b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/endpoint/mod.rs new file mode 100644 index 0000000..55255ab --- /dev/null +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/endpoint/mod.rs @@ -0,0 +1,4 @@ +pub mod handler; +pub mod registration; + +pub use handler::GenEndpointCliCommand; diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/endpoint/registration.rs b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/endpoint/registration.rs new file mode 100644 index 0000000..219fe96 --- /dev/null +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/endpoint/registration.rs @@ -0,0 +1,30 @@ +use n_framework_core_cli_abstractions::{CliCommandSpec, CliOptionSpec}; + +pub fn register() -> CliCommandSpec { + CliCommandSpec::new("endpoint") + .with_about("Generate a minimal API endpoint mapping") + .with_option( + CliOptionSpec::positional("operation-type", 1) + .with_help("HTTP Operation Type (GET, POST, PUT, DELETE)"), + ) + .with_option( + CliOptionSpec::positional("name", 2) + .with_help("Command or Query name to map (e.g. GetProduct)"), + ) + .with_option( + CliOptionSpec::new("feature", "feature").with_help("The target feature or module"), + ) + .with_option(CliOptionSpec::new("param", "param").with_help( + "Comma-separated parameters for the template (e.g. Key=Value,OtherKey=OtherValue)", + )) + .with_option( + CliOptionSpec::new("param-json", "param-json").with_help( + "JSON string of parameters for the template (e.g. '{\"Key\": \"Value\"}')", + ), + ) + .with_option( + CliOptionSpec::new("no-input", "no-input") + .with_help("Disable all interactive prompts") + .flag(), + ) +} diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/mod.rs b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/mod.rs index 40ec123..89f2932 100644 --- a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/mod.rs +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/mod.rs @@ -1,4 +1,5 @@ pub mod command; +pub mod endpoint; pub mod entity; pub mod query; pub mod registration; diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/query/handler.rs b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/query/handler.rs index 4de5e9d..9554898 100644 --- a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/query/handler.rs +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/query/handler.rs @@ -83,7 +83,7 @@ where let feature = self.resolve_feature(&request, existing_features)?; let name = self.resolve_name(&request)?; - let resolved_params = self.resolve_params(&request, context.config.inputs())?; + let resolved_params = self.resolve_params(&request, context.config().inputs())?; let params_opt = if resolved_params.as_object().is_none_or(|o| o.is_empty()) { None @@ -135,7 +135,7 @@ where } self.prompt - .text("Enter query name (e.g. GetProductByIdQuery):", None) + .text("Enter query name (e.g. GetProductById):", None) .map_err(|e| AddArtifactError::WorkspaceError(e.to_string())) } @@ -152,19 +152,6 @@ where return Ok(None); } - if existing_features.is_empty() { - let feature = self - .prompt - .text("Enter feature name (e.g. Catalog):", None) - .map_err(|e| AddArtifactError::WorkspaceError(e.to_string()))?; - - if feature.trim().is_empty() { - return Ok(None); - } else { - return Ok(Some(feature.trim().to_string())); - } - } - let mut options: Vec = existing_features .iter() .map(|f| SelectOption::new(f, f)) diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/registration.rs b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/registration.rs index ad96ad5..c252fba 100644 --- a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/registration.rs +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/commands/gen/registration.rs @@ -1,6 +1,7 @@ use n_framework_core_cli_abstractions::CliCommandSpec; use crate::commands::r#gen::command::registration::register as command_register; +use crate::commands::r#gen::endpoint::registration::register as endpoint_register; use crate::commands::r#gen::entity::registration::register as entity_register; use crate::commands::r#gen::query::registration::register as query_register; use crate::commands::r#gen::repository::registration::register as repository_register; @@ -10,6 +11,7 @@ pub fn register() -> CliCommandSpec { .with_about("Generate workspace artifacts from templates") .require_subcommand() .with_subcommand(command_register()) + .with_subcommand(endpoint_register()) .with_subcommand(entity_register()) .with_subcommand(query_register()) .with_subcommand(repository_register()) diff --git a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/runtime/nfw_cli_runtime.rs b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/runtime/nfw_cli_runtime.rs index 9c48d83..081f0c9 100644 --- a/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/runtime/nfw_cli_runtime.rs +++ b/src/n-framework-nfw/presentation/n-framework-nfw-cli/src/runtime/nfw_cli_runtime.rs @@ -10,6 +10,7 @@ use crate::commands::add::webapi::handler::AddWebApiCliCommand; use crate::commands::check::RunCheckCliCommand; use crate::commands::check::registration::register as check_registration; use crate::commands::r#gen::command::GenMediatorCommandCliCommand; +use crate::commands::r#gen::endpoint::handler::GenEndpointCliCommand; use crate::commands::r#gen::entity::GenEntityCliCommand; use crate::commands::r#gen::query::GenMediatorQueryCliCommand; use crate::commands::r#gen::registration::register as gen_registration; @@ -54,6 +55,7 @@ pub fn build_nfw_cli_runtime(services: CliServiceCollection) -> CliRuntime; use n_framework_nfw_core_application::features::template_management::commands::gen_repository::gen_repository_command_handler::GenRepositoryCommandHandler; +use n_framework_nfw_core_application::features::template_management::commands::gen_endpoint::gen_endpoint_command_handler::GenEndpointCommandHandler; pub type CliGenRepositoryCommandHandler = GenRepositoryCommandHandler< CliWorkingDirectoryProvider, FileSystemTemplateRootResolver, FileSystemTemplateEngine, >; +pub type CliGenEndpointCommandHandler = GenEndpointCommandHandler< + CliWorkingDirectoryProvider, + FileSystemTemplateRootResolver, + FileSystemTemplateEngine, +>; + use n_framework_nfw_core_application::features::check::commands::check::check_command_handler::CheckCommandHandler; pub type CliCheckCommandHandler = CheckCommandHandler; @@ -139,6 +148,7 @@ pub struct CliServiceCollection { pub gen_mediator_command_command_handler: CliGenMediatorCommandCommandHandler, pub gen_mediator_query_command_handler: CliGenMediatorQueryCommandHandler, pub gen_repository_command_handler: CliGenRepositoryCommandHandler, + pub gen_endpoint_command_handler: CliGenEndpointCommandHandler, pub gen_entity_command_handler: CliAddEntityCommandHandler, pub template_engine: FileSystemTemplateEngine, } diff --git a/tests/integration/n-framework-nfw/features/artifact/artifact_smoke_test.rs b/tests/integration/n-framework-nfw/features/artifact/artifact_smoke_test.rs index d0bfb02..e1f438f 100644 --- a/tests/integration/n-framework-nfw/features/artifact/artifact_smoke_test.rs +++ b/tests/integration/n-framework-nfw/features/artifact/artifact_smoke_test.rs @@ -53,7 +53,7 @@ fn setup_template(sandbox: &Path, template_yaml: &str, tera_source: &str) { fs::create_dir_all(&root_tpl_dir).expect("failed to create root template dir"); fs::write( root_tpl_dir.join("template.yaml"), - "id: mock-cmd-template\nname: Mock Cmd\nversion: 1.0.0\ngenerators:\n command: command\n", + "id: mock-cmd-template\nname: Mock Cmd\nversion: 1.0.0\ngenerators:\n command: command\nsteps:\n - action: run_command\n command: 'echo root'\n", ) .expect("failed to write root template.yaml"); diff --git a/tests/integration/n-framework-nfw/features/artifact/mediator_add_test.rs b/tests/integration/n-framework-nfw/features/artifact/mediator_add_test.rs index fddd6c3..8e025ef 100644 --- a/tests/integration/n-framework-nfw/features/artifact/mediator_add_test.rs +++ b/tests/integration/n-framework-nfw/features/artifact/mediator_add_test.rs @@ -52,10 +52,27 @@ template_sources: fs::create_dir_all(&root_tpl_dir).expect("failed to create root template dir"); fs::write( root_tpl_dir.join("template.yaml"), - "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n mediator: mediator\n", + "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n mediator: mediator\n webapi: webapi\n", ) .expect("failed to write root template.yaml"); + // Scaffold a minimal webapi sub-template so list_presentation_layers can derive the path. + let webapi_tpl_dir = root_tpl_dir.join("webapi"); + fs::create_dir_all(&webapi_tpl_dir).expect("failed to create webapi template dir"); + fs::write( + webapi_tpl_dir.join("template.yaml"), + r#" +id: dotnet-service/webapi +steps: + - action: render + source: "Startup.cs.tera" + destination: "src/presentation/{{ Service }}.Presentation.WebApi/Startup.cs" +"#, + ) + .expect("failed to write webapi template.yaml"); + fs::write(webapi_tpl_dir.join("Startup.cs.tera"), "// startup") + .expect("failed to write webapi tera"); + let tpl_dir = root_tpl_dir.join("mediator"); fs::create_dir_all(&tpl_dir).expect("failed to create sub-template dir"); fs::write( @@ -230,15 +247,24 @@ template_sources: fs::create_dir_all(&root_tpl_dir).unwrap(); fs::write( root_tpl_dir.join("template.yaml"), - "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n mediator: mediator\n", + "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n mediator: mediator\n webapi: webapi\n", + ) + .unwrap(); + + let webapi_tpl_dir = root_tpl_dir.join("webapi"); + fs::create_dir_all(&webapi_tpl_dir).unwrap(); + fs::write( + webapi_tpl_dir.join("template.yaml"), + "id: dotnet-service/webapi\nsteps:\n - action: render\n source: \"Startup.cs.tera\"\n destination: \"src/presentation/{{ Service }}.Presentation.WebApi/Startup.cs\"\n", ) .unwrap(); + fs::write(webapi_tpl_dir.join("Startup.cs.tera"), "// startup").unwrap(); let tpl_dir = root_tpl_dir.join("mediator"); fs::create_dir_all(&tpl_dir).unwrap(); fs::write( tpl_dir.join("template.yaml"), - "id: dotnet-service/mediator\nsteps: []", + "id: dotnet-service/mediator\nsteps:\n - action: run_command\n command: \"echo done\"\n", ) .unwrap(); fs::create_dir_all( diff --git a/tests/integration/n-framework-nfw/features/artifact/persistence_add_test.rs b/tests/integration/n-framework-nfw/features/artifact/persistence_add_test.rs index e148d81..f2f5261 100644 --- a/tests/integration/n-framework-nfw/features/artifact/persistence_add_test.rs +++ b/tests/integration/n-framework-nfw/features/artifact/persistence_add_test.rs @@ -50,10 +50,21 @@ template_sources: fs::create_dir_all(&root_tpl_dir).expect("failed to create root template dir"); fs::write( root_tpl_dir.join("template.yaml"), - "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n persistence: persistence\n", + "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n persistence: persistence\n webapi: webapi\n", ) .expect("failed to write root template.yaml"); + // Scaffold a minimal webapi sub-template so list_presentation_layers can derive the path. + let webapi_tpl_dir = root_tpl_dir.join("webapi"); + fs::create_dir_all(&webapi_tpl_dir).expect("failed to create webapi template dir"); + fs::write( + webapi_tpl_dir.join("template.yaml"), + "id: dotnet-service/webapi\nsteps:\n - action: render\n source: \"Startup.cs.tera\"\n destination: \"src/presentation/{{ Service }}.Presentation.WebApi/Startup.cs\"\n", + ) + .expect("failed to write webapi template.yaml"); + fs::write(webapi_tpl_dir.join("Startup.cs.tera"), "// startup") + .expect("failed to write webapi tera"); + let tpl_dir = root_tpl_dir.join("persistence"); fs::create_dir_all(&tpl_dir).expect("failed to create sub-template dir"); fs::write( @@ -227,15 +238,24 @@ template_sources: fs::create_dir_all(&root_tpl_dir).unwrap(); fs::write( root_tpl_dir.join("template.yaml"), - "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n persistence: persistence\n", + "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n persistence: persistence\n webapi: webapi\n", ) .unwrap(); + let webapi_tpl_dir = root_tpl_dir.join("webapi"); + fs::create_dir_all(&webapi_tpl_dir).unwrap(); + fs::write( + webapi_tpl_dir.join("template.yaml"), + "id: dotnet-service/webapi\nsteps:\n - action: render\n source: \"Startup.cs.tera\"\n destination: \"src/presentation/{{ Service }}.Presentation.WebApi/Startup.cs\"\n", + ) + .unwrap(); + fs::write(webapi_tpl_dir.join("Startup.cs.tera"), "// startup").unwrap(); + let tpl_dir = root_tpl_dir.join("persistence"); fs::create_dir_all(&tpl_dir).unwrap(); fs::write( tpl_dir.join("template.yaml"), - "id: dotnet-service/persistence\nsteps: []", + "id: dotnet-service/persistence\nsteps:\n - action: run_command\n command: \"echo done\"\n", ) .unwrap(); fs::create_dir_all( @@ -307,9 +327,18 @@ template_sources: fs::create_dir_all(&root_tpl_dir).unwrap(); fs::write( root_tpl_dir.join("template.yaml"), - "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n persistence: persistence\n", + "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n persistence: persistence\n webapi: webapi\n", + ) + .unwrap(); + + let webapi_tpl_dir = root_tpl_dir.join("webapi"); + fs::create_dir_all(&webapi_tpl_dir).unwrap(); + fs::write( + webapi_tpl_dir.join("template.yaml"), + "id: dotnet-service/webapi\nsteps:\n - action: render\n source: \"Startup.cs.tera\"\n destination: \"src/presentation/{{ Service }}.Presentation.WebApi/Startup.cs\"\n", ) .unwrap(); + fs::write(webapi_tpl_dir.join("Startup.cs.tera"), "// startup").unwrap(); let tpl_dir = root_tpl_dir.join("persistence"); fs::create_dir_all(&tpl_dir).unwrap(); diff --git a/tests/integration/n-framework-nfw/features/entity_generation/error_tests.rs b/tests/integration/n-framework-nfw/features/entity_generation/error_tests.rs index 42c3e25..78e2176 100644 --- a/tests/integration/n-framework-nfw/features/entity_generation/error_tests.rs +++ b/tests/integration/n-framework-nfw/features/entity_generation/error_tests.rs @@ -66,11 +66,13 @@ fn run(sandbox: &Path, opts: HashMap) -> Result<(), String> { fn fails_on_invalid_entity_name() { let sandbox = support::create_sandbox_directory("gen-entity-invalid-name"); + support::create_service_template(&sandbox, "basic-api", "service"); + fs::create_dir_all(sandbox.join("src/Application/Features/Catalog")).unwrap(); fs::write( - sandbox.join("nfw.yaml"), - "workspace:\n name: Test\n namespace: TestApp\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", - ).unwrap(); + sandbox.join("nfw.yaml"), + "workspace:\n name: Test\n namespace: TestApp\ntemplate_sources:\n local: .\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", + ).unwrap(); let opts = make_opts( "invalid_name", @@ -96,11 +98,13 @@ fn fails_on_invalid_entity_name() { fn fails_on_invalid_property_syntax() { let sandbox = support::create_sandbox_directory("gen-entity-invalid-prop"); + support::create_service_template(&sandbox, "basic-api", "service"); + fs::create_dir_all(sandbox.join("src/Application/Features/Catalog")).unwrap(); fs::write( - sandbox.join("nfw.yaml"), - "workspace:\n name: Test\n namespace: TestApp\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", - ).unwrap(); + sandbox.join("nfw.yaml"), + "workspace:\n name: Test\n namespace: TestApp\ntemplate_sources:\n local: .\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", + ).unwrap(); let opts = make_opts("Product", Some("Catalog"), Some("Name-string"), None, true); // Missing colon @@ -120,12 +124,14 @@ fn fails_on_invalid_property_syntax() { fn fails_when_no_persistence_module_found() { let sandbox = support::create_sandbox_directory("gen-entity-no-persist"); + support::create_service_template(&sandbox, "basic-api", "service"); + fs::create_dir_all(sandbox.join("src/Application/Features/Catalog")).unwrap(); // Missing persistence module fs::write( - sandbox.join("nfw.yaml"), - "workspace:\n name: Test\n namespace: TestApp\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n", - ).unwrap(); + sandbox.join("nfw.yaml"), + "workspace:\n name: Test\n namespace: TestApp\ntemplate_sources:\n local: .\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n", + ).unwrap(); let opts = make_opts("Product", Some("Catalog"), Some("Name:string"), None, true); @@ -145,11 +151,13 @@ fn fails_when_no_persistence_module_found() { fn fails_on_missing_schema_file() { let sandbox = support::create_sandbox_directory("gen-entity-missing-schema"); + support::create_service_template(&sandbox, "basic-api", "service"); + fs::create_dir_all(sandbox.join("src/Application/Features/Catalog")).unwrap(); fs::write( - sandbox.join("nfw.yaml"), - "workspace:\n name: Test\n namespace: TestApp\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", - ).unwrap(); + sandbox.join("nfw.yaml"), + "workspace:\n name: Test\n namespace: TestApp\ntemplate_sources:\n local: .\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", + ).unwrap(); let opts = make_opts( "Product", @@ -175,11 +183,13 @@ fn fails_on_missing_schema_file() { fn fails_on_invalid_schema_yaml() { let sandbox = support::create_sandbox_directory("gen-entity-invalid-schema-yaml"); + support::create_service_template(&sandbox, "basic-api", "service"); + fs::create_dir_all(sandbox.join("src/Application/Features/Catalog")).unwrap(); fs::write( - sandbox.join("nfw.yaml"), - "workspace:\n name: Test\n namespace: TestApp\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", - ).unwrap(); + sandbox.join("nfw.yaml"), + "workspace:\n name: Test\n namespace: TestApp\ntemplate_sources:\n local: .\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", + ).unwrap(); let schema_path = sandbox.join("invalid.yaml"); fs::write(&schema_path, "invalid: yaml: [ : }").unwrap(); diff --git a/tests/integration/n-framework-nfw/features/entity_generation/schema_tests.rs b/tests/integration/n-framework-nfw/features/entity_generation/schema_tests.rs index 6657aa8..06227cb 100644 --- a/tests/integration/n-framework-nfw/features/entity_generation/schema_tests.rs +++ b/tests/integration/n-framework-nfw/features/entity_generation/schema_tests.rs @@ -70,11 +70,13 @@ fn run(sandbox: &Path, opts: HashMap) -> Result<(), String> { fn schema_conflict_detection_test() { let sandbox = support::create_sandbox_directory("gen-entity-schema-conflict"); + support::create_service_template(&sandbox, "basic-api", "service"); + fs::create_dir_all(sandbox.join("src/Application/Features/Catalog")).unwrap(); fs::write( - sandbox.join("nfw.yaml"), - "workspace:\n name: Test\n namespace: TestApp\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", - ).unwrap(); + sandbox.join("nfw.yaml"), + "workspace:\n name: Test\n namespace: TestApp\ntemplate_sources:\n local: .\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", + ).unwrap(); let specs_dir = sandbox.join("src/Application/specs/features/Catalog/entities"); fs::create_dir_all(&specs_dir).unwrap(); @@ -105,11 +107,13 @@ fn schema_conflict_detection_test() { fn schema_only_generation_test() { let sandbox = support::create_sandbox_directory("gen-entity-schema-only"); + support::create_service_template(&sandbox, "basic-api", "service"); + fs::create_dir_all(sandbox.join("src/Application/Features/Catalog")).unwrap(); fs::write( - sandbox.join("nfw.yaml"), - "workspace:\n name: Test\n namespace: TestApp\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", - ).unwrap(); + sandbox.join("nfw.yaml"), + "workspace:\n name: Test\n namespace: TestApp\ntemplate_sources:\n local: .\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", + ).unwrap(); let opts = make_opts( "Product", @@ -136,11 +140,13 @@ fn schema_only_generation_test() { fn from_schema_generation_test() { let sandbox = support::create_sandbox_directory("gen-entity-from-schema"); + support::create_service_template(&sandbox, "basic-api", "service"); + fs::create_dir_all(sandbox.join("src/Application/Features/Catalog")).unwrap(); fs::write( - sandbox.join("nfw.yaml"), - "workspace:\n name: Test\n namespace: TestApp\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", - ).unwrap(); + sandbox.join("nfw.yaml"), + "workspace:\n name: Test\n namespace: TestApp\ntemplate_sources:\n local: .\nservices:\n Application:\n path: src/Application\n template:\n id: basic-api\n modules:\n - persistence\n", + ).unwrap(); let specs_dir = sandbox.join("src/Application/specs/features/Catalog/entities"); fs::create_dir_all(&specs_dir).unwrap(); diff --git a/tests/integration/n-framework-nfw/features/entity_generation/success_tests.rs b/tests/integration/n-framework-nfw/features/entity_generation/success_tests.rs index 9499374..0b648ec 100644 --- a/tests/integration/n-framework-nfw/features/entity_generation/success_tests.rs +++ b/tests/integration/n-framework-nfw/features/entity_generation/success_tests.rs @@ -77,7 +77,7 @@ fn generates_entity_successfully() { fs::create_dir_all(&root_tpl_dir).expect("failed to create root template dir"); fs::write( root_tpl_dir.join("template.yaml"), - "id: mock-entity-template\nname: Mock Entity\nversion: 1.0.0\ngenerators:\n entity: entity\n", + "id: mock-entity-template\nname: Mock Entity\nversion: 1.0.0\ngenerators:\n entity: entity\nsteps:\n - action: run_command\n command: 'echo root'\n", ) .expect("failed to write root template.yaml"); diff --git a/tests/integration/n-framework-nfw/features/service_add/service_generation_path_safety_test.rs b/tests/integration/n-framework-nfw/features/service_add/service_generation_path_safety_test.rs index 0349315..de8db65 100644 --- a/tests/integration/n-framework-nfw/features/service_add/service_generation_path_safety_test.rs +++ b/tests/integration/n-framework-nfw/features/service_add/service_generation_path_safety_test.rs @@ -40,7 +40,7 @@ fn rejects_rendered_paths_that_escape_service_output_root() { match error { AddServiceError::RenderFailed(message) => { assert!( - message.contains("unsafe rendered path"), + message.to_lowercase().contains("unsafe rendered path"), "expected unsafe path render error, got: {message}" ); } diff --git a/tests/integration/n-framework-nfw/features/service_add/support.rs b/tests/integration/n-framework-nfw/features/service_add/support.rs index f88370a..d96d130 100644 --- a/tests/integration/n-framework-nfw/features/service_add/support.rs +++ b/tests/integration/n-framework-nfw/features/service_add/support.rs @@ -255,11 +255,11 @@ pub fn create_service_template(root: &Path, template_name: &str, _template_type: .expect("infrastructure template directory should be created"); fs::create_dir_all(content_root.join("Api")).expect("api template directory should be created"); - // Root template.yaml — identity metadata only, no steps + // Root template.yaml — identity metadata with generators pointing to service sub-template fs::write( - template_root.join("template.yaml"), - format!("id: {template_name}\nname: {template_name}\ndescription: test\nversion: 1.0.0\n"), - ) + template_root.join("template.yaml"), + format!("id: {template_name}\nname: {template_name}\ndescription: test\nversion: 1.0.0\ngenerators:\n entity: service\n"), + ) .expect("root template metadata should be written"); // Service sub-template — owns the rendering steps diff --git a/tests/integration/n-framework-nfw/features/template_management/commands/gen_endpoint/gen_endpoint_test.rs b/tests/integration/n-framework-nfw/features/template_management/commands/gen_endpoint/gen_endpoint_test.rs new file mode 100644 index 0000000..2964c63 --- /dev/null +++ b/tests/integration/n-framework-nfw/features/template_management/commands/gen_endpoint/gen_endpoint_test.rs @@ -0,0 +1,449 @@ +#[path = "../../../service_add/support.rs"] +mod support; + +#[path = "support.rs"] +mod gen_support; + +use std::fs; +use std::path::Path; +use std::sync::Mutex; +use std::time::Instant; + +static DIR_LOCK: Mutex<()> = Mutex::new(()); + +fn setup_endpoint_workspace( + sandbox: &Path, + with_webapi: bool, + with_feature: bool, + with_command: bool, + with_existing_endpoint: bool, + feature: &str, +) { + let modules_str = if with_webapi { + r#" + modules: + - webapi"# + } else { + "" + }; + + fs::write( + sandbox.join("nfw.yaml"), + format!( + r#" +workspace: + name: Test + namespace: TestApp +services: + TestService: + path: src/TestService + template: + id: dotnet-service{} +template_sources: + local: "templates" +"#, + modules_str + ), + ) + .expect("failed to write nfw.yaml"); + + // Scaffold the template configuration + let root_tpl_dir = sandbox.join("templates").join("dotnet-service"); + fs::create_dir_all(&root_tpl_dir).expect("failed to create root template dir"); + fs::write( + root_tpl_dir.join("template.yaml"), + "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n endpoint: ./endpoint/\n command: ./command/\n query: ./query/\nsteps:\n - action: run_command\n command: 'echo root'\n", + ) + .expect("failed to write root template.yaml"); + + let tpl_dir = root_tpl_dir.join("endpoint"); + fs::create_dir_all(&tpl_dir).expect("failed to create sub-template dir"); + + fs::write( + tpl_dir.join("template.yaml"), + r#" +id: dotnet-service/endpoint +required_modules: ["webapi"] +mediator_sources: ["command", "query"] +steps: + - action: render + source: "Endpoint.cs.tera" + destination: "src/presentation/{{ Service }}.Presentation.WebApi/Endpoints/{{ Feature }}/{{ Name }}Endpoint.cs" +"#, + ).expect("failed to write sub-template template.yaml"); + + fs::write( + tpl_dir.join("Endpoint.cs.tera"), + "public class {{ Name }}Endpoint", + ) + .unwrap(); + + // Add command and query templates for mediator validation + let cmd_tpl_dir = root_tpl_dir.join("command"); + fs::create_dir_all(&cmd_tpl_dir).expect("failed to create command template dir"); + fs::write( + cmd_tpl_dir.join("template.yaml"), + r#" +id: dotnet-service/command +required_modules: ["mediator"] +steps: + - action: render + source: "Command.cs.tera" + destination: 'src/core/{{ Namespace }}.Core.Application/Features/{{ Feature }}/Commands/{{ Name }}/{{ Name }}Command.cs' +"#, + ).expect("failed to write command template.yaml"); + + let query_tpl_dir = root_tpl_dir.join("query"); + fs::create_dir_all(&query_tpl_dir).expect("failed to create query template dir"); + fs::write( + query_tpl_dir.join("template.yaml"), + r#" +id: dotnet-service/query +required_modules: ["mediator"] +steps: + - action: render + source: "Query.cs.tera" + destination: 'src/core/{{ Namespace }}.Core.Application/Features/{{ Feature }}/Queries/{{ Name }}/{{ Name }}Query.cs' +"#, + ).expect("failed to write query template.yaml"); + + let service_dir = sandbox.join("src/TestService"); + + if with_feature { + let feature_dir = service_dir + .join("src/core/TestApp.Core.Application/Features") + .join(feature); + fs::create_dir_all(&feature_dir).expect("failed to create feature dir"); + + if with_command { + let command_dir = feature_dir.join("Commands").join("Test"); + fs::create_dir_all(&command_dir).expect("failed to create command dir"); + fs::write( + command_dir.join("TestCommand.cs"), + "public class TestCommand {}", + ) + .expect("failed to write command file"); + } + } + + if with_existing_endpoint { + let endpoint_dir = service_dir + .join("src/presentation/TestService.Presentation.WebApi/Endpoints") + .join(feature); + fs::create_dir_all(&endpoint_dir).expect("failed to create endpoint dir"); + fs::write( + endpoint_dir.join("TestEndpoint.cs"), + "public class TestEndpoint {}", + ) + .expect("failed to write existing endpoint"); + } +} + +fn run_test_in_sandbox(test_name: &str, params: (bool, bool, bool, bool, &str), action: F) +where + F: FnOnce(&Path, Instant) -> Result<(), String> + std::panic::UnwindSafe, +{ + let _lock = DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let sandbox_name = format!("gen-endpoint-{}", test_name); + let sandbox = support::create_sandbox_directory(&sandbox_name); + + if sandbox.exists() { + fs::remove_dir_all(&sandbox).unwrap(); + } + fs::create_dir_all(&sandbox).unwrap(); + + setup_endpoint_workspace(&sandbox, params.0, params.1, params.2, params.3, params.4); + + let start = Instant::now(); + let current_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(&sandbox).unwrap(); + + let result = std::panic::catch_unwind(|| action(&sandbox, start)); + + std::env::set_current_dir(current_dir).unwrap(); + + if let Err(e) = result { + std::panic::resume_unwind(e); + } else if let Ok(Err(e)) = result { + panic!("{}", e); + } +} + +#[test] +fn gen_endpoint_success() { + run_test_in_sandbox( + "success", + (true, true, true, false, "Catalog"), + |sandbox, _| { + let result = gen_support::execute_non_interactive_gen_endpoint( + &sandbox, "Test", "Catalog", "POST", + ); + + if let Err(e) = result { + return Err(format!("Expected success, but got error: {:?}", e)); + } + + let endpoint_file = sandbox.join("src/TestService/src/presentation/TestService.Presentation.WebApi/Endpoints/Catalog/TestEndpoint.cs"); + if !endpoint_file.exists() { + return Err("Endpoint file was not created".to_string()); + } + + let content = fs::read_to_string(endpoint_file).unwrap(); + if !content.contains("public class TestEndpoint") { + return Err("Endpoint file content is incorrect".to_string()); + } + + Ok(()) + }, + ); +} + +#[test] +fn gen_endpoint_fails_if_feature_not_found() { + run_test_in_sandbox( + "no-feature", + (true, false, false, false, "Catalog"), + |sandbox, _| { + let result = gen_support::execute_non_interactive_gen_endpoint( + &sandbox, "Test", "Catalog", "POST", + ); + + if result.is_ok() { + return Err("Expected error, but got success".to_string()); + } + + let err_str = format!("{:?}", result.err().unwrap()); + if !err_str.contains("No Command or Query artifact found") { + return Err(format!("Unexpected error message: {}", err_str)); + } + + Ok(()) + }, + ); +} + +#[test] +fn gen_endpoint_fails_if_command_not_found() { + run_test_in_sandbox( + "no-command", + (true, true, false, false, "Catalog"), + |sandbox, _| { + let result = gen_support::execute_non_interactive_gen_endpoint( + &sandbox, "Test", "Catalog", "POST", + ); + + if result.is_ok() { + return Err("Expected error, but got success".to_string()); + } + + let err_str = format!("{:?}", result.err().unwrap()); + if !err_str.contains("No Command or Query artifact found") { + return Err(format!("Unexpected error message: {}", err_str)); + } + + Ok(()) + }, + ); +} + +#[test] +fn gen_endpoint_fails_if_endpoint_already_exists() { + run_test_in_sandbox( + "existing-endpoint", + (true, true, true, true, "Catalog"), + |sandbox, _| { + let result = gen_support::execute_non_interactive_gen_endpoint( + &sandbox, "Test", "Catalog", "POST", + ); + + if result.is_ok() { + return Err("Expected error, but got success".to_string()); + } + + let err_str = format!("{:?}", result.err().unwrap()); + if !err_str.contains("Target endpoint file already exists") { + return Err(format!("Unexpected error message: {}", err_str)); + } + + Ok(()) + }, + ); +} + +#[test] +fn gen_endpoint_fails_if_webapi_missing() { + run_test_in_sandbox( + "no-webapi", + (false, true, true, false, "Catalog"), + |sandbox, _| { + let result = gen_support::execute_non_interactive_gen_endpoint( + &sandbox, "Test", "Catalog", "POST", + ); + + if result.is_ok() { + return Err("Expected error, but got success".to_string()); + } + + let err_str = format!("{:?}", result.err().unwrap()); + if !err_str.contains("Required module 'webapi' is missing") + && !err_str.contains("module 'webapi' is required but not installed") + { + return Err(format!("Unexpected error message: {}", err_str)); + } + + Ok(()) + }, + ); +} + +#[test] +fn gen_endpoint_fails_if_template_resolution_fails() { + run_test_in_sandbox( + "invalid-template", + (true, true, false, false, "Catalog"), + |sandbox, _| { + // Corrupt the template by removing the steps + let tpl_dir = sandbox.join("templates/dotnet-service/endpoint"); + fs::write( + tpl_dir.join("template.yaml"), + "id: dotnet-service/endpoint\nrequired_modules: [\"webapi\"]\nmediator_sources: [\"command\", \"query\"]\nsteps: [{ action: render, source: 't', destination: '{{ Feature }}/d' }]", + ) + .unwrap(); + + let result = gen_support::execute_non_interactive_gen_endpoint( + &sandbox, "Test", "Catalog", "POST", + ); + + if result.is_ok() { + return Err("Expected error for empty template steps, but got success".to_string()); + } + + let err_str = format!("{:?}", result.err().unwrap()); + // Since there are no steps, it won't find the features root. + if !err_str.contains("No Command or Query artifact found") { + return Err(format!("Unexpected error message: {}", err_str)); + } + + Ok(()) + }, + ); +} + +#[test] +fn test_get_mediator_items() { + let _lock = DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let sandbox = support::create_sandbox_directory("get-mediator-items"); + if sandbox.exists() { + fs::remove_dir_all(&sandbox).unwrap(); + } + fs::create_dir_all(&sandbox).unwrap(); + + setup_endpoint_workspace(&sandbox, true, true, true, false, "Catalog"); + + let current_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(&sandbox).unwrap(); + + use n_framework_nfw_infrastructure_filesystem::features::workspace_management::services::standard_working_directory_provider::StandardWorkingDirectoryProvider; + use n_framework_nfw_infrastructure_filesystem::features::template_management::services::file_system_template_root_resolver::FileSystemTemplateRootResolver; + use n_framework_nfw_infrastructure_filesystem::features::template_management::template_engine::FileSystemTemplateEngine; + use n_framework_nfw_core_application::features::template_management::commands::gen_endpoint::gen_endpoint_command_handler::GenEndpointCommandHandler; + + let handler = GenEndpointCommandHandler::new( + StandardWorkingDirectoryProvider::new(), + FileSystemTemplateRootResolver::new(), + FileSystemTemplateEngine::new(), + ); + + let workspace_context = handler.get_workspace_context().unwrap(); + let services = handler.extract_services(&workspace_context).unwrap(); + let service = services[0].clone(); + + // Test get_mediator_items for commands + let commands = handler + .get_mediator_items(&workspace_context, &service, "Catalog", false) + .unwrap(); + assert!(commands.contains(&"Test".to_string())); + + // Test get_mediator_items for queries (should be empty) + let queries = handler + .get_mediator_items(&workspace_context, &service, "Catalog", true) + .unwrap(); + assert!(queries.is_empty()); + + std::env::set_current_dir(current_dir).unwrap(); +} + +#[test] +fn test_has_mediator_sources() { + let _lock = DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let sandbox = support::create_sandbox_directory("has-mediator-sources"); + if sandbox.exists() { + fs::remove_dir_all(&sandbox).unwrap(); + } + fs::create_dir_all(&sandbox).unwrap(); + + // Case 1: Service has webapi but no mediator module - has_mediator_sources should be false + // because command/query templates require 'mediator' module. + setup_endpoint_workspace(&sandbox, true, false, false, false, "Catalog"); + + let current_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(&sandbox).unwrap(); + + use n_framework_nfw_infrastructure_filesystem::features::workspace_management::services::standard_working_directory_provider::StandardWorkingDirectoryProvider; + use n_framework_nfw_infrastructure_filesystem::features::template_management::services::file_system_template_root_resolver::FileSystemTemplateRootResolver; + use n_framework_nfw_infrastructure_filesystem::features::template_management::template_engine::FileSystemTemplateEngine; + use n_framework_nfw_core_application::features::template_management::commands::gen_endpoint::gen_endpoint_command_handler::GenEndpointCommandHandler; + + let handler = GenEndpointCommandHandler::new( + StandardWorkingDirectoryProvider::new(), + FileSystemTemplateRootResolver::new(), + FileSystemTemplateEngine::new(), + ); + + let workspace_context = handler.get_workspace_context().unwrap(); + let services = handler.extract_services(&workspace_context).unwrap(); + let service = services[0].clone(); + + let mediator_sources = vec!["command".to_string(), "query".to_string()]; + + let has_sources = handler.has_mediator_sources(&workspace_context, &service, &mediator_sources); + assert!( + !has_sources, + "Should NOT have mediator sources when mediator module is missing" + ); + + // Case 2: Add mediator module to nfw.yaml + fs::write( + sandbox.join("nfw.yaml"), + r#" +workspace: + name: Test + namespace: TestApp +services: + TestService: + path: src/TestService + template: + id: dotnet-service + modules: + - webapi + - mediator +template_sources: + local: "templates" +"#, + ) + .unwrap(); + + // Reload context + let workspace_context = handler.get_workspace_context().unwrap(); + let services = handler.extract_services(&workspace_context).unwrap(); + let service = services[0].clone(); + + let has_sources = handler.has_mediator_sources(&workspace_context, &service, &mediator_sources); + assert!( + has_sources, + "Should HAVE mediator sources when mediator module is present" + ); + + std::env::set_current_dir(current_dir).unwrap(); +} diff --git a/tests/integration/n-framework-nfw/features/template_management/commands/gen_endpoint/support.rs b/tests/integration/n-framework-nfw/features/template_management/commands/gen_endpoint/support.rs new file mode 100644 index 0000000..6afcbea --- /dev/null +++ b/tests/integration/n-framework-nfw/features/template_management/commands/gen_endpoint/support.rs @@ -0,0 +1,52 @@ +use std::path::Path; +use std::str::FromStr; + +use n_framework_nfw_infrastructure_filesystem::features::workspace_management::services::standard_working_directory_provider::StandardWorkingDirectoryProvider; +use n_framework_nfw_infrastructure_filesystem::features::template_management::services::file_system_template_root_resolver::FileSystemTemplateRootResolver; +use n_framework_nfw_infrastructure_filesystem::features::template_management::template_engine::FileSystemTemplateEngine; + +use n_framework_nfw_core_application::features::template_management::commands::gen_endpoint::gen_endpoint_command::{GenEndpointCommand, HttpMethod}; +use n_framework_nfw_core_application::features::template_management::commands::gen_endpoint::gen_endpoint_command_handler::GenEndpointCommandHandler; + +pub fn execute_non_interactive_gen_endpoint( + _workspace_root: &Path, + name: &str, + feature: &str, + operation_type: &str, +) -> Result<(), n_framework_nfw_core_application::features::template_management::models::errors::add_artifact_error::AddArtifactError>{ + let working_dir_provider = StandardWorkingDirectoryProvider::new(); + let template_root_resolver = FileSystemTemplateRootResolver::new(); + let engine = FileSystemTemplateEngine::new(); + + let handler = + GenEndpointCommandHandler::new(working_dir_provider, template_root_resolver, engine); + + let workspace_context = handler.get_workspace_context()?; + let services = handler.extract_services(&workspace_context)?; + + if services.is_empty() { + return Err(n_framework_nfw_core_application::features::template_management::models::errors::add_artifact_error::AddArtifactError::ConfigError("No services found".to_string())); + } + + let service = services[0].clone(); + + let template_context = + handler.load_template_context("endpoint", &service, &workspace_context)?; + + let feature_opt = if feature.is_empty() { + None + } else { + Some(feature.to_string()) + }; + + let command = GenEndpointCommand::new( + name.to_string(), + feature_opt, + HttpMethod::from_str(operation_type).map_err(|e| n_framework_nfw_core_application::features::template_management::models::errors::add_artifact_error::AddArtifactError::InvalidParameter(e.to_string()))?, + None, + template_context, + true, + ).map_err(|e| n_framework_nfw_core_application::features::template_management::models::errors::add_artifact_error::AddArtifactError::InvalidParameter(e.to_string()))?; + + handler.handle(command).map(|_| ()) +} diff --git a/tests/integration/n-framework-nfw/features/template_management/commands/gen_repository/gen_repository_test.rs b/tests/integration/n-framework-nfw/features/template_management/commands/gen_repository/gen_repository_test.rs index 3a03dd5..fb82c3f 100644 --- a/tests/integration/n-framework-nfw/features/template_management/commands/gen_repository/gen_repository_test.rs +++ b/tests/integration/n-framework-nfw/features/template_management/commands/gen_repository/gen_repository_test.rs @@ -50,10 +50,31 @@ template_sources: fs::create_dir_all(&root_tpl_dir).expect("failed to create root template dir"); fs::write( root_tpl_dir.join("template.yaml"), - "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n repository: ./repository/\n", + "id: dotnet-service\nname: Dotnet Service\nversion: 1.0.0\ngenerators:\n repository: ./repository/\n entity: ./entity/\n", ) .expect("failed to write root template.yaml"); + // Scaffold the entity sub-template so find_artifact_in_features can derive the path. + let entity_tpl_dir = root_tpl_dir.join("entity"); + fs::create_dir_all(entity_tpl_dir.join("content")) + .expect("failed to create entity template dir"); + fs::write( + entity_tpl_dir.join("template.yaml"), + r#" +id: dotnet-service/entity +steps: + - action: render + source: "content/Entity.cs.tera" + destination: "src/core/{{ Namespace }}.Core.Domain/Features/{{ Feature }}/Entities/{{ Name }}.cs" +"#, + ) + .expect("failed to write entity template.yaml"); + fs::write( + entity_tpl_dir.join("content/Entity.cs.tera"), + "public class {{ Name }} {}", + ) + .expect("failed to write entity template"); + let tpl_dir = root_tpl_dir.join("repository"); fs::create_dir_all(tpl_dir.join("content/interface")) .expect("failed to create sub-template dir"); @@ -285,7 +306,8 @@ fn gen_repository_fails_if_entity_not_found() { let err_str = format!("{:?}", result.err().unwrap()); if !err_str.contains("not found in feature") - && !err_str.contains("does not contain an Entities folder") + && !err_str.contains("does not contain") + && !err_str.contains("not found in any feature") { return Err(format!("Unexpected error message: {}", err_str)); } @@ -321,7 +343,7 @@ fn gen_repository_fails_if_invalid_feature_folder() { } let err_str = format!("{:?}", result.err().unwrap()); - if !err_str.contains("does not contain an Entities folder") { + if !err_str.contains("does not contain") && !err_str.contains("not found in feature") { return Err(format!("Unexpected error message: {}", err_str)); }