Skip to main content

rch_common/e2e/
test_workers.rs

1//! Test worker configuration loading for true E2E tests.
2//!
3//! This module handles loading and validating worker configurations
4//! specifically for end-to-end testing scenarios that require real
5//! SSH-accessible worker machines.
6//!
7//! # Configuration File
8//!
9//! Tests use the configuration in `tests/true_e2e/workers_test.toml`.
10//! Override with the `RCH_E2E_WORKERS_CONFIG` environment variable.
11//!
12//! # Example Configuration
13//!
14//! ```toml
15//! [settings]
16//! default_timeout_secs = 300
17//! ssh_connection_timeout_secs = 10
18//! remote_work_dir = "/tmp/rch_test"
19//!
20//! [[workers]]
21//! id = "test-worker"
22//! host = "192.168.1.100"
23//! user = "ubuntu"
24//! identity_file = "~/.ssh/id_rsa"
25//! total_slots = 16
26//! enabled = true
27//! ```
28
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use std::net::{TcpStream, ToSocketAddrs};
32use std::path::{Path, PathBuf};
33use std::time::{Duration, Instant};
34
35use super::logging::{LogLevel, LogSource, TestLogger};
36
37/// Environment variable to override config file location.
38pub const ENV_WORKERS_CONFIG: &str = "RCH_E2E_WORKERS_CONFIG";
39
40/// Environment variable to skip worker availability checks.
41pub const ENV_SKIP_WORKER_CHECK: &str = "RCH_E2E_SKIP_WORKER_CHECK";
42
43/// Environment variable to skip all true E2E tests.
44pub const ENV_SKIP_ALL_TESTS: &str = "RCH_E2E_SKIP";
45
46/// Environment variable to override command timeout.
47pub const ENV_TIMEOUT_SECS: &str = "RCH_E2E_TIMEOUT_SECS";
48
49/// Default config file path relative to workspace root.
50pub const DEFAULT_CONFIG_PATH: &str = "tests/true_e2e/workers_test.toml";
51
52/// Error type for configuration operations.
53#[derive(Debug, thiserror::Error)]
54pub enum TestConfigError {
55    #[error("Configuration file not found: {0}")]
56    NotFound(PathBuf),
57
58    #[error("Failed to read configuration file: {0}")]
59    ReadError(#[from] std::io::Error),
60
61    #[error("Failed to parse configuration: {0}")]
62    ParseError(#[from] toml::de::Error),
63
64    #[error("Invalid configuration: {0}")]
65    ValidationError(String),
66
67    #[error("No workers configured")]
68    NoWorkersConfigured,
69
70    #[error("Path expansion failed for: {0}")]
71    PathExpansionFailed(String),
72}
73
74/// Result type for configuration operations.
75pub type TestConfigResult<T> = Result<T, TestConfigError>;
76
77/// Test-specific settings for E2E tests.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct TestSettings {
80    /// Default timeout for test commands in seconds.
81    #[serde(default = "default_timeout_secs")]
82    pub default_timeout_secs: u64,
83
84    /// SSH connection timeout in seconds.
85    #[serde(default = "default_ssh_timeout")]
86    pub ssh_connection_timeout_secs: u64,
87
88    /// Remote working directory for test artifacts.
89    #[serde(default = "default_remote_work_dir")]
90    pub remote_work_dir: String,
91
92    /// Rsync compression method.
93    #[serde(default = "default_rsync_compression")]
94    pub rsync_compression: String,
95
96    /// Whether to clean up remote artifacts after tests.
97    #[serde(default = "default_cleanup_after_test")]
98    pub cleanup_after_test: bool,
99
100    /// Minimum required Rust version on workers.
101    #[serde(default)]
102    pub min_rust_version: Option<String>,
103}
104
105impl Default for TestSettings {
106    fn default() -> Self {
107        Self {
108            default_timeout_secs: default_timeout_secs(),
109            ssh_connection_timeout_secs: default_ssh_timeout(),
110            remote_work_dir: default_remote_work_dir(),
111            rsync_compression: default_rsync_compression(),
112            cleanup_after_test: default_cleanup_after_test(),
113            min_rust_version: None,
114        }
115    }
116}
117
118fn default_timeout_secs() -> u64 {
119    300
120}
121
122fn default_ssh_timeout() -> u64 {
123    10
124}
125
126fn default_remote_work_dir() -> String {
127    "/tmp/rch_test".to_string()
128}
129
130fn default_rsync_compression() -> String {
131    "zstd".to_string()
132}
133
134fn default_cleanup_after_test() -> bool {
135    true
136}
137
138/// Single worker entry in test configuration.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct TestWorkerEntry {
141    /// Unique identifier for this worker.
142    pub id: String,
143
144    /// SSH hostname or IP address.
145    pub host: String,
146
147    /// SSH username.
148    #[serde(default = "default_user")]
149    pub user: String,
150
151    /// SSH port.
152    #[serde(default = "default_port")]
153    pub port: u16,
154
155    /// Path to SSH private key.
156    #[serde(default = "default_identity_file")]
157    pub identity_file: String,
158
159    /// Total CPU slots available on this worker.
160    #[serde(default = "default_slots")]
161    pub total_slots: u32,
162
163    /// Priority for worker selection (higher = preferred).
164    #[serde(default = "default_priority")]
165    pub priority: u32,
166
167    /// Optional tags for filtering.
168    #[serde(default)]
169    pub tags: Vec<String>,
170
171    /// Whether this worker is enabled.
172    #[serde(default = "default_enabled")]
173    pub enabled: bool,
174}
175
176fn default_user() -> String {
177    "ubuntu".to_string()
178}
179
180fn default_port() -> u16 {
181    22
182}
183
184fn default_identity_file() -> String {
185    "~/.ssh/id_rsa".to_string()
186}
187
188fn default_slots() -> u32 {
189    8
190}
191
192fn default_priority() -> u32 {
193    100
194}
195
196fn default_enabled() -> bool {
197    true
198}
199
200impl TestWorkerEntry {
201    /// Expand tilde in the identity file path.
202    pub fn expanded_identity_file(&self) -> TestConfigResult<PathBuf> {
203        expand_tilde_path(&self.identity_file)
204    }
205
206    /// Convert to the standard WorkerConfig type for daemon integration.
207    pub fn to_worker_config(&self) -> crate::WorkerConfig {
208        crate::WorkerConfig {
209            id: crate::WorkerId::new(&self.id),
210            host: self.host.clone(),
211            user: self.user.clone(),
212            identity_file: self.identity_file.clone(),
213            total_slots: self.total_slots,
214            priority: self.priority,
215            tags: self.tags.clone(),
216        }
217    }
218}
219
220/// Complete test worker configuration.
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222pub struct TestWorkersConfig {
223    /// Test-specific settings.
224    #[serde(default)]
225    pub settings: TestSettings,
226
227    /// List of worker definitions.
228    #[serde(default)]
229    pub workers: Vec<TestWorkerEntry>,
230}
231
232impl TestWorkersConfig {
233    /// Load configuration from the default path or environment override.
234    pub fn load() -> TestConfigResult<Self> {
235        let path = get_config_path();
236        Self::load_from(&path)
237    }
238
239    /// Load configuration from a specific path.
240    pub fn load_from(path: &Path) -> TestConfigResult<Self> {
241        if !path.exists() {
242            return Err(TestConfigError::NotFound(path.to_path_buf()));
243        }
244
245        let contents = std::fs::read_to_string(path)?;
246        let config: TestWorkersConfig = toml::from_str(&contents)?;
247
248        config.validate()?;
249        Ok(config)
250    }
251
252    /// Try to load configuration, returning None if not found.
253    pub fn try_load() -> Option<Self> {
254        Self::load().ok()
255    }
256
257    /// Validate the configuration.
258    pub fn validate(&self) -> TestConfigResult<()> {
259        // Check for duplicate worker IDs
260        let mut seen_ids: HashMap<&str, usize> = HashMap::new();
261        for (i, worker) in self.workers.iter().enumerate() {
262            if let Some(prev_idx) = seen_ids.insert(&worker.id, i) {
263                return Err(TestConfigError::ValidationError(format!(
264                    "Duplicate worker ID '{}' at indices {} and {}",
265                    worker.id, prev_idx, i
266                )));
267            }
268
269            // Validate individual worker entries
270            if worker.host.is_empty() {
271                return Err(TestConfigError::ValidationError(format!(
272                    "Worker '{}' has empty hostname",
273                    worker.id
274                )));
275            }
276
277            if worker.user.is_empty() {
278                return Err(TestConfigError::ValidationError(format!(
279                    "Worker '{}' has empty username",
280                    worker.id
281                )));
282            }
283        }
284
285        Ok(())
286    }
287
288    /// Get only enabled workers.
289    pub fn enabled_workers(&self) -> Vec<&TestWorkerEntry> {
290        self.workers.iter().filter(|w| w.enabled).collect()
291    }
292
293    /// Check if any workers are configured.
294    pub fn has_workers(&self) -> bool {
295        !self.workers.is_empty()
296    }
297
298    /// Check if any workers are enabled.
299    pub fn has_enabled_workers(&self) -> bool {
300        self.workers.iter().any(|w| w.enabled)
301    }
302
303    /// Get the effective timeout, considering environment override.
304    pub fn effective_timeout_secs(&self) -> u64 {
305        std::env::var(ENV_TIMEOUT_SECS)
306            .ok()
307            .and_then(|v| v.parse().ok())
308            .unwrap_or(self.settings.default_timeout_secs)
309    }
310
311    /// Convert all enabled workers to standard WorkerConfig for daemon integration.
312    pub fn to_worker_configs(&self) -> Vec<crate::WorkerConfig> {
313        self.enabled_workers()
314            .iter()
315            .map(|w| w.to_worker_config())
316            .collect()
317    }
318}
319
320/// Get the configuration file path, considering environment override.
321pub fn get_config_path() -> PathBuf {
322    if let Ok(override_path) = std::env::var(ENV_WORKERS_CONFIG) {
323        return PathBuf::from(override_path);
324    }
325
326    // Try to find the config relative to CARGO_MANIFEST_DIR
327    if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
328        // Go up one level from the crate directory to workspace root
329        let path = PathBuf::from(manifest_dir)
330            .parent()
331            .map(|p| p.join(DEFAULT_CONFIG_PATH))
332            .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
333        if path.exists() {
334            return path;
335        }
336    }
337
338    // Fall back to relative path from current directory
339    PathBuf::from(DEFAULT_CONFIG_PATH)
340}
341
342/// Expand tilde (~) in a path to the user's home directory.
343pub fn expand_tilde_path(path: &str) -> TestConfigResult<PathBuf> {
344    if let Some(rest) = path.strip_prefix("~/") {
345        let home = std::env::var("HOME")
346            .or_else(|_| std::env::var("USERPROFILE"))
347            .map_err(|_| TestConfigError::PathExpansionFailed(path.to_string()))?;
348        Ok(PathBuf::from(home).join(rest))
349    } else if path == "~" {
350        let home = std::env::var("HOME")
351            .or_else(|_| std::env::var("USERPROFILE"))
352            .map_err(|_| TestConfigError::PathExpansionFailed(path.to_string()))?;
353        Ok(PathBuf::from(home))
354    } else {
355        Ok(PathBuf::from(path))
356    }
357}
358
359/// Check if worker availability checks should be skipped.
360pub fn should_skip_worker_check() -> bool {
361    std::env::var(ENV_SKIP_WORKER_CHECK)
362        .map(|v| v == "1" || v.to_lowercase() == "true")
363        .unwrap_or(false)
364}
365
366/// Check if all true E2E tests should be skipped.
367pub fn should_skip_all_tests() -> bool {
368    std::env::var(ENV_SKIP_ALL_TESTS)
369        .map(|v| v == "1" || v.to_lowercase() == "true")
370        .unwrap_or(false)
371}
372
373/// Check if mock SSH mode is enabled (for CI testing without real workers).
374pub fn is_mock_ssh_mode() -> bool {
375    std::env::var("RCH_MOCK_SSH")
376        .map(|v| v == "1" || v.to_lowercase() == "true")
377        .unwrap_or(false)
378}
379
380fn is_ci() -> bool {
381    std::env::var("CI")
382        .map(|v| v == "1" || v.to_lowercase() == "true")
383        .unwrap_or(false)
384}
385
386fn log_skip_event(
387    logger: &TestLogger,
388    level: LogLevel,
389    message: &str,
390    context: Vec<(String, String)>,
391) {
392    let mut ctx = Vec::with_capacity(context.len() + 1);
393    ctx.push(("phase".to_string(), "skip_check".to_string()));
394    ctx.extend(context);
395    logger.log_with_context(
396        level,
397        LogSource::Custom("skip_check".to_string()),
398        message,
399        ctx,
400    );
401}
402
403fn ping_worker(host: &str, port: u16, timeout: Duration) -> Result<bool, std::io::Error> {
404    let addrs = (host, port).to_socket_addrs()?;
405    for addr in addrs {
406        if TcpStream::connect_timeout(&addr, timeout).is_ok() {
407            return Ok(true);
408        }
409    }
410    Ok(false)
411}
412
413fn effective_ping_timeout(settings: &TestSettings) -> Duration {
414    let requested = settings.ssh_connection_timeout_secs.max(1);
415    let cap = if is_ci() { 3 } else { 8 };
416    Duration::from_secs(requested.min(cap))
417}
418
419/// Load worker config and verify at least one reachable worker.
420/// Returns None when tests should be skipped, with structured logs explaining why.
421pub fn require_workers(logger: &TestLogger) -> Option<TestWorkersConfig> {
422    let config_path = get_config_path();
423    log_skip_event(
424        logger,
425        LogLevel::Info,
426        "Checking worker availability",
427        vec![
428            ("config_path".to_string(), config_path.display().to_string()),
429            ("ci".to_string(), is_ci().to_string()),
430        ],
431    );
432
433    if should_skip_all_tests() {
434        log_skip_event(
435            logger,
436            LogLevel::Info,
437            "E2E tests disabled via env",
438            vec![
439                ("reason".to_string(), "env_skip_all".to_string()),
440                ("env".to_string(), ENV_SKIP_ALL_TESTS.to_string()),
441                ("skip".to_string(), "true".to_string()),
442            ],
443        );
444        return None;
445    }
446
447    let config = match TestWorkersConfig::load_from(&config_path) {
448        Ok(config) => config,
449        Err(TestConfigError::NotFound(path)) => {
450            log_skip_event(
451                logger,
452                LogLevel::Info,
453                "Workers unavailable, skipping test",
454                vec![
455                    ("reason".to_string(), "config_not_found".to_string()),
456                    ("path".to_string(), path.display().to_string()),
457                    ("skip".to_string(), "true".to_string()),
458                ],
459            );
460            return None;
461        }
462        Err(e) => {
463            log_skip_event(
464                logger,
465                LogLevel::Warn,
466                "Workers unavailable, skipping test",
467                vec![
468                    ("reason".to_string(), "config_load_failed".to_string()),
469                    ("error".to_string(), e.to_string()),
470                    ("skip".to_string(), "true".to_string()),
471                ],
472            );
473            return None;
474        }
475    };
476
477    if !config.has_enabled_workers() {
478        log_skip_event(
479            logger,
480            LogLevel::Info,
481            "Workers unavailable, skipping test",
482            vec![
483                ("reason".to_string(), "no_enabled_workers".to_string()),
484                ("configured".to_string(), config.workers.len().to_string()),
485                ("skip".to_string(), "true".to_string()),
486            ],
487        );
488        return None;
489    }
490
491    if should_skip_worker_check() {
492        log_skip_event(
493            logger,
494            LogLevel::Info,
495            "Skipping worker reachability check via env",
496            vec![
497                ("reason".to_string(), "env_skip_check".to_string()),
498                ("env".to_string(), ENV_SKIP_WORKER_CHECK.to_string()),
499                ("skip".to_string(), "false".to_string()),
500            ],
501        );
502        return Some(config);
503    }
504
505    let timeout = effective_ping_timeout(&config.settings);
506    let mut reachable = 0usize;
507
508    for worker in config.enabled_workers() {
509        let start = Instant::now();
510        let outcome = ping_worker(&worker.host, worker.port, timeout);
511        let elapsed_ms = start.elapsed().as_millis().to_string();
512
513        match outcome {
514            Ok(true) => {
515                reachable += 1;
516                logger.log_with_context(
517                    LogLevel::Info,
518                    LogSource::Custom("skip_check".to_string()),
519                    "Worker reachable",
520                    vec![
521                        ("phase".to_string(), "skip_check".to_string()),
522                        ("worker_id".to_string(), worker.id.clone()),
523                        ("host".to_string(), worker.host.clone()),
524                        ("port".to_string(), worker.port.to_string()),
525                        ("reachable".to_string(), "true".to_string()),
526                        ("duration_ms".to_string(), elapsed_ms),
527                    ],
528                );
529            }
530            Ok(false) => {
531                logger.log_with_context(
532                    LogLevel::Warn,
533                    LogSource::Custom("skip_check".to_string()),
534                    "Worker unreachable",
535                    vec![
536                        ("phase".to_string(), "skip_check".to_string()),
537                        ("worker_id".to_string(), worker.id.clone()),
538                        ("host".to_string(), worker.host.clone()),
539                        ("port".to_string(), worker.port.to_string()),
540                        ("reachable".to_string(), "false".to_string()),
541                        ("duration_ms".to_string(), elapsed_ms),
542                    ],
543                );
544            }
545            Err(e) => {
546                logger.log_with_context(
547                    LogLevel::Warn,
548                    LogSource::Custom("skip_check".to_string()),
549                    "Worker reachability check failed",
550                    vec![
551                        ("phase".to_string(), "skip_check".to_string()),
552                        ("worker_id".to_string(), worker.id.clone()),
553                        ("host".to_string(), worker.host.clone()),
554                        ("port".to_string(), worker.port.to_string()),
555                        ("error".to_string(), e.to_string()),
556                        ("duration_ms".to_string(), elapsed_ms),
557                    ],
558                );
559            }
560        }
561    }
562
563    if reachable == 0 {
564        log_skip_event(
565            logger,
566            LogLevel::Info,
567            "Workers unavailable, skipping test",
568            vec![
569                ("reason".to_string(), "no_reachable_workers".to_string()),
570                (
571                    "configured".to_string(),
572                    config.enabled_workers().len().to_string(),
573                ),
574                ("reachable".to_string(), reachable.to_string()),
575                ("skip".to_string(), "true".to_string()),
576            ],
577        );
578        return None;
579    }
580
581    log_skip_event(
582        logger,
583        LogLevel::Info,
584        "Worker availability check complete",
585        vec![
586            (
587                "configured".to_string(),
588                config.enabled_workers().len().to_string(),
589            ),
590            ("reachable".to_string(), reachable.to_string()),
591            ("skip".to_string(), "false".to_string()),
592        ],
593    );
594
595    Some(config)
596}
597
598#[cfg(test)]
599#[allow(unsafe_code)] // Tests need to set/remove env vars, which is unsafe in Rust 2024
600mod tests {
601    use super::*;
602    use std::io::Write;
603    use tempfile::TempDir;
604
605    #[test]
606    fn test_config_parses_valid_toml() {
607        let config_str = r#"
608[settings]
609default_timeout_secs = 300
610
611[[workers]]
612id = "test"
613host = "test.example.com"
614user = "builder"
615"#;
616        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
617        assert_eq!(config.workers.len(), 1);
618        assert_eq!(config.workers[0].id, "test");
619        assert_eq!(config.workers[0].host, "test.example.com");
620    }
621
622    #[test]
623    fn test_config_rejects_missing_hostname() {
624        let config_str = r#"
625[[workers]]
626id = "test"
627user = "builder"
628"#;
629        let result: Result<TestWorkersConfig, _> = toml::from_str(config_str);
630        assert!(result.is_err());
631    }
632
633    #[test]
634    fn test_config_rejects_missing_id() {
635        let config_str = r#"
636[[workers]]
637host = "test.example.com"
638user = "builder"
639"#;
640        let result: Result<TestWorkersConfig, _> = toml::from_str(config_str);
641        assert!(result.is_err());
642    }
643
644    #[test]
645    fn test_config_default_port() {
646        let config_str = r#"
647[[workers]]
648id = "test"
649host = "test.example.com"
650user = "builder"
651"#;
652        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
653        assert_eq!(config.workers[0].port, 22);
654    }
655
656    #[test]
657    fn test_config_custom_port() {
658        let config_str = r#"
659[[workers]]
660id = "test"
661host = "test.example.com"
662user = "builder"
663port = 2222
664"#;
665        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
666        assert_eq!(config.workers[0].port, 2222);
667    }
668
669    #[test]
670    fn test_config_expands_home_in_identity_file() {
671        let config_str = r#"
672[[workers]]
673id = "test"
674host = "test.example.com"
675user = "builder"
676identity_file = "~/.ssh/id_ed25519"
677"#;
678        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
679        let expanded = config.workers[0].expanded_identity_file().unwrap();
680        assert!(!expanded.to_string_lossy().contains("~"));
681    }
682
683    #[test]
684    fn test_config_from_env_override() {
685        let temp_dir = TempDir::new().unwrap();
686        let custom_path = temp_dir.path().join("custom_workers.toml");
687
688        // SAFETY: Test isolation - env var is set and removed in same test
689        unsafe {
690            std::env::set_var(ENV_WORKERS_CONFIG, custom_path.to_string_lossy().as_ref());
691        }
692        let path = get_config_path();
693        assert_eq!(path, custom_path);
694        unsafe {
695            std::env::remove_var(ENV_WORKERS_CONFIG);
696        }
697    }
698
699    #[test]
700    fn test_config_missing_file_returns_error() {
701        let result = TestWorkersConfig::load_from(Path::new("/nonexistent/workers_test.toml"));
702        assert!(result.is_err());
703        match result {
704            Err(TestConfigError::NotFound(_)) => {}
705            _ => panic!("Expected NotFound error"),
706        }
707    }
708
709    #[test]
710    fn test_config_empty_workers_is_valid() {
711        let config_str = r#"
712[settings]
713default_timeout_secs = 300
714"#;
715        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
716        assert!(config.workers.is_empty());
717        assert!(!config.has_workers());
718    }
719
720    #[test]
721    fn test_config_duplicate_worker_ids_rejected() {
722        let config_str = r#"
723[[workers]]
724id = "duplicate"
725host = "host1.example.com"
726
727[[workers]]
728id = "duplicate"
729host = "host2.example.com"
730"#;
731        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
732        let result = config.validate();
733        assert!(result.is_err());
734        match result {
735            Err(TestConfigError::ValidationError(msg)) => {
736                assert!(msg.contains("Duplicate worker ID"));
737            }
738            _ => panic!("Expected ValidationError"),
739        }
740    }
741
742    #[test]
743    fn test_config_enabled_workers_filter() {
744        let config_str = r#"
745[[workers]]
746id = "enabled1"
747host = "host1.example.com"
748enabled = true
749
750[[workers]]
751id = "disabled"
752host = "host2.example.com"
753enabled = false
754
755[[workers]]
756id = "enabled2"
757host = "host3.example.com"
758enabled = true
759"#;
760        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
761        let enabled = config.enabled_workers();
762        assert_eq!(enabled.len(), 2);
763        assert_eq!(enabled[0].id, "enabled1");
764        assert_eq!(enabled[1].id, "enabled2");
765    }
766
767    #[test]
768    fn test_config_default_settings() {
769        let config_str = r#"
770[[workers]]
771id = "test"
772host = "test.example.com"
773"#;
774        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
775        assert_eq!(config.settings.default_timeout_secs, 300);
776        assert_eq!(config.settings.ssh_connection_timeout_secs, 10);
777        assert_eq!(config.settings.remote_work_dir, "/tmp/rch_test");
778        assert_eq!(config.settings.rsync_compression, "zstd");
779        assert!(config.settings.cleanup_after_test);
780    }
781
782    #[test]
783    fn test_config_custom_settings() {
784        let config_str = r#"
785[settings]
786default_timeout_secs = 600
787ssh_connection_timeout_secs = 30
788remote_work_dir = "/data/rch_test"
789rsync_compression = "lz4"
790cleanup_after_test = false
791min_rust_version = "1.85.0"
792
793[[workers]]
794id = "test"
795host = "test.example.com"
796"#;
797        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
798        assert_eq!(config.settings.default_timeout_secs, 600);
799        assert_eq!(config.settings.ssh_connection_timeout_secs, 30);
800        assert_eq!(config.settings.remote_work_dir, "/data/rch_test");
801        assert_eq!(config.settings.rsync_compression, "lz4");
802        assert!(!config.settings.cleanup_after_test);
803        assert_eq!(config.settings.min_rust_version, Some("1.85.0".to_string()));
804    }
805
806    #[test]
807    fn test_effective_timeout_from_env() {
808        let config_str = r#"
809[settings]
810default_timeout_secs = 300
811
812[[workers]]
813id = "test"
814host = "test.example.com"
815"#;
816        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
817
818        // Without env var, use config value
819        assert_eq!(config.effective_timeout_secs(), 300);
820
821        // With env var, use override
822        // SAFETY: Test isolation
823        unsafe {
824            std::env::set_var(ENV_TIMEOUT_SECS, "600");
825        }
826        assert_eq!(config.effective_timeout_secs(), 600);
827        unsafe {
828            std::env::remove_var(ENV_TIMEOUT_SECS);
829        }
830    }
831
832    #[test]
833    fn test_config_loads_from_file() {
834        let temp_dir = TempDir::new().unwrap();
835        let config_path = temp_dir.path().join("workers_test.toml");
836
837        let config_content = r#"
838[settings]
839default_timeout_secs = 120
840
841[[workers]]
842id = "file-test"
843host = "192.168.1.100"
844user = "testuser"
845total_slots = 16
846"#;
847        let mut file = std::fs::File::create(&config_path).unwrap();
848        file.write_all(config_content.as_bytes()).unwrap();
849
850        let config = TestWorkersConfig::load_from(&config_path).unwrap();
851        assert_eq!(config.settings.default_timeout_secs, 120);
852        assert_eq!(config.workers.len(), 1);
853        assert_eq!(config.workers[0].id, "file-test");
854        assert_eq!(config.workers[0].total_slots, 16);
855    }
856
857    #[test]
858    fn test_should_skip_worker_check() {
859        // Default is false
860        unsafe {
861            std::env::remove_var(ENV_SKIP_WORKER_CHECK);
862        }
863        assert!(!should_skip_worker_check());
864
865        // Set to "1"
866        unsafe {
867            std::env::set_var(ENV_SKIP_WORKER_CHECK, "1");
868        }
869        assert!(should_skip_worker_check());
870
871        // Set to "true"
872        unsafe {
873            std::env::set_var(ENV_SKIP_WORKER_CHECK, "true");
874        }
875        assert!(should_skip_worker_check());
876
877        // Set to "0" (false)
878        unsafe {
879            std::env::set_var(ENV_SKIP_WORKER_CHECK, "0");
880        }
881        assert!(!should_skip_worker_check());
882
883        // Clean up
884        unsafe {
885            std::env::remove_var(ENV_SKIP_WORKER_CHECK);
886        }
887    }
888
889    #[test]
890    fn test_expand_path_tilde() {
891        let expanded = expand_tilde_path("~/.ssh/id_rsa").unwrap();
892        assert!(!expanded.to_string_lossy().starts_with("~"));
893        assert!(expanded.to_string_lossy().ends_with(".ssh/id_rsa"));
894    }
895
896    #[test]
897    fn test_expand_path_absolute() {
898        let expanded = expand_tilde_path("/etc/ssh/ssh_host_key").unwrap();
899        assert_eq!(expanded, PathBuf::from("/etc/ssh/ssh_host_key"));
900    }
901
902    #[test]
903    fn test_expand_path_relative() {
904        let expanded = expand_tilde_path("./keys/id_rsa").unwrap();
905        assert_eq!(expanded, PathBuf::from("./keys/id_rsa"));
906    }
907
908    #[test]
909    fn test_worker_tags() {
910        let config_str = r#"
911[[workers]]
912id = "tagged"
913host = "test.example.com"
914tags = ["rust", "fast", "production"]
915"#;
916        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
917        assert_eq!(config.workers[0].tags.len(), 3);
918        assert!(config.workers[0].tags.contains(&"rust".to_string()));
919        assert!(config.workers[0].tags.contains(&"fast".to_string()));
920        assert!(config.workers[0].tags.contains(&"production".to_string()));
921    }
922
923    #[test]
924    fn test_config_all_default_values() {
925        let config_str = r#"
926[[workers]]
927id = "minimal"
928host = "minimal.example.com"
929"#;
930        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
931        let worker = &config.workers[0];
932
933        assert_eq!(worker.user, "ubuntu");
934        assert_eq!(worker.port, 22);
935        assert_eq!(worker.identity_file, "~/.ssh/id_rsa");
936        assert_eq!(worker.total_slots, 8);
937        assert_eq!(worker.priority, 100);
938        assert!(worker.tags.is_empty());
939        assert!(worker.enabled);
940    }
941
942    #[test]
943    fn test_validation_empty_hostname_rejected() {
944        let config = TestWorkersConfig {
945            settings: TestSettings::default(),
946            workers: vec![TestWorkerEntry {
947                id: "test".to_string(),
948                host: String::new(), // Empty!
949                user: "ubuntu".to_string(),
950                port: 22,
951                identity_file: "~/.ssh/id_rsa".to_string(),
952                total_slots: 8,
953                priority: 100,
954                tags: vec![],
955                enabled: true,
956            }],
957        };
958        let result = config.validate();
959        assert!(result.is_err());
960        match result {
961            Err(TestConfigError::ValidationError(msg)) => {
962                assert!(msg.contains("empty hostname"));
963            }
964            _ => panic!("Expected ValidationError"),
965        }
966    }
967
968    #[test]
969    fn test_validation_empty_username_rejected() {
970        let config = TestWorkersConfig {
971            settings: TestSettings::default(),
972            workers: vec![TestWorkerEntry {
973                id: "test".to_string(),
974                host: "test.example.com".to_string(),
975                user: String::new(), // Empty!
976                port: 22,
977                identity_file: "~/.ssh/id_rsa".to_string(),
978                total_slots: 8,
979                priority: 100,
980                tags: vec![],
981                enabled: true,
982            }],
983        };
984        let result = config.validate();
985        assert!(result.is_err());
986        match result {
987            Err(TestConfigError::ValidationError(msg)) => {
988                assert!(msg.contains("empty username"));
989            }
990            _ => panic!("Expected ValidationError"),
991        }
992    }
993
994    #[test]
995    fn test_to_worker_config_conversion() {
996        let config_str = r#"
997[[workers]]
998id = "convert-test"
999host = "192.168.1.50"
1000user = "admin"
1001identity_file = "~/.ssh/admin_key"
1002total_slots = 32
1003priority = 150
1004tags = ["fast", "ssd"]
1005"#;
1006        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
1007        let worker_config = config.workers[0].to_worker_config();
1008
1009        assert_eq!(worker_config.id.as_str(), "convert-test");
1010        assert_eq!(worker_config.host, "192.168.1.50");
1011        assert_eq!(worker_config.user, "admin");
1012        assert_eq!(worker_config.identity_file, "~/.ssh/admin_key");
1013        assert_eq!(worker_config.total_slots, 32);
1014        assert_eq!(worker_config.priority, 150);
1015        assert_eq!(worker_config.tags, vec!["fast", "ssd"]);
1016    }
1017
1018    #[test]
1019    fn test_to_worker_configs_batch() {
1020        let config_str = r#"
1021[[workers]]
1022id = "worker1"
1023host = "host1.example.com"
1024enabled = true
1025
1026[[workers]]
1027id = "worker2"
1028host = "host2.example.com"
1029enabled = false
1030
1031[[workers]]
1032id = "worker3"
1033host = "host3.example.com"
1034enabled = true
1035"#;
1036        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
1037        let worker_configs = config.to_worker_configs();
1038
1039        // Only enabled workers should be converted
1040        assert_eq!(worker_configs.len(), 2);
1041        assert_eq!(worker_configs[0].id.as_str(), "worker1");
1042        assert_eq!(worker_configs[1].id.as_str(), "worker3");
1043    }
1044}