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            tools: Vec::new(),
217        }
218    }
219}
220
221/// Complete test worker configuration.
222#[derive(Debug, Clone, Default, Serialize, Deserialize)]
223pub struct TestWorkersConfig {
224    /// Test-specific settings.
225    #[serde(default)]
226    pub settings: TestSettings,
227
228    /// List of worker definitions.
229    #[serde(default)]
230    pub workers: Vec<TestWorkerEntry>,
231}
232
233impl TestWorkersConfig {
234    /// Load configuration from the default path or environment override.
235    pub fn load() -> TestConfigResult<Self> {
236        let path = get_config_path();
237        Self::load_from(&path)
238    }
239
240    /// Load configuration from a specific path.
241    pub fn load_from(path: &Path) -> TestConfigResult<Self> {
242        if !path.exists() {
243            return Err(TestConfigError::NotFound(path.to_path_buf()));
244        }
245
246        let contents = std::fs::read_to_string(path)?;
247        let config: TestWorkersConfig = toml::from_str(&contents)?;
248
249        config.validate()?;
250        Ok(config)
251    }
252
253    /// Try to load configuration, returning None if not found.
254    pub fn try_load() -> Option<Self> {
255        Self::load().ok()
256    }
257
258    /// Validate the configuration.
259    pub fn validate(&self) -> TestConfigResult<()> {
260        // Check for duplicate worker IDs
261        let mut seen_ids: HashMap<&str, usize> = HashMap::new();
262        for (i, worker) in self.workers.iter().enumerate() {
263            if let Some(prev_idx) = seen_ids.insert(&worker.id, i) {
264                return Err(TestConfigError::ValidationError(format!(
265                    "Duplicate worker ID '{}' at indices {} and {}",
266                    worker.id, prev_idx, i
267                )));
268            }
269
270            // Validate individual worker entries
271            if worker.host.is_empty() {
272                return Err(TestConfigError::ValidationError(format!(
273                    "Worker '{}' has empty hostname",
274                    worker.id
275                )));
276            }
277
278            if worker.user.is_empty() {
279                return Err(TestConfigError::ValidationError(format!(
280                    "Worker '{}' has empty username",
281                    worker.id
282                )));
283            }
284        }
285
286        Ok(())
287    }
288
289    /// Get only enabled workers.
290    pub fn enabled_workers(&self) -> Vec<&TestWorkerEntry> {
291        self.workers.iter().filter(|w| w.enabled).collect()
292    }
293
294    /// Check if any workers are configured.
295    pub fn has_workers(&self) -> bool {
296        !self.workers.is_empty()
297    }
298
299    /// Check if any workers are enabled.
300    pub fn has_enabled_workers(&self) -> bool {
301        self.workers.iter().any(|w| w.enabled)
302    }
303
304    /// Get the effective timeout, considering environment override.
305    pub fn effective_timeout_secs(&self) -> u64 {
306        std::env::var(ENV_TIMEOUT_SECS)
307            .ok()
308            .and_then(|v| v.parse().ok())
309            .unwrap_or(self.settings.default_timeout_secs)
310    }
311
312    /// Convert all enabled workers to standard WorkerConfig for daemon integration.
313    pub fn to_worker_configs(&self) -> Vec<crate::WorkerConfig> {
314        self.enabled_workers()
315            .iter()
316            .map(|w| w.to_worker_config())
317            .collect()
318    }
319}
320
321/// Get the configuration file path, considering environment override.
322pub fn get_config_path() -> PathBuf {
323    if let Ok(override_path) = std::env::var(ENV_WORKERS_CONFIG) {
324        return PathBuf::from(override_path);
325    }
326
327    // Try to find the config relative to CARGO_MANIFEST_DIR
328    if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
329        // Go up one level from the crate directory to workspace root
330        let path = PathBuf::from(manifest_dir)
331            .parent()
332            .map(|p| p.join(DEFAULT_CONFIG_PATH))
333            .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
334        if path.exists() {
335            return path;
336        }
337    }
338
339    // Fall back to relative path from current directory
340    PathBuf::from(DEFAULT_CONFIG_PATH)
341}
342
343/// Expand tilde (~) in a path to the user's home directory.
344pub fn expand_tilde_path(path: &str) -> TestConfigResult<PathBuf> {
345    if let Some(rest) = path.strip_prefix("~/") {
346        let home = std::env::var("HOME")
347            .or_else(|_| std::env::var("USERPROFILE"))
348            .map_err(|_| TestConfigError::PathExpansionFailed(path.to_string()))?;
349        Ok(PathBuf::from(home).join(rest))
350    } else if path == "~" {
351        let home = std::env::var("HOME")
352            .or_else(|_| std::env::var("USERPROFILE"))
353            .map_err(|_| TestConfigError::PathExpansionFailed(path.to_string()))?;
354        Ok(PathBuf::from(home))
355    } else {
356        Ok(PathBuf::from(path))
357    }
358}
359
360/// Check if worker availability checks should be skipped.
361pub fn should_skip_worker_check() -> bool {
362    std::env::var(ENV_SKIP_WORKER_CHECK)
363        .map(|v| v == "1" || v.to_lowercase() == "true")
364        .unwrap_or(false)
365}
366
367/// Check if all true E2E tests should be skipped.
368pub fn should_skip_all_tests() -> bool {
369    std::env::var(ENV_SKIP_ALL_TESTS)
370        .map(|v| v == "1" || v.to_lowercase() == "true")
371        .unwrap_or(false)
372}
373
374/// Check if mock SSH mode is enabled (for CI testing without real workers).
375pub fn is_mock_ssh_mode() -> bool {
376    std::env::var("RCH_MOCK_SSH")
377        .map(|v| v == "1" || v.to_lowercase() == "true")
378        .unwrap_or(false)
379}
380
381fn is_ci() -> bool {
382    std::env::var("CI")
383        .map(|v| v == "1" || v.to_lowercase() == "true")
384        .unwrap_or(false)
385}
386
387fn log_skip_event(
388    logger: &TestLogger,
389    level: LogLevel,
390    message: &str,
391    context: Vec<(String, String)>,
392) {
393    let mut ctx = Vec::with_capacity(context.len() + 1);
394    ctx.push(("phase".to_string(), "skip_check".to_string()));
395    ctx.extend(context);
396    logger.log_with_context(
397        level,
398        LogSource::Custom("skip_check".to_string()),
399        message,
400        ctx,
401    );
402}
403
404fn ping_worker(host: &str, port: u16, timeout: Duration) -> Result<bool, std::io::Error> {
405    let addrs = (host, port).to_socket_addrs()?;
406    for addr in addrs {
407        if TcpStream::connect_timeout(&addr, timeout).is_ok() {
408            return Ok(true);
409        }
410    }
411    Ok(false)
412}
413
414fn effective_ping_timeout(settings: &TestSettings) -> Duration {
415    let requested = settings.ssh_connection_timeout_secs.max(1);
416    let cap = if is_ci() { 3 } else { 8 };
417    Duration::from_secs(requested.min(cap))
418}
419
420/// Load worker config and verify at least one reachable worker.
421/// Returns None when tests should be skipped, with structured logs explaining why.
422pub fn require_workers(logger: &TestLogger) -> Option<TestWorkersConfig> {
423    let config_path = get_config_path();
424    log_skip_event(
425        logger,
426        LogLevel::Info,
427        "Checking worker availability",
428        vec![
429            ("config_path".to_string(), config_path.display().to_string()),
430            ("ci".to_string(), is_ci().to_string()),
431        ],
432    );
433
434    if should_skip_all_tests() {
435        log_skip_event(
436            logger,
437            LogLevel::Info,
438            "E2E tests disabled via env",
439            vec![
440                ("reason".to_string(), "env_skip_all".to_string()),
441                ("env".to_string(), ENV_SKIP_ALL_TESTS.to_string()),
442                ("skip".to_string(), "true".to_string()),
443            ],
444        );
445        return None;
446    }
447
448    let config = match TestWorkersConfig::load_from(&config_path) {
449        Ok(config) => config,
450        Err(TestConfigError::NotFound(path)) => {
451            log_skip_event(
452                logger,
453                LogLevel::Info,
454                "Workers unavailable, skipping test",
455                vec![
456                    ("reason".to_string(), "config_not_found".to_string()),
457                    ("path".to_string(), path.display().to_string()),
458                    ("skip".to_string(), "true".to_string()),
459                ],
460            );
461            return None;
462        }
463        Err(e) => {
464            log_skip_event(
465                logger,
466                LogLevel::Warn,
467                "Workers unavailable, skipping test",
468                vec![
469                    ("reason".to_string(), "config_load_failed".to_string()),
470                    ("error".to_string(), e.to_string()),
471                    ("skip".to_string(), "true".to_string()),
472                ],
473            );
474            return None;
475        }
476    };
477
478    if !config.has_enabled_workers() {
479        log_skip_event(
480            logger,
481            LogLevel::Info,
482            "Workers unavailable, skipping test",
483            vec![
484                ("reason".to_string(), "no_enabled_workers".to_string()),
485                ("configured".to_string(), config.workers.len().to_string()),
486                ("skip".to_string(), "true".to_string()),
487            ],
488        );
489        return None;
490    }
491
492    if should_skip_worker_check() {
493        log_skip_event(
494            logger,
495            LogLevel::Info,
496            "Skipping worker reachability check via env",
497            vec![
498                ("reason".to_string(), "env_skip_check".to_string()),
499                ("env".to_string(), ENV_SKIP_WORKER_CHECK.to_string()),
500                ("skip".to_string(), "false".to_string()),
501            ],
502        );
503        return Some(config);
504    }
505
506    let timeout = effective_ping_timeout(&config.settings);
507    let mut reachable = 0usize;
508
509    for worker in config.enabled_workers() {
510        let start = Instant::now();
511        let outcome = ping_worker(&worker.host, worker.port, timeout);
512        let elapsed_ms = start.elapsed().as_millis().to_string();
513
514        match outcome {
515            Ok(true) => {
516                reachable += 1;
517                logger.log_with_context(
518                    LogLevel::Info,
519                    LogSource::Custom("skip_check".to_string()),
520                    "Worker reachable",
521                    vec![
522                        ("phase".to_string(), "skip_check".to_string()),
523                        ("worker_id".to_string(), worker.id.clone()),
524                        ("host".to_string(), worker.host.clone()),
525                        ("port".to_string(), worker.port.to_string()),
526                        ("reachable".to_string(), "true".to_string()),
527                        ("duration_ms".to_string(), elapsed_ms),
528                    ],
529                );
530            }
531            Ok(false) => {
532                logger.log_with_context(
533                    LogLevel::Warn,
534                    LogSource::Custom("skip_check".to_string()),
535                    "Worker unreachable",
536                    vec![
537                        ("phase".to_string(), "skip_check".to_string()),
538                        ("worker_id".to_string(), worker.id.clone()),
539                        ("host".to_string(), worker.host.clone()),
540                        ("port".to_string(), worker.port.to_string()),
541                        ("reachable".to_string(), "false".to_string()),
542                        ("duration_ms".to_string(), elapsed_ms),
543                    ],
544                );
545            }
546            Err(e) => {
547                logger.log_with_context(
548                    LogLevel::Warn,
549                    LogSource::Custom("skip_check".to_string()),
550                    "Worker reachability check failed",
551                    vec![
552                        ("phase".to_string(), "skip_check".to_string()),
553                        ("worker_id".to_string(), worker.id.clone()),
554                        ("host".to_string(), worker.host.clone()),
555                        ("port".to_string(), worker.port.to_string()),
556                        ("error".to_string(), e.to_string()),
557                        ("duration_ms".to_string(), elapsed_ms),
558                    ],
559                );
560            }
561        }
562    }
563
564    if reachable == 0 {
565        log_skip_event(
566            logger,
567            LogLevel::Info,
568            "Workers unavailable, skipping test",
569            vec![
570                ("reason".to_string(), "no_reachable_workers".to_string()),
571                (
572                    "configured".to_string(),
573                    config.enabled_workers().len().to_string(),
574                ),
575                ("reachable".to_string(), reachable.to_string()),
576                ("skip".to_string(), "true".to_string()),
577            ],
578        );
579        return None;
580    }
581
582    log_skip_event(
583        logger,
584        LogLevel::Info,
585        "Worker availability check complete",
586        vec![
587            (
588                "configured".to_string(),
589                config.enabled_workers().len().to_string(),
590            ),
591            ("reachable".to_string(), reachable.to_string()),
592            ("skip".to_string(), "false".to_string()),
593        ],
594    );
595
596    Some(config)
597}
598
599#[cfg(test)]
600#[allow(unsafe_code)] // Tests need to set/remove env vars, which is unsafe in Rust 2024
601mod tests {
602    use super::*;
603    use std::io::Write;
604    use tempfile::TempDir;
605
606    /// Process env is GLOBAL: every test that touches
607    /// `ENV_WORKERS_CONFIG` / `ENV_TIMEOUT_SECS` /
608    /// `ENV_SKIP_WORKER_CHECK` must hold this lock, or parallel test
609    /// threads clobber each other's config resolution (bead tzk2s).
610    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
611
612    #[test]
613    fn test_config_parses_valid_toml() {
614        let config_str = r#"
615[settings]
616default_timeout_secs = 300
617
618[[workers]]
619id = "test"
620host = "test.example.com"
621user = "builder"
622"#;
623        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
624        assert_eq!(config.workers.len(), 1);
625        assert_eq!(config.workers[0].id, "test");
626        assert_eq!(config.workers[0].host, "test.example.com");
627    }
628
629    #[test]
630    fn test_config_rejects_missing_hostname() {
631        let config_str = r#"
632[[workers]]
633id = "test"
634user = "builder"
635"#;
636        let result: Result<TestWorkersConfig, _> = toml::from_str(config_str);
637        assert!(result.is_err());
638    }
639
640    #[test]
641    fn test_config_rejects_missing_id() {
642        let config_str = r#"
643[[workers]]
644host = "test.example.com"
645user = "builder"
646"#;
647        let result: Result<TestWorkersConfig, _> = toml::from_str(config_str);
648        assert!(result.is_err());
649    }
650
651    #[test]
652    fn test_config_default_port() {
653        let config_str = r#"
654[[workers]]
655id = "test"
656host = "test.example.com"
657user = "builder"
658"#;
659        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
660        assert_eq!(config.workers[0].port, 22);
661    }
662
663    #[test]
664    fn test_config_custom_port() {
665        let config_str = r#"
666[[workers]]
667id = "test"
668host = "test.example.com"
669user = "builder"
670port = 2222
671"#;
672        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
673        assert_eq!(config.workers[0].port, 2222);
674    }
675
676    #[test]
677    fn test_config_expands_home_in_identity_file() {
678        let config_str = r#"
679[[workers]]
680id = "test"
681host = "test.example.com"
682user = "builder"
683identity_file = "~/.ssh/id_ed25519"
684"#;
685        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
686        let expanded = config.workers[0].expanded_identity_file().unwrap();
687        assert!(!expanded.to_string_lossy().contains("~"));
688    }
689
690    #[test]
691    fn test_config_from_env_override() {
692        let _env = ENV_LOCK.lock().unwrap();
693        let temp_dir = TempDir::new().unwrap();
694        let custom_path = temp_dir.path().join("custom_workers.toml");
695
696        // SAFETY: serialized by ENV_LOCK; set and removed in this test.
697        unsafe {
698            std::env::set_var(ENV_WORKERS_CONFIG, custom_path.to_string_lossy().as_ref());
699        }
700        let path = get_config_path();
701        assert_eq!(path, custom_path);
702        unsafe {
703            std::env::remove_var(ENV_WORKERS_CONFIG);
704        }
705    }
706
707    #[test]
708    fn test_config_missing_file_returns_error() {
709        let result = TestWorkersConfig::load_from(Path::new("/nonexistent/workers_test.toml"));
710        assert!(result.is_err());
711        match result {
712            Err(TestConfigError::NotFound(_)) => {}
713            _ => panic!("Expected NotFound error"),
714        }
715    }
716
717    #[test]
718    fn test_config_empty_workers_is_valid() {
719        let config_str = r#"
720[settings]
721default_timeout_secs = 300
722"#;
723        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
724        assert!(config.workers.is_empty());
725        assert!(!config.has_workers());
726    }
727
728    #[test]
729    fn test_config_duplicate_worker_ids_rejected() {
730        let config_str = r#"
731[[workers]]
732id = "duplicate"
733host = "host1.example.com"
734
735[[workers]]
736id = "duplicate"
737host = "host2.example.com"
738"#;
739        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
740        let result = config.validate();
741        assert!(result.is_err());
742        match result {
743            Err(TestConfigError::ValidationError(msg)) => {
744                assert!(msg.contains("Duplicate worker ID"));
745            }
746            _ => panic!("Expected ValidationError"),
747        }
748    }
749
750    #[test]
751    fn test_config_enabled_workers_filter() {
752        let config_str = r#"
753[[workers]]
754id = "enabled1"
755host = "host1.example.com"
756enabled = true
757
758[[workers]]
759id = "disabled"
760host = "host2.example.com"
761enabled = false
762
763[[workers]]
764id = "enabled2"
765host = "host3.example.com"
766enabled = true
767"#;
768        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
769        let enabled = config.enabled_workers();
770        assert_eq!(enabled.len(), 2);
771        assert_eq!(enabled[0].id, "enabled1");
772        assert_eq!(enabled[1].id, "enabled2");
773    }
774
775    #[test]
776    fn test_config_default_settings() {
777        let config_str = r#"
778[[workers]]
779id = "test"
780host = "test.example.com"
781"#;
782        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
783        assert_eq!(config.settings.default_timeout_secs, 300);
784        assert_eq!(config.settings.ssh_connection_timeout_secs, 10);
785        assert_eq!(config.settings.remote_work_dir, "/tmp/rch_test");
786        assert_eq!(config.settings.rsync_compression, "zstd");
787        assert!(config.settings.cleanup_after_test);
788    }
789
790    #[test]
791    fn test_config_custom_settings() {
792        let config_str = r#"
793[settings]
794default_timeout_secs = 600
795ssh_connection_timeout_secs = 30
796remote_work_dir = "/data/rch_test"
797rsync_compression = "lz4"
798cleanup_after_test = false
799min_rust_version = "1.85.0"
800
801[[workers]]
802id = "test"
803host = "test.example.com"
804"#;
805        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
806        assert_eq!(config.settings.default_timeout_secs, 600);
807        assert_eq!(config.settings.ssh_connection_timeout_secs, 30);
808        assert_eq!(config.settings.remote_work_dir, "/data/rch_test");
809        assert_eq!(config.settings.rsync_compression, "lz4");
810        assert!(!config.settings.cleanup_after_test);
811        assert_eq!(config.settings.min_rust_version, Some("1.85.0".to_string()));
812    }
813
814    #[test]
815    fn test_effective_timeout_from_env() {
816        let _env = ENV_LOCK.lock().unwrap();
817        let config_str = r#"
818[settings]
819default_timeout_secs = 300
820
821[[workers]]
822id = "test"
823host = "test.example.com"
824"#;
825        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
826
827        // Without env var, use config value
828        assert_eq!(config.effective_timeout_secs(), 300);
829
830        // With env var, use override
831        // SAFETY: Test isolation
832        unsafe {
833            std::env::set_var(ENV_TIMEOUT_SECS, "600");
834        }
835        assert_eq!(config.effective_timeout_secs(), 600);
836        unsafe {
837            std::env::remove_var(ENV_TIMEOUT_SECS);
838        }
839    }
840
841    #[test]
842    fn test_config_loads_from_file() {
843        let temp_dir = TempDir::new().unwrap();
844        let config_path = temp_dir.path().join("workers_test.toml");
845
846        let config_content = r#"
847[settings]
848default_timeout_secs = 120
849
850[[workers]]
851id = "file-test"
852host = "192.168.1.100"
853user = "testuser"
854total_slots = 16
855"#;
856        let mut file = std::fs::File::create(&config_path).unwrap();
857        file.write_all(config_content.as_bytes()).unwrap();
858
859        let config = TestWorkersConfig::load_from(&config_path).unwrap();
860        assert_eq!(config.settings.default_timeout_secs, 120);
861        assert_eq!(config.workers.len(), 1);
862        assert_eq!(config.workers[0].id, "file-test");
863        assert_eq!(config.workers[0].total_slots, 16);
864    }
865
866    #[test]
867    fn test_should_skip_worker_check() {
868        let _env = ENV_LOCK.lock().unwrap();
869        // Default is false
870        unsafe {
871            std::env::remove_var(ENV_SKIP_WORKER_CHECK);
872        }
873        assert!(!should_skip_worker_check());
874
875        // Set to "1"
876        unsafe {
877            std::env::set_var(ENV_SKIP_WORKER_CHECK, "1");
878        }
879        assert!(should_skip_worker_check());
880
881        // Set to "true"
882        unsafe {
883            std::env::set_var(ENV_SKIP_WORKER_CHECK, "true");
884        }
885        assert!(should_skip_worker_check());
886
887        // Set to "0" (false)
888        unsafe {
889            std::env::set_var(ENV_SKIP_WORKER_CHECK, "0");
890        }
891        assert!(!should_skip_worker_check());
892
893        // Clean up
894        unsafe {
895            std::env::remove_var(ENV_SKIP_WORKER_CHECK);
896        }
897    }
898
899    #[test]
900    fn test_expand_path_tilde() {
901        let expanded = expand_tilde_path("~/.ssh/id_rsa").unwrap();
902        assert!(!expanded.to_string_lossy().starts_with("~"));
903        assert!(expanded.to_string_lossy().ends_with(".ssh/id_rsa"));
904    }
905
906    #[test]
907    fn test_expand_path_absolute() {
908        let expanded = expand_tilde_path("/etc/ssh/ssh_host_key").unwrap();
909        assert_eq!(expanded, PathBuf::from("/etc/ssh/ssh_host_key"));
910    }
911
912    #[test]
913    fn test_expand_path_relative() {
914        let expanded = expand_tilde_path("./keys/id_rsa").unwrap();
915        assert_eq!(expanded, PathBuf::from("./keys/id_rsa"));
916    }
917
918    #[test]
919    fn test_worker_tags() {
920        let config_str = r#"
921[[workers]]
922id = "tagged"
923host = "test.example.com"
924tags = ["rust", "fast", "production"]
925"#;
926        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
927        assert_eq!(config.workers[0].tags.len(), 3);
928        assert!(config.workers[0].tags.contains(&"rust".to_string()));
929        assert!(config.workers[0].tags.contains(&"fast".to_string()));
930        assert!(config.workers[0].tags.contains(&"production".to_string()));
931    }
932
933    #[test]
934    fn test_config_all_default_values() {
935        let config_str = r#"
936[[workers]]
937id = "minimal"
938host = "minimal.example.com"
939"#;
940        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
941        let worker = &config.workers[0];
942
943        assert_eq!(worker.user, "ubuntu");
944        assert_eq!(worker.port, 22);
945        assert_eq!(worker.identity_file, "~/.ssh/id_rsa");
946        assert_eq!(worker.total_slots, 8);
947        assert_eq!(worker.priority, 100);
948        assert!(worker.tags.is_empty());
949        assert!(worker.enabled);
950    }
951
952    #[test]
953    fn test_validation_empty_hostname_rejected() {
954        let config = TestWorkersConfig {
955            settings: TestSettings::default(),
956            workers: vec![TestWorkerEntry {
957                id: "test".to_string(),
958                host: String::new(), // Empty!
959                user: "ubuntu".to_string(),
960                port: 22,
961                identity_file: "~/.ssh/id_rsa".to_string(),
962                total_slots: 8,
963                priority: 100,
964                tags: vec![],
965                enabled: true,
966            }],
967        };
968        let result = config.validate();
969        assert!(result.is_err());
970        match result {
971            Err(TestConfigError::ValidationError(msg)) => {
972                assert!(msg.contains("empty hostname"));
973            }
974            _ => panic!("Expected ValidationError"),
975        }
976    }
977
978    #[test]
979    fn test_validation_empty_username_rejected() {
980        let config = TestWorkersConfig {
981            settings: TestSettings::default(),
982            workers: vec![TestWorkerEntry {
983                id: "test".to_string(),
984                host: "test.example.com".to_string(),
985                user: String::new(), // Empty!
986                port: 22,
987                identity_file: "~/.ssh/id_rsa".to_string(),
988                total_slots: 8,
989                priority: 100,
990                tags: vec![],
991                enabled: true,
992            }],
993        };
994        let result = config.validate();
995        assert!(result.is_err());
996        match result {
997            Err(TestConfigError::ValidationError(msg)) => {
998                assert!(msg.contains("empty username"));
999            }
1000            _ => panic!("Expected ValidationError"),
1001        }
1002    }
1003
1004    #[test]
1005    fn test_to_worker_config_conversion() {
1006        let config_str = r#"
1007[[workers]]
1008id = "convert-test"
1009host = "192.168.1.50"
1010user = "admin"
1011identity_file = "~/.ssh/admin_key"
1012total_slots = 32
1013priority = 150
1014tags = ["fast", "ssd"]
1015"#;
1016        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
1017        let worker_config = config.workers[0].to_worker_config();
1018
1019        assert_eq!(worker_config.id.as_str(), "convert-test");
1020        assert_eq!(worker_config.host, "192.168.1.50");
1021        assert_eq!(worker_config.user, "admin");
1022        assert_eq!(worker_config.identity_file, "~/.ssh/admin_key");
1023        assert_eq!(worker_config.total_slots, 32);
1024        assert_eq!(worker_config.priority, 150);
1025        assert_eq!(worker_config.tags, vec!["fast", "ssd"]);
1026    }
1027
1028    #[test]
1029    fn test_to_worker_configs_batch() {
1030        let config_str = r#"
1031[[workers]]
1032id = "worker1"
1033host = "host1.example.com"
1034enabled = true
1035
1036[[workers]]
1037id = "worker2"
1038host = "host2.example.com"
1039enabled = false
1040
1041[[workers]]
1042id = "worker3"
1043host = "host3.example.com"
1044enabled = true
1045"#;
1046        let config: TestWorkersConfig = toml::from_str(config_str).unwrap();
1047        let worker_configs = config.to_worker_configs();
1048
1049        // Only enabled workers should be converted
1050        assert_eq!(worker_configs.len(), 2);
1051        assert_eq!(worker_configs[0].id.as_str(), "worker1");
1052        assert_eq!(worker_configs[1].id.as_str(), "worker3");
1053    }
1054}