1use 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
37pub const ENV_WORKERS_CONFIG: &str = "RCH_E2E_WORKERS_CONFIG";
39
40pub const ENV_SKIP_WORKER_CHECK: &str = "RCH_E2E_SKIP_WORKER_CHECK";
42
43pub const ENV_SKIP_ALL_TESTS: &str = "RCH_E2E_SKIP";
45
46pub const ENV_TIMEOUT_SECS: &str = "RCH_E2E_TIMEOUT_SECS";
48
49pub const DEFAULT_CONFIG_PATH: &str = "tests/true_e2e/workers_test.toml";
51
52#[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
74pub type TestConfigResult<T> = Result<T, TestConfigError>;
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct TestSettings {
80 #[serde(default = "default_timeout_secs")]
82 pub default_timeout_secs: u64,
83
84 #[serde(default = "default_ssh_timeout")]
86 pub ssh_connection_timeout_secs: u64,
87
88 #[serde(default = "default_remote_work_dir")]
90 pub remote_work_dir: String,
91
92 #[serde(default = "default_rsync_compression")]
94 pub rsync_compression: String,
95
96 #[serde(default = "default_cleanup_after_test")]
98 pub cleanup_after_test: bool,
99
100 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct TestWorkerEntry {
141 pub id: String,
143
144 pub host: String,
146
147 #[serde(default = "default_user")]
149 pub user: String,
150
151 #[serde(default = "default_port")]
153 pub port: u16,
154
155 #[serde(default = "default_identity_file")]
157 pub identity_file: String,
158
159 #[serde(default = "default_slots")]
161 pub total_slots: u32,
162
163 #[serde(default = "default_priority")]
165 pub priority: u32,
166
167 #[serde(default)]
169 pub tags: Vec<String>,
170
171 #[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 pub fn expanded_identity_file(&self) -> TestConfigResult<PathBuf> {
203 expand_tilde_path(&self.identity_file)
204 }
205
206 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222pub struct TestWorkersConfig {
223 #[serde(default)]
225 pub settings: TestSettings,
226
227 #[serde(default)]
229 pub workers: Vec<TestWorkerEntry>,
230}
231
232impl TestWorkersConfig {
233 pub fn load() -> TestConfigResult<Self> {
235 let path = get_config_path();
236 Self::load_from(&path)
237 }
238
239 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 pub fn try_load() -> Option<Self> {
254 Self::load().ok()
255 }
256
257 pub fn validate(&self) -> TestConfigResult<()> {
259 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 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 pub fn enabled_workers(&self) -> Vec<&TestWorkerEntry> {
290 self.workers.iter().filter(|w| w.enabled).collect()
291 }
292
293 pub fn has_workers(&self) -> bool {
295 !self.workers.is_empty()
296 }
297
298 pub fn has_enabled_workers(&self) -> bool {
300 self.workers.iter().any(|w| w.enabled)
301 }
302
303 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 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
320pub 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 if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
328 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 PathBuf::from(DEFAULT_CONFIG_PATH)
340}
341
342pub 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
359pub 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
366pub 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
373pub 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
419pub 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)] mod 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 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 assert_eq!(config.effective_timeout_secs(), 300);
820
821 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 unsafe {
861 std::env::remove_var(ENV_SKIP_WORKER_CHECK);
862 }
863 assert!(!should_skip_worker_check());
864
865 unsafe {
867 std::env::set_var(ENV_SKIP_WORKER_CHECK, "1");
868 }
869 assert!(should_skip_worker_check());
870
871 unsafe {
873 std::env::set_var(ENV_SKIP_WORKER_CHECK, "true");
874 }
875 assert!(should_skip_worker_check());
876
877 unsafe {
879 std::env::set_var(ENV_SKIP_WORKER_CHECK, "0");
880 }
881 assert!(!should_skip_worker_check());
882
883 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(), 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(), 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 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}