Skip to main content

torrust_tracker_deployer_lib/domain/environment/
mod.rs

1//! Environment Domain Module
2//!
3//! This module contains all environment-related domain entities and types.
4//!
5//! ## Architecture: Context + State Design
6//!
7//! The `Environment` entity uses a two-part design to separate immutable identity
8//! from mutable lifecycle state:
9//!
10//! ### `EnvironmentContext` - Three Semantic Categories
11//!
12//! The context is organized into three distinct semantic types, each with a clear purpose:
13//!
14//! #### 1. **User Inputs** (`UserInputs`)
15//! - **Purpose**: Configuration provided when creating an environment
16//! - **Characteristics**: Immutable throughout environment lifecycle
17//! - **Fields**: `name`, `instance_name`, `profile_name`, `ssh_credentials`, `ssh_port`
18//! - **When to add**: User needs to configure something at creation time
19//!
20//! #### 2. **Internal Config** (`InternalConfig`)
21//! - **Purpose**: Derived configuration for internal use
22//! - **Characteristics**: Calculated from user inputs
23//! - **Fields**: `build_dir`, `data_dir`
24//! - **When to add**: Need internal paths or derived configuration
25//!
26//! #### 3. **Runtime Outputs** (`RuntimeOutputs`)
27//! - **Purpose**: Data generated during deployment operations
28//! - **Characteristics**: Mutable as operations progress
29//! - **Fields**: `instance_ip` (more fields expected as deployment evolves)
30//! - **When to add**: Operations produce new data about deployed infrastructure
31//!
32//! ### `state: S` - Mutable Lifecycle State
33//!
34//! Tracks the current phase in the deployment lifecycle using the type-state pattern:
35//! - **Success states**: `Created`, `Provisioning`, `Provisioned`, `Configuring`, etc.
36//! - **Error states**: `ProvisionFailed`, `ConfigureFailed`, etc.
37//!
38//! ### Benefits of This Design
39//!
40//! - **Compile-time safety**: Invalid state transitions caught at compile time
41//! - **Reduced pattern matching**: Access common fields without matching on state (83% reduction)
42//! - **Clear separation**: Identity vs. lifecycle are distinct concerns
43//! - **Semantic clarity**: Types document the purpose of each field
44//! - **Developer guidance**: Clear where to add new fields based on their purpose
45//! - **Easy extension**: Adding fields or states is straightforward
46//!
47//! ## Submodules
48//!
49//! - `context` - Environment context composing the three semantic types
50//! - `user_inputs` - User-provided configuration
51//! - `internal_config` - Derived paths and internal settings
52//! - `runtime_outputs` - Data generated during deployment
53//! - `name` - Environment name validation and management
54//! - `state` - State marker types and type erasure for environment state machine
55//!
56//! ## Main Entity
57//!
58//! The `Environment` entity encapsulates all environment-specific configuration for deployments.
59//! Each environment represents an isolated deployment context with its own directories,
60//! SSH keys, and instance naming.
61//!
62//! ## Purpose
63//!
64//! The Environment entity provides:
65//! - Environment-specific directory structure (`data/{env_name}/`, `build/{env_name}/`)
66//! - Instance naming with conflict avoidance (`torrust-tracker-vm-{env_name}`)
67//! - SSH key pair management per environment
68//! - JSON serialization for future state persistence
69//!
70//! ## Usage Example
71//!
72//! ```rust
73//! use torrust_tracker_deployer_lib::domain::environment::{Environment, name::EnvironmentName};
74//! use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
75//! use torrust_tracker_deployer_lib::domain::ProfileName;
76//! use torrust_tracker_deployer_lib::shared::Username;
77//! use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
78//! use std::path::PathBuf;
79//! use chrono::{TimeZone, Utc};
80//!
81//! let env_name = EnvironmentName::new("e2e-config".to_string())?;
82//! let ssh_username = Username::new("torrust".to_string())?;
83//! let ssh_credentials = SshCredentials::new(
84//!     PathBuf::from("fixtures/testing_rsa"),
85//!     PathBuf::from("fixtures/testing_rsa.pub"),
86//!     ssh_username,
87//! );
88//! let provider_config = ProviderConfig::Lxd(LxdConfig {
89//!     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
90//! });
91//! let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
92//! let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
93//!
94//! // Environment automatically generates paths
95//! assert_eq!(*environment.data_dir(), PathBuf::from("./data/e2e-config"));
96//! assert_eq!(*environment.build_dir(), PathBuf::from("./build/e2e-config"));
97//! assert_eq!(environment.templates_dir(), PathBuf::from("./data/e2e-config/templates"));
98//!
99//! # Ok::<(), Box<dyn std::error::Error>>(())
100//! ```
101
102pub mod context;
103pub mod internal_config;
104pub mod name;
105pub mod params;
106pub mod repository;
107pub mod runtime_outputs;
108pub mod state;
109mod trace_id;
110pub mod user_inputs;
111
112// Test utilities (only available in test configuration)
113#[cfg(test)]
114pub mod testing;
115
116// Re-export TraceId for use by state module
117pub use trace_id::TraceId;
118
119// Re-export commonly used types for convenience
120pub use context::EnvironmentContext;
121pub use internal_config::InternalConfig;
122pub use name::{EnvironmentName, EnvironmentNameError};
123pub use params::EnvironmentParams;
124pub use runtime_outputs::{ProvisionMethod, RuntimeOutputs};
125pub use state::{
126    AnyEnvironmentState, ConfigureFailed, Configured, Configuring, Created, DestroyFailed,
127    Destroyed, Destroying, ProvisionFailed, Provisioned, Provisioning, ReleaseFailed, Released,
128    Releasing, RunFailed, Running,
129};
130pub use user_inputs::{UserInputs, UserInputsError};
131
132// Re-export tracker types for convenience
133pub use crate::domain::tracker::{
134    DatabaseConfig, HealthCheckApiConfig, HttpApiConfig, HttpTrackerConfig, MysqlConfig,
135    SqliteConfig, TrackerConfig, TrackerCoreConfig, UdpTrackerConfig,
136};
137
138// Re-export Prometheus types for convenience
139pub use crate::domain::prometheus::PrometheusConfig;
140
141// Re-export Grafana types for convenience
142pub use crate::domain::grafana::GrafanaConfig;
143
144// Re-export Backup types for convenience
145pub use crate::domain::backup::BackupConfig;
146
147use crate::adapters::ssh::SshCredentials;
148use crate::domain::provider::ProviderConfig;
149use crate::domain::{InstanceName, ProfileName};
150use crate::shared::Username;
151use chrono::{DateTime, Utc};
152use serde::{Deserialize, Serialize};
153use std::net::IpAddr;
154use std::path::PathBuf;
155
156/// Directory name for trace files within an environment's data directory
157pub const TRACES_DIR_NAME: &str = "traces";
158
159/// Directory name for template files within an environment's data directory
160pub const TEMPLATES_DIR_NAME: &str = "templates";
161
162/// Directory name for Ansible-related files
163pub const ANSIBLE_DIR_NAME: &str = "ansible";
164
165/// Directory name for OpenTofu-related files
166pub const TOFU_DIR_NAME: &str = "tofu";
167
168/// Provider name for LXD infrastructure
169pub const LXD_PROVIDER_NAME: &str = "lxd";
170
171/// Environment configuration encapsulating all environment-specific settings
172///
173/// This entity represents a complete environment configuration including naming,
174/// directory structure, SSH keys, and derived paths. It follows the principle of
175/// environment isolation where each environment has its own separate resources.
176///
177/// # Architecture: Context + State Design
178///
179/// The `Environment<S>` is composed of two distinct parts:
180///
181/// ## `context: EnvironmentContext` - Immutable Identity
182///
183/// Contains all state-independent data that remains constant throughout the
184/// environment's lifecycle. This includes identity (`name`, `instance_name`),
185/// configuration (SSH credentials, port), and paths (`build_dir`, `data_dir`).
186///
187/// Accessing context data is efficient and requires no pattern matching on state.
188///
189/// ## `state: S` - Mutable Lifecycle State
190///
191/// Represents the current phase in the deployment lifecycle using type parameters.
192/// The type-state pattern ensures that state transitions are validated at compile-time.
193///
194/// # Type-State Pattern
195///
196/// The Environment uses the type-state pattern to enforce valid state transitions
197/// at compile-time. Each state is represented by a distinct type parameter `S`,
198/// ensuring that operations are only callable on appropriate states.
199///
200/// # Design Principles
201///
202/// - **Isolation**: Each environment is completely isolated from others
203/// - **Compile-time Safety**: Invalid state transitions caught during compilation
204/// - **Separation of Concerns**: Context (identity) vs. State (lifecycle) are distinct
205/// - **Consistency**: All paths follow the same naming pattern
206/// - **Predictability**: Paths are derived automatically from environment name
207/// - **Traceability**: All artifacts are organized by environment for debugging
208/// - **Type Safety**: Invalid state transitions are prevented at compile-time
209///
210/// # Directory Structure
211///
212/// ```text
213/// data/{env_name}/
214///   templates/         # Environment-specific templates
215/// build/{env_name}/    # Environment-specific build artifacts
216/// ```
217///
218/// # Instance Naming
219///
220/// Instance names follow the pattern: `torrust-tracker-vm-{env_name}`
221/// This ensures multiple environments can run concurrently without conflicts.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct Environment<S = Created> {
224    /// Core environment data shared across all states
225    context: EnvironmentContext,
226
227    /// Current state of the environment in the deployment lifecycle
228    state: S,
229}
230
231impl Environment {
232    /// Creates a new Environment with auto-generated paths and instance name
233    ///
234    /// # Arguments
235    ///
236    /// * `name` - The validated environment name
237    /// * `provider_config` - Provider-specific configuration (LXD, Hetzner, etc.)
238    /// * `ssh_credentials` - SSH credentials for connecting to instances
239    /// * `ssh_port` - SSH port for connecting to instances
240    ///
241    /// # Returns
242    ///
243    /// A new Environment instance with all paths and instance name automatically
244    /// generated based on the environment name.
245    ///
246    /// # Examples
247    ///
248    /// ```rust
249    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName, ProfileName};
250    /// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig};
251    /// use torrust_tracker_deployer_lib::shared::Username;
252    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
253    /// use std::path::PathBuf;
254    /// use chrono::{TimeZone, Utc};
255    ///
256    /// let env_name = EnvironmentName::new("production".to_string())?;
257    /// let ssh_username = Username::new("torrust".to_string())?;
258    /// let ssh_credentials = SshCredentials::new(
259    ///     PathBuf::from("keys/prod_rsa"),
260    ///     PathBuf::from("keys/prod_rsa.pub"),
261    ///     ssh_username,
262    /// );
263    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
264    ///     profile_name: ProfileName::new("torrust-profile-production".to_string())?,
265    /// });
266    /// let ssh_port = 22;
267    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
268    /// let environment = Environment::new(env_name, provider_config, ssh_credentials, ssh_port, created_at);
269    ///
270    /// assert_eq!(environment.instance_name().as_str(), "torrust-tracker-vm-production");
271    /// assert_eq!(*environment.data_dir(), PathBuf::from("./data/production"));
272    /// assert_eq!(*environment.build_dir(), PathBuf::from("./build/production"));
273    ///
274    /// # Ok::<(), Box<dyn std::error::Error>>(())
275    /// ```
276    ///
277    /// # Panics
278    ///
279    /// This function does not panic. All instance name generation is guaranteed
280    /// to succeed for valid environment names.
281    #[must_use]
282    #[allow(clippy::needless_pass_by_value)] // Public API takes ownership for ergonomics
283    pub fn new(
284        name: EnvironmentName,
285        provider_config: ProviderConfig,
286        ssh_credentials: SshCredentials,
287        ssh_port: u16,
288        created_at: DateTime<Utc>,
289    ) -> Environment<Created> {
290        let context = EnvironmentContext::new(
291            &name,
292            provider_config,
293            ssh_credentials,
294            ssh_port,
295            created_at,
296        );
297
298        Environment {
299            context,
300            state: Created,
301        }
302    }
303
304    /// Creates a new environment in Created state from validated parameters
305    ///
306    /// This is the primary factory method for creating a fully-configured
307    /// `Environment` aggregate. It accepts an `EnvironmentParams` value object
308    /// containing all validated domain inputs.
309    ///
310    /// This creates absolute paths for data and build directories by using the
311    /// provided working directory as the base.
312    ///
313    /// # Arguments
314    ///
315    /// * `params` - Validated environment parameters (domain value object)
316    /// * `working_dir` - Base directory for data and build directories
317    /// * `created_at` - Timestamp for environment creation
318    ///
319    /// # Errors
320    ///
321    /// Returns `UserInputsError` if the cross-service configuration is invalid:
322    /// - `GrafanaRequiresPrometheus`: Grafana is configured but Prometheus is not
323    /// - `HttpsSectionWithoutTlsServices`: HTTPS section exists but no service uses TLS
324    /// - `TlsServicesWithoutHttpsSection`: Service has TLS but HTTPS section is missing
325    #[allow(clippy::needless_pass_by_value)] // Public API takes ownership for ergonomics
326    pub fn create(
327        params: EnvironmentParams,
328        working_dir: &std::path::Path,
329        created_at: DateTime<Utc>,
330    ) -> Result<Environment<Created>, UserInputsError> {
331        let context = EnvironmentContext::create(params, working_dir, created_at)?;
332
333        Ok(Environment {
334            context,
335            state: Created,
336        })
337    }
338}
339
340// Common transitions available from any state
341impl<S> Environment<S> {
342    /// Internal helper: Creates a new environment with a different state
343    ///
344    /// This is a private helper method used by state transition methods to avoid
345    /// duplicating field copying code. It transfers all environment data while
346    /// changing only the state type parameter.
347    ///
348    /// This method automatically logs all state transitions at info level with
349    /// structured fields for observability and audit trail purposes.
350    ///
351    /// # Type Parameters
352    ///
353    /// * `T` - The target state type
354    ///
355    /// # Arguments
356    ///
357    /// * `new_state` - The new state instance to transition to
358    ///
359    /// # Returns
360    ///
361    /// A new `Environment<T>` with all fields preserved except the state
362    fn with_state<T>(self, new_state: T) -> Environment<T> {
363        // Log state transition for observability and audit trail
364        tracing::info!(
365            environment_name = %self.context.user_inputs.name(),
366            instance_name = %self.context.user_inputs.instance_name(),
367            from_state = std::any::type_name::<S>(),
368            to_state = std::any::type_name::<T>(),
369            "Environment state transition"
370        );
371
372        Environment {
373            context: self.context,
374            state: new_state,
375        }
376    }
377
378    /// Transitions from any state to Destroying state
379    ///
380    /// This method can be called from any state to begin the environment destruction process.
381    /// It indicates that the destroy command has started executing.
382    #[must_use]
383    pub fn start_destroying(self) -> Environment<Destroying> {
384        self.with_state(Destroying)
385    }
386
387    /// Transitions from any state to Destroyed state
388    ///
389    /// This method can be called from any state to destroy the environment.
390    /// It indicates that all infrastructure resources have been released.
391    #[must_use]
392    pub fn destroy(self) -> Environment<Destroyed> {
393        self.with_state(Destroyed)
394    }
395}
396
397// Type Erasure: Typed → Runtime conversions (into_any)
398// Generic implementations for all states
399impl<S> Environment<S> {
400    /// Get a reference to the environment context
401    ///
402    /// Provides access to all state-independent environment data.
403    #[must_use]
404    pub fn context(&self) -> &EnvironmentContext {
405        &self.context
406    }
407
408    /// Get a mutable reference to the environment context
409    ///
410    /// Used for operations that need to modify context data, such as
411    /// setting the instance IP after provisioning.
412    fn context_mut(&mut self) -> &mut EnvironmentContext {
413        &mut self.context
414    }
415
416    /// Returns a reference to the current state
417    #[must_use]
418    pub fn state(&self) -> &S {
419        &self.state
420    }
421
422    /// Returns the environment name
423    #[must_use]
424    pub fn name(&self) -> &EnvironmentName {
425        self.context.user_inputs.name()
426    }
427
428    /// Returns the instance name for this environment
429    #[must_use]
430    pub fn instance_name(&self) -> &InstanceName {
431        self.context.instance_name()
432    }
433
434    /// Returns the provider configuration for this environment
435    #[must_use]
436    pub fn provider_config(&self) -> &ProviderConfig {
437        self.context.provider_config()
438    }
439
440    /// Returns the SSH credentials for this environment
441    #[must_use]
442    pub fn ssh_credentials(&self) -> &SshCredentials {
443        self.context.ssh_credentials()
444    }
445
446    /// Returns the SSH port for this environment
447    #[must_use]
448    pub fn ssh_port(&self) -> u16 {
449        self.context.ssh_port()
450    }
451
452    /// Returns the database configuration for this environment
453    #[must_use]
454    pub fn database_config(&self) -> &DatabaseConfig {
455        self.context.database_config()
456    }
457
458    /// Returns the tracker configuration for this environment
459    #[must_use]
460    pub fn tracker_config(&self) -> &TrackerConfig {
461        self.context.tracker_config()
462    }
463
464    /// Returns the admin token for the HTTP API
465    #[must_use]
466    pub fn admin_token(&self) -> &str {
467        self.context.admin_token()
468    }
469
470    /// Returns the Prometheus configuration if enabled
471    #[must_use]
472    pub fn prometheus_config(&self) -> Option<&PrometheusConfig> {
473        self.context.prometheus_config()
474    }
475
476    /// Returns the Grafana configuration if enabled
477    #[must_use]
478    pub fn grafana_config(&self) -> Option<&GrafanaConfig> {
479        self.context.grafana_config()
480    }
481
482    /// Returns the Backup configuration if enabled
483    #[must_use]
484    pub fn backup_config(&self) -> Option<&BackupConfig> {
485        self.context.backup_config()
486    }
487
488    /// Returns the SSH username for this environment
489    #[must_use]
490    pub fn ssh_username(&self) -> &Username {
491        self.context.ssh_username()
492    }
493
494    /// Returns the SSH private key path for this environment
495    #[must_use]
496    pub fn ssh_private_key_path(&self) -> &PathBuf {
497        self.context.ssh_private_key_path()
498    }
499
500    /// Returns the SSH public key path for this environment
501    #[must_use]
502    pub fn ssh_public_key_path(&self) -> &PathBuf {
503        self.context.ssh_public_key_path()
504    }
505
506    /// Returns the build directory for this environment
507    #[must_use]
508    pub fn build_dir(&self) -> &PathBuf {
509        self.context.build_dir()
510    }
511
512    /// Returns the data directory for this environment
513    #[must_use]
514    pub fn data_dir(&self) -> &PathBuf {
515        self.context.data_dir()
516    }
517
518    /// Returns the instance IP address if available
519    ///
520    /// The instance IP is populated after successful provisioning and is
521    /// `None` for environments that haven't been provisioned yet.
522    ///
523    /// # Returns
524    ///
525    /// - `Some(IpAddr)` if the environment has been provisioned
526    /// - `None` if the environment hasn't been provisioned yet
527    ///
528    /// # Examples
529    ///
530    /// ```rust
531    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
532    /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
533    /// use torrust_tracker_deployer_lib::domain::ProfileName;
534    /// use torrust_tracker_deployer_lib::shared::Username;
535    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
536    /// use std::path::PathBuf;
537    /// use std::net::{IpAddr, Ipv4Addr};
538    /// use chrono::{TimeZone, Utc};
539    ///
540    /// let env_name = EnvironmentName::new("test".to_string())?;
541    /// let ssh_username = Username::new("torrust".to_string())?;
542    /// let ssh_credentials = SshCredentials::new(
543    ///     PathBuf::from("keys/test_rsa"),
544    ///     PathBuf::from("keys/test_rsa.pub"),
545    ///     ssh_username,
546    /// );
547    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
548    ///     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
549    /// });
550    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
551    /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
552    ///
553    /// // Before provisioning
554    /// assert_eq!(environment.instance_ip(), None);
555    ///
556    /// // After provisioning (simulated)
557    /// let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100));
558    /// let environment = environment.with_instance_ip(ip);
559    /// assert_eq!(environment.instance_ip(), Some(ip));
560    ///
561    /// # Ok::<(), Box<dyn std::error::Error>>(())
562    /// ```
563    #[must_use]
564    pub fn instance_ip(&self) -> Option<IpAddr> {
565        self.context.instance_ip()
566    }
567
568    /// Returns when this environment was created
569    ///
570    /// This timestamp is set when the environment is first created using the
571    /// `create environment` command and never changes throughout the
572    /// environment's lifecycle.
573    ///
574    /// # Returns
575    ///
576    /// The UTC timestamp when the environment was created.
577    #[must_use]
578    pub fn created_at(&self) -> DateTime<Utc> {
579        self.context.created_at()
580    }
581
582    /// Returns the provision method for this environment
583    ///
584    /// This method indicates how the infrastructure was provisioned:
585    /// - `Some(Provisioned)`: Created via `provision` command using `OpenTofu`
586    /// - `Some(Registered)`: Connected to existing infrastructure via `register` command
587    /// - `None`: Unknown or legacy state (before this field was added)
588    ///
589    /// # Returns
590    ///
591    /// The provision method, if set.
592    #[must_use]
593    pub fn provision_method(&self) -> Option<ProvisionMethod> {
594        self.context.provision_method()
595    }
596
597    /// Returns whether this environment's infrastructure is managed by this tool
598    ///
599    /// Infrastructure is considered "managed" if it was created via the `provision` command
600    /// using `OpenTofu`. Managed infrastructure can be destroyed using `tofu destroy`.
601    ///
602    /// Infrastructure is NOT managed if:
603    /// - It was registered from existing infrastructure via the `register` command
604    /// - The provision method is unknown (legacy state)
605    ///
606    /// # Returns
607    ///
608    /// `true` if the infrastructure was provisioned by this tool and can be destroyed,
609    /// `false` if the infrastructure is external and should not be touched.
610    ///
611    /// # Examples
612    ///
613    /// ```rust
614    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
615    /// use torrust_tracker_deployer_lib::domain::environment::runtime_outputs::ProvisionMethod;
616    /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
617    /// use torrust_tracker_deployer_lib::domain::ProfileName;
618    /// use torrust_tracker_deployer_lib::shared::Username;
619    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
620    /// use std::path::PathBuf;
621    /// use chrono::{TimeZone, Utc};
622    ///
623    /// let env_name = EnvironmentName::new("production".to_string())?;
624    /// let ssh_username = Username::new("torrust".to_string())?;
625    /// let ssh_credentials = SshCredentials::new(
626    ///     PathBuf::from("keys/prod_rsa"),
627    ///     PathBuf::from("keys/prod_rsa.pub"),
628    ///     ssh_username,
629    /// );
630    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
631    ///     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
632    /// });
633    ///
634    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
635    /// // Provisioned environment - infrastructure is managed
636    /// let provisioned_env = Environment::new(env_name.clone(), provider_config.clone(), ssh_credentials.clone(), 22, created_at)
637    ///     .with_provision_method(ProvisionMethod::Provisioned);
638    /// assert!(provisioned_env.is_infrastructure_managed());
639    ///
640    /// // Registered environment - infrastructure is NOT managed
641    /// let registered_env = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at)
642    ///     .with_provision_method(ProvisionMethod::Registered);
643    /// assert!(!registered_env.is_infrastructure_managed());
644    ///
645    /// # Ok::<(), Box<dyn std::error::Error>>(())
646    /// ```
647    #[must_use]
648    pub fn is_infrastructure_managed(&self) -> bool {
649        // Only infrastructure provisioned by this tool can be managed/destroyed
650        // Registered environments have external infrastructure we don't control
651        match self.provision_method() {
652            Some(ProvisionMethod::Registered) => false,
653            // Provisioned or legacy (None) environments are assumed managed
654            Some(ProvisionMethod::Provisioned) | None => true,
655        }
656    }
657
658    /// Sets the instance IP address for this environment
659    ///
660    /// This method is typically called by the `ProvisionCommandHandler` after successfully
661    /// provisioning the infrastructure and obtaining the instance's IP address.
662    ///
663    /// # Arguments
664    ///
665    /// * `ip` - The IP address of the provisioned instance
666    ///
667    /// # Returns
668    ///
669    /// A new Environment instance with the IP address set
670    ///
671    /// # Examples
672    ///
673    /// ```rust
674    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
675    /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
676    /// use torrust_tracker_deployer_lib::domain::ProfileName;
677    /// use torrust_tracker_deployer_lib::shared::Username;
678    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
679    /// use std::path::PathBuf;
680    /// use std::net::{IpAddr, Ipv4Addr};
681    /// use chrono::{TimeZone, Utc};
682    ///
683    /// let env_name = EnvironmentName::new("production".to_string())?;
684    /// let ssh_username = Username::new("torrust".to_string())?;
685    /// let ssh_credentials = SshCredentials::new(
686    ///     PathBuf::from("keys/prod_rsa"),
687    ///     PathBuf::from("keys/prod_rsa.pub"),
688    ///     ssh_username,
689    /// );
690    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
691    ///     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
692    /// });
693    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
694    /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
695    ///
696    /// // Set IP after provisioning
697    /// let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 42));
698    /// let environment = environment.with_instance_ip(ip);
699    ///
700    /// assert_eq!(environment.instance_ip(), Some(ip));
701    ///
702    /// # Ok::<(), Box<dyn std::error::Error>>(())
703    /// ```
704    #[must_use]
705    pub fn with_instance_ip(mut self, ip: IpAddr) -> Self {
706        self.context_mut().runtime_outputs.set_instance_ip(ip);
707        self
708    }
709
710    /// Sets the provision method and returns a new environment with the method set
711    ///
712    /// This method is used to track how the infrastructure was provisioned:
713    /// - `Provisioned`: Created via `provision` command using `OpenTofu`
714    /// - `Registered`: Connected to existing infrastructure via `register` command
715    ///
716    /// # Arguments
717    ///
718    /// * `method` - The provision method to set
719    ///
720    /// # Returns
721    ///
722    /// Returns the environment with the provision method set.
723    #[must_use]
724    pub fn with_provision_method(mut self, method: runtime_outputs::ProvisionMethod) -> Self {
725        self.context_mut()
726            .runtime_outputs
727            .set_provision_method(method);
728        self
729    }
730
731    /// Returns the templates directory for this environment
732    ///
733    /// The templates directory is located at `data/{env_name}/templates/`
734    /// and contains environment-specific template files.
735    ///
736    /// # Examples
737    ///
738    /// ```rust
739    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
740    /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
741    /// use torrust_tracker_deployer_lib::domain::ProfileName;
742    /// use torrust_tracker_deployer_lib::shared::Username;
743    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
744    /// use std::path::PathBuf;
745    /// use chrono::{TimeZone, Utc};
746    ///
747    /// let env_name = EnvironmentName::new("staging".to_string())?;
748    /// let ssh_username = Username::new("torrust".to_string())?;
749    /// let ssh_credentials = SshCredentials::new(
750    ///     PathBuf::from("keys/staging_rsa"),
751    ///     PathBuf::from("keys/staging_rsa.pub"),
752    ///     ssh_username,
753    /// );
754    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
755    ///     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
756    /// });
757    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
758    /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
759    ///
760    /// assert_eq!(
761    ///     environment.templates_dir(),
762    ///     PathBuf::from("./data/staging/templates")
763    /// );
764    ///
765    /// # Ok::<(), Box<dyn std::error::Error>>(())
766    /// ```
767    #[must_use]
768    pub fn templates_dir(&self) -> PathBuf {
769        self.context.templates_dir()
770    }
771
772    /// Returns the traces directory for this environment
773    ///
774    /// The traces directory is located at `data/{env_name}/traces/`
775    /// and contains error trace files for failed operations.
776    ///
777    /// # Examples
778    ///
779    /// ```rust
780    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
781    /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
782    /// use torrust_tracker_deployer_lib::domain::ProfileName;
783    /// use torrust_tracker_deployer_lib::shared::Username;
784    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
785    /// use std::path::PathBuf;
786    /// use chrono::{TimeZone, Utc};
787    ///
788    /// let env_name = EnvironmentName::new("production".to_string())?;
789    /// let ssh_username = Username::new("torrust".to_string())?;
790    /// let ssh_credentials = SshCredentials::new(
791    ///     PathBuf::from("keys/prod_rsa"),
792    ///     PathBuf::from("keys/prod_rsa.pub"),
793    ///     ssh_username,
794    /// );
795    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
796    ///     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
797    /// });
798    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
799    /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
800    ///
801    /// assert_eq!(
802    ///     environment.traces_dir(),
803    ///     PathBuf::from("./data/production/traces")
804    /// );
805    ///
806    /// # Ok::<(), Box<dyn std::error::Error>>(())
807    /// ```
808    #[must_use]
809    pub fn traces_dir(&self) -> PathBuf {
810        self.context.traces_dir()
811    }
812
813    /// Returns the ansible build directory for this environment
814    ///
815    /// # Examples
816    ///
817    /// ```rust
818    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
819    /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
820    /// use torrust_tracker_deployer_lib::domain::ProfileName;
821    /// use torrust_tracker_deployer_lib::shared::Username;
822    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
823    /// use std::path::PathBuf;
824    /// use chrono::{TimeZone, Utc};
825    ///
826    /// let env_name = EnvironmentName::new("dev".to_string())?;
827    /// let ssh_username = Username::new("torrust".to_string())?;
828    /// let ssh_credentials = SshCredentials::new(
829    ///     PathBuf::from("keys/dev_rsa"),
830    ///     PathBuf::from("keys/dev_rsa.pub"),
831    ///     ssh_username,
832    /// );
833    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
834    ///     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
835    /// });
836    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
837    /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
838    ///
839    /// assert_eq!(
840    ///     environment.ansible_build_dir(),
841    ///     PathBuf::from("./build/dev/ansible")
842    /// );
843    ///
844    /// # Ok::<(), Box<dyn std::error::Error>>(())
845    /// ```
846    #[must_use]
847    pub fn ansible_build_dir(&self) -> PathBuf {
848        self.context.ansible_build_dir()
849    }
850
851    /// Returns the tofu build directory for this environment
852    ///
853    /// # Examples
854    ///
855    /// ```rust
856    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
857    /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
858    /// use torrust_tracker_deployer_lib::domain::ProfileName;
859    /// use torrust_tracker_deployer_lib::shared::Username;
860    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
861    /// use std::path::PathBuf;
862    /// use chrono::{TimeZone, Utc};
863    ///
864    /// let env_name = EnvironmentName::new("test".to_string())?;
865    /// let ssh_username = Username::new("torrust".to_string())?;
866    /// let ssh_credentials = SshCredentials::new(
867    ///     PathBuf::from("keys/test_rsa"),
868    ///     PathBuf::from("keys/test_rsa.pub"),
869    ///     ssh_username,
870    /// );
871    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
872    ///     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
873    /// });
874    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
875    /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
876    ///
877    /// assert_eq!(
878    ///     environment.tofu_build_dir(),
879    ///     PathBuf::from("./build/test/tofu/lxd")
880    /// );
881    ///
882    /// # Ok::<(), Box<dyn std::error::Error>>(())
883    /// ```
884    #[must_use]
885    pub fn tofu_build_dir(&self) -> PathBuf {
886        self.context.tofu_build_dir()
887    }
888
889    /// Returns the ansible templates directory for this environment
890    ///
891    /// # Examples
892    ///
893    /// ```rust
894    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
895    /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
896    /// use torrust_tracker_deployer_lib::domain::ProfileName;
897    /// use torrust_tracker_deployer_lib::shared::Username;
898    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
899    /// use std::path::PathBuf;
900    /// use chrono::{TimeZone, Utc};
901    ///
902    /// let env_name = EnvironmentName::new("integration".to_string())?;
903    /// let ssh_username = Username::new("torrust".to_string())?;
904    /// let ssh_credentials = SshCredentials::new(
905    ///     PathBuf::from("keys/integration_rsa"),
906    ///     PathBuf::from("keys/integration_rsa.pub"),
907    ///     ssh_username,
908    /// );
909    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
910    ///     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
911    /// });
912    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
913    /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
914    ///
915    /// assert_eq!(
916    ///     environment.ansible_templates_dir(),
917    ///     PathBuf::from("./data/integration/templates/ansible")
918    /// );
919    ///
920    /// # Ok::<(), Box<dyn std::error::Error>>(())
921    /// ```
922    #[must_use]
923    pub fn ansible_templates_dir(&self) -> PathBuf {
924        self.context.ansible_templates_dir()
925    }
926
927    /// Returns the tofu templates directory for this environment
928    ///
929    /// # Examples
930    ///
931    /// ```rust
932    /// use torrust_tracker_deployer_lib::domain::{Environment, EnvironmentName};
933    /// use torrust_tracker_deployer_lib::domain::provider::{LxdConfig, ProviderConfig};
934    /// use torrust_tracker_deployer_lib::domain::ProfileName;
935    /// use torrust_tracker_deployer_lib::shared::Username;
936    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
937    /// use std::path::PathBuf;
938    /// use chrono::{TimeZone, Utc};
939    ///
940    /// let env_name = EnvironmentName::new("load-test".to_string())?;
941    /// let ssh_username = Username::new("torrust".to_string())?;
942    /// let ssh_credentials = SshCredentials::new(
943    ///     PathBuf::from("keys/load-test-rsa"),
944    ///     PathBuf::from("keys/load-test-rsa.pub"),
945    ///     ssh_username,
946    /// );
947    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
948    ///     profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
949    /// });
950    /// let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
951    /// let environment = Environment::new(env_name, provider_config, ssh_credentials, 22, created_at);
952    ///
953    /// assert_eq!(
954    ///     environment.tofu_templates_dir(),
955    ///     PathBuf::from("./data/load-test/templates/tofu")
956    /// );
957    ///
958    /// # Ok::<(), Box<dyn std::error::Error>>(())
959    /// ```
960    #[must_use]
961    pub fn tofu_templates_dir(&self) -> PathBuf {
962        self.context.tofu_templates_dir()
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969    use crate::adapters::ssh::SshCredentials;
970    use crate::domain::grafana::GrafanaConfig;
971    use crate::domain::prometheus::PrometheusConfig;
972    use crate::domain::provider::{LxdConfig, ProviderConfig};
973    use crate::domain::tracker::TrackerConfig;
974    use crate::domain::EnvironmentName;
975    use std::path::Path;
976    use tempfile::TempDir;
977
978    // ============================================================================
979    // Test Fixtures - Builder Pattern
980    // ============================================================================
981
982    /// Test builder for creating Environment instances with sensible defaults
983    ///
984    /// This builder simplifies test setup by providing default values and allowing
985    /// customization through a fluent API. It automatically manages temporary
986    /// directories and creates all required value objects.
987    ///
988    /// # Examples
989    ///
990    /// ```rust
991    /// // Simple environment with defaults
992    /// let env = EnvironmentTestBuilder::new().build();
993    ///
994    /// // Customized environment
995    /// let env = EnvironmentTestBuilder::new()
996    ///     .with_name("staging")
997    ///     .with_ssh_key_name("custom_key")
998    ///     .build();
999    ///
1000    /// // Environment with access to temp directory
1001    /// let (env, temp_dir) = EnvironmentTestBuilder::new()
1002    ///     .with_name("test-env")
1003    ///     .build_with_temp_dir();
1004    /// ```
1005    pub struct EnvironmentTestBuilder {
1006        env_name: String,
1007        ssh_key_name: String,
1008        ssh_username: String,
1009        temp_dir: TempDir,
1010    }
1011
1012    impl EnvironmentTestBuilder {
1013        /// Creates a new builder with sensible defaults
1014        pub fn new() -> Self {
1015            Self {
1016                env_name: "test-env".to_string(),
1017                ssh_key_name: "test_key".to_string(),
1018                ssh_username: "torrust".to_string(),
1019                temp_dir: TempDir::new().expect("Failed to create temp directory"),
1020            }
1021        }
1022
1023        /// Sets the environment name
1024        pub fn with_name(mut self, name: &str) -> Self {
1025            self.env_name = name.to_string();
1026            self
1027        }
1028
1029        /// Sets the SSH key name (without .pub extension)
1030        pub fn with_ssh_key_name(mut self, key_name: &str) -> Self {
1031            self.ssh_key_name = key_name.to_string();
1032            self
1033        }
1034
1035        /// Sets the SSH username
1036        #[allow(dead_code)]
1037        pub fn with_ssh_username(mut self, username: &str) -> Self {
1038            self.ssh_username = username.to_string();
1039            self
1040        }
1041
1042        /// Builds an Environment in Created state
1043        ///
1044        /// This is the most common use case - creates an environment with
1045        /// auto-generated paths based on the environment name.
1046        pub fn build(self) -> Environment<Created> {
1047            let env_name = EnvironmentName::new(self.env_name).unwrap();
1048            let ssh_username = Username::new(self.ssh_username).unwrap();
1049            let temp_path = self.temp_dir.path();
1050
1051            let ssh_credentials = SshCredentials::new(
1052                temp_path.join(&self.ssh_key_name),
1053                temp_path.join(format!("{}.pub", &self.ssh_key_name)),
1054                ssh_username,
1055            );
1056
1057            let ssh_port = 22;
1058            let provider_config = ProviderConfig::Lxd(LxdConfig {
1059                profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
1060            });
1061
1062            Environment::new(
1063                env_name,
1064                provider_config,
1065                ssh_credentials,
1066                ssh_port,
1067                chrono::Utc::now(),
1068            )
1069        }
1070
1071        /// Builds an Environment and returns the `TempDir`
1072        ///
1073        /// Use this when you need access to the temp directory in your test,
1074        /// for example to verify paths or create additional test files.
1075        #[allow(dead_code)]
1076        pub fn build_with_temp_dir(self) -> (Environment<Created>, TempDir) {
1077            let env_name = EnvironmentName::new(self.env_name).unwrap();
1078            let ssh_username = Username::new(self.ssh_username).unwrap();
1079            let temp_path = self.temp_dir.path();
1080
1081            let ssh_credentials = SshCredentials::new(
1082                temp_path.join(&self.ssh_key_name),
1083                temp_path.join(format!("{}.pub", &self.ssh_key_name)),
1084                ssh_username,
1085            );
1086
1087            let ssh_port = 22;
1088            let provider_config = ProviderConfig::Lxd(LxdConfig {
1089                profile_name: ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap(),
1090            });
1091            let environment = Environment::new(
1092                env_name,
1093                provider_config,
1094                ssh_credentials,
1095                ssh_port,
1096                chrono::Utc::now(),
1097            );
1098            (environment, self.temp_dir)
1099        }
1100
1101        /// Builds an Environment with custom paths
1102        ///
1103        /// Use this when you need full control over the data and build directories.
1104        /// Returns the environment, `data_dir`, `build_dir`, and `temp_dir`.
1105        pub fn build_with_custom_paths(self) -> (Environment<Created>, PathBuf, PathBuf, TempDir) {
1106            let temp_path = self.temp_dir.path();
1107            let data_dir = temp_path.join("data").join(&self.env_name);
1108            let build_dir = temp_path.join("build").join(&self.env_name);
1109
1110            let env_name = EnvironmentName::new(self.env_name).unwrap();
1111            let ssh_username = Username::new(self.ssh_username).unwrap();
1112            let ssh_credentials = SshCredentials::new(
1113                temp_path.join(&self.ssh_key_name),
1114                temp_path.join(format!("{}.pub", &self.ssh_key_name)),
1115                ssh_username,
1116            );
1117
1118            let profile_name = ProfileName::new(format!("lxd-{}", env_name.as_str())).unwrap();
1119            let provider_config = ProviderConfig::Lxd(LxdConfig { profile_name });
1120
1121            let user_inputs = UserInputs::with_tracker(
1122                &env_name,
1123                provider_config,
1124                ssh_credentials,
1125                22,
1126                TrackerConfig::default(),
1127                Some(PrometheusConfig::default()),
1128                Some(GrafanaConfig::default()),
1129                None,
1130                None, // No backup
1131            )
1132            .expect("Test UserInputs should always be valid with defaults");
1133
1134            let context = EnvironmentContext {
1135                user_inputs,
1136                internal_config: InternalConfig {
1137                    data_dir: data_dir.clone(),
1138                    build_dir: build_dir.clone(),
1139                },
1140                runtime_outputs: RuntimeOutputs::new(),
1141                created_at: chrono::Utc::now(),
1142            };
1143
1144            let environment = Environment {
1145                context,
1146                state: Created,
1147            };
1148
1149            (environment, data_dir, build_dir, self.temp_dir)
1150        }
1151
1152        /// Returns a reference to the temp directory path
1153        #[allow(dead_code)]
1154        pub fn temp_path(&self) -> &Path {
1155            self.temp_dir.path()
1156        }
1157    }
1158
1159    impl Default for EnvironmentTestBuilder {
1160        fn default() -> Self {
1161            Self::new()
1162        }
1163    }
1164
1165    // ============================================================================
1166    // Custom Assertion Helpers
1167    // ============================================================================
1168
1169    /// Asserts that the environment's paths are within the temp directory
1170    #[allow(dead_code)]
1171    fn assert_paths_in_temp_dir(env: &Environment<impl Clone>, temp_path: &Path, env_name: &str) {
1172        assert!(
1173            env.data_dir().starts_with(temp_path),
1174            "data_dir should be in temp: {:?} not in {:?}",
1175            env.data_dir(),
1176            temp_path
1177        );
1178        assert!(
1179            env.build_dir().starts_with(temp_path),
1180            "build_dir should be in temp: {:?} not in {:?}",
1181            env.build_dir(),
1182            temp_path
1183        );
1184        assert!(
1185            env.data_dir().to_string_lossy().contains(env_name),
1186            "data_dir should contain env name: {:?}",
1187            env.data_dir()
1188        );
1189        assert!(
1190            env.build_dir().to_string_lossy().contains(env_name),
1191            "build_dir should contain env name: {:?}",
1192            env.build_dir()
1193        );
1194    }
1195
1196    /// Asserts that SSH credentials match expected paths
1197    fn assert_ssh_credentials(
1198        env: &Environment<impl Clone>,
1199        expected_private: &Path,
1200        expected_public: &Path,
1201    ) {
1202        assert_eq!(
1203            env.ssh_private_key_path(),
1204            expected_private,
1205            "SSH private key path mismatch"
1206        );
1207        assert_eq!(
1208            env.ssh_public_key_path(),
1209            expected_public,
1210            "SSH public key path mismatch"
1211        );
1212    }
1213
1214    /// Asserts that the instance name matches the expected format
1215    fn assert_instance_name_format(env: &Environment<impl Clone>, env_name: &str) {
1216        let expected = format!("torrust-tracker-vm-{env_name}");
1217        assert_eq!(
1218            env.instance_name().as_str(),
1219            expected,
1220            "Instance name should match expected format"
1221        );
1222    }
1223
1224    /// Asserts that a path ends with the expected suffix
1225    #[allow(dead_code)]
1226    fn assert_path_ends_with(actual: &Path, expected_suffix: &str) {
1227        assert!(
1228            actual.to_string_lossy().ends_with(expected_suffix),
1229            "Path {actual:?} should end with {expected_suffix:?}"
1230        );
1231    }
1232
1233    // ============================================================================
1234    // Tests
1235    // ============================================================================
1236
1237    #[test]
1238    fn it_should_create_environment_with_auto_generated_paths() {
1239        // Arrange
1240        let (environment, data_dir, build_dir, temp_dir) = EnvironmentTestBuilder::new()
1241            .with_name("test-env")
1242            .with_ssh_key_name("testing_rsa")
1243            .build_with_custom_paths();
1244        let temp_path = temp_dir.path();
1245
1246        // Act & Assert: Check basic fields
1247        assert_eq!(environment.name().as_str(), "test-env");
1248        assert_eq!(environment.ssh_username().as_str(), "torrust");
1249
1250        // Assert: Check SSH credentials
1251        assert_ssh_credentials(
1252            &environment,
1253            &temp_path.join("testing_rsa"),
1254            &temp_path.join("testing_rsa.pub"),
1255        );
1256
1257        // Assert: Check paths are in temp directory
1258        assert_eq!(*environment.data_dir(), data_dir);
1259        assert_eq!(*environment.build_dir(), build_dir);
1260
1261        // Assert: Check instance name format
1262        assert_instance_name_format(&environment, "test-env");
1263    }
1264
1265    #[test]
1266    fn it_should_generate_correct_template_directories() {
1267        // Arrange
1268        let (environment, data_dir, _build_dir, _temp_dir) = EnvironmentTestBuilder::new()
1269            .with_name("test-production")
1270            .with_ssh_key_name("prod_rsa")
1271            .build_with_custom_paths();
1272
1273        // Act
1274        let templates_dir = environment.templates_dir();
1275        let ansible_dir = environment.ansible_templates_dir();
1276        let tofu_dir = environment.tofu_templates_dir();
1277
1278        // Assert
1279        assert_eq!(templates_dir, data_dir.join("templates"));
1280        assert_eq!(ansible_dir, data_dir.join("templates").join("ansible"));
1281        assert_eq!(tofu_dir, data_dir.join("templates").join("tofu"));
1282    }
1283
1284    #[test]
1285    fn it_should_generate_correct_build_directories() {
1286        // Arrange
1287        let (environment, _data_dir, build_dir, _temp_dir) = EnvironmentTestBuilder::new()
1288            .with_name("test-staging")
1289            .with_ssh_key_name("staging_rsa")
1290            .build_with_custom_paths();
1291
1292        // Act
1293        let ansible_dir = environment.ansible_build_dir();
1294        let tofu_dir = environment.tofu_build_dir();
1295
1296        // Assert
1297        assert_eq!(ansible_dir, build_dir.join("ansible"));
1298        assert_eq!(tofu_dir, build_dir.join("tofu").join("lxd"));
1299    }
1300
1301    #[test]
1302    fn it_should_handle_different_environment_names() {
1303        // Arrange: Test cases with environment names and expected instance names
1304        let test_cases = vec![
1305            ("test-dev", "torrust-tracker-vm-test-dev"),
1306            (
1307                "test-e2e-provision",
1308                "torrust-tracker-vm-test-e2e-provision",
1309            ),
1310            ("test-integration", "torrust-tracker-vm-test-integration"),
1311            ("test-release-v1-2", "torrust-tracker-vm-test-release-v1-2"),
1312        ];
1313
1314        for (env_name_str, expected_instance_name) in test_cases {
1315            // Arrange
1316            let (environment, data_dir, build_dir, _temp_dir) = EnvironmentTestBuilder::new()
1317                .with_name(env_name_str)
1318                .build_with_custom_paths();
1319
1320            // Act & Assert
1321            assert_eq!(environment.instance_name().as_str(), expected_instance_name);
1322            assert_eq!(*environment.data_dir(), data_dir);
1323            assert_eq!(*environment.build_dir(), build_dir);
1324        }
1325    }
1326
1327    #[test]
1328    fn it_should_be_serializable_to_json() {
1329        // Arrange
1330        let (environment, data_dir, build_dir, temp_dir) = EnvironmentTestBuilder::new()
1331            .with_name("test-serialization")
1332            .with_ssh_key_name("test_private_key")
1333            .build_with_custom_paths();
1334        let temp_path = temp_dir.path();
1335
1336        // Act: Serialize to JSON
1337        let json = serde_json::to_string(&environment).unwrap();
1338
1339        // Act: Deserialize back
1340        let deserialized: Environment = serde_json::from_str(&json).unwrap();
1341
1342        // Assert: Check that all fields are preserved
1343        assert_eq!(deserialized.name().as_str(), "test-serialization");
1344        assert_instance_name_format(&deserialized, "test-serialization");
1345        assert_ssh_credentials(
1346            &deserialized,
1347            &temp_path.join("test_private_key"),
1348            &temp_path.join("test_private_key.pub"),
1349        );
1350        assert_eq!(*deserialized.data_dir(), data_dir);
1351        assert_eq!(*deserialized.build_dir(), build_dir);
1352    }
1353
1354    #[test]
1355    fn it_should_support_common_e2e_environment_names() {
1356        // Arrange: Common E2E environment names
1357        let e2e_environments = vec!["test-e2e-config", "test-e2e-provision", "test-e2e-full"];
1358
1359        for env_name_str in e2e_environments {
1360            // Arrange
1361            let environment = EnvironmentTestBuilder::new()
1362                .with_name(env_name_str)
1363                .with_ssh_key_name("testing_rsa")
1364                .build();
1365
1366            // Act & Assert: Verify the environment is created successfully
1367            assert_eq!(environment.name().as_str(), env_name_str);
1368            assert!(environment
1369                .instance_name()
1370                .as_str()
1371                .starts_with("torrust-tracker-vm-"));
1372            assert!(environment
1373                .data_dir()
1374                .to_string_lossy()
1375                .contains(env_name_str));
1376            assert!(environment
1377                .build_dir()
1378                .to_string_lossy()
1379                .contains(env_name_str));
1380        }
1381    }
1382
1383    #[test]
1384    fn it_should_handle_dash_separated_environment_names() {
1385        // Arrange
1386        let (environment, data_dir, build_dir, _temp_dir) = EnvironmentTestBuilder::new()
1387            .with_name("test-feature-user-auth")
1388            .with_ssh_key_name("feature_rsa")
1389            .build_with_custom_paths();
1390
1391        // Act & Assert
1392        assert_instance_name_format(&environment, "test-feature-user-auth");
1393        assert_eq!(*environment.data_dir(), data_dir);
1394        assert_eq!(*environment.build_dir(), build_dir);
1395        assert_eq!(environment.templates_dir(), data_dir.join("templates"));
1396    }
1397
1398    // State transition tests
1399    mod state_transitions {
1400        use std::net::{IpAddr, Ipv4Addr};
1401
1402        use super::*;
1403
1404        /// Helper function to create a test environment for state transition tests
1405        fn create_test_environment() -> Environment<Created> {
1406            EnvironmentTestBuilder::new()
1407                .with_name("test-state")
1408                .build()
1409        }
1410
1411        #[test]
1412        fn it_should_transition_to_destroyed_from_created() {
1413            // Arrange
1414            let env = create_test_environment();
1415
1416            // Act
1417            let env = env.destroy();
1418
1419            // Assert
1420            assert_eq!(*env.state(), Destroyed);
1421            assert_eq!(env.name().as_str(), "test-state");
1422        }
1423
1424        #[test]
1425        fn it_should_complete_full_happy_path_transition() {
1426            // Arrange
1427            let env = create_test_environment();
1428
1429            // Act: Complete happy path: Created -> Running
1430            let env = env
1431                .start_provisioning()
1432                .provisioned(
1433                    IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
1434                    ProvisionMethod::Provisioned,
1435                )
1436                .start_configuring()
1437                .configured()
1438                .start_releasing()
1439                .released()
1440                .start_running();
1441
1442            // Assert
1443            assert_eq!(*env.state(), Running);
1444            assert_eq!(env.name().as_str(), "test-state");
1445
1446            // Act: Then destroy
1447            let env = env.destroy();
1448
1449            // Assert
1450            assert_eq!(*env.state(), Destroyed);
1451        }
1452
1453        #[test]
1454        fn it_should_preserve_all_fields_during_transitions() {
1455            // Arrange
1456            let env = create_test_environment();
1457            let initial_name = env.name().clone();
1458            let initial_instance_name = env.instance_name().clone();
1459            let initial_data_dir = env.data_dir().clone();
1460            let initial_build_dir = env.build_dir().clone();
1461
1462            // Act: Go through several transitions
1463            let env = env
1464                .start_provisioning()
1465                .provisioned(
1466                    IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
1467                    ProvisionMethod::Provisioned,
1468                )
1469                .start_configuring()
1470                .configured();
1471
1472            // Assert: Verify all fields are preserved
1473            assert_eq!(env.name(), &initial_name);
1474            assert_eq!(env.instance_name(), &initial_instance_name);
1475            assert_eq!(env.data_dir(), &initial_data_dir);
1476            assert_eq!(env.build_dir(), &initial_build_dir);
1477        }
1478
1479        // State transition logging tests
1480        mod logging {
1481            use super::*;
1482            use tracing_test::traced_test;
1483
1484            #[traced_test]
1485            #[test]
1486            fn it_should_log_state_transition_from_created_to_provisioning() {
1487                let env = create_test_environment();
1488
1489                let _provisioning = env.start_provisioning();
1490
1491                // Assert log contains expected fields
1492                assert!(logs_contain("Environment state transition"));
1493                assert!(logs_contain("environment_name=test-state"));
1494                assert!(logs_contain("from_state="));
1495                assert!(logs_contain("Created"));
1496                assert!(logs_contain("to_state="));
1497                assert!(logs_contain("Provisioning"));
1498            }
1499
1500            #[traced_test]
1501            #[test]
1502            fn it_should_log_state_transition_with_instance_name() {
1503                let env = create_test_environment();
1504
1505                let _provisioning = env.start_provisioning();
1506
1507                assert!(logs_contain("instance_name=torrust-tracker-vm-test-state"));
1508            }
1509
1510            #[traced_test]
1511            #[test]
1512            fn it_should_log_complete_state_transition_chain() {
1513                let env = create_test_environment();
1514
1515                let _env = env
1516                    .start_provisioning()
1517                    .provisioned(
1518                        IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
1519                        ProvisionMethod::Provisioned,
1520                    )
1521                    .start_configuring()
1522                    .configured();
1523
1524                // Verify multiple transitions were logged
1525                assert!(logs_contain("Provisioning"));
1526                assert!(logs_contain("Provisioned"));
1527                assert!(logs_contain("Configuring"));
1528                assert!(logs_contain("Configured"));
1529            }
1530
1531            #[traced_test]
1532            #[test]
1533            fn it_should_log_destroy_transition_from_any_state() {
1534                let env = create_test_environment();
1535                let env = env.start_provisioning();
1536
1537                let _destroyed = env.destroy();
1538
1539                assert!(logs_contain("Destroyed"));
1540            }
1541        }
1542
1543        // Three-way split tests
1544        mod three_way_split {
1545            use super::*;
1546            use std::net::{IpAddr, Ipv4Addr};
1547
1548            #[test]
1549            fn it_should_separate_user_inputs_from_context() {
1550                let env = EnvironmentTestBuilder::new()
1551                    .with_name("test-split")
1552                    .build();
1553
1554                // Can access user inputs directly
1555                assert_eq!(env.context.user_inputs.name().as_str(), "test-split");
1556                assert_eq!(env.context.user_inputs.ssh_port(), 22);
1557            }
1558
1559            #[test]
1560            fn it_should_derive_internal_config_automatically() {
1561                let env = EnvironmentTestBuilder::new()
1562                    .with_name("test-derived")
1563                    .build();
1564
1565                // Internal config is derived from name
1566                let data_dir = &env.context.internal_config.data_dir;
1567                let build_dir = &env.context.internal_config.build_dir;
1568
1569                assert!(data_dir.to_string_lossy().contains("test-derived"));
1570                assert!(build_dir.to_string_lossy().contains("test-derived"));
1571            }
1572
1573            #[test]
1574            fn it_should_initialize_runtime_outputs_as_empty() {
1575                let env = EnvironmentTestBuilder::new()
1576                    .with_name("test-runtime")
1577                    .build();
1578
1579                // Runtime outputs start empty
1580                assert_eq!(env.context.runtime_outputs.instance_ip(), None);
1581            }
1582
1583            #[test]
1584            fn it_should_populate_runtime_outputs_during_operations() {
1585                let env = EnvironmentTestBuilder::new()
1586                    .with_name("test-populate")
1587                    .build();
1588
1589                // Simulate provisioning operation setting the IP
1590                let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
1591                let env = env.with_instance_ip(ip);
1592
1593                assert_eq!(env.context.runtime_outputs.instance_ip(), Some(ip));
1594            }
1595
1596            #[test]
1597            fn it_should_serialize_with_semantic_structure() {
1598                let env = EnvironmentTestBuilder::new()
1599                    .with_name("test-serialize")
1600                    .build();
1601
1602                let json = serde_json::to_value(&env.context).unwrap();
1603
1604                // Verify JSON has three top-level keys
1605                assert!(json.get("user_inputs").is_some());
1606                assert!(json.get("internal_config").is_some());
1607                assert!(json.get("runtime_outputs").is_some());
1608            }
1609
1610            #[test]
1611            fn it_should_provide_accessor_methods_for_backward_compatibility() {
1612                let env = EnvironmentTestBuilder::new()
1613                    .with_name("test-accessors")
1614                    .build();
1615
1616                // Accessor methods should work through the context
1617                assert_eq!(env.name().as_str(), "test-accessors");
1618                assert_eq!(env.ssh_port(), 22);
1619                assert_eq!(env.instance_ip(), None);
1620            }
1621        }
1622    }
1623}