Skip to content

Commit baa125c

Browse files
committed
docs: [#242] update rustdoc for display_connection_details method
Improved documentation to emphasize functional pipeline pattern and MVC architecture. The rustdoc now clearly shows the data flow transformation: Environment<Provisioned> → ConnectionDetailsData → String → stdout. Detailed the MVC pattern components (Model, DTO, View, Controller, Output) to make the architectural design explicit. Updated error documentation to specify return type.
1 parent 8902f01 commit baa125c

File tree

5 files changed

+327
-0
lines changed

5 files changed

+327
-0
lines changed

src/presentation/controllers/provision/handler.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ use crate::domain::environment::name::EnvironmentName;
1313
use crate::domain::environment::repository::EnvironmentRepository;
1414
use crate::domain::environment::state::Provisioned;
1515
use crate::domain::environment::Environment;
16+
use crate::presentation::views::commands::provision::connection_details::{
17+
ConnectionDetailsData, ConnectionDetailsView,
18+
};
1619
use crate::presentation::views::progress::ProgressReporter;
1720
use crate::presentation::views::UserOutput;
1821
use crate::shared::clock::Clock;
@@ -131,6 +134,8 @@ impl ProvisionCommandController {
131134

132135
self.complete_workflow(environment_name)?;
133136

137+
self.display_connection_details(&provisioned)?;
138+
134139
Ok(provisioned)
135140
}
136141

@@ -215,6 +220,51 @@ impl ProvisionCommandController {
215220
.complete(&format!("Environment '{name}' provisioned successfully"))?;
216221
Ok(())
217222
}
223+
224+
/// Display connection details after successful provisioning
225+
///
226+
/// Orchestrates a functional pipeline to display SSH connection information:
227+
/// `Environment<Provisioned>` → `ConnectionDetailsData` → `String` → stdout
228+
///
229+
/// The output is written to stdout (not stderr) as it represents the final
230+
/// command result rather than progress information.
231+
///
232+
/// # MVC Architecture
233+
///
234+
/// Following the MVC pattern with functional composition:
235+
/// - Model: `Environment<Provisioned>` (domain model)
236+
/// - DTO: `ConnectionDetailsData::from()` (data transformation)
237+
/// - View: `ConnectionDetailsView::render()` (formatting)
238+
/// - Controller (this method): Orchestrates the pipeline
239+
/// - Output: `ProgressReporter::result()` (routing to stdout)
240+
///
241+
/// # Arguments
242+
///
243+
/// * `provisioned` - The provisioned environment containing connection details
244+
///
245+
/// # Missing IP Handling
246+
///
247+
/// If the instance IP is missing (which should not happen after successful
248+
/// provisioning), the view displays a warning but does not cause an error.
249+
/// This is intentional - the controller's responsibility is to display
250+
/// information, not to validate state (that's the domain/application layer's job).
251+
///
252+
/// # Errors
253+
///
254+
/// Returns `ProvisionSubcommandError` if the `ProgressReporter` encounters
255+
/// a mutex error while writing to stdout.
256+
#[allow(clippy::result_large_err)]
257+
fn display_connection_details(
258+
&mut self,
259+
provisioned: &Environment<Provisioned>,
260+
) -> Result<(), ProvisionSubcommandError> {
261+
// Pipeline: Environment<Provisioned> → DTO → render → output to stdout
262+
self.progress.result(&ConnectionDetailsView::render(
263+
&ConnectionDetailsData::from(provisioned),
264+
))?;
265+
266+
Ok(())
267+
}
218268
}
219269

220270
#[cfg(test)]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//! Command-specific views
2+
//!
3+
//! This module contains view components organized by command.
4+
//! Each command has its own submodule with views for rendering
5+
//! command-specific output.
6+
7+
pub mod provision;
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
//! Connection Details View for Provision Command
2+
//!
3+
//! This module provides a view for rendering SSH connection details after
4+
//! successful infrastructure provisioning.
5+
6+
use std::net::IpAddr;
7+
use std::path::PathBuf;
8+
9+
use crate::domain::environment::state::Provisioned;
10+
use crate::domain::environment::Environment;
11+
12+
/// Connection details data for rendering
13+
///
14+
/// This struct holds all the data needed to render SSH connection information
15+
/// for a provisioned instance.
16+
#[derive(Debug, Clone)]
17+
pub struct ConnectionDetailsData {
18+
/// Instance IP address (None if not available)
19+
pub instance_ip: Option<IpAddr>,
20+
/// SSH port for connections
21+
pub ssh_port: u16,
22+
/// Path to SSH private key
23+
pub ssh_priv_key_path: PathBuf,
24+
/// SSH username for connections
25+
pub ssh_username: String,
26+
}
27+
28+
/// Conversion from domain model to presentation DTO
29+
///
30+
/// This `From` trait implementation is placed in the presentation layer
31+
/// (not in the domain layer) to maintain proper DDD layering:
32+
///
33+
/// - Domain layer should not depend on presentation layer DTOs
34+
/// - Presentation layer can depend on domain models (allowed)
35+
/// - This keeps the domain clean and focused on business logic
36+
///
37+
/// Alternative approaches considered:
38+
/// - Adding method to `Environment<Provisioned>`: Would violate DDD by making
39+
/// domain depend on presentation DTOs
40+
/// - Keeping mapping in controller: Works but less idiomatic than `From` trait
41+
impl From<&Environment<Provisioned>> for ConnectionDetailsData {
42+
fn from(provisioned: &Environment<Provisioned>) -> Self {
43+
Self {
44+
instance_ip: provisioned.instance_ip(),
45+
ssh_port: provisioned.ssh_port(),
46+
ssh_priv_key_path: provisioned.ssh_private_key_path().clone(),
47+
ssh_username: provisioned.ssh_username().as_str().to_string(),
48+
}
49+
}
50+
}
51+
52+
/// View for rendering SSH connection details
53+
///
54+
/// This view is responsible for formatting and rendering the connection
55+
/// information that users need to SSH into a provisioned instance.
56+
///
57+
/// # Design
58+
///
59+
/// Following MVC pattern, this view:
60+
/// - Receives data from the controller
61+
/// - Formats the output for display
62+
/// - Handles missing data gracefully
63+
/// - Returns a string ready for output to stdout
64+
///
65+
/// # Examples
66+
///
67+
/// ```rust
68+
/// use std::net::{IpAddr, Ipv4Addr};
69+
/// use std::path::PathBuf;
70+
/// use torrust_tracker_deployer_lib::presentation::views::commands::provision::ConnectionDetailsView;
71+
/// use torrust_tracker_deployer_lib::presentation::views::commands::provision::connection_details::ConnectionDetailsData;
72+
///
73+
/// let data = ConnectionDetailsData {
74+
/// instance_ip: Some(IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171))),
75+
/// ssh_port: 22,
76+
/// ssh_priv_key_path: PathBuf::from("fixtures/testing_rsa"),
77+
/// ssh_username: "torrust".to_string(),
78+
/// };
79+
///
80+
/// let output = ConnectionDetailsView::render(&data);
81+
/// assert!(output.contains("Instance Connection Details"));
82+
/// assert!(output.contains("10.140.190.171"));
83+
/// ```
84+
pub struct ConnectionDetailsView;
85+
86+
impl ConnectionDetailsView {
87+
/// Render connection details as a formatted string
88+
///
89+
/// Takes connection data and produces a human-readable output suitable
90+
/// for displaying to users via stdout.
91+
///
92+
/// # Arguments
93+
///
94+
/// * `data` - Connection details to render
95+
///
96+
/// # Returns
97+
///
98+
/// A formatted string containing:
99+
/// - Section header
100+
/// - IP address (or warning if missing)
101+
/// - SSH port
102+
/// - SSH private key path (absolute - as provided)
103+
/// - SSH username
104+
/// - Ready-to-copy SSH command (if IP is available)
105+
///
106+
/// # Missing IP Handling
107+
///
108+
/// If the instance IP is not available (which should not happen after
109+
/// successful provisioning), the view displays a warning message and
110+
/// omits the SSH connection command.
111+
///
112+
/// # Path Handling
113+
///
114+
/// SSH private key paths are expected to be absolute paths and are
115+
/// displayed as-is without further resolution.
116+
///
117+
/// # Examples
118+
///
119+
/// ```rust
120+
/// use std::net::{IpAddr, Ipv4Addr};
121+
/// use std::path::PathBuf;
122+
/// use torrust_tracker_deployer_lib::presentation::views::commands::provision::ConnectionDetailsView;
123+
/// use torrust_tracker_deployer_lib::presentation::views::commands::provision::connection_details::ConnectionDetailsData;
124+
///
125+
/// let data = ConnectionDetailsData {
126+
/// instance_ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))),
127+
/// ssh_port: 2222,
128+
/// ssh_priv_key_path: PathBuf::from("/home/user/.ssh/deploy_key"),
129+
/// ssh_username: "admin".to_string(),
130+
/// };
131+
///
132+
/// let output = ConnectionDetailsView::render(&data);
133+
/// assert!(output.contains("192.168.1.100"));
134+
/// assert!(output.contains("2222"));
135+
/// assert!(output.contains("admin"));
136+
/// ```
137+
#[must_use]
138+
pub fn render(data: &ConnectionDetailsData) -> String {
139+
match data.instance_ip {
140+
Some(ip) => format!(
141+
"\nInstance Connection Details:\n\
142+
\x20 IP Address: {}\n\
143+
\x20 SSH Port: {}\n\
144+
\x20 SSH Private Key: {}\n\
145+
\x20 SSH Username: {}\n\
146+
\n\
147+
Connect using:\n\
148+
\x20 ssh -i {} {}@{} -p {}",
149+
ip,
150+
data.ssh_port,
151+
data.ssh_priv_key_path.display(),
152+
data.ssh_username,
153+
data.ssh_priv_key_path.display(),
154+
data.ssh_username,
155+
ip,
156+
data.ssh_port
157+
),
158+
None => format!(
159+
"\nInstance Connection Details:\n\
160+
\x20 IP Address: <not available>\n\
161+
\x20 WARNING: Instance IP not captured - this is an unexpected state.\n\
162+
\x20 The environment may not be fully provisioned.\n\
163+
\x20 SSH Port: {}\n\
164+
\x20 SSH Private Key: {}\n\
165+
\x20 SSH Username: {}",
166+
data.ssh_port,
167+
data.ssh_priv_key_path.display(),
168+
data.ssh_username
169+
),
170+
}
171+
}
172+
}
173+
174+
#[cfg(test)]
175+
mod tests {
176+
use super::*;
177+
use std::net::{IpAddr, Ipv4Addr};
178+
179+
#[test]
180+
fn it_should_render_complete_connection_details_when_ip_is_available() {
181+
let data = ConnectionDetailsData {
182+
instance_ip: Some(IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171))),
183+
ssh_port: 22,
184+
ssh_priv_key_path: PathBuf::from("fixtures/testing_rsa"),
185+
ssh_username: "torrust".to_string(),
186+
};
187+
188+
let output = ConnectionDetailsView::render(&data);
189+
190+
assert!(output.contains("Instance Connection Details"));
191+
assert!(output.contains("10.140.190.171"));
192+
assert!(output.contains("SSH Port: 22"));
193+
assert!(output.contains("SSH Username: torrust"));
194+
assert!(output.contains("Connect using:"));
195+
assert!(output.contains("ssh -i"));
196+
}
197+
198+
#[test]
199+
fn it_should_render_warning_when_ip_is_missing() {
200+
let data = ConnectionDetailsData {
201+
instance_ip: None,
202+
ssh_port: 22,
203+
ssh_priv_key_path: PathBuf::from("fixtures/testing_rsa"),
204+
ssh_username: "torrust".to_string(),
205+
};
206+
207+
let output = ConnectionDetailsView::render(&data);
208+
209+
assert!(output.contains("Instance Connection Details"));
210+
assert!(output.contains("<not available>"));
211+
assert!(output.contains("WARNING"));
212+
assert!(output.contains("unexpected state"));
213+
assert!(!output.contains("Connect using:"));
214+
}
215+
216+
#[test]
217+
fn it_should_display_custom_ssh_port() {
218+
let data = ConnectionDetailsData {
219+
instance_ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))),
220+
ssh_port: 2222,
221+
ssh_priv_key_path: PathBuf::from("/home/user/.ssh/key"),
222+
ssh_username: "admin".to_string(),
223+
};
224+
225+
let output = ConnectionDetailsView::render(&data);
226+
227+
assert!(output.contains("SSH Port: 2222"));
228+
assert!(output.contains("-p 2222"));
229+
}
230+
231+
#[test]
232+
fn it_should_include_absolute_path_in_output() {
233+
let data = ConnectionDetailsData {
234+
instance_ip: Some(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))),
235+
ssh_port: 22,
236+
ssh_priv_key_path: PathBuf::from("/absolute/path/to/key"),
237+
ssh_username: "user".to_string(),
238+
};
239+
240+
let output = ConnectionDetailsView::render(&data);
241+
242+
// Should contain the path as provided
243+
assert!(output.contains("SSH Private Key:"));
244+
assert!(output.contains("/absolute/path/to/key"));
245+
}
246+
247+
#[test]
248+
fn it_should_preserve_absolute_paths() {
249+
let data = ConnectionDetailsData {
250+
instance_ip: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
251+
ssh_port: 22,
252+
ssh_priv_key_path: PathBuf::from("/absolute/path/to/key"),
253+
ssh_username: "deploy".to_string(),
254+
};
255+
256+
let output = ConnectionDetailsView::render(&data);
257+
258+
assert!(output.contains("/absolute/path/to/key"));
259+
}
260+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//! Views for Provision Command
2+
//!
3+
//! This module contains view components for rendering provision command output.
4+
5+
pub mod connection_details;
6+
7+
pub use connection_details::ConnectionDetailsView;

src/presentation/views/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,5 +92,8 @@ mod verbosity;
9292
// Progress indicators module (moved from presentation root for clear ownership)
9393
pub mod progress;
9494

95+
// Command-specific views (organized by command)
96+
pub mod commands;
97+
9598
// Testing utilities module (public for use in tests across the codebase)
9699
pub mod testing;

0 commit comments

Comments
 (0)