Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions src/presentation/cli/controllers/register/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ use crate::domain::environment::name::EnvironmentName;
use crate::domain::environment::repository::EnvironmentRepository;
use crate::domain::environment::state::Provisioned;
use crate::domain::environment::Environment;
use crate::presentation::cli::input::cli::OutputFormat;
use crate::presentation::cli::views::commands::register::{
JsonView, RegisterDetailsData, TextView,
};
use crate::presentation::cli::views::progress::ProgressReporter;
use crate::presentation::cli::views::UserOutput;
use crate::shared::clock::Clock;
Expand Down Expand Up @@ -105,6 +109,7 @@ impl RegisterCommandController {
/// * `environment_name` - The name of the environment to register the instance with
/// * `instance_ip_str` - The IP address string of the existing instance
/// * `ssh_port` - Optional SSH port (overrides environment config if provided)
/// * `output_format` - Output format (text or JSON)
///
/// # Errors
///
Expand All @@ -119,6 +124,7 @@ impl RegisterCommandController {
environment_name: &str,
instance_ip_str: &str,
ssh_port: Option<u16>,
output_format: OutputFormat,
) -> Result<Environment<Provisioned>, RegisterSubcommandError> {
let (env_name, instance_ip) = self.validate_input(environment_name, instance_ip_str)?;

Expand All @@ -128,7 +134,7 @@ impl RegisterCommandController {
.register_instance(&handler, &env_name, instance_ip, ssh_port)
.await?;

self.complete_workflow(environment_name)?;
self.complete_workflow(&provisioned, output_format)?;

Ok(provisioned)
}
Expand Down Expand Up @@ -206,12 +212,23 @@ impl RegisterCommandController {
}

/// Complete the workflow with success message
///
/// Dispatches to `TextView` or `JsonView` based on `output_format`.
#[allow(clippy::result_large_err)]
fn complete_workflow(&mut self, environment_name: &str) -> Result<(), RegisterSubcommandError> {
self.progress.complete(&format!(
"Instance registered successfully with environment '{environment_name}'"
))?;

fn complete_workflow(
&mut self,
provisioned: &Environment<Provisioned>,
output_format: OutputFormat,
) -> Result<(), RegisterSubcommandError> {
let data = RegisterDetailsData::from_environment(provisioned);
match output_format {
OutputFormat::Text => {
self.progress.complete(&TextView::render(&data))?;
}
OutputFormat::Json => {
self.progress.result(&JsonView::render(&data))?;
}
}
Ok(())
}
}
3 changes: 2 additions & 1 deletion src/presentation/cli/dispatch/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,11 @@ pub async fn route_command(
instance_ip,
ssh_port,
} => {
let output_format = context.output_format();
context
.container()
.create_register_controller()
.execute(&environment, &instance_ip, ssh_port)
.execute(&environment, &instance_ip, ssh_port, output_format)
.await?;
Ok(())
}
Expand Down
1 change: 1 addition & 0 deletions src/presentation/cli/views/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod destroy;
pub mod list;
pub mod provision;
pub mod purge;
pub mod register;
pub mod release;
pub mod render;
pub mod run;
Expand Down
52 changes: 52 additions & 0 deletions src/presentation/cli/views/commands/register/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//! Views for Register Command
//!
//! This module contains view components for rendering register command output.
//!
//! # Architecture
//!
//! This module follows the Strategy Pattern for rendering:
//! - `RegisterDetailsData`: The data DTO passed to all views
//! - `TextView`: Renders human-readable text output
//! - `JsonView`: Renders machine-readable JSON output
//!
//! # Structure
//!
//! - `view_data/`: Data structures (DTOs) passed to views
//! - `register_details.rs`: Main DTO with register result data
//! - `views/`: View rendering implementations
//! - `text_view.rs`: Human-readable text rendering
//! - `json_view.rs`: Machine-readable JSON rendering
//!
//! # 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 in `views/`: `xml_view.rs`, `yaml_view.rs`, etc.
//! 2. Implement the view with `render(data: &RegisterDetailsData) -> String`
//! 3. Export it from this module
//! 4. No need to modify existing views or the DTO

pub mod view_data {
pub mod register_details;

// Re-export main types for convenience
pub use register_details::RegisterDetailsData;
}

pub mod views {
pub mod json_view;
pub mod text_view;

// Re-export views for convenience
pub use json_view::JsonView;
pub use text_view::TextView;
}

// Re-export at module root for convenience
pub use view_data::RegisterDetailsData;
pub use views::{JsonView, TextView};
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Register Details Data Transfer Object
//!
//! This module contains the presentation DTO for register command 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 serde::Serialize;

use crate::domain::environment::state::Provisioned;
use crate::domain::environment::Environment;

/// Register details data for rendering
///
/// This struct holds all the data needed to render register command
/// 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 application types from view formatting
/// - Provides a stable interface for multiple view strategies
/// - Contains all fields needed for any output format
///
/// # Named Constructor
///
/// `RegisterDetailsData` is built from an `Environment<Provisioned>` since
/// the register command handler returns the provisioned environment on success.
/// `registered: true` is always set because this DTO is only constructed on
/// the success path.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RegisterDetailsData {
/// Name of the environment that was registered
pub environment_name: String,
/// IP address of the registered instance (empty string if unknown)
pub instance_ip: String,
/// SSH port of the registered instance
pub ssh_port: u16,
/// Always `true` when the command exits successfully
pub registered: bool,
}

impl RegisterDetailsData {
/// Construct a `RegisterDetailsData` from a provisioned environment
///
/// This named constructor always sets `registered: true` because it is only
/// called on the success path — register failures result in an error return,
/// never a `RegisterDetailsData`.
///
/// # Arguments
///
/// * `env` - The provisioned environment returned by the register command handler
///
/// # Examples
///
/// ```rust,ignore
/// use torrust_tracker_deployer_lib::presentation::cli::views::commands::register::RegisterDetailsData;
///
/// // Built from a provisioned environment in the success path
/// let data = RegisterDetailsData::from_environment(&provisioned_env);
///
/// assert!(data.registered);
/// assert_eq!(data.ssh_port, 22);
/// ```
#[must_use]
pub fn from_environment(env: &Environment<Provisioned>) -> Self {
Self {
environment_name: env.name().to_string(),
instance_ip: env
.instance_ip()
.map_or_else(String::new, |ip| ip.to_string()),

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instance_ip() is optional, but here it’s converted to an empty string when missing. For the register success path the IP should be present (provided by the user and set during the Created→Provisioned transition), and emitting "" can silently violate the expected JSON contract. Consider treating None as an internal invariant violation (e.g., expect with a clear message) or representing absence explicitly in the DTO (e.g., Option<IpAddr>/Option<String>) and reflecting that in the JSON view.

Suggested change
.map_or_else(String::new, |ip| ip.to_string()),
.expect("Provisioned environment must have an instance IP on register success path")
.to_string(),

Copilot uses AI. Check for mistakes.
ssh_port: env.ssh_port(),
registered: true,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn it_should_always_set_registered_to_true() {
// The constructor only produces RegisterDetailsData on the success path.
// We verify the field directly since we cannot construct Environment<Provisioned>
// without full infrastructure in a unit test.
let data = RegisterDetailsData {
Comment on lines +96 to +99

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests don’t cover RegisterDetailsData::from_environment(), and the comment suggests Environment<Provisioned> can’t be constructed in a unit test. The domain state machine provides Environment<Created>::register(ip) -> Environment<Provisioned>, so it should be possible to build a minimal environment and assert the DTO mapping (env name, instance_ip, ssh_port, registered) is correct.

Copilot uses AI. Check for mistakes.
environment_name: "test-env".to_string(),
instance_ip: "192.168.1.1".to_string(),
ssh_port: 22,
registered: true,
};

assert!(
data.registered,
"registered should always be true on success path"
);
}

#[test]
fn it_should_store_all_fields() {
// Arrange
let data = RegisterDetailsData {
environment_name: "my-env".to_string(),
instance_ip: "10.0.0.1".to_string(),
ssh_port: 2222,
registered: true,
};

// Assert
assert_eq!(data.environment_name, "my-env");
assert_eq!(data.instance_ip, "10.0.0.1");
assert_eq!(data.ssh_port, 2222);
assert!(data.registered);
}
}
Loading
Loading