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 tools: Vec::new(),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
223pub struct TestWorkersConfig {
224 #[serde(default)]
226 pub settings: TestSettings,
227
228 #[serde(default)]
230 pub workers: Vec<TestWorkerEntry>,
231}
232
233impl TestWorkersConfig {
234 pub fn load() -> TestConfigResult<Self> {
236 let path = get_config_path();
237 Self::load_from(&path)
238 }
239
240 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 pub fn try_load() -> Option<Self> {
255 Self::load().ok()
256 }
257
258 pub fn validate(&self) -> TestConfigResult<()> {
260 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 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 pub fn enabled_workers(&self) -> Vec<&TestWorkerEntry> {
291 self.workers.iter().filter(|w| w.enabled).collect()
292 }
293
294 pub fn has_workers(&self) -> bool {
296 !self.workers.is_empty()
297 }
298
299 pub fn has_enabled_workers(&self) -> bool {
301 self.workers.iter().any(|w| w.enabled)
302 }
303
304 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 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
321pub 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 if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
329 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 PathBuf::from(DEFAULT_CONFIG_PATH)
341}
342
343pub 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
360pub 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
367pub 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
374pub 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
420pub 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)] mod tests {
602 use super::*;
603 use std::io::Write;
604 use tempfile::TempDir;
605
606 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 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 assert_eq!(config.effective_timeout_secs(), 300);
829
830 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 unsafe {
871 std::env::remove_var(ENV_SKIP_WORKER_CHECK);
872 }
873 assert!(!should_skip_worker_check());
874
875 unsafe {
877 std::env::set_var(ENV_SKIP_WORKER_CHECK, "1");
878 }
879 assert!(should_skip_worker_check());
880
881 unsafe {
883 std::env::set_var(ENV_SKIP_WORKER_CHECK, "true");
884 }
885 assert!(should_skip_worker_check());
886
887 unsafe {
889 std::env::set_var(ENV_SKIP_WORKER_CHECK, "0");
890 }
891 assert!(!should_skip_worker_check());
892
893 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(), 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(), 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 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}