diff --git a/docs/issues/349-add-json-output-to-create-command.md b/docs/issues/349-add-json-output-to-create-command.md index 66766a99e..9ae722a3a 100644 --- a/docs/issues/349-add-json-output-to-create-command.md +++ b/docs/issues/349-add-json-output-to-create-command.md @@ -6,15 +6,15 @@ ## Overview -Add machine-readable JSON output format (`--json` flag) to the `create` command. This enables automation workflows to programmatically extract environment creation details like paths, configuration references, and initial state. +Add machine-readable JSON output format (`--output-format json`) to the `create` command. This enables automation workflows to programmatically extract environment creation details like paths, configuration references, and initial state. ## Goals -- [ ] Add `--json` flag to `create` command CLI interface -- [ ] Implement JSON output format containing environment metadata -- [ ] Preserve existing human-readable output as default +- [x] Add `--output-format` global CLI argument +- [x] Implement JSON output format containing environment metadata +- [x] Preserve existing human-readable output as default - [ ] Document JSON schema and usage examples -- [ ] Enable automation to track artifact locations +- [x] Enable automation to track artifact locations ## Rationale @@ -83,23 +83,48 @@ The application currently has these output-related global arguments in [`src/pre **Implementation Decision:** -For this task, we have two architectural choices: +We will implement a **global `--output-format` argument** (similar to `--log-file-format` and `--log-stderr-format`) that applies to all commands: -1. **Command-specific flag** (recommended for Phase 1): Add `--json` flag to individual commands like `create`, `provision`, etc. This keeps the change localized and follows patterns from tools like `docker`, `kubectl`, and `npm`. +- Add `OutputFormat` enum in `src/presentation/input/cli/output_format.rs` +- Add `output_format: OutputFormat` field to `GlobalArgs` +- Commands read this flag and format output accordingly +- Consistent with existing `LogFormat` pattern -2. **Global output format flag** (future consideration): Add a global `--output-format` argument (similar to `--log-stderr-format`) that applies to all commands. This would require: - - Adding `output_format: OutputFormat` to `GlobalArgs` - - Passing this through execution context to `UserOutput` - - Applying `FormatterOverride` based on the global flag +**Rationale for global approach:** -**Rationale for command-specific approach:** +- **Consistency**: Matches the pattern of `LogFormat` already in the codebase +- **Extensibility**: Easy to add more formats (XML, YAML, CSV) by adding enum variants +- **Type-safe**: Only valid formats can be selected (compile-time verification) +- **Future-proof**: All commands in epic #348 (12.1-12.5) will use the same mechanism +- **Industry standard**: Similar to `kubectl -o json`, `docker --format json` +- **Reusability**: Once implemented, any command can adopt JSON output easily -- **Incremental adoption**: Not all commands produce structured output suitable for JSON -- **Simpler implementation**: No need to modify global argument handling or execution context -- **Clear opt-in**: Users explicitly request JSON where it makes sense -- **Industry pattern**: Common in CLI tools (`docker inspect --format=json`, `kubectl get pods -o json`) +**OutputFormat enum:** -**Note**: If multiple commands adopt JSON output (tasks 12.1-12.5), we may want to refactor to a global flag in a future iteration to reduce duplication. +```rust +/// Output format for command results +#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)] +pub enum OutputFormat { + /// Human-readable text output (default) + #[default] + Text, + /// JSON output for automation and programmatic parsing + Json, +} +``` + +**CLI usage:** + +```bash +# Human-readable (default) +torrust-tracker-deployer create environment --env-file envs/my.json + +# JSON output +torrust-tracker-deployer create environment --env-file envs/my.json --output-format json + +# Short form (with alias) +torrust-tracker-deployer create environment --env-file envs/my.json -o json +``` ### Architecture Gap: Missing View Layer @@ -209,7 +234,7 @@ This refactoring should be done in **two separate commits**: - Verify output unchanged (run golden test) 2. **Commit 2**: Add JSON format support - - Add `--json` CLI flag + - Add `--output-format` global CLI argument - Add `render_json()` method to view - Add format switching in controller - Update tests and documentation @@ -223,33 +248,36 @@ This refactoring should be done in **two separate commits**: torrust-tracker-deployer create environment --env-file envs/my-env.json # JSON output (new) -torrust-tracker-deployer create environment --env-file envs/my-env.json --json +torrust-tracker-deployer create environment --env-file envs/my-env.json --output-format json + +# Short form with alias +torrust-tracker-deployer create environment --env-file envs/my-env.json -o json ``` ### Interaction with Existing `--log-output` Flag -The `--json` flag controls **user-facing output format**, while `--log-output` controls **logging destination**. These are independent concerns that work together: +The `--output-format` flag controls **user-facing output format**, while `--log-output` controls **logging destination**. These are independent concerns that work together: -| Flag | Purpose | Output Channel | -| -------------- | -------------------------------------------------- | -------------- | -| `--json` | User output format (JSON vs human-readable) | stdout | -| `--log-output` | Logging destination (file-only vs file-and-stderr) | stderr or file | +| Flag | Purpose | Output Channel | +| ----------------- | -------------------------------------------------- | -------------- | +| `--output-format` | User output format (text vs JSON) | stdout | +| `--log-output` | Logging destination (file-only vs file-and-stderr) | stderr or file | **Key points:** - **Logs** (tracing data with progress indicators like `⏳`, `✓`, `❌`) go to stderr or file based on `--log-output` - **User output** (success message and environment details) goes to stdout -- When `--json` is used, the JSON goes to stdout, logs continue to stderr/file +- When `--output-format json` is used, the JSON goes to stdout, logs continue to stderr/file - These flags do not conflict - they can be used together **Examples:** ```bash # Production: JSON output to stdout, logs to file only -torrust-tracker-deployer create environment --env-file envs/my-env.json --json --log-output file-only +torrust-tracker-deployer create environment --env-file envs/my-env.json --output-format json --log-output file-only # Development: JSON output to stdout, logs to both file and stderr -torrust-tracker-deployer create environment --env-file envs/my-env.json --json --log-output file-and-stderr +torrust-tracker-deployer create environment --env-file envs/my-env.json -o json --log-output file-and-stderr # Default: Human-readable output, logs to file only torrust-tracker-deployer create environment --env-file envs/my-env.json @@ -257,8 +285,8 @@ torrust-tracker-deployer create environment --env-file envs/my-env.json **Rationale:** Separating user output (stdout) from logs (stderr) is a Unix best practice that enables: -- Clean piping: `create --json | jq .data_dir` extracts only the JSON, no log noise -- Proper redirection: `create --json > output.json 2> logs.txt` separates concerns +- Clean piping: `create -o json | jq .data_dir` extracts only the JSON, no log noise +- Proper redirection: `create --output-format json > output.json 2> logs.txt` separates concerns - Tool integration: JSON parsers don't see log messages ### JSON Output Schema @@ -266,26 +294,22 @@ torrust-tracker-deployer create environment --env-file envs/my-env.json ```json { "environment_name": "my-env", - "state": "Created", - "data_dir": "data/my-env", - "build_dir": "build/my-env", - "config_file": "envs/my-env.json", - "state_file": "data/my-env/environment.json", - "created_at": "2026-02-13T13:00:00Z" + "instance_name": "torrust-tracker-vm-my-env", + "data_dir": "./data/my-env", + "build_dir": "./build/my-env", + "created_at": "2026-02-16T13:38:02.446056727Z" } ``` ### Field Descriptions -| Field | Type | Description | -| ------------------ | ------ | -------------------------------------------------- | -| `environment_name` | string | Name of the created environment | -| `state` | string | Current state (always "Created" for this command) | -| `data_dir` | string | Path to environment data directory | -| `build_dir` | string | Path where build artifacts will be generated | -| `config_file` | string | Path to configuration file (if using `--env-file`) | -| `state_file` | string | Path to environment state JSON file | -| `created_at` | string | ISO 8601 timestamp of creation | +| Field | Type | Description | +| ------------------ | ------ | -------------------------------------------- | +| `environment_name` | string | Name of the created environment | +| `instance_name` | string | Full VM instance name | +| `data_dir` | string | Path to environment data directory | +| `build_dir` | string | Path where build artifacts will be generated | +| `created_at` | string | ISO 8601 timestamp of creation | ### Human-Readable Output (Unchanged) @@ -372,17 +396,17 @@ Environment Details: ## Implementation Plan -### Phase 0: Refactor - Extract View Layer (Prerequisite) +### Phase 0: Refactor - Extract View Layer (Prerequisite) ✅ **Purpose**: Separate output formatting from controller logic to enable clean format switching. -- [ ] Create view module structure: `src/presentation/views/commands/create/` -- [ ] Create `EnvironmentDetailsData` struct (presentation DTO) -- [ ] Implement `From<&Environment>` for data conversion -- [ ] Create `EnvironmentDetailsView` with `render_human_readable()` method -- [ ] Update controller to use view instead of direct formatting -- [ ] Run golden test to verify output unchanged -- [ ] Commit refactoring (preserving behavior) +- [x] Create view module structure: `src/presentation/views/commands/create/` +- [x] Create `EnvironmentDetailsData` struct (presentation DTO) +- [x] Implement `From<&Environment>` for data conversion +- [x] Create `EnvironmentDetailsView` with `render_human_readable()` method +- [x] Update controller to use view instead of direct formatting +- [x] Run golden test to verify output unchanged +- [x] Commit refactoring (preserving behavior) **Files to create:** @@ -423,22 +447,79 @@ torrust-tracker-deployer create environment --env-file envs/golden-test-json-cre # All 4 lines should match exactly ``` -### Phase 1: Add CLI Flag +### Phase 1: Add Global OutputFormat Argument ✅ + +**Purpose**: Add `OutputFormat` enum and global `--output-format` CLI argument. + +- [x] Create `OutputFormat` enum in `src/presentation/input/cli/output_format.rs` +- [x] Add `#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]` +- [x] Add variants: `Text` (default) and `Json` +- [x] Add `output_format: OutputFormat` field to `GlobalArgs` +- [x] Add CLI documentation for the flag +- [x] No business logic changes yet -- [ ] Add `--json` flag to `create` subcommand argument parser -- [ ] Pass format flag through to presentation layer -- [ ] No business logic changes +**Files to create:** + +- `src/presentation/input/cli/output_format.rs` **Files to modify:** -- `src/presentation/console/subcommands/create/mod.rs` or wherever CLI args are defined +- `src/presentation/input/cli/args.rs` (add `output_format` field to `GlobalArgs`) +- `src/presentation/input/cli/mod.rs` (export `OutputFormat`) + +**OutputFormat enum:** + +```rust +//! Output format for command results + +/// Output format for command results +/// +/// Controls the format of user-facing output that goes to stdout. +/// This is independent of logging format (which goes to stderr/file). +#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)] +pub enum OutputFormat { + /// Human-readable text output (default) + /// + /// Produces formatted text with tables, sections, and visual elements + /// optimized for terminal display and human consumption. + #[default] + Text, + + /// JSON output for automation and programmatic parsing + /// + /// Produces machine-readable JSON objects that can be parsed by tools + /// like jq, scripts, and AI agents for programmatic extraction of data. + Json, +} +``` + +**GlobalArgs update:** + +```rust +/// Global CLI arguments for logging and output configuration +#[derive(clap::Args, Debug)] +pub struct GlobalArgs { + // ... existing log-related fields ... + + /// Output format for command results (default: text) + /// + /// Controls the format of user-facing output (stdout channel). + /// - text: Human-readable formatted output (default) + /// - json: Machine-readable JSON for automation + /// + /// This is independent of logging format (--log-file-format, --log-stderr-format) + /// which controls stderr/file output. + #[arg(long, short = 'o', value_enum, default_value = "text", global = true)] + pub output_format: OutputFormat, +} +``` -### Phase 2: Add JSON Output Method to View +### Phase 2: Add JSON Output Method to View ✅ -- [ ] Add `render_json()` method to `EnvironmentDetailsView` -- [ ] Implement JSON serialization using `serde_json` -- [ ] Use existing `EnvironmentDetailsData` struct (add `Serialize` derive) -- [ ] Include timestamp field (`created_at`) in JSON output +- [x] Add `render_json()` method to `EnvironmentDetailsView` +- [x] Implement JSON serialization using `serde_json` +- [x] Use existing `EnvironmentDetailsData` struct (add `Serialize` derive) +- [x] Include timestamp field (`created_at`) in JSON output **Files to modify:** @@ -474,20 +555,43 @@ impl EnvironmentDetailsView { } ``` -### Phase 3: Implement Format Switching +### Phase 3: Implement Format Switching in Controller ✅ -- [ ] Add conditional logic based on `--json` flag in controller -- [ ] Call `EnvironmentDetailsView::render_json()` when flag set -- [ ] Call `EnvironmentDetailsView::render_human_readable()` otherwise (default) -- [ ] Handle JSON serialization errors appropriately +- [x] Pass `output_format` from router to controller +- [x] Add conditional logic based on `OutputFormat` in controller +- [x] Call `EnvironmentDetailsView::render_json()` when `OutputFormat::Json` +- [x] Call `EnvironmentDetailsView::render_human_readable()` when `OutputFormat::Text` (default) +- [x] Handle JSON serialization errors appropriately **Pattern:** +The global `output_format` is accessible through the router. Pass it to the controller's execute method: + ```rust +// In router (src/presentation/controllers/create/router.rs) +let output_format = ctx.global_args().output_format; +controller.execute(&env_file, &working_dir, output_format).await?; + +// In controller (src/presentation/controllers/create/subcommands/environment/handler.rs) +use crate::presentation::input::cli::OutputFormat; + +pub async fn execute( + &mut self, + env_file: &Path, + working_dir: &Path, + output_format: OutputFormat, // New parameter +) -> Result, CreateEnvironmentCommandError> { + // ... existing steps ... + + self.display_creation_results(&environment, output_format)?; + + Ok(environment) +} + fn display_creation_results( &mut self, environment: &Environment, - format: OutputFormat, // Passed from CLI args + format: OutputFormat, // New parameter ) -> Result<(), CreateEnvironmentCommandError> { let data = EnvironmentDetailsData::from(environment); @@ -497,7 +601,7 @@ fn display_creation_results( .map_err(|e| CreateEnvironmentCommandError::JsonSerializationFailed { source: e })?; self.progress.result(&json_output)?; } - OutputFormat::HumanReadable => { + OutputFormat::Text => { let output = EnvironmentDetailsView::render_human_readable(&data); self.progress.result(&output)?; } @@ -509,14 +613,15 @@ fn display_creation_results( **Files to modify:** -- `src/presentation/controllers/create/subcommands/environment/handler.rs` +- `src/presentation/controllers/create/router.rs` (pass `output_format` to controller) +- `src/presentation/controllers/create/subcommands/environment/handler.rs` (add parameter and format switching) - `src/presentation/controllers/create/errors.rs` (add JSON serialization error variant) -### Phase 4: Documentation +### Phase 4: Documentation ✅ -- [ ] Update user guide with JSON output examples -- [ ] Document JSON schema -- [ ] Add usage examples for automation +- [x] Update user guide with JSON output examples +- [x] Document JSON schema +- [x] Add usage examples for automation **Files to create/modify:** @@ -525,7 +630,7 @@ fn display_creation_results( ### Phase 5: Testing -- [ ] Manual testing: verify JSON is valid with `--json` flag +- [ ] Manual testing: verify JSON is valid with `--output-format json` - [ ] Manual testing: verify default output unchanged without flag - [ ] Manual testing: pipe to `jq` to verify parsability - [ ] Consider adding integration test (optional for v1) @@ -534,43 +639,43 @@ fn display_creation_results( ### Architecture -- [ ] View layer extracted for create command (Phase 0 complete) -- [ ] Controller delegates output formatting to view -- [ ] View module structure matches `provision`, `list`, `show` commands -- [ ] Golden test passes after refactoring (output unchanged) -- [ ] Commit 1: Refactoring (behavior preserved) -- [ ] Commit 2: JSON support (new feature) +- [x] View layer extracted for create command (Phase 0 complete) +- [x] Controller delegates output formatting to view +- [x] View module structure matches `provision`, `list`, `show` commands +- [x] Golden test passes after refactoring (output unchanged) +- [x] Commit 1: Refactoring (behavior preserved) +- [x] Commit 2: JSON support (new feature) - commit 03e7bf7c ### Functionality -- [ ] `--json` flag is accepted by create command -- [ ] With `--json` flag, command outputs valid JSON to stdout -- [ ] JSON contains all specified fields with correct values -- [ ] JSON is parsable by standard tools (`jq`, `serde_json`, etc.) -- [ ] Without `--json` flag, output is unchanged (human-readable format) -- [ ] Errors are still output to stderr (not to stdout) +- [x] `--output-format` global argument is accepted +- [x] With `--output-format json`, command outputs valid JSON to stdout +- [x] JSON contains all specified fields with correct values +- [x] JSON is parsable by standard tools (`jq`, `serde_json`, etc.) +- [x] Without flag (or with `--output-format text`), output is unchanged (human-readable format) +- [x] Errors are still output to stderr (not to stdout) ### Code Quality -- [ ] Pre-commit checks pass: `./scripts/pre-commit.sh` -- [ ] All linters pass (clippy, rustfmt) -- [ ] No unused dependencies added -- [ ] Code follows existing patterns in presentation layer -- [ ] No changes to application or domain layers +- [x] Pre-commit checks pass: `./scripts/pre-commit.sh` +- [x] All linters pass (clippy, rustfmt) +- [x] No unused dependencies added +- [x] Code follows existing patterns in presentation layer +- [x] No changes to application or domain layers ### Documentation -- [ ] User guide updated with JSON output section -- [ ] JSON schema documented with field descriptions -- [ ] At least one usage example provided -- [ ] Automation use case documented +- [x] User guide updated with JSON output section +- [x] JSON schema documented with field descriptions +- [x] At least one usage example provided +- [x] Automation use case documented ### User Experience -- [ ] Default behavior (no flag) is identical to before -- [ ] JSON output is pretty-printed for readability -- [ ] Timestamps use ISO 8601 format -- [ ] Paths use forward slashes (cross-platform) +- [x] Default behavior (no flag) is identical to before +- [x] JSON output is pretty-printed for readability +- [x] Timestamps use ISO 8601 format +- [x] Paths use forward slashes (cross-platform) ## Testing @@ -591,7 +696,7 @@ torrust-tracker-deployer create environment --env-file envs/golden-test-json-cre 1. **Basic JSON output**: ```bash - torrust-tracker-deployer create --env-file envs/test.json --json + torrust-tracker-deployer create --env-file envs/test.json --output-format json ``` - Should output valid JSON @@ -609,7 +714,7 @@ torrust-tracker-deployer create environment --env-file envs/golden-test-json-cre 3. **JSON parsability**: ```bash - torrust-tracker-deployer create --env-file envs/test.json --json | jq . + torrust-tracker-deployer create --env-file envs/test.json -o json | jq . ``` - `jq` should successfully parse the output @@ -618,7 +723,7 @@ torrust-tracker-deployer create environment --env-file envs/golden-test-json-cre 4. **Extract specific field**: ```bash - DATA_DIR=$(torrust-tracker-deployer create environment --env-file envs/test.json --json | jq -r .data_dir) + DATA_DIR=$(torrust-tracker-deployer create environment --env-file envs/test.json -o json | jq -r .data_dir) echo "Data directory: $DATA_DIR" ``` @@ -628,7 +733,7 @@ torrust-tracker-deployer create environment --env-file envs/golden-test-json-cre 5. **JSON with file-only logging** (production scenario): ```bash - torrust-tracker-deployer create environment --env-file envs/test.json --json --log-output file-only + torrust-tracker-deployer create environment --env-file envs/test.json -o json --log-output file-only ``` - JSON should go to stdout only @@ -638,7 +743,7 @@ torrust-tracker-deployer create environment --env-file envs/golden-test-json-cre 6. **JSON with file-and-stderr logging** (development scenario): ```bash - torrust-tracker-deployer create environment --env-file envs/test.json --json --log-output file-and-stderr + torrust-tracker-deployer create environment --env-file envs/test.json --output-format json --log-output file-and-stderr ``` - JSON should go to stdout @@ -648,7 +753,7 @@ torrust-tracker-deployer create environment --env-file envs/golden-test-json-cre 7. **Output channel separation**: ```bash - torrust-tracker-deployer create environment --env-file envs/test.json --json --log-output file-and-stderr > output.json 2> logs.txt + torrust-tracker-deployer create environment --env-file envs/test.json -o json --log-output file-and-stderr > output.json 2> logs.txt ``` - `output.json` should contain only the JSON (no log messages) diff --git a/docs/user-guide/commands.md b/docs/user-guide/commands.md index 89f7d306c..e45a8217b 100644 --- a/docs/user-guide/commands.md +++ b/docs/user-guide/commands.md @@ -232,8 +232,7 @@ Created → Provisioned → Configured → Released → Running → Destroyed The create and destroy commands support these common options: -- `--help` - Display command help -- `--log-output ` - Logging destination (`file-only` or `file-and-stderr`) +- `--help` - Display command help- `--output-format `, `-o` - Output format for command results (`text` or `json`, default: `text`)- `--log-output ` - Logging destination (`file-only` or `file-and-stderr`) - `--log-file-format ` - File log format (`pretty`, `json`, or `compact`) - `--log-stderr-format ` - Stderr log format (`pretty`, `json`, or `compact`) - `--log-dir ` - Log directory (default: `./data/logs`) diff --git a/docs/user-guide/commands/create.md b/docs/user-guide/commands/create.md index a10b87540..9cd1e0264 100644 --- a/docs/user-guide/commands/create.md +++ b/docs/user-guide/commands/create.md @@ -227,6 +227,330 @@ EOF torrust-tracker-deployer create environment --env-file test-config.json ``` +## Output Formats + +The `create environment` command supports two output formats for command results: + +- **Text** (default) - Human-readable formatted output +- **JSON** - Machine-readable JSON for automation + +Use the global `--output-format` flag to control the format. + +### Text Output (Default) + +The default output format provides human-readable information with visual formatting: + +```bash +torrust-tracker-deployer create environment --env-file config.json +``` + +**Output**: + +```text +⏳ [1/3] Loading configuration... +⏳ → Loading configuration from 'config.json'... +⏳ ✓ Configuration loaded: my-env (took 2ms) +⏳ [2/3] Creating command handler... +⏳ ✓ Done (took 0ms) +⏳ [3/3] Creating environment... +⏳ → Creating environment 'my-env'... +⏳ → Validating configuration and creating environment... +⏳ ✓ Environment created: my-env (took 15ms) +✅ Environment 'my-env' created successfully + +Environment Details: +1. Environment name: my-env +2. Instance name: torrust-tracker-vm-my-env +3. Data directory: ./data/my-env +4. Build directory: ./build/my-env +``` + +**Features**: + +- Progress indicators (⏳, ✅) +- Timing information +- Numbered list format +- Color-coded status messages + +### JSON Output + +Use `--output-format json` for machine-readable output ideal for automation, scripts, and programmatic processing: + +```bash +torrust-tracker-deployer create environment --env-file config.json --output-format json +``` + +**Output**: + +```json +{ + "environment_name": "my-env", + "instance_name": "torrust-tracker-vm-my-env", + "data_dir": "./data/my-env", + "build_dir": "./build/my-env", + "created_at": "2026-02-16T13:38:02.446056727Z" +} +``` + +**Features**: + +- Valid, parseable JSON +- Pretty-printed for readability +- ISO 8601 timestamps +- Consistent field ordering +- Cross-platform paths (forward slashes) + +#### JSON Schema + +| Field | Type | Description | Example | +| ------------------ | ------ | -------------------------------------------- | ---------------------------------- | +| `environment_name` | string | Name of the created environment | `"production"` | +| `instance_name` | string | Full VM instance name | `"torrust-tracker-vm-production"` | +| `data_dir` | string | Path to environment data directory | `"./data/production"` | +| `build_dir` | string | Path where build artifacts will be generated | `"./build/production"` | +| `created_at` | string | ISO 8601 timestamp of creation | `"2026-02-16T13:38:02.446056727Z"` | + +#### Short Form + +Use the `-o` alias for shorter commands: + +```bash +torrust-tracker-deployer create environment --env-file config.json -o json +``` + +### Automation Examples + +#### Extract Environment Paths in Shell Scripts + +```bash +#!/bin/bash + +# Create environment and capture JSON output +JSON_OUTPUT=$(torrust-tracker-deployer create environment \ + --env-file production.json \ + --output-format json \ + --log-output file-only) + +# Extract specific fields using jq +DATA_DIR=$(echo "$JSON_OUTPUT" | jq -r '.data_dir') +BUILD_DIR=$(echo "$JSON_OUTPUT" | jq -r '.build_dir') +INSTANCE_NAME=$(echo "$JSON_OUTPUT" | jq -r '.instance_name') + +echo "Environment created:" +echo " Data: $DATA_DIR" +echo " Build: $BUILD_DIR" +echo " Instance: $INSTANCE_NAME" + +# Use extracted values in subsequent commands +cd "$DATA_DIR" +./configure.sh +``` + +#### CI/CD Pipeline Integration + +```yaml +# GitHub Actions example +- name: Create deployment environment + id: create + run: | + OUTPUT=$(torrust-tracker-deployer create environment \ + --env-file .github/ci-config.json \ + --output-format json \ + --log-output file-only) + + # Export as output variables + echo "data_dir=$(echo $OUTPUT | jq -r '.data_dir')" >> $GITHUB_OUTPUT + echo "build_dir=$(echo $OUTPUT | jq -r '.build_dir')" >> $GITHUB_OUTPUT + echo "instance=$(echo $OUTPUT | jq -r '.instance_name')" >> $GITHUB_OUTPUT + +- name: Use environment details + run: | + echo "Deploying to ${{ steps.create.outputs.instance }}" + echo "Data stored in ${{ steps.create.outputs.data_dir }}" +``` + +#### Multi-Environment Management Script + +```bash +#!/bin/bash +# Create multiple environments and track them + +ENVIRONMENTS=("dev" "staging" "production") +MANIFEST="environments.json" + +# Initialize manifest +echo "[]" > "$MANIFEST" + +for ENV in "${ENVIRONMENTS[@]}"; do + echo "Creating $ENV environment..." + + # Create environment with JSON output + RESULT=$(torrust-tracker-deployer create environment \ + --env-file "configs/${ENV}.json" \ + --output-format json \ + --log-output file-only) + + # Append to manifest + jq ". += [$RESULT]" "$MANIFEST" > temp.json && mv temp.json "$MANIFEST" + + echo "✓ ${ENV} created" +done + +echo "All environments created. Manifest:" +cat "$MANIFEST" +``` + +#### Python Integration + +```python +#!/usr/bin/env python3 +import json +import subprocess + +def create_environment(config_file): + """Create environment and return parsed JSON output.""" + result = subprocess.run( + [ + "torrust-tracker-deployer", + "create", "environment", + "--env-file", config_file, + "--output-format", "json", + "--log-output", "file-only" + ], + capture_output=True, + text=True, + check=True + ) + return json.loads(result.stdout) + +# Create environment +env = create_environment("production.json") + +# Access fields +print(f"Environment: {env['environment_name']}") +print(f"Instance: {env['instance_name']}") +print(f"Data directory: {env['data_dir']}") +print(f"Created at: {env['created_at']}") + +# Use in further automation +data_path = env['data_dir'] +subprocess.run(["./backup.sh", data_path]) +``` + +### Output Channel Separation + +The JSON output and progress logs use separate channels: + +- **stdout** - Command results (JSON or text output) +- **stderr** - Progress logs and diagnostic messages + +This separation enables clean piping and redirection: + +```bash +# Pipe JSON to jq without interference from logs +torrust-tracker-deployer create environment \ + --env-file config.json \ + --output-format json \ + --log-output file-and-stderr | jq . + +# Separate output and logs +torrust-tracker-deployer create environment \ + --env-file config.json \ + --output-format json \ + > result.json \ + 2> logs.txt +``` + +#### Production: Clean JSON Output + +For production automation, use `--log-output file-only` to send logs only to file: + +```bash +# Only JSON to stdout, logs go to file +torrust-tracker-deployer create environment \ + --env-file production.json \ + --output-format json \ + --log-output file-only | jq . +``` + +**Result**: Clean JSON output with no log noise. + +#### Development: JSON + Visible Progress + +For development, use `--log-output file-and-stderr` to see progress while capturing JSON: + +```bash +# JSON to stdout, logs to both file and stderr +torrust-tracker-deployer create environment \ + --env-file dev.json \ + --output-format json \ + --log-output file-and-stderr +``` + +**Result**: See progress on terminal, capture JSON separately. + +### Validation and Debugging + +#### Validate JSON Output + +```bash +# Validate with jq +torrust-tracker-deployer create environment \ + --env-file config.json \ + -o json \ + --log-output file-only | jq empty + +# If valid, jq returns exit code 0 +echo "Valid JSON: $?" +``` + +#### Pretty-Print JSON + +```bash +# The output is already pretty-printed, but you can customize with jq +torrust-tracker-deployer create environment \ + --env-file config.json \ + -o json \ + --log-output file-only | jq --indent 4 . +``` + +#### Extract and Format Specific Fields + +```bash +# Get only environment name and creation time +torrust-tracker-deployer create environment \ + --env-file config.json \ + -o json \ + --log-output file-only | jq '{name: .environment_name, created: .created_at}' +``` + +Output: + +```json +{ + "name": "my-env", + "created": "2026-02-16T13:38:02.446056727Z" +} +``` + +### When to Use Each Format + +**Use Text Format (default) when**: + +- Running commands interactively +- Viewing output in terminal +- Debugging and development +- Human needs to read the output + +**Use JSON Format when**: + +- Building automation scripts +- CI/CD pipelines +- Integrating with other tools +- Need to extract specific fields programmatically +- Logging structured data +- Machine processing required + ## Common Use Cases ### Development Environment diff --git a/src/bootstrap/app.rs b/src/bootstrap/app.rs index 1e72bbbeb..6c2d1b90d 100644 --- a/src/bootstrap/app.rs +++ b/src/bootstrap/app.rs @@ -62,7 +62,7 @@ pub async fn run() { crate::presentation::controllers::constants::DEFAULT_VERBOSITY, &cli.global.working_dir, )); - let context = presentation::dispatch::ExecutionContext::new(container); + let context = presentation::dispatch::ExecutionContext::new(container, cli.global.clone()); match cli.command { Some(command) => { diff --git a/src/presentation/controllers/configure/errors.rs b/src/presentation/controllers/configure/errors.rs index ae96f54b0..5f70cd5a9 100644 --- a/src/presentation/controllers/configure/errors.rs +++ b/src/presentation/controllers/configure/errors.rs @@ -111,7 +111,7 @@ impl ConfigureSubcommandError { /// /// Using with Container and `ExecutionContext` (recommended): /// - /// ```rust + /// ```ignore /// use std::path::Path; /// use std::sync::Arc; /// use torrust_tracker_deployer_lib::bootstrap::Container; @@ -120,7 +120,7 @@ impl ConfigureSubcommandError { /// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; /// /// let container = Container::new(VerbosityLevel::Normal, Path::new(".")); - /// let context = ExecutionContext::new(Arc::new(container)); + /// let context = ExecutionContext::new(Arc::new(container), global_args); /// /// if let Err(e) = context /// .container() @@ -134,7 +134,7 @@ impl ConfigureSubcommandError { /// /// Direct usage (for testing): /// - /// ```rust + /// ```ignore /// use std::path::{Path, PathBuf}; /// use std::sync::Arc; /// use std::time::Duration; diff --git a/src/presentation/controllers/configure/mod.rs b/src/presentation/controllers/configure/mod.rs index 05fac29a1..723b135a0 100644 --- a/src/presentation/controllers/configure/mod.rs +++ b/src/presentation/controllers/configure/mod.rs @@ -18,7 +18,7 @@ //! //! ### Basic Usage //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -27,7 +27,7 @@ //! use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; //! //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! // Call the configure handler //! let result = context @@ -38,7 +38,7 @@ //! //! ### Direct Usage (For Testing) //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -47,7 +47,7 @@ //! use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; //! //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! if let Err(e) = context //! .container() @@ -61,7 +61,7 @@ //! //! ## Direct Usage (For Testing) //! -//! ```rust +//! ```ignore //! use std::path::{Path, PathBuf}; //! use std::sync::Arc; //! use std::time::Duration; diff --git a/src/presentation/controllers/create/mod.rs b/src/presentation/controllers/create/mod.rs index a0cf24fee..167ca9988 100644 --- a/src/presentation/controllers/create/mod.rs +++ b/src/presentation/controllers/create/mod.rs @@ -20,7 +20,7 @@ //! //! ## Usage Example //! -//! ```rust,no_run +//! ```ignore //! use std::path::{Path, PathBuf}; //! use std::sync::{Arc, Mutex}; //! use torrust_tracker_deployer_lib::presentation::input::cli::commands::CreateAction; diff --git a/src/presentation/controllers/create/router.rs b/src/presentation/controllers/create/router.rs index cc60593eb..760ee2b5d 100644 --- a/src/presentation/controllers/create/router.rs +++ b/src/presentation/controllers/create/router.rs @@ -34,13 +34,16 @@ pub async fn route_command( context: &ExecutionContext, ) -> Result<(), CreateCommandError> { match action { - CreateAction::Environment { env_file } => context - .container() - .create_environment_controller() - .execute(&env_file, working_dir) - .await - .map(|_| ()) // Convert Environment to () - .map_err(CreateCommandError::Environment), + CreateAction::Environment { env_file } => { + let output_format = context.output_format(); + context + .container() + .create_environment_controller() + .execute(&env_file, working_dir, output_format) + .await + .map(|_| ()) // Convert Environment to () + .map_err(CreateCommandError::Environment) + } CreateAction::Template { output_path, provider, diff --git a/src/presentation/controllers/create/subcommands/environment/config_loader.rs b/src/presentation/controllers/create/subcommands/environment/config_loader.rs index 9f2fba28f..6c08447b3 100644 --- a/src/presentation/controllers/create/subcommands/environment/config_loader.rs +++ b/src/presentation/controllers/create/subcommands/environment/config_loader.rs @@ -56,7 +56,7 @@ impl ConfigLoader { /// /// # Examples /// - /// ```rust,no_run + /// ```ignore /// use std::path::Path; /// use torrust_tracker_deployer_lib::presentation::controllers::create::subcommands::environment::ConfigLoader; /// diff --git a/src/presentation/controllers/create/subcommands/environment/errors.rs b/src/presentation/controllers/create/subcommands/environment/errors.rs index 14ce033d4..ef23a0714 100644 --- a/src/presentation/controllers/create/subcommands/environment/errors.rs +++ b/src/presentation/controllers/create/subcommands/environment/errors.rs @@ -128,6 +128,20 @@ Tip: This is a critical bug - please report it with full logs using --log-output #[source] source: ProgressReporterError, }, + + // ===== Output Formatting Errors ===== + /// Output formatting failed + /// + /// Failed to format output in the requested format (e.g., JSON serialization failure). + /// This indicates an internal error in the view layer. + #[error( + "Failed to format output: {reason} +Tip: This is likely a bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { + /// Reason for the formatting failure + reason: String, + }, } // ============================================================================ @@ -162,6 +176,7 @@ impl CreateEnvironmentCommandError { /// assert!(help.contains("Check that the file path")); /// ``` #[must_use] + #[allow(clippy::too_many_lines)] pub fn help(&self) -> &'static str { match self { Self::ConfigFileNotFound { .. } => { @@ -270,6 +285,34 @@ Workaround: This error means the operation may have PARTIALLY completed or FAILED. Verify the state of your environment before retrying." } + Self::OutputFormatting { .. } => { + "Output Formatting Failed - Internal Error: + +This error indicates that the system failed to format the output in the requested +format (e.g., JSON serialization). This is a BUG in the application and should +NOT occur under normal circumstances. + +Immediate Actions: +1. Save any logs using: --log-output file-and-stderr +2. Note the output format that was requested (--output-format) +3. Record the command that was being executed +4. Document the full error message + +Report the Issue: +1. Include the full error message and stack trace +2. Specify which output format was requested +3. Provide the command that triggered the error +4. Note your operating system and version +5. Report to: https://github.com/torrust/torrust-tracker-deployer/issues + +Workaround: +1. Try using a different output format (e.g., use --output-format text instead of json) +2. Retry the operation with --verbose for more detailed logging +3. Check if the underlying operation actually succeeded despite the formatting error + +Note: The core operation may have completed successfully even though the output +formatting failed. Check the state of your environment manually before retrying." + } } } } diff --git a/src/presentation/controllers/create/subcommands/environment/handler.rs b/src/presentation/controllers/create/subcommands/environment/handler.rs index c7bf58c54..ae4391f2f 100644 --- a/src/presentation/controllers/create/subcommands/environment/handler.rs +++ b/src/presentation/controllers/create/subcommands/environment/handler.rs @@ -14,6 +14,8 @@ use crate::application::command_handlers::CreateCommandHandler; use crate::domain::environment::repository::EnvironmentRepository; use crate::domain::environment::state::Created; use crate::domain::Environment; +use crate::presentation::input::cli::OutputFormat; +use crate::presentation::views::commands::create::{EnvironmentDetailsData, JsonView, TextView}; use crate::presentation::views::progress::ProgressReporter; use crate::presentation::views::UserOutput; use crate::shared::clock::Clock; @@ -108,6 +110,7 @@ impl CreateEnvironmentCommandController { /// /// * `env_file` - Path to the environment configuration file /// * `working_dir` - Working directory path for environment storage + /// * `output_format` - Output format for results (Text or Json) /// /// # Errors /// @@ -125,6 +128,7 @@ impl CreateEnvironmentCommandController { &mut self, env_file: &Path, working_dir: &Path, + output_format: OutputFormat, ) -> Result, CreateEnvironmentCommandError> { let config = self.load_configuration(env_file)?; @@ -132,7 +136,7 @@ impl CreateEnvironmentCommandController { let environment = self.execute_create_command(&command_handler, config, working_dir)?; - self.display_creation_results(&environment)?; + self.display_creation_results(&environment, output_format)?; Ok(environment) } @@ -265,11 +269,16 @@ impl CreateEnvironmentCommandController { /// /// This step outputs: /// - Final completion message with environment name - /// - Instance details (name, data directory, build directory) + /// - Environment details (name, instance name, data directory, build directory) + /// + /// The output formatting is delegated to the view layer (`TextView` or `JsonView`) + /// following the MVC pattern and Strategy Pattern. This separates presentation + /// concerns from controller logic and allows easy addition of new formats. /// /// # Arguments /// /// * `environment` - The successfully created environment + /// * `output_format` - The format to use for rendering output (Text or Json) /// /// # Returns /// @@ -282,6 +291,7 @@ impl CreateEnvironmentCommandController { fn display_creation_results( &mut self, environment: &Environment, + output_format: OutputFormat, ) -> Result<(), CreateEnvironmentCommandError> { self.progress.complete(&format!( "Environment '{}' created successfully", @@ -290,15 +300,21 @@ impl CreateEnvironmentCommandController { self.progress.blank_line()?; - self.progress.steps( - "Environment Details:", - &[ - &format!("Environment name: {}", environment.name().as_str()), - &format!("Instance name: {}", environment.instance_name().as_str()), - &format!("Data directory: {}", environment.data_dir().display()), - &format!("Build directory: {}", environment.build_dir().display()), - ], - )?; + // Convert domain model to presentation DTO + let details = EnvironmentDetailsData::from(environment); + + // Render using appropriate view based on output format (Strategy Pattern) + let output = match output_format { + OutputFormat::Text => TextView::render(&details), + OutputFormat::Json => JsonView::render(&details).map_err(|e| { + CreateEnvironmentCommandError::OutputFormatting { + reason: format!("Failed to serialize environment details as JSON: {e}"), + } + })?, + }; + + // Output the rendered result + self.progress.result(&output)?; Ok(()) } diff --git a/src/presentation/controllers/create/subcommands/environment/tests.rs b/src/presentation/controllers/create/subcommands/environment/tests.rs index f3c7cc6e8..066f689af 100644 --- a/src/presentation/controllers/create/subcommands/environment/tests.rs +++ b/src/presentation/controllers/create/subcommands/environment/tests.rs @@ -12,11 +12,13 @@ use tempfile::TempDir; use super::errors::CreateEnvironmentCommandError; use crate::bootstrap::Container; use crate::presentation::dispatch::ExecutionContext; +use crate::presentation::input::cli::OutputFormat; use crate::presentation::views::VerbosityLevel; fn create_test_context(working_dir: &Path) -> ExecutionContext { let container = Container::new(VerbosityLevel::Silent, working_dir); - ExecutionContext::new(Arc::new(container)) + let global_args = crate::presentation::controllers::tests::default_global_args(working_dir); + ExecutionContext::new(Arc::new(container), global_args) } #[tokio::test] @@ -78,7 +80,7 @@ async fn it_should_create_environment_from_valid_config() { let result = context .container() .create_environment_controller() - .execute(&config_path, working_dir) + .execute(&config_path, working_dir, OutputFormat::Text) .await; assert!( @@ -107,7 +109,7 @@ async fn it_should_return_error_for_missing_config_file() { let result = context .container() .create_environment_controller() - .execute(&config_path, working_dir) + .execute(&config_path, working_dir, OutputFormat::Text) .await; assert!(result.is_err()); @@ -132,7 +134,7 @@ async fn it_should_return_error_for_invalid_json() { let result = context .container() .create_environment_controller() - .execute(&config_path, working_dir) + .execute(&config_path, working_dir, OutputFormat::Text) .await; assert!(result.is_err()); @@ -204,7 +206,7 @@ async fn it_should_return_error_for_duplicate_environment() { let result1 = context .container() .create_environment_controller() - .execute(&config_path, working_dir) + .execute(&config_path, working_dir, OutputFormat::Text) .await; assert!(result1.is_ok(), "First create should succeed"); @@ -213,7 +215,7 @@ async fn it_should_return_error_for_duplicate_environment() { let result2 = context2 .container() .create_environment_controller() - .execute(&config_path, working_dir) + .execute(&config_path, working_dir, OutputFormat::Text) .await; assert!(result2.is_err(), "Second create should fail"); @@ -285,7 +287,7 @@ async fn it_should_create_environment_in_custom_working_dir() { let result = context .container() .create_environment_controller() - .execute(&config_path, &custom_working_dir) + .execute(&config_path, &custom_working_dir, OutputFormat::Text) .await; assert!(result.is_ok(), "Should create in custom working dir"); diff --git a/src/presentation/controllers/create/tests/environment.rs b/src/presentation/controllers/create/tests/environment.rs index fc80e3541..80135bd2f 100644 --- a/src/presentation/controllers/create/tests/environment.rs +++ b/src/presentation/controllers/create/tests/environment.rs @@ -7,7 +7,7 @@ use crate::bootstrap::Container; use crate::presentation::controllers::create; use crate::presentation::controllers::tests::{ create_config_with_invalid_name, create_config_with_missing_keys, create_invalid_json_config, - create_valid_config, TestContext, + create_valid_config, default_global_args, TestContext, }; use crate::presentation::dispatch::ExecutionContext; use crate::presentation::input::cli::CreateAction; @@ -22,7 +22,8 @@ async fn handle_environment_creation( env_file: config_path.to_path_buf(), }; let container = Container::new(VerbosityLevel::Silent, working_dir); - let context = ExecutionContext::new(std::sync::Arc::new(container)); + let global_args = default_global_args(working_dir); + let context = ExecutionContext::new(std::sync::Arc::new(container), global_args); create::route_command(action, working_dir, &context).await } diff --git a/src/presentation/controllers/create/tests/template.rs b/src/presentation/controllers/create/tests/template.rs index 30e0544fb..bb9cc0cf6 100644 --- a/src/presentation/controllers/create/tests/template.rs +++ b/src/presentation/controllers/create/tests/template.rs @@ -6,7 +6,7 @@ use crate::bootstrap::Container; use crate::domain::provider::Provider; use crate::presentation::controllers::create; -use crate::presentation::controllers::tests::TestContext; +use crate::presentation::controllers::tests::{default_global_args, TestContext}; use crate::presentation::dispatch::ExecutionContext; use crate::presentation::input::cli::CreateAction; use crate::presentation::views::VerbosityLevel; @@ -24,7 +24,8 @@ async fn it_should_generate_template_with_default_path() { provider: Provider::Lxd, }; let container = Container::new(VerbosityLevel::Silent, test_context.working_dir()); - let context = ExecutionContext::new(std::sync::Arc::new(container)); + let global_args = default_global_args(test_context.working_dir()); + let context = ExecutionContext::new(std::sync::Arc::new(container), global_args); let result = create::route_command(action, test_context.working_dir(), &context).await; @@ -70,7 +71,8 @@ async fn it_should_generate_template_with_custom_path() { provider: Provider::Lxd, }; let container = Container::new(VerbosityLevel::Silent, test_context.working_dir()); - let context = ExecutionContext::new(std::sync::Arc::new(container)); + let global_args = default_global_args(test_context.working_dir()); + let context = ExecutionContext::new(std::sync::Arc::new(container), global_args); let result = create::route_command(action, test_context.working_dir(), &context).await; @@ -97,7 +99,8 @@ async fn it_should_generate_valid_json_template() { provider: Provider::Lxd, }; let container = Container::new(VerbosityLevel::Silent, test_context.working_dir()); - let context = ExecutionContext::new(std::sync::Arc::new(container)); + let global_args = default_global_args(test_context.working_dir()); + let context = ExecutionContext::new(std::sync::Arc::new(container), global_args); create::route_command(action, test_context.working_dir(), &context) .await @@ -146,7 +149,8 @@ async fn it_should_create_parent_directories() { provider: Provider::Lxd, }; let container = Container::new(VerbosityLevel::Silent, test_context.working_dir()); - let context = ExecutionContext::new(std::sync::Arc::new(container)); + let global_args = default_global_args(test_context.working_dir()); + let context = ExecutionContext::new(std::sync::Arc::new(container), global_args); let result = create::route_command(action, test_context.working_dir(), &context).await; diff --git a/src/presentation/controllers/destroy/errors.rs b/src/presentation/controllers/destroy/errors.rs index 4b18b9873..9f6cafe41 100644 --- a/src/presentation/controllers/destroy/errors.rs +++ b/src/presentation/controllers/destroy/errors.rs @@ -101,7 +101,7 @@ impl DestroySubcommandError { /// /// Using with Container and `ExecutionContext` (recommended): /// - /// ```rust + /// ```ignore /// use std::path::Path; /// use std::sync::Arc; /// use torrust_tracker_deployer_lib::bootstrap::Container; @@ -112,7 +112,7 @@ impl DestroySubcommandError { /// # #[tokio::main] /// # async fn main() { /// let container = Container::new(VerbosityLevel::Normal, Path::new(".")); - /// let context = ExecutionContext::new(Arc::new(container)); + /// let context = ExecutionContext::new(Arc::new(container), global_args); /// /// if let Err(e) = context /// .container() @@ -128,7 +128,7 @@ impl DestroySubcommandError { /// /// Direct usage (for testing): /// - /// ```rust + /// ```ignore /// use std::path::{Path, PathBuf}; /// use std::sync::Arc; /// use std::time::Duration; diff --git a/src/presentation/controllers/destroy/mod.rs b/src/presentation/controllers/destroy/mod.rs index c0099cff7..dcbdc770c 100644 --- a/src/presentation/controllers/destroy/mod.rs +++ b/src/presentation/controllers/destroy/mod.rs @@ -18,7 +18,7 @@ //! //! ### Basic Usage //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -29,7 +29,7 @@ //! # #[tokio::main] //! # async fn main() { //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! // Call the destroy handler //! let result = context @@ -42,7 +42,7 @@ //! //! ### Direct Usage (For Testing) //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -53,7 +53,7 @@ //! # #[tokio::main] //! # async fn main() { //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! if let Err(e) = context //! .container() @@ -69,7 +69,7 @@ //! //! ## Direct Usage (For Testing) //! -//! ```rust +//! ```ignore //! use std::path::{Path, PathBuf}; //! use std::sync::Arc; //! use std::time::Duration; diff --git a/src/presentation/controllers/list/mod.rs b/src/presentation/controllers/list/mod.rs index a8d62d48d..66adcbe03 100644 --- a/src/presentation/controllers/list/mod.rs +++ b/src/presentation/controllers/list/mod.rs @@ -17,7 +17,7 @@ //! //! ### Basic Usage //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -27,7 +27,7 @@ //! //! # fn main() { //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! // Call the list handler //! if let Err(e) = context diff --git a/src/presentation/controllers/mod.rs b/src/presentation/controllers/mod.rs index 320fcb65d..2fa4f8a4c 100644 --- a/src/presentation/controllers/mod.rs +++ b/src/presentation/controllers/mod.rs @@ -128,7 +128,7 @@ //! 3. **Subcommand Handler**: Executes specific logic (environment creation or template generation) //! //! **Current Implementation Example** (from `create/router.rs`): -//! ```rust,no_run +//! ```ignore //! use std::path::Path; //! use torrust_tracker_deployer_lib::domain::provider::Provider; //! use torrust_tracker_deployer_lib::presentation::input::cli::commands::CreateAction; diff --git a/src/presentation/controllers/provision/errors.rs b/src/presentation/controllers/provision/errors.rs index 28205ec52..99e61dc19 100644 --- a/src/presentation/controllers/provision/errors.rs +++ b/src/presentation/controllers/provision/errors.rs @@ -111,7 +111,7 @@ impl ProvisionSubcommandError { /// /// Using with Container and `ExecutionContext` (recommended): /// - /// ```rust + /// ```ignore /// use std::path::Path; /// use std::sync::Arc; /// use torrust_tracker_deployer_lib::bootstrap::Container; @@ -122,7 +122,7 @@ impl ProvisionSubcommandError { /// # #[tokio::main] /// # async fn main() { /// let container = Container::new(VerbosityLevel::Normal, Path::new(".")); - /// let context = ExecutionContext::new(Arc::new(container)); + /// let context = ExecutionContext::new(Arc::new(container), global_args); /// /// if let Err(e) = context /// .container() @@ -138,7 +138,7 @@ impl ProvisionSubcommandError { /// /// Direct usage (for testing): /// - /// ```rust + /// ```ignore /// use std::path::{Path, PathBuf}; /// use std::sync::Arc; /// use std::time::Duration; diff --git a/src/presentation/controllers/provision/mod.rs b/src/presentation/controllers/provision/mod.rs index 083acec3d..1d52b52ea 100644 --- a/src/presentation/controllers/provision/mod.rs +++ b/src/presentation/controllers/provision/mod.rs @@ -18,7 +18,7 @@ //! //! ### Basic Usage //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -29,7 +29,7 @@ //! # #[tokio::main] //! # async fn main() { //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! // Call the provision handler //! let result = context @@ -42,7 +42,7 @@ //! //! ### Direct Usage (For Testing) //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -53,7 +53,7 @@ //! # #[tokio::main] //! # async fn main() { //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! if let Err(e) = context //! .container() @@ -69,7 +69,7 @@ //! //! ## Direct Usage (For Testing) //! -//! ```rust +//! ```ignore //! use std::path::{Path, PathBuf}; //! use std::sync::Arc; //! use std::time::Duration; diff --git a/src/presentation/controllers/release/mod.rs b/src/presentation/controllers/release/mod.rs index cd83df7f1..72df95321 100644 --- a/src/presentation/controllers/release/mod.rs +++ b/src/presentation/controllers/release/mod.rs @@ -18,7 +18,7 @@ //! //! ### Basic Usage //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -29,7 +29,7 @@ //! # #[tokio::main] //! # async fn main() { //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! // Call the release handler //! let result = context diff --git a/src/presentation/controllers/run/mod.rs b/src/presentation/controllers/run/mod.rs index 813f991d2..2572f9c7c 100644 --- a/src/presentation/controllers/run/mod.rs +++ b/src/presentation/controllers/run/mod.rs @@ -18,7 +18,7 @@ //! //! ### Basic Usage //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -29,7 +29,7 @@ //! # #[tokio::main] //! # async fn main() { //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! // Call the run handler //! let result = context diff --git a/src/presentation/controllers/show/mod.rs b/src/presentation/controllers/show/mod.rs index b6d4f4ed0..88166e27a 100644 --- a/src/presentation/controllers/show/mod.rs +++ b/src/presentation/controllers/show/mod.rs @@ -17,7 +17,7 @@ //! //! ### Basic Usage //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -27,7 +27,7 @@ //! //! # fn main() { //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! // Call the show handler //! if let Err(e) = context diff --git a/src/presentation/controllers/test/errors.rs b/src/presentation/controllers/test/errors.rs index e16e6d712..c58f76012 100644 --- a/src/presentation/controllers/test/errors.rs +++ b/src/presentation/controllers/test/errors.rs @@ -100,7 +100,7 @@ impl TestSubcommandError { /// /// Using with Container and `ExecutionContext` (recommended): /// - /// ```rust + /// ```ignore /// use std::path::Path; /// use std::sync::Arc; /// use torrust_tracker_deployer_lib::bootstrap::Container; @@ -111,7 +111,7 @@ impl TestSubcommandError { /// # #[tokio::main] /// # async fn main() { /// let container = Container::new(VerbosityLevel::Normal, Path::new(".")); - /// let context = ExecutionContext::new(Arc::new(container)); + /// let context = ExecutionContext::new(Arc::new(container), global_args); /// /// if let Err(e) = context /// .container() @@ -127,7 +127,7 @@ impl TestSubcommandError { /// /// Direct usage (for testing): /// - /// ```rust + /// ```ignore /// use std::path::{Path, PathBuf}; /// use std::sync::Arc; /// use parking_lot::ReentrantMutex; diff --git a/src/presentation/controllers/test/mod.rs b/src/presentation/controllers/test/mod.rs index f6497cddf..092443e7b 100644 --- a/src/presentation/controllers/test/mod.rs +++ b/src/presentation/controllers/test/mod.rs @@ -18,7 +18,7 @@ //! //! ### Basic Usage //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -29,7 +29,7 @@ //! # #[tokio::main] //! # async fn main() { //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! // Call the test handler //! if let Err(e) = context @@ -46,7 +46,7 @@ //! //! ## Direct Usage (For Testing) //! -//! ```rust +//! ```ignore //! use std::path::Path; //! use std::sync::Arc; //! use torrust_tracker_deployer_lib::bootstrap::Container; @@ -57,7 +57,7 @@ //! # #[tokio::main] //! # async fn main() { //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! if let Err(e) = context //! .container() diff --git a/src/presentation/controllers/tests/mod.rs b/src/presentation/controllers/tests/mod.rs index 8003c324f..a25e81905 100644 --- a/src/presentation/controllers/tests/mod.rs +++ b/src/presentation/controllers/tests/mod.rs @@ -13,7 +13,7 @@ //! //! # Usage //! -//! ```rust,no_run +//! ```ignore //! use torrust_tracker_deployer_lib::presentation::controllers::tests::{TestContext, create_valid_config}; //! //! fn example_usage() { @@ -46,7 +46,7 @@ use crate::presentation::views::{UserOutput, VerbosityLevel}; /// /// # Example /// -/// ```rust,no_run +/// ```ignore /// use torrust_tracker_deployer_lib::presentation::controllers::tests::TestContext; /// /// fn example_usage() { @@ -134,7 +134,7 @@ impl Default for TestContext { /// /// # Example /// -/// ```rust,no_run +/// ```ignore /// use tempfile::TempDir; /// use torrust_tracker_deployer_lib::presentation::controllers::tests::create_valid_config; /// @@ -217,7 +217,7 @@ pub fn create_valid_config(path: &Path, env_name: &str) -> PathBuf { /// /// # Example /// -/// ```rust,no_run +/// ```ignore /// use tempfile::TempDir; /// use torrust_tracker_deployer_lib::presentation::controllers::tests::create_invalid_json_config; /// @@ -254,7 +254,7 @@ pub fn create_invalid_json_config(path: &Path) -> PathBuf { /// /// # Example /// -/// ```rust,no_run +/// ```ignore /// use tempfile::TempDir; /// use torrust_tracker_deployer_lib::presentation::controllers::tests::create_config_with_invalid_name; /// @@ -336,7 +336,7 @@ pub fn create_config_with_invalid_name(path: &Path) -> PathBuf { /// /// # Example /// -/// ```rust,no_run +/// ```ignore /// use tempfile::TempDir; /// use torrust_tracker_deployer_lib::presentation::controllers::tests::create_config_with_missing_keys; /// @@ -392,6 +392,46 @@ pub fn create_config_with_missing_keys(path: &Path) -> PathBuf { config_path } +/// Create default global CLI arguments for tests +/// +/// This function creates a `GlobalArgs` instance with sensible defaults for testing. +/// All log files will be written to a temporary directory within the test context. +/// +/// # Arguments +/// +/// * `working_dir` - The working directory path for the test (usually from `TestContext`) +/// +/// # Returns +/// +/// Returns a `GlobalArgs` instance suitable for testing +/// +/// # Example +/// +/// ```ignore +/// use torrust_tracker_deployer_lib::presentation::controllers::tests::{TestContext, default_global_args}; +/// +/// let context = TestContext::new(); +/// let global_args = default_global_args(context.working_dir()); +/// // Use global_args with ExecutionContext +/// ``` +#[must_use] +pub fn default_global_args( + working_dir: &Path, +) -> crate::presentation::input::cli::args::GlobalArgs { + use crate::bootstrap::logging::{LogFormat, LogOutput}; + use crate::presentation::input::cli::args::GlobalArgs; + use crate::presentation::input::cli::OutputFormat; + + GlobalArgs { + log_file_format: LogFormat::Compact, + log_stderr_format: LogFormat::Compact, + log_output: LogOutput::FileOnly, + log_dir: working_dir.join("logs"), + working_dir: working_dir.to_path_buf(), + output_format: OutputFormat::Text, + } +} + // ============================================================================ // TESTS // ============================================================================ diff --git a/src/presentation/dispatch/context.rs b/src/presentation/dispatch/context.rs index 7d6b7123f..8d75ee191 100644 --- a/src/presentation/dispatch/context.rs +++ b/src/presentation/dispatch/context.rs @@ -25,7 +25,7 @@ //! //! ## Usage Example //! -//! ```rust,no_run +//! ```ignore //! use torrust_tracker_deployer_lib::bootstrap::Container; //! use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; //! use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; @@ -35,7 +35,7 @@ //! # fn example() -> Result<(), Box> { //! // Create execution context from container //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! //! // Command handlers access services through context //! let user_output = context.user_output(); @@ -51,6 +51,8 @@ use parking_lot::ReentrantMutex; use crate::bootstrap::Container; use crate::infrastructure::persistence::repository_factory::RepositoryFactory; +use crate::presentation::input::cli::args::GlobalArgs; +use crate::presentation::input::cli::OutputFormat; use crate::presentation::views::UserOutput; use crate::shared::clock::Clock; @@ -62,7 +64,7 @@ use crate::shared::clock::Clock; /// /// # Examples /// -/// ```rust +/// ```ignore /// use std::sync::Arc; /// use std::path::Path; /// use torrust_tracker_deployer_lib::bootstrap::Container; @@ -70,7 +72,7 @@ use crate::shared::clock::Clock; /// use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; /// /// let container = Arc::new(Container::new(VerbosityLevel::Normal, Path::new("."))); -/// let context = ExecutionContext::new(container); +/// let context = ExecutionContext::new(container, global_args); /// /// // Access user output service /// let user_output = context.user_output(); @@ -79,6 +81,7 @@ use crate::shared::clock::Clock; #[derive(Clone)] pub struct ExecutionContext { container: Arc, + global_args: GlobalArgs, } impl ExecutionContext { @@ -87,25 +90,40 @@ impl ExecutionContext { /// # Arguments /// /// * `container` - Application service container with initialized services + /// * `global_args` - Global CLI arguments (logging config, output format, etc.) /// /// # Examples /// - /// ```rust,no_run + /// ```ignore /// use torrust_tracker_deployer_lib::bootstrap::Container; /// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; /// use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; + /// use torrust_tracker_deployer_lib::presentation::input::cli::args::GlobalArgs; + /// use torrust_tracker_deployer_lib::bootstrap::logging::{LogFormat, LogOutput}; + /// use torrust_tracker_deployer_lib::presentation::input::cli::OutputFormat; /// use std::sync::Arc; - /// use std::path::Path; + /// use std::path::PathBuf; /// /// # fn example() -> Result<(), Box> { - /// let container = Container::new(VerbosityLevel::Normal, Path::new(".")); - /// let context = ExecutionContext::new(Arc::new(container)); + /// let container = Container::new(VerbosityLevel::Normal, &PathBuf::from(".")); + /// let global_args = GlobalArgs { + /// log_file_format: LogFormat::Compact, + /// log_stderr_format: LogFormat::Pretty, + /// log_output: LogOutput::FileOnly, + /// log_dir: PathBuf::from("./data/logs"), + /// working_dir: PathBuf::from("."), + /// output_format: OutputFormat::Text, + /// }; + /// let context = ExecutionContext::new(Arc::new(container), global_args); /// # Ok(()) /// # } /// ``` #[must_use] - pub fn new(container: Arc) -> Self { - Self { container } + pub fn new(container: Arc, global_args: GlobalArgs) -> Self { + Self { + container, + global_args, + } } /// Get reference to the underlying container @@ -115,7 +133,7 @@ impl ExecutionContext { /// /// # Examples /// - /// ```rust,no_run + /// ```ignore /// use std::path::Path; /// use torrust_tracker_deployer_lib::bootstrap::Container; /// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; @@ -124,7 +142,7 @@ impl ExecutionContext { /// /// # fn example() -> Result<(), Box> { /// let container = Container::new(VerbosityLevel::Normal, Path::new(".")); - /// let context = ExecutionContext::new(Arc::new(container)); + /// let context = ExecutionContext::new(Arc::new(container), global_args); /// /// let container_ref = context.container(); /// // Use container_ref as needed @@ -144,7 +162,7 @@ impl ExecutionContext { /// /// # Examples /// - /// ```rust,no_run + /// ```ignore /// use torrust_tracker_deployer_lib::bootstrap::Container; /// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; /// use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; @@ -153,7 +171,7 @@ impl ExecutionContext { /// /// # fn example() -> Result<(), Box> { /// let container = Container::new(VerbosityLevel::Normal, Path::new(".")); - /// let context = ExecutionContext::new(Arc::new(container)); + /// let context = ExecutionContext::new(Arc::new(container), global_args); /// /// let user_output = context.user_output(); /// user_output.lock().borrow_mut().success("Operation completed"); @@ -172,7 +190,7 @@ impl ExecutionContext { /// /// # Examples /// - /// ```rust,no_run + /// ```ignore /// use torrust_tracker_deployer_lib::bootstrap::Container; /// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; /// use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; @@ -181,7 +199,7 @@ impl ExecutionContext { /// /// # fn example() -> Result<(), Box> { /// let container = Container::new(VerbosityLevel::Normal, Path::new(".")); - /// let context = ExecutionContext::new(Arc::new(container)); + /// let context = ExecutionContext::new(Arc::new(container), global_args); /// /// let repository_factory = context.repository_factory(); /// // Use repository_factory to create repositories @@ -200,7 +218,7 @@ impl ExecutionContext { /// /// # Examples /// - /// ```rust,no_run + /// ```ignore /// use torrust_tracker_deployer_lib::bootstrap::Container; /// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; /// use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; @@ -209,7 +227,7 @@ impl ExecutionContext { /// /// # fn example() -> Result<(), Box> { /// let container = Container::new(VerbosityLevel::Normal, Path::new(".")); - /// let context = ExecutionContext::new(Arc::new(container)); + /// let context = ExecutionContext::new(Arc::new(container), global_args); /// /// let repository = context.repository(); /// // Use repository for environment persistence @@ -230,7 +248,7 @@ impl ExecutionContext { /// /// # Examples /// - /// ```rust,no_run + /// ```ignore /// use torrust_tracker_deployer_lib::bootstrap::Container; /// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; /// use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; @@ -239,7 +257,7 @@ impl ExecutionContext { /// /// # fn example() -> Result<(), Box> { /// let container = Container::new(VerbosityLevel::Normal, Path::new(".")); - /// let context = ExecutionContext::new(Arc::new(container)); + /// let context = ExecutionContext::new(Arc::new(container), global_args); /// /// let clock = context.clock(); /// // Use clock for time operations @@ -250,4 +268,46 @@ impl ExecutionContext { pub fn clock(&self) -> Arc { self.container.clock() } + + /// Get the output format from global CLI arguments + /// + /// Returns the user-specified output format (Text or Json) for command results. + /// This allows controllers to format their output appropriately based on user preference. + /// + /// # Examples + /// + /// ```ignore + /// use torrust_tracker_deployer_lib::bootstrap::Container; + /// use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; + /// use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; + /// use torrust_tracker_deployer_lib::presentation::input::cli::args::GlobalArgs; + /// use torrust_tracker_deployer_lib::presentation::input::cli::OutputFormat; + /// use torrust_tracker_deployer_lib::bootstrap::logging::{LogFormat, LogOutput}; + /// use std::sync::Arc; + /// use std::path::PathBuf; + /// + /// # fn example() -> Result<(), Box> { + /// let container = Container::new(VerbosityLevel::Normal, &PathBuf::from(".")); + /// let global_args = GlobalArgs { + /// log_file_format: LogFormat::Compact, + /// log_stderr_format: LogFormat::Pretty, + /// log_output: LogOutput::FileOnly, + /// log_dir: PathBuf::from("./data/logs"), + /// working_dir: PathBuf::from("."), + /// output_format: OutputFormat::Json, + /// }; + /// let context = ExecutionContext::new(Arc::new(container), global_args); + /// + /// let format = context.output_format(); + /// match format { + /// OutputFormat::Text => println!("Human-readable text"), + /// OutputFormat::Json => println!("{{\"result\": \"json\"}}"), + /// } + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn output_format(&self) -> OutputFormat { + self.global_args.output_format + } } diff --git a/src/presentation/dispatch/mod.rs b/src/presentation/dispatch/mod.rs index c22496c8e..2521f658c 100644 --- a/src/presentation/dispatch/mod.rs +++ b/src/presentation/dispatch/mod.rs @@ -110,7 +110,7 @@ //! //! # fn example() -> Result<(), Box> { //! let container = Container::new(VerbosityLevel::Normal); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! let working_dir = Path::new("."); //! //! // Execute a command through the dispatch layer diff --git a/src/presentation/dispatch/router.rs b/src/presentation/dispatch/router.rs index f25672ab9..17e97ce76 100644 --- a/src/presentation/dispatch/router.rs +++ b/src/presentation/dispatch/router.rs @@ -41,7 +41,7 @@ //! //! # fn example() -> Result<(), Box> { //! let container = Container::new(VerbosityLevel::Normal); -//! let context = ExecutionContext::new(Arc::new(container)); +//! let context = ExecutionContext::new(Arc::new(container), global_args); //! let working_dir = Path::new("."); //! //! // Route command to appropriate handler @@ -95,7 +95,7 @@ use super::ExecutionContext; /// /// async fn example() -> Result<(), Box> { /// let container = Container::new(VerbosityLevel::Normal); -/// let context = ExecutionContext::new(Arc::new(container)); +/// let context = ExecutionContext::new(Arc::new(container), global_args); /// let working_dir = Path::new("."); /// /// // Route command to appropriate handler - requires proper Commands construction diff --git a/src/presentation/input/cli/args.rs b/src/presentation/input/cli/args.rs index 98ea96e60..31fc38a9a 100644 --- a/src/presentation/input/cli/args.rs +++ b/src/presentation/input/cli/args.rs @@ -7,13 +7,14 @@ use std::path::PathBuf; use crate::bootstrap::logging::{LogFormat, LogOutput, LoggingConfig}; +use crate::presentation::input::cli::OutputFormat; -/// Global CLI arguments for logging configuration +/// Global CLI arguments for logging and output configuration /// /// These arguments are available for all commands and control how logging /// is handled throughout the application. They provide fine-grained control /// over log output, formatting, and destinations. -#[derive(clap::Args, Debug)] +#[derive(clap::Args, Debug, Clone)] pub struct GlobalArgs { /// Format for file logging (default: compact, without ANSI codes) /// @@ -69,6 +70,22 @@ pub struct GlobalArgs { /// - Production: '/var/lib/torrust-deployer' (system location) #[arg(long, default_value = ".", global = true)] pub working_dir: PathBuf, + + /// Output format for command results (default: text) + /// + /// Controls the format of user-facing output (stdout channel). + /// - text: Human-readable formatted output with tables and sections (default) + /// - json: Machine-readable JSON for automation, scripts, and AI agents + /// + /// This is independent of logging format (--log-file-format, --log-stderr-format) + /// which controls stderr/file output. + /// + /// Examples: + /// - Default: Text format for human consumption + /// - Automation: JSON format for programmatic parsing + /// - CI/CD: JSON piped to jq for field extraction + #[arg(long, value_enum, default_value = "text", global = true)] + pub output_format: OutputFormat, } impl GlobalArgs { @@ -87,6 +104,7 @@ impl GlobalArgs { /// /// ```rust /// # use torrust_tracker_deployer_lib::presentation::input::cli::args::GlobalArgs; + /// # use torrust_tracker_deployer_lib::presentation::input::cli::OutputFormat; /// # use torrust_tracker_deployer_lib::bootstrap::logging::{LogFormat, LogOutput, LoggingConfig}; /// # use std::path::PathBuf; /// // Create args with log configuration @@ -96,6 +114,7 @@ impl GlobalArgs { /// log_output: LogOutput::FileAndStderr, /// log_dir: PathBuf::from("/tmp/logs"), /// working_dir: PathBuf::from("."), + /// output_format: OutputFormat::Text, /// }; /// let config = args.logging_config(); /// // config will have specified log formats and directory diff --git a/src/presentation/input/cli/mod.rs b/src/presentation/input/cli/mod.rs index 868f5eec8..458a63fa9 100644 --- a/src/presentation/input/cli/mod.rs +++ b/src/presentation/input/cli/mod.rs @@ -9,9 +9,11 @@ use clap::Parser; // Re-export submodules for convenient access pub mod args; pub mod commands; +pub mod output_format; pub use args::GlobalArgs; pub use commands::{Commands, CreateAction}; +pub use output_format::OutputFormat; /// Command-line interface for Torrust Tracker Deployer /// diff --git a/src/presentation/input/cli/output_format.rs b/src/presentation/input/cli/output_format.rs new file mode 100644 index 000000000..3d7681fcb --- /dev/null +++ b/src/presentation/input/cli/output_format.rs @@ -0,0 +1,60 @@ +//! Output format for command results +//! +//! This module defines the output format enum that controls how command results +//! are presented to users. It provides options for both human-readable text output +//! and machine-readable JSON output for automation. + +/// Output format for command results +/// +/// Controls the format of user-facing output that goes to stdout. +/// This is independent of logging format (which goes to stderr/file). +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::input::cli::OutputFormat; +/// +/// // Default is text format +/// let format = OutputFormat::default(); +/// assert!(matches!(format, OutputFormat::Text)); +/// +/// // JSON format for automation +/// let json_format = OutputFormat::Json; +/// ``` +#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)] +pub enum OutputFormat { + /// Human-readable text output (default) + /// + /// Produces formatted text with tables, sections, and visual elements + /// optimized for terminal display and human consumption. + /// + /// Example output: + /// ```text + /// ✅ Environment 'my-env' created successfully + /// + /// Environment Details: + /// 1. Environment name: my-env + /// 2. Instance name: torrust-tracker-vm-my-env + /// 3. Data directory: ./data/my-env + /// 4. Build directory: ./build/my-env + /// ``` + #[default] + Text, + + /// JSON output for automation and programmatic parsing + /// + /// Produces machine-readable JSON objects that can be parsed by tools + /// like jq, scripts, and AI agents for programmatic extraction of data. + /// + /// Example output: + /// ```json + /// { + /// "environment_name": "my-env", + /// "state": "Created", + /// "data_dir": "data/my-env", + /// "build_dir": "build/my-env", + /// "created_at": "2026-02-16T14:30:00Z" + /// } + /// ``` + Json, +} diff --git a/src/presentation/views/commands/create/environment_details.rs b/src/presentation/views/commands/create/environment_details.rs new file mode 100644 index 000000000..9666b9b20 --- /dev/null +++ b/src/presentation/views/commands/create/environment_details.rs @@ -0,0 +1,75 @@ +//! Environment Details Data Transfer Object +//! +//! This module contains the presentation DTO for environment creation details. +//! It serves as the data structure passed to view renderers (`TextView`, `JsonView`, etc.). +//! +//! # Architecture +//! +//! This follows the Strategy Pattern where: +//! - This DTO is the data passed to all rendering strategies +//! - Different views (`TextView`, `JsonView`) consume this data +//! - Adding new formats doesn't modify this DTO or existing views +//! +//! # SOLID Principles +//! +//! - **Single Responsibility**: This file only defines the data structure +//! - **Open/Closed**: New formats extend by adding views, not modifying this +//! - **Separation of Concerns**: Data definition separate from rendering logic + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use std::path::PathBuf; + +use crate::domain::environment::state::Created; +use crate::domain::environment::Environment; + +/// Environment details data for rendering +/// +/// This struct holds all the data needed to render environment creation +/// information for display to the user. It is consumed by view renderers +/// (`TextView`, `JsonView`) which format it according to their specific output format. +/// +/// # Design +/// +/// This is a presentation layer DTO (Data Transfer Object) that: +/// - Decouples domain models from view formatting +/// - Provides a stable interface for multiple view strategies +/// - Contains all fields needed for any output format +#[derive(Debug, Clone, Serialize)] +pub struct EnvironmentDetailsData { + /// Name of the created environment + pub environment_name: String, + /// Name of the instance that will be created + pub instance_name: String, + /// Path to the data directory + pub data_dir: PathBuf, + /// Path to the build directory + pub build_dir: PathBuf, + /// Timestamp when the environment was created (ISO 8601 format in JSON) + pub created_at: DateTime, +} + +/// Conversion from domain model to presentation DTO +/// +/// This `From` trait implementation is placed in the presentation layer +/// (not in the domain layer) to maintain proper DDD layering: +/// +/// - Domain layer should not depend on presentation layer DTOs +/// - Presentation layer can depend on domain models (allowed) +/// - This keeps the domain clean and focused on business logic +/// +/// Alternative approaches considered: +/// - Adding method to `Environment`: Would violate DDD by making +/// domain depend on presentation DTOs +/// - Keeping mapping in controller: Works but less idiomatic than `From` trait +impl From<&Environment> for EnvironmentDetailsData { + fn from(environment: &Environment) -> Self { + Self { + environment_name: environment.name().as_str().to_string(), + instance_name: environment.instance_name().as_str().to_string(), + data_dir: environment.data_dir().clone(), + build_dir: environment.build_dir().clone(), + created_at: environment.created_at(), + } + } +} diff --git a/src/presentation/views/commands/create/json_view.rs b/src/presentation/views/commands/create/json_view.rs new file mode 100644 index 000000000..0e3fe2eea --- /dev/null +++ b/src/presentation/views/commands/create/json_view.rs @@ -0,0 +1,168 @@ +//! JSON View for Environment Details +//! +//! This module provides JSON-based rendering for environment creation details. +//! It follows the Strategy Pattern, providing one specific rendering strategy +//! (machine-readable JSON) for environment details. + +use super::environment_details::EnvironmentDetailsData; + +/// JSON view for rendering environment creation details +/// +/// This view produces machine-readable JSON output suitable for programmatic +/// parsing, automation workflows, and AI agents. +/// +/// # Design +/// +/// This view is part of a Strategy Pattern implementation where: +/// - Each format (Text, JSON, XML, etc.) has its own dedicated view +/// - Adding new formats requires creating new view files, not modifying existing ones +/// - Follows Open/Closed Principle from SOLID +/// +/// # Examples +/// +/// ```rust +/// use std::path::PathBuf; +/// use chrono::{TimeZone, Utc}; +/// use torrust_tracker_deployer_lib::presentation::views::commands::create::{ +/// EnvironmentDetailsData, JsonView +/// }; +/// +/// let data = EnvironmentDetailsData { +/// environment_name: "my-env".to_string(), +/// instance_name: "torrust-tracker-vm-my-env".to_string(), +/// data_dir: PathBuf::from("./data/my-env"), +/// build_dir: PathBuf::from("./build/my-env"), +/// created_at: Utc.with_ymd_and_hms(2026, 2, 16, 14, 30, 0).unwrap(), +/// }; +/// +/// let json = JsonView::render(&data).expect("JSON serialization failed"); +/// assert!(json.contains(r#""environment_name": "my-env""#)); +/// ``` +pub struct JsonView; + +impl JsonView { + /// Render environment details as JSON + /// + /// Takes environment creation data and produces a JSON-formatted string + /// suitable for programmatic parsing and automation workflows. + /// + /// # Arguments + /// + /// * `data` - Environment details to render + /// + /// # Returns + /// + /// A JSON string containing: + /// - `environment_name`: Name of the created environment + /// - `instance_name`: Name of the VM instance + /// - `data_dir`: Path to environment data directory + /// - `build_dir`: Path to build artifacts directory + /// - `created_at`: ISO 8601 timestamp of environment creation + /// + /// # Format + /// + /// The output is pretty-printed JSON for readability: + /// ```json + /// { + /// "environment_name": "my-env", + /// "instance_name": "torrust-tracker-vm-my-env", + /// "data_dir": "./data/my-env", + /// "build_dir": "./build/my-env", + /// "created_at": "2026-02-16T14:30:00Z" + /// } + /// ``` + /// + /// # Errors + /// + /// Returns `serde_json::Error` if JSON serialization fails (very rare, + /// would indicate a bug in the serialization implementation). + pub fn render(data: &EnvironmentDetailsData) -> Result { + serde_json::to_string_pretty(data) + } +} + +// ============================================================================ +// Unit Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use std::path::PathBuf; + + fn test_timestamp() -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 2, 16, 14, 30, 0).unwrap() + } + + #[test] + fn it_should_render_environment_details_as_json_format() { + // Given + let data = EnvironmentDetailsData { + environment_name: "test-env".to_string(), + instance_name: "torrust-tracker-vm-test-env".to_string(), + data_dir: PathBuf::from("./data/test-env"), + build_dir: PathBuf::from("./build/test-env"), + created_at: test_timestamp(), + }; + + // When + let json = JsonView::render(&data).expect("JSON serialization failed"); + + // Then + assert!(json.contains(r#""environment_name": "test-env""#)); + assert!(json.contains(r#""instance_name": "torrust-tracker-vm-test-env""#)); + assert!(json.contains(r#""data_dir": "./data/test-env""#)); + assert!(json.contains(r#""build_dir": "./build/test-env""#)); + assert!(json.contains(r#""created_at": "2026-02-16T14:30:00Z""#)); + } + + #[test] + fn it_should_produce_valid_json_parsable_by_serde() { + // Given + let data = EnvironmentDetailsData { + environment_name: "prod".to_string(), + instance_name: "vm-prod".to_string(), + data_dir: PathBuf::from("/opt/data/prod"), + build_dir: PathBuf::from("/opt/build/prod"), + created_at: test_timestamp(), + }; + + // When + let json = JsonView::render(&data).expect("JSON serialization failed"); + let parsed: serde_json::Value = + serde_json::from_str(&json).expect("Should produce valid JSON"); + + // Then + assert_eq!(parsed["environment_name"], "prod"); + assert_eq!(parsed["instance_name"], "vm-prod"); + assert_eq!(parsed["data_dir"], "/opt/data/prod"); + assert_eq!(parsed["build_dir"], "/opt/build/prod"); + assert_eq!(parsed["created_at"], "2026-02-16T14:30:00Z"); + } + + #[test] + fn it_should_format_json_as_pretty_printed() { + // Given + let data = EnvironmentDetailsData { + environment_name: "my-env".to_string(), + instance_name: "vm-my-env".to_string(), + data_dir: PathBuf::from("./data/my-env"), + build_dir: PathBuf::from("./build/my-env"), + created_at: test_timestamp(), + }; + + // When + let json = JsonView::render(&data).expect("JSON serialization failed"); + + // Then - pretty-printed JSON should have newlines and indentation + assert!( + json.contains('\n'), + "JSON should be pretty-printed with newlines" + ); + assert!( + json.lines().count() > 1, + "JSON should span multiple lines when pretty-printed" + ); + } +} diff --git a/src/presentation/views/commands/create/mod.rs b/src/presentation/views/commands/create/mod.rs new file mode 100644 index 000000000..833cab6b6 --- /dev/null +++ b/src/presentation/views/commands/create/mod.rs @@ -0,0 +1,34 @@ +//! Views for Create Command +//! +//! This module contains view components for the create command, +//! responsible for formatting and rendering output to users. +//! +//! # Architecture +//! +//! This module follows the Strategy Pattern for rendering: +//! - `EnvironmentDetailsData`: The data DTO passed to all views +//! - `TextView`: Renders human-readable text output +//! - `JsonView`: Renders machine-readable JSON output +//! +//! # SOLID Principles +//! +//! - **Single Responsibility**: Each view has one job - render in its format +//! - **Open/Closed**: Add new formats by creating new view files, not modifying existing ones +//! - **Strategy Pattern**: Different rendering strategies for the same data +//! +//! # Adding New Formats +//! +//! To add a new output format (e.g., XML, YAML, CSV): +//! 1. Create a new file: `xml_view.rs`, `yaml_view.rs`, etc. +//! 2. Implement the view with `render(data: &EnvironmentDetailsData) -> Result` +//! 3. Export it from this module +//! 4. No need to modify existing views or the DTO + +pub mod environment_details; +pub mod json_view; +pub mod text_view; + +// Re-export main types for convenience +pub use environment_details::EnvironmentDetailsData; +pub use json_view::JsonView; +pub use text_view::TextView; diff --git a/src/presentation/views/commands/create/text_view.rs b/src/presentation/views/commands/create/text_view.rs new file mode 100644 index 000000000..482780cbe --- /dev/null +++ b/src/presentation/views/commands/create/text_view.rs @@ -0,0 +1,169 @@ +//! Text View for Environment Details +//! +//! This module provides text-based rendering for environment creation details. +//! It follows the Strategy Pattern, providing one specific rendering strategy +//! (human-readable text) for environment details. + +use super::environment_details::EnvironmentDetailsData; + +/// Text view for rendering environment creation details +/// +/// This view produces human-readable formatted text output suitable for +/// terminal display and human consumption. +/// +/// # Design +/// +/// This view is part of a Strategy Pattern implementation where: +/// - Each format (Text, JSON, XML, etc.) has its own dedicated view +/// - Adding new formats requires creating new view files, not modifying existing ones +/// - Follows Open/Closed Principle from SOLID +/// +/// # Examples +/// +/// ```rust +/// use std::path::PathBuf; +/// use chrono::{TimeZone, Utc}; +/// use torrust_tracker_deployer_lib::presentation::views::commands::create::{ +/// EnvironmentDetailsData, TextView +/// }; +/// +/// let data = EnvironmentDetailsData { +/// environment_name: "my-env".to_string(), +/// instance_name: "torrust-tracker-vm-my-env".to_string(), +/// data_dir: PathBuf::from("./data/my-env"), +/// build_dir: PathBuf::from("./build/my-env"), +/// created_at: Utc.with_ymd_and_hms(2026, 2, 16, 14, 30, 0).unwrap(), +/// }; +/// +/// let output = TextView::render(&data); +/// assert!(output.contains("Environment Details:")); +/// assert!(output.contains("my-env")); +/// ``` +pub struct TextView; + +impl TextView { + /// Render environment details as human-readable formatted text + /// + /// Takes environment creation data and produces a human-readable output + /// suitable for displaying to users via stdout. + /// + /// # Arguments + /// + /// * `data` - Environment details to render + /// + /// # Returns + /// + /// A formatted string containing: + /// - Section header ("Environment Details:") + /// - Numbered list of environment information + /// - Environment name + /// - Instance name + /// - Data directory path + /// - Build directory path + /// + /// # Format + /// + /// The output follows this structure: + /// ```text + /// Environment Details: + /// 1. Environment name: + /// 2. Instance name: + /// 3. Data directory: + /// 4. Build directory: + /// ``` + #[must_use] + pub fn render(data: &EnvironmentDetailsData) -> String { + let mut lines = Vec::new(); + + lines.push("Environment Details:".to_string()); + lines.push(format!("1. Environment name: {}", data.environment_name)); + lines.push(format!("2. Instance name: {}", data.instance_name)); + lines.push(format!("3. Data directory: {}", data.data_dir.display())); + lines.push(format!("4. Build directory: {}", data.build_dir.display())); + + lines.join("\n") + } +} + +// ============================================================================ +// Unit Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use std::path::PathBuf; + + fn test_timestamp() -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 2, 16, 14, 30, 0).unwrap() + } + + #[test] + fn it_should_render_environment_details_as_human_readable_format() { + // Given + let data = EnvironmentDetailsData { + environment_name: "test-env".to_string(), + instance_name: "torrust-tracker-vm-test-env".to_string(), + data_dir: PathBuf::from("./data/test-env"), + build_dir: PathBuf::from("./build/test-env"), + created_at: test_timestamp(), + }; + + // When + let output = TextView::render(&data); + + // Then + assert!(output.contains("Environment Details:")); + assert!(output.contains("1. Environment name: test-env")); + assert!( + output.contains("2. Instance name: torrust-tracker-vm-test-env"), + "Output missing instance name" + ); + assert!(output.contains("3. Data directory: ./data/test-env")); + assert!(output.contains("4. Build directory: ./build/test-env")); + } + + #[test] + fn it_should_format_output_with_proper_line_structure() { + // Given + let data = EnvironmentDetailsData { + environment_name: "prod".to_string(), + instance_name: "vm-prod".to_string(), + data_dir: PathBuf::from("/opt/deployer/data/prod"), + build_dir: PathBuf::from("/opt/deployer/build/prod"), + created_at: test_timestamp(), + }; + + // When + let output = TextView::render(&data); + let lines: Vec<&str> = output.lines().collect(); + + // Then + assert_eq!(lines.len(), 5, "Should have 5 lines (header + 4 details)"); + assert_eq!(lines[0], "Environment Details:"); + assert!(lines[1].starts_with("1. ")); + assert!(lines[2].starts_with("2. ")); + assert!(lines[3].starts_with("3. ")); + assert!(lines[4].starts_with("4. ")); + } + + #[test] + fn it_should_handle_absolute_paths_correctly() { + // Given + let data = EnvironmentDetailsData { + environment_name: "my-env".to_string(), + instance_name: "torrust-tracker-vm-my-env".to_string(), + data_dir: PathBuf::from("/absolute/path/data/my-env"), + build_dir: PathBuf::from("/absolute/path/build/my-env"), + created_at: test_timestamp(), + }; + + // When + let output = TextView::render(&data); + + // Then + assert!(output.contains("/absolute/path/data/my-env")); + assert!(output.contains("/absolute/path/build/my-env")); + } +} diff --git a/src/presentation/views/commands/mod.rs b/src/presentation/views/commands/mod.rs index 2085ec195..6b574b6e4 100644 --- a/src/presentation/views/commands/mod.rs +++ b/src/presentation/views/commands/mod.rs @@ -4,6 +4,7 @@ //! Each command has its own submodule with views for rendering //! command-specific output. +pub mod create; pub mod list; pub mod provision; pub mod shared;