Skip to main content

torrust_tracker_deployer_lib/domain/environment/
user_inputs.rs

1//! User Inputs Module
2//!
3//! This module contains the `UserInputs` struct which holds all user-provided
4//! configuration when creating an environment.
5//!
6//! ## Purpose
7//!
8//! User inputs represent the immutable configuration choices made by the user
9//! when creating an environment. These fields never change throughout the
10//! environment's lifecycle.
11//!
12//! ## Semantic Category
13//!
14//! **User Inputs** are:
15//! - Provided by the user when creating an environment
16//! - Immutable throughout environment lifecycle
17//! - Examples: name, SSH credentials, port numbers
18//!
19//! Add new fields here when: User needs to configure something at environment creation time.
20
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::adapters::ssh::SshCredentials;
25use crate::domain::backup::BackupConfig;
26use crate::domain::environment::EnvironmentName;
27use crate::domain::grafana::GrafanaConfig;
28use crate::domain::https::HttpsConfig;
29use crate::domain::prometheus::PrometheusConfig;
30use crate::domain::provider::{Provider, ProviderConfig};
31use crate::domain::tracker::TrackerConfig;
32use crate::domain::InstanceName;
33
34/// Errors for user inputs validation
35///
36/// These errors represent cross-service invariant violations that can only be
37/// detected when considering multiple service configurations together.
38#[derive(Debug, Clone, PartialEq, Error)]
39pub enum UserInputsError {
40    /// Grafana requires Prometheus to be configured as its data source
41    ///
42    /// Use `.help()` for detailed troubleshooting steps.
43    #[error(
44        "Grafana requires Prometheus to be configured as its data source
45Tip: Add a 'prometheus' section or remove the 'grafana' section"
46    )]
47    GrafanaRequiresPrometheus,
48
49    /// HTTPS section is defined but no service has TLS configured
50    ///
51    /// Use `.help()` for detailed troubleshooting steps.
52    #[error(
53        "HTTPS section is defined but no service has TLS configured
54Tip: Set 'use_tls_proxy: true' on at least one service, or remove the 'https' section"
55    )]
56    HttpsSectionWithoutTlsServices,
57
58    /// At least one service has TLS configured but HTTPS section is missing
59    ///
60    /// Use `.help()` for detailed troubleshooting steps.
61    #[error(
62        "At least one service has TLS configured but HTTPS section is missing
63Tip: Add an 'https' section with 'admin_email' for Let's Encrypt certificate management"
64    )]
65    TlsServicesWithoutHttpsSection,
66}
67
68impl UserInputsError {
69    /// Provides actionable help text for fixing the error
70    #[must_use]
71    pub fn help(&self) -> &'static str {
72        match self {
73            Self::GrafanaRequiresPrometheus => {
74                "Add a 'prometheus' section to your configuration, or remove the 'grafana' section. \
75                Grafana needs Prometheus as its metrics data source."
76            }
77            Self::HttpsSectionWithoutTlsServices => {
78                "Either remove the 'https' section, or set 'use_tls_proxy: true' on at least one \
79                service (http_api, http_trackers, or health_check_api)."
80            }
81            Self::TlsServicesWithoutHttpsSection => {
82                "Add an 'https' section with 'admin_email' for Let's Encrypt certificate management. \
83                Services with 'use_tls_proxy: true' require Caddy for TLS termination."
84            }
85        }
86    }
87}
88
89/// User-provided configuration when creating an environment
90///
91/// This struct contains all fields that are provided by the user when creating
92/// an environment. These fields are immutable throughout the environment lifecycle
93/// and represent the user's configuration choices.
94///
95/// # Cross-Service Invariants
96///
97/// The following invariants are validated at construction time:
98/// - **Grafana requires Prometheus**: If Grafana is enabled, Prometheus must also be enabled
99/// - **HTTPS requires TLS services**: If HTTPS section is present, at least one service must have TLS
100/// - **TLS requires HTTPS**: If any service has TLS, HTTPS section must be present
101///
102/// # Examples
103///
104/// ```rust
105/// use torrust_tracker_deployer_lib::domain::{InstanceName, EnvironmentName, ProfileName};
106/// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig};
107/// use torrust_tracker_deployer_lib::domain::environment::user_inputs::UserInputs;
108/// use torrust_tracker_deployer_lib::domain::tracker::TrackerConfig;
109/// use torrust_tracker_deployer_lib::domain::prometheus::PrometheusConfig;
110/// use torrust_tracker_deployer_lib::domain::grafana::GrafanaConfig;
111/// use torrust_tracker_deployer_lib::shared::Username;
112/// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
113/// use std::path::PathBuf;
114///
115/// let provider_config = ProviderConfig::Lxd(LxdConfig {
116///     profile_name: ProfileName::new("torrust-profile-production".to_string())?,
117/// });
118/// let ssh_credentials = SshCredentials::new(
119///     PathBuf::from("keys/prod_rsa"),
120///     PathBuf::from("keys/prod_rsa.pub"),
121///     Username::new("torrust".to_string())?,
122/// );
123/// let env_name = EnvironmentName::new("production".to_string())?;
124///
125/// // Create with defaults (includes Prometheus and Grafana)
126/// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22)?;
127///
128/// assert_eq!(user_inputs.name().as_str(), "production");
129/// assert!(user_inputs.prometheus().is_some());
130/// assert!(user_inputs.grafana().is_some());
131/// # Ok::<(), Box<dyn std::error::Error>>(())
132/// ```
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct UserInputs {
135    /// The validated environment name
136    name: EnvironmentName,
137
138    /// The instance name for this environment (auto-generated from name)
139    instance_name: InstanceName,
140
141    /// Provider-specific configuration (e.g., LXD profile, Hetzner settings)
142    provider_config: ProviderConfig,
143
144    /// SSH credentials for connecting to instances in this environment
145    ssh_credentials: SshCredentials,
146
147    /// SSH port for connecting to instances in this environment
148    ssh_port: u16,
149
150    /// Tracker deployment configuration
151    tracker: TrackerConfig,
152
153    /// Prometheus metrics collection configuration (optional)
154    ///
155    /// When present, Prometheus service is enabled in the deployment.
156    /// When absent (`None`), Prometheus service is disabled.
157    /// Default: `Some(PrometheusConfig::default())` in generated templates.
158    prometheus: Option<PrometheusConfig>,
159
160    /// Grafana visualization and dashboard configuration (optional)
161    ///
162    /// When present, Grafana service is enabled in the deployment.
163    /// When absent (`None`), Grafana service is disabled.
164    /// Requires Prometheus to be enabled - dependency validated at construction time.
165    /// Default: `Some(GrafanaConfig::default())` in generated templates.
166    grafana: Option<GrafanaConfig>,
167
168    /// HTTPS/TLS configuration for Caddy reverse proxy (optional)
169    ///
170    /// When present, Caddy service is deployed as a TLS termination proxy.
171    /// When absent (`None`), services are exposed directly over HTTP.
172    /// Requires at least one service to have TLS configuration.
173    https: Option<HttpsConfig>,
174
175    /// Backup configuration (optional)
176    ///
177    /// When present, backup service is enabled with scheduled backups.
178    /// When absent (`None`), backup service is disabled.
179    /// Default: `None` in generated templates.
180    backup: Option<BackupConfig>,
181}
182
183impl UserInputs {
184    /// Creates a new `UserInputs` with auto-generated instance name and default services
185    ///
186    /// Creates a `UserInputs` with default tracker configuration, Prometheus, and Grafana
187    /// enabled. This is the standard setup for most deployments.
188    ///
189    /// # Arguments
190    ///
191    /// * `name` - The validated environment name
192    /// * `provider_config` - Provider-specific configuration
193    /// * `ssh_credentials` - SSH credentials for connecting to instances
194    /// * `ssh_port` - SSH port for connecting to instances
195    ///
196    /// # Returns
197    ///
198    /// A new `UserInputs` with:
199    /// - Auto-generated instance name: `torrust-tracker-vm-{env_name}`
200    /// - Default tracker configuration
201    /// - Prometheus and Grafana enabled (satisfies cross-service invariants)
202    ///
203    /// # Errors
204    ///
205    /// This constructor with defaults cannot fail because the default configuration
206    /// (Prometheus + Grafana, no HTTPS) always satisfies cross-service invariants.
207    ///
208    /// # Examples
209    ///
210    /// ```rust
211    /// use torrust_tracker_deployer_lib::domain::environment::{EnvironmentName, UserInputs};
212    /// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig, Provider};
213    /// use torrust_tracker_deployer_lib::domain::ProfileName;
214    /// use torrust_tracker_deployer_lib::shared::Username;
215    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
216    /// use std::path::PathBuf;
217    ///
218    /// let env_name = EnvironmentName::new("production".to_string())?;
219    /// let ssh_username = Username::new("torrust".to_string())?;
220    /// let ssh_credentials = SshCredentials::new(
221    ///     PathBuf::from("keys/prod_rsa"),
222    ///     PathBuf::from("keys/prod_rsa.pub"),
223    ///     ssh_username,
224    /// );
225    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
226    ///     profile_name: ProfileName::new("torrust-profile-production".to_string())?,
227    /// });
228    ///
229    /// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22)?;
230    ///
231    /// assert_eq!(user_inputs.instance_name().as_str(), "torrust-tracker-vm-production");
232    /// assert_eq!(user_inputs.provider(), Provider::Lxd);
233    ///
234    /// # Ok::<(), Box<dyn std::error::Error>>(())
235    /// ```
236    pub fn new(
237        name: &EnvironmentName,
238        provider_config: ProviderConfig,
239        ssh_credentials: SshCredentials,
240        ssh_port: u16,
241    ) -> Result<Self, UserInputsError> {
242        // Default configuration: Prometheus + Grafana, no HTTPS, no backup
243        // This always passes validation (Grafana has Prometheus, no TLS configured)
244        Self::with_tracker(
245            name,
246            provider_config,
247            ssh_credentials,
248            ssh_port,
249            TrackerConfig::default(),
250            Some(PrometheusConfig::default()),
251            Some(GrafanaConfig::default()),
252            None,
253            None,
254        )
255    }
256
257    /// Creates a new `UserInputs` with custom tracker and service configuration
258    ///
259    /// This constructor allows full control over all service configurations.
260    /// Cross-service invariants are validated at construction time.
261    ///
262    /// # Arguments
263    ///
264    /// * `name` - The validated environment name
265    /// * `provider_config` - Provider-specific configuration
266    /// * `ssh_credentials` - SSH credentials for connecting to instances
267    /// * `ssh_port` - SSH port for connecting to instances
268    /// * `tracker` - Tracker deployment configuration
269    /// * `prometheus` - Optional Prometheus configuration
270    /// * `grafana` - Optional Grafana configuration (requires Prometheus)
271    /// * `https` - Optional HTTPS/TLS configuration (requires TLS services)
272    /// * `backup` - Optional backup configuration
273    ///
274    /// # Errors
275    ///
276    /// - `GrafanaRequiresPrometheus` if Grafana is configured without Prometheus
277    /// - `HttpsSectionWithoutTlsServices` if HTTPS section exists but no service uses TLS
278    /// - `TlsServicesWithoutHttpsSection` if a service uses TLS but HTTPS section is missing
279    #[allow(clippy::too_many_arguments)]
280    pub fn with_tracker(
281        name: &EnvironmentName,
282        provider_config: ProviderConfig,
283        ssh_credentials: SshCredentials,
284        ssh_port: u16,
285        tracker: TrackerConfig,
286        prometheus: Option<PrometheusConfig>,
287        grafana: Option<GrafanaConfig>,
288        https: Option<HttpsConfig>,
289        backup: Option<BackupConfig>,
290    ) -> Result<Self, UserInputsError> {
291        // Cross-service invariant: Grafana requires Prometheus as data source
292        if grafana.is_some() && prometheus.is_none() {
293            return Err(UserInputsError::GrafanaRequiresPrometheus);
294        }
295
296        // Cross-service invariant: HTTPS section requires at least one TLS service
297        let has_tls = tracker.has_any_tls_configured();
298        if https.is_some() && !has_tls {
299            return Err(UserInputsError::HttpsSectionWithoutTlsServices);
300        }
301
302        // Inverse: TLS services require HTTPS section
303        if has_tls && https.is_none() {
304            return Err(UserInputsError::TlsServicesWithoutHttpsSection);
305        }
306
307        let instance_name = Self::generate_instance_name(name);
308
309        Ok(Self {
310            name: name.clone(),
311            instance_name,
312            provider_config,
313            ssh_credentials,
314            ssh_port,
315            tracker,
316            prometheus,
317            grafana,
318            https,
319            backup,
320        })
321    }
322
323    // ========================================================================
324    // Getter Methods
325    // ========================================================================
326
327    /// Returns the environment name
328    #[must_use]
329    pub fn name(&self) -> &EnvironmentName {
330        &self.name
331    }
332
333    /// Returns the instance name
334    #[must_use]
335    pub fn instance_name(&self) -> &InstanceName {
336        &self.instance_name
337    }
338
339    /// Returns the SSH credentials
340    #[must_use]
341    pub fn ssh_credentials(&self) -> &SshCredentials {
342        &self.ssh_credentials
343    }
344
345    /// Returns the SSH port
346    #[must_use]
347    pub fn ssh_port(&self) -> u16 {
348        self.ssh_port
349    }
350
351    /// Returns the tracker configuration
352    #[must_use]
353    pub fn tracker(&self) -> &TrackerConfig {
354        &self.tracker
355    }
356
357    /// Returns the Prometheus configuration if enabled
358    #[must_use]
359    pub fn prometheus(&self) -> Option<&PrometheusConfig> {
360        self.prometheus.as_ref()
361    }
362
363    /// Returns the Grafana configuration if enabled
364    #[must_use]
365    pub fn grafana(&self) -> Option<&GrafanaConfig> {
366        self.grafana.as_ref()
367    }
368
369    /// Returns the HTTPS configuration if enabled
370    #[must_use]
371    pub fn https(&self) -> Option<&HttpsConfig> {
372        self.https.as_ref()
373    }
374
375    /// Returns the backup configuration if enabled
376    #[must_use]
377    pub fn backup(&self) -> Option<&BackupConfig> {
378        self.backup.as_ref()
379    }
380
381    // ========================================================================
382    // Provider Accessor Methods
383    // ========================================================================
384
385    /// Returns the provider type for this environment
386    ///
387    /// # Examples
388    ///
389    /// ```rust
390    /// use torrust_tracker_deployer_lib::domain::environment::{EnvironmentName, UserInputs};
391    /// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig, Provider};
392    /// use torrust_tracker_deployer_lib::domain::ProfileName;
393    /// use torrust_tracker_deployer_lib::shared::Username;
394    /// use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
395    /// use std::path::PathBuf;
396    ///
397    /// let env_name = EnvironmentName::new("test".to_string())?;
398    /// let ssh_credentials = SshCredentials::new(
399    ///     PathBuf::from("keys/test_rsa"),
400    ///     PathBuf::from("keys/test_rsa.pub"),
401    ///     Username::new("torrust".to_string())?,
402    /// );
403    /// let provider_config = ProviderConfig::Lxd(LxdConfig {
404    ///     profile_name: ProfileName::new("test-profile".to_string())?,
405    /// });
406    ///
407    /// let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22)?;
408    /// assert_eq!(user_inputs.provider(), Provider::Lxd);
409    ///
410    /// # Ok::<(), Box<dyn std::error::Error>>(())
411    /// ```
412    #[must_use]
413    pub fn provider(&self) -> Provider {
414        self.provider_config.provider()
415    }
416
417    /// Returns a reference to the provider configuration
418    ///
419    /// Use this to access provider-specific fields. For example:
420    /// ```rust,ignore
421    /// if let Some(lxd_config) = user_inputs.provider_config().as_lxd() {
422    ///     println!("LXD profile: {}", lxd_config.profile_name.as_str());
423    /// }
424    /// ```
425    #[must_use]
426    pub fn provider_config(&self) -> &ProviderConfig {
427        &self.provider_config
428    }
429
430    // ========================================================================
431    // Private Helper Methods
432    // ========================================================================
433
434    /// Generates an instance name from the environment name
435    ///
436    /// Format: `torrust-tracker-vm-{env_name}`
437    ///
438    /// # Panics
439    ///
440    /// This function does not panic. The generated instance name is guaranteed
441    /// to be valid for any valid environment name.
442    fn generate_instance_name(env_name: &EnvironmentName) -> InstanceName {
443        let instance_name_str = format!("torrust-tracker-vm-{}", env_name.as_str());
444        InstanceName::new(instance_name_str)
445            .expect("Generated instance name should always be valid")
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use std::path::PathBuf;
452
453    use super::*;
454    use crate::domain::provider::LxdConfig;
455    use crate::domain::tracker::{
456        DatabaseConfig, HealthCheckApiConfig, HttpApiConfig, SqliteConfig, TrackerCoreConfig,
457        UdpTrackerConfig,
458    };
459    use crate::domain::ProfileName;
460    use crate::shared::{ApiToken, DomainName, Username};
461
462    fn create_test_ssh_credentials() -> SshCredentials {
463        SshCredentials::new(
464            PathBuf::from("keys/test_rsa"),
465            PathBuf::from("keys/test_rsa.pub"),
466            Username::new("testuser".to_string()).unwrap(),
467        )
468    }
469
470    fn create_lxd_provider_config(profile_name: &str) -> ProviderConfig {
471        ProviderConfig::Lxd(LxdConfig {
472            profile_name: ProfileName::new(profile_name.to_string()).unwrap(),
473        })
474    }
475
476    fn create_test_env_name() -> EnvironmentName {
477        EnvironmentName::new("test-env".to_string()).unwrap()
478    }
479
480    fn create_tracker_config_with_tls() -> TrackerConfig {
481        TrackerConfig::new(
482            TrackerCoreConfig::new(
483                DatabaseConfig::Sqlite(SqliteConfig::new("tracker.db").unwrap()),
484                false,
485            ),
486            vec![UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).unwrap()],
487            vec![],
488            HttpApiConfig::new(
489                "0.0.0.0:1212".parse().unwrap(),
490                "token".to_string().into(),
491                Some(DomainName::new("api.example.com").unwrap()),
492                true, // TLS enabled
493            )
494            .unwrap(),
495            HealthCheckApiConfig::new("127.0.0.1:1313".parse().unwrap(), None, false).unwrap(),
496        )
497        .unwrap()
498    }
499
500    fn create_tracker_config_without_tls() -> TrackerConfig {
501        TrackerConfig::default()
502    }
503
504    #[test]
505    fn it_should_create_user_inputs_with_lxd_provider() {
506        let env_name = create_test_env_name();
507        let provider_config = create_lxd_provider_config("test-profile");
508        let ssh_credentials = create_test_ssh_credentials();
509
510        let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap();
511
512        assert_eq!(user_inputs.name().as_str(), "test-env");
513        assert_eq!(
514            user_inputs.instance_name().as_str(),
515            "torrust-tracker-vm-test-env"
516        );
517        assert_eq!(user_inputs.provider(), Provider::Lxd);
518        assert_eq!(user_inputs.provider_config().provider_name(), "lxd");
519        assert_eq!(user_inputs.ssh_port(), 22);
520    }
521
522    #[test]
523    fn it_should_return_provider_config_for_lxd() {
524        let env_name = create_test_env_name();
525        let provider_config = create_lxd_provider_config("my-custom-profile");
526        let ssh_credentials = create_test_ssh_credentials();
527
528        let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap();
529
530        let lxd_config = user_inputs.provider_config().as_lxd().unwrap();
531        assert_eq!(lxd_config.profile_name.as_str(), "my-custom-profile");
532    }
533
534    #[test]
535    fn it_should_return_provider_config_for_hetzner() {
536        use crate::domain::provider::HetznerConfig;
537
538        let env_name = create_test_env_name();
539        let provider_config = ProviderConfig::Hetzner(HetznerConfig {
540            api_token: ApiToken::from("test-token"),
541            server_type: "cx22".to_string(),
542            location: "nbg1".to_string(),
543            image: "ubuntu-24.04".to_string(),
544        });
545        let ssh_credentials = create_test_ssh_credentials();
546
547        let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap();
548
549        assert_eq!(user_inputs.provider(), Provider::Hetzner);
550        assert!(user_inputs.provider_config().as_lxd().is_none());
551
552        let hetzner_config = user_inputs.provider_config().as_hetzner().unwrap();
553        assert_eq!(hetzner_config.api_token.expose_secret(), "test-token");
554        assert_eq!(hetzner_config.server_type, "cx22");
555        assert_eq!(hetzner_config.location, "nbg1");
556        assert_eq!(hetzner_config.image, "ubuntu-24.04");
557    }
558
559    #[test]
560    fn it_should_auto_generate_instance_name_from_environment_name() {
561        let env_name = EnvironmentName::new("production".to_string()).unwrap();
562        let provider_config = create_lxd_provider_config("prod-profile");
563        let ssh_credentials = create_test_ssh_credentials();
564
565        let user_inputs = UserInputs::new(&env_name, provider_config, ssh_credentials, 22).unwrap();
566
567        assert_eq!(
568            user_inputs.instance_name().as_str(),
569            "torrust-tracker-vm-production"
570        );
571    }
572
573    // ========================================================================
574    // Cross-Service Invariant Tests
575    // ========================================================================
576
577    #[test]
578    fn it_should_reject_grafana_without_prometheus() {
579        let env_name = create_test_env_name();
580        let provider_config = create_lxd_provider_config("test-profile");
581        let ssh_credentials = create_test_ssh_credentials();
582
583        let result = UserInputs::with_tracker(
584            &env_name,
585            provider_config,
586            ssh_credentials,
587            22,
588            create_tracker_config_without_tls(),
589            None,                           // No Prometheus
590            Some(GrafanaConfig::default()), // Grafana enabled
591            None,
592            None, // No backup
593        );
594
595        assert!(
596            matches!(result, Err(UserInputsError::GrafanaRequiresPrometheus)),
597            "Expected GrafanaRequiresPrometheus error, got {result:?}"
598        );
599    }
600
601    #[test]
602    fn it_should_accept_grafana_with_prometheus() {
603        let env_name = create_test_env_name();
604        let provider_config = create_lxd_provider_config("test-profile");
605        let ssh_credentials = create_test_ssh_credentials();
606
607        let result = UserInputs::with_tracker(
608            &env_name,
609            provider_config,
610            ssh_credentials,
611            22,
612            create_tracker_config_without_tls(),
613            Some(PrometheusConfig::default()), // Prometheus enabled
614            Some(GrafanaConfig::default()),    // Grafana enabled
615            None,
616            None, // No backup
617        );
618
619        assert!(result.is_ok());
620    }
621
622    #[test]
623    fn it_should_reject_https_section_without_tls_services() {
624        let env_name = create_test_env_name();
625        let provider_config = create_lxd_provider_config("test-profile");
626        let ssh_credentials = create_test_ssh_credentials();
627
628        let result = UserInputs::with_tracker(
629            &env_name,
630            provider_config,
631            ssh_credentials,
632            22,
633            create_tracker_config_without_tls(), // No TLS on any service
634            Some(PrometheusConfig::default()),
635            Some(GrafanaConfig::default()),
636            Some(HttpsConfig::new("admin@example.com", false).expect("valid email")), // HTTPS section present
637            None,                                                                     // No backup
638        );
639
640        assert!(
641            matches!(result, Err(UserInputsError::HttpsSectionWithoutTlsServices)),
642            "Expected HttpsSectionWithoutTlsServices error, got {result:?}"
643        );
644    }
645
646    #[test]
647    fn it_should_reject_tls_services_without_https_section() {
648        let env_name = create_test_env_name();
649        let provider_config = create_lxd_provider_config("test-profile");
650        let ssh_credentials = create_test_ssh_credentials();
651
652        let result = UserInputs::with_tracker(
653            &env_name,
654            provider_config,
655            ssh_credentials,
656            22,
657            create_tracker_config_with_tls(), // Has TLS on HTTP API
658            Some(PrometheusConfig::default()),
659            Some(GrafanaConfig::default()),
660            None, // No HTTPS section
661            None, // No backup
662        );
663
664        assert!(
665            matches!(result, Err(UserInputsError::TlsServicesWithoutHttpsSection)),
666            "Expected TlsServicesWithoutHttpsSection error, got {result:?}"
667        );
668    }
669
670    #[test]
671    fn it_should_accept_tls_services_with_https_section() {
672        let env_name = create_test_env_name();
673        let provider_config = create_lxd_provider_config("test-profile");
674        let ssh_credentials = create_test_ssh_credentials();
675
676        let result = UserInputs::with_tracker(
677            &env_name,
678            provider_config,
679            ssh_credentials,
680            22,
681            create_tracker_config_with_tls(),
682            Some(PrometheusConfig::default()),
683            Some(GrafanaConfig::default()),
684            Some(HttpsConfig::new("admin@example.com", false).expect("valid email")),
685            None, // No backup
686        );
687
688        assert!(result.is_ok());
689    }
690
691    #[test]
692    fn it_should_accept_no_tls_and_no_https() {
693        let env_name = create_test_env_name();
694        let provider_config = create_lxd_provider_config("test-profile");
695        let ssh_credentials = create_test_ssh_credentials();
696
697        let result = UserInputs::with_tracker(
698            &env_name,
699            provider_config,
700            ssh_credentials,
701            22,
702            create_tracker_config_without_tls(),
703            Some(PrometheusConfig::default()),
704            Some(GrafanaConfig::default()),
705            None, // No HTTPS
706            None, // No backup
707        );
708
709        assert!(result.is_ok());
710    }
711
712    #[test]
713    fn it_should_provide_helpful_error_messages() {
714        assert!(UserInputsError::GrafanaRequiresPrometheus
715            .to_string()
716            .contains("Grafana requires Prometheus"));
717        assert!(UserInputsError::GrafanaRequiresPrometheus
718            .to_string()
719            .contains("Tip:"));
720        assert!(UserInputsError::GrafanaRequiresPrometheus
721            .help()
722            .contains("prometheus"));
723
724        assert!(UserInputsError::HttpsSectionWithoutTlsServices
725            .to_string()
726            .contains("Tip:"));
727        assert!(UserInputsError::HttpsSectionWithoutTlsServices
728            .help()
729            .contains("use_tls_proxy"));
730
731        assert!(UserInputsError::TlsServicesWithoutHttpsSection
732            .to_string()
733            .contains("Tip:"));
734        assert!(UserInputsError::TlsServicesWithoutHttpsSection
735            .help()
736            .contains("https"));
737    }
738}