1use crate::ssh_utils::CommandResult;
9use crate::types::{WorkerConfig, WorkerId};
10use std::cell::Cell;
11use std::collections::HashMap;
12use std::sync::atomic::{AtomicU32, Ordering};
13use std::sync::{Arc, Mutex, OnceLock};
14use std::time::Instant;
15use tracing::{debug, info};
16
17fn env_flag(key: &str) -> bool {
18 std::env::var(key)
19 .map(|v| v == "1" || v.to_lowercase() == "true")
20 .unwrap_or(false)
21}
22
23#[derive(Debug, Default, Clone)]
24struct MockOverrides {
25 enabled: Option<bool>,
26 ssh_config: Option<MockConfig>,
27 rsync_config: Option<MockRsyncConfig>,
28 active_scopes: usize,
35}
36
37fn overrides() -> &'static Mutex<MockOverrides> {
38 static OVERRIDES: OnceLock<Mutex<MockOverrides>> = OnceLock::new();
39 OVERRIDES.get_or_init(|| Mutex::new(MockOverrides::default()))
40}
41
42thread_local! {
58 static THREAD_MOCK_ENABLED: Cell<Option<bool>> = const { Cell::new(None) };
59}
60
61pub fn set_thread_mock_override(enabled: Option<bool>) {
67 THREAD_MOCK_ENABLED.with(|c| c.set(enabled));
68}
69
70pub fn clear_thread_mock_override() {
72 THREAD_MOCK_ENABLED.with(|c| c.set(None));
73}
74
75pub fn set_mock_enabled_override(enabled: Option<bool>) {
81 let mut guard = overrides().lock().unwrap();
82 if enabled.is_some() {
83 guard.active_scopes = guard.active_scopes.saturating_add(1);
84 }
85 guard.enabled = enabled;
86 drop(guard);
87 THREAD_MOCK_ENABLED.with(|c| c.set(enabled));
89}
90
91pub fn set_mock_ssh_config_override(config: Option<MockConfig>) {
93 overrides().lock().unwrap().ssh_config = config;
94}
95
96pub fn set_mock_rsync_config_override(config: Option<MockRsyncConfig>) {
98 overrides().lock().unwrap().rsync_config = config;
99}
100
101pub fn clear_mock_overrides() {
103 let mut guard = overrides().lock().unwrap();
104 if guard.active_scopes > 0 {
105 guard.active_scopes -= 1;
106 }
107 if guard.active_scopes == 0 {
108 guard.enabled = None;
109 guard.ssh_config = None;
110 guard.rsync_config = None;
111 }
112 drop(guard);
113 THREAD_MOCK_ENABLED.with(|c| c.set(None));
116}
117
118pub fn is_mock_enabled() -> bool {
125 if let Some(enabled) = THREAD_MOCK_ENABLED.with(|c| c.get()) {
127 return enabled;
128 }
129 if let Some(enabled) = overrides().lock().unwrap().enabled {
130 return enabled;
131 }
132 std::env::var("RCH_MOCK_SSH")
133 .map(|v| v == "1" || v.to_lowercase() == "true")
134 .unwrap_or(false)
135}
136
137pub fn is_mock_host(host: &str) -> bool {
139 host.starts_with("mock://")
140}
141
142pub fn is_mock_worker(worker: &WorkerConfig) -> bool {
144 is_mock_host(&worker.host)
145}
146
147fn global_ssh_invocations() -> &'static Mutex<Vec<MockInvocation>> {
148 static GLOBAL: OnceLock<Mutex<Vec<MockInvocation>>> = OnceLock::new();
149 GLOBAL.get_or_init(|| Mutex::new(Vec::new()))
150}
151
152fn global_rsync_invocations() -> &'static Mutex<Vec<MockSyncInvocation>> {
153 static GLOBAL: OnceLock<Mutex<Vec<MockSyncInvocation>>> = OnceLock::new();
154 GLOBAL.get_or_init(|| Mutex::new(Vec::new()))
155}
156
157pub fn clear_global_invocations() {
159 global_ssh_invocations().lock().unwrap().clear();
160 global_rsync_invocations().lock().unwrap().clear();
161}
162
163pub fn global_ssh_invocations_snapshot() -> Vec<MockInvocation> {
165 global_ssh_invocations().lock().unwrap().clone()
166}
167
168pub fn global_rsync_invocations_snapshot() -> Vec<MockSyncInvocation> {
170 global_rsync_invocations().lock().unwrap().clone()
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Phase {
176 Sync,
178 Execute,
180 Artifacts,
182 Connect,
184 Disconnect,
186}
187
188impl std::fmt::Display for Phase {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 match self {
191 Phase::Sync => write!(f, "SYNC"),
192 Phase::Execute => write!(f, "EXEC"),
193 Phase::Artifacts => write!(f, "ARTIFACTS"),
194 Phase::Connect => write!(f, "CONNECT"),
195 Phase::Disconnect => write!(f, "DISCONNECT"),
196 }
197 }
198}
199
200pub fn log_phase(phase: Phase, message: &str) {
202 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ");
203 info!("[{}] [{}] {}", timestamp, phase, message);
204}
205
206pub fn debug_phase(phase: Phase, message: &str) {
208 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ");
209 debug!("[{}] [{}] {}", timestamp, phase, message);
210}
211
212#[derive(Debug, Clone)]
214pub struct MockInvocation {
215 pub worker_id: WorkerId,
217 pub command: Option<String>,
219 pub phase: Phase,
221 pub timestamp: std::time::SystemTime,
223}
224
225#[derive(Debug, Clone)]
227pub struct MockConfig {
228 pub default_exit_code: i32,
230 pub default_stdout: String,
232 pub default_stderr: String,
234 pub fail_connect: bool,
236 pub fail_connect_attempts: u32,
238 pub fail_execute: bool,
240 pub fail_execute_attempts: u32,
242 pub execution_delay_ms: u64,
244 pub command_results: HashMap<String, CommandResult>,
246 pub fail_toolchain_install: bool,
248 pub no_rustup: bool,
250}
251
252impl Default for MockConfig {
253 fn default() -> Self {
254 Self {
255 default_exit_code: 0,
256 default_stdout: String::new(),
257 default_stderr: String::new(),
258 fail_connect: false,
259 fail_connect_attempts: 0,
260 fail_execute: false,
261 fail_execute_attempts: 0,
262 execution_delay_ms: 10,
263 command_results: HashMap::new(),
264 fail_toolchain_install: false,
265 no_rustup: false,
266 }
267 }
268}
269
270impl MockConfig {
271 pub fn success() -> Self {
273 Self::default()
274 }
275
276 pub fn connection_failure() -> Self {
278 Self {
279 fail_connect: true,
280 ..Self::default()
281 }
282 }
283
284 pub fn command_failure(exit_code: i32, stderr: &str) -> Self {
286 Self {
287 default_exit_code: exit_code,
288 default_stderr: stderr.to_string(),
289 fail_execute: true,
290 ..Self::default()
291 }
292 }
293
294 pub fn with_command_result(mut self, command: &str, result: CommandResult) -> Self {
296 self.command_results.insert(command.to_string(), result);
297 self
298 }
299
300 pub fn with_stdout(mut self, stdout: &str) -> Self {
302 self.default_stdout = stdout.to_string();
303 self
304 }
305
306 pub fn from_env() -> Self {
308 if let Some(config) = overrides().lock().unwrap().ssh_config.clone() {
309 return config;
310 }
311
312 let mut config = MockConfig::default();
313
314 if let Ok(val) = std::env::var("RCH_MOCK_SSH_EXIT_CODE")
315 && let Ok(code) = val.parse()
316 {
317 config.default_exit_code = code;
318 }
319 if let Ok(val) = std::env::var("RCH_MOCK_SSH_STDOUT") {
320 config.default_stdout = val;
321 }
322 if let Ok(val) = std::env::var("RCH_MOCK_SSH_STDERR") {
323 config.default_stderr = val;
324 }
325 if let Ok(val) = std::env::var("RCH_MOCK_SSH_DELAY_MS")
326 && let Ok(delay) = val.parse()
327 {
328 config.execution_delay_ms = delay;
329 }
330
331 config.fail_connect = env_flag("RCH_MOCK_SSH_FAIL_CONNECT");
332 config.fail_execute = env_flag("RCH_MOCK_SSH_FAIL_EXECUTE");
333
334 if let Ok(val) = std::env::var("RCH_MOCK_SSH_FAIL_CONNECT_ATTEMPTS")
335 && let Ok(count) = val.parse()
336 {
337 config.fail_connect_attempts = count;
338 }
339 if let Ok(val) = std::env::var("RCH_MOCK_SSH_FAIL_EXECUTE_ATTEMPTS")
340 && let Ok(count) = val.parse()
341 {
342 config.fail_execute_attempts = count;
343 }
344
345 config.fail_toolchain_install = env_flag("RCH_MOCK_TOOLCHAIN_INSTALL_FAIL");
346 config.no_rustup = env_flag("RCH_MOCK_NO_RUSTUP");
347
348 config
349 }
350
351 pub fn toolchain_install_failure() -> Self {
353 Self {
354 fail_toolchain_install: true,
355 ..Self::default()
356 }
357 }
358
359 pub fn no_rustup() -> Self {
361 Self {
362 no_rustup: true,
363 ..Self::default()
364 }
365 }
366}
367
368pub struct MockSshClient {
370 config: WorkerConfig,
372 mock_config: MockConfig,
374 connected: bool,
376 invocations: Arc<Mutex<Vec<MockInvocation>>>,
378 connect_failures_remaining: AtomicU32,
380 execute_failures_remaining: AtomicU32,
382}
383
384impl MockSshClient {
385 pub fn new(config: WorkerConfig, mock_config: MockConfig) -> Self {
387 Self {
388 config,
389 connect_failures_remaining: AtomicU32::new(mock_config.fail_connect_attempts),
390 execute_failures_remaining: AtomicU32::new(mock_config.fail_execute_attempts),
391 mock_config,
392 connected: false,
393 invocations: Arc::new(Mutex::new(Vec::new())),
394 }
395 }
396
397 pub fn new_default(config: WorkerConfig) -> Self {
399 Self::new(config, MockConfig::default())
400 }
401
402 pub fn worker_id(&self) -> &WorkerId {
404 &self.config.id
405 }
406
407 pub fn is_connected(&self) -> bool {
409 self.connected
410 }
411
412 pub fn invocations(&self) -> Vec<MockInvocation> {
414 self.invocations.lock().unwrap().clone()
415 }
416
417 pub fn clear_invocations(&self) {
419 self.invocations.lock().unwrap().clear();
420 }
421
422 fn record(&self, phase: Phase, command: Option<String>) {
423 let invocation = MockInvocation {
424 worker_id: self.config.id.clone(),
425 command,
426 phase,
427 timestamp: std::time::SystemTime::now(),
428 };
429
430 let mut invocations = self.invocations.lock().unwrap();
431 invocations.push(invocation.clone());
432
433 let mut global = global_ssh_invocations().lock().unwrap();
434 global.push(invocation);
435 }
436
437 pub async fn connect(&mut self) -> anyhow::Result<()> {
439 log_phase(
440 Phase::Connect,
441 &format!("Connecting to mock worker {}", self.config.id),
442 );
443 self.record(Phase::Connect, None);
444
445 if self.mock_config.fail_connect {
446 log_phase(
447 Phase::Connect,
448 &format!("Mock connection failed for {}", self.config.id),
449 );
450 return Err(anyhow::anyhow!(
451 "Mock: Connection failed to {}",
452 self.config.id
453 ));
454 }
455
456 if self
458 .connect_failures_remaining
459 .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
460 current.checked_sub(1)
461 })
462 .is_ok()
463 {
464 log_phase(
465 Phase::Connect,
466 &format!("Mock transient connect failure for {}", self.config.id),
467 );
468 return Err(anyhow::anyhow!(
469 "Mock: Connection timed out to {}",
470 self.config.id
471 ));
472 }
473
474 tokio::time::sleep(tokio::time::Duration::from_millis(5)).await;
476
477 self.connected = true;
478 log_phase(
479 Phase::Connect,
480 &format!("Mock connected to {}", self.config.id),
481 );
482 Ok(())
483 }
484
485 pub async fn disconnect(&mut self) -> anyhow::Result<()> {
487 log_phase(
488 Phase::Disconnect,
489 &format!("Disconnecting from mock worker {}", self.config.id),
490 );
491 self.record(Phase::Disconnect, None);
492 self.connected = false;
493 Ok(())
494 }
495
496 pub async fn execute(&self, command: &str) -> anyhow::Result<CommandResult> {
498 if !self.connected {
499 return Err(anyhow::anyhow!("Mock: Not connected to worker"));
500 }
501
502 log_phase(
503 Phase::Execute,
504 &format!("Executing on {}: {}", self.config.id, command),
505 );
506 self.record(Phase::Execute, Some(command.to_string()));
507
508 if self.mock_config.fail_execute {
509 log_phase(
510 Phase::Execute,
511 &format!("Mock execution failed for {}", self.config.id),
512 );
513 return Err(anyhow::anyhow!("Mock: Command execution failed"));
514 }
515
516 if self
518 .execute_failures_remaining
519 .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
520 current.checked_sub(1)
521 })
522 .is_ok()
523 {
524 log_phase(
525 Phase::Execute,
526 &format!("Mock transient execute failure for {}", self.config.id),
527 );
528 return Err(anyhow::anyhow!("Mock: Broken pipe"));
529 }
530
531 let start = Instant::now();
533 tokio::time::sleep(tokio::time::Duration::from_millis(
534 self.mock_config.execution_delay_ms,
535 ))
536 .await;
537
538 if self.mock_config.no_rustup && command.contains("rustup") {
540 log_phase(
541 Phase::Execute,
542 "Mock: rustup not available (no_rustup mode)",
543 );
544 return Ok(CommandResult {
545 exit_code: 127,
546 stdout: String::new(),
547 stderr: "rustup: command not found".to_string(),
548 duration_ms: start.elapsed().as_millis() as u64,
549 });
550 }
551
552 if self.mock_config.fail_toolchain_install
553 && command.contains("rustup")
554 && (command.contains("toolchain install") || command.contains("run"))
555 {
556 log_phase(
557 Phase::Execute,
558 "Mock: toolchain install failed (fail_toolchain_install mode)",
559 );
560 return Ok(CommandResult {
561 exit_code: 1,
562 stdout: String::new(),
563 stderr: "error: toolchain 'nightly-2024-01-15' is not installed".to_string(),
564 duration_ms: start.elapsed().as_millis() as u64,
565 });
566 }
567
568 if let Some(result) = self.mock_config.command_results.get(command) {
570 log_phase(
571 Phase::Execute,
572 &format!(
573 "Mock command completed (specific): exit={}",
574 result.exit_code
575 ),
576 );
577 return Ok(result.clone());
578 }
579
580 let result = CommandResult {
581 exit_code: self.mock_config.default_exit_code,
582 stdout: self.mock_config.default_stdout.clone(),
583 stderr: self.mock_config.default_stderr.clone(),
584 duration_ms: start.elapsed().as_millis() as u64,
585 };
586
587 log_phase(
588 Phase::Execute,
589 &format!("Mock command completed: exit={}", result.exit_code),
590 );
591 Ok(result)
592 }
593
594 pub async fn execute_streaming<F, G>(
596 &self,
597 command: &str,
598 mut on_stdout: F,
599 mut on_stderr: G,
600 ) -> anyhow::Result<CommandResult>
601 where
602 F: FnMut(&str),
603 G: FnMut(&str),
604 {
605 let result = self.execute(command).await?;
606
607 for line in result.stdout.lines() {
609 on_stdout(&format!("{}\n", line));
610 }
611 for line in result.stderr.lines() {
612 on_stderr(&format!("{}\n", line));
613 }
614
615 Ok(result)
616 }
617
618 pub async fn health_check(&self) -> anyhow::Result<bool> {
620 match self.execute("echo ok").await {
621 Ok(result) => Ok(result.exit_code == 0),
622 Err(_) => Ok(false),
623 }
624 }
625}
626
627#[derive(Debug, Clone)]
629pub struct MockRsyncResult {
630 pub files_transferred: u32,
632 pub bytes_transferred: u64,
634 pub duration_ms: u64,
636}
637
638pub struct MockRsync {
640 sync_invocations: Arc<Mutex<Vec<MockSyncInvocation>>>,
642 config: MockRsyncConfig,
644 sync_failures_remaining: AtomicU32,
646 artifacts_failures_remaining: AtomicU32,
648}
649
650#[derive(Debug, Clone, Default)]
652pub struct MockRsyncConfig {
653 pub fail_sync: bool,
655 pub fail_sync_attempts: u32,
657 pub fail_artifacts: bool,
659 pub fail_artifacts_attempts: u32,
661 pub files_per_sync: u32,
663 pub bytes_per_sync: u64,
665}
666
667impl MockRsyncConfig {
668 pub fn success() -> Self {
670 Self {
671 fail_sync: false,
672 fail_sync_attempts: 0,
673 fail_artifacts: false,
674 fail_artifacts_attempts: 0,
675 files_per_sync: 10,
676 bytes_per_sync: 1024 * 100,
677 }
678 }
679
680 pub fn sync_failure() -> Self {
682 Self {
683 fail_sync: true,
684 ..Self::default()
685 }
686 }
687
688 pub fn artifact_failure() -> Self {
690 Self {
691 fail_artifacts: true,
692 ..Self::default()
693 }
694 }
695
696 pub fn from_env() -> Self {
698 if let Some(config) = overrides().lock().unwrap().rsync_config.clone() {
699 return config;
700 }
701
702 let mut config = MockRsyncConfig::success();
703
704 config.fail_sync = env_flag("RCH_MOCK_RSYNC_FAIL_SYNC");
705 config.fail_artifacts = env_flag("RCH_MOCK_RSYNC_FAIL_ARTIFACTS");
706
707 if let Ok(val) = std::env::var("RCH_MOCK_RSYNC_FAIL_SYNC_ATTEMPTS")
708 && let Ok(count) = val.parse()
709 {
710 config.fail_sync_attempts = count;
711 }
712 if let Ok(val) = std::env::var("RCH_MOCK_RSYNC_FAIL_ARTIFACTS_ATTEMPTS")
713 && let Ok(count) = val.parse()
714 {
715 config.fail_artifacts_attempts = count;
716 }
717
718 if let Ok(val) = std::env::var("RCH_MOCK_RSYNC_FILES")
719 && let Ok(files) = val.parse()
720 {
721 config.files_per_sync = files;
722 }
723 if let Ok(val) = std::env::var("RCH_MOCK_RSYNC_BYTES")
724 && let Ok(bytes) = val.parse()
725 {
726 config.bytes_per_sync = bytes;
727 }
728
729 config
730 }
731}
732
733#[derive(Debug, Clone)]
735pub struct MockSyncInvocation {
736 pub source: String,
738 pub destination: String,
740 pub phase: Phase,
742 pub timestamp: std::time::SystemTime,
744}
745
746impl MockRsync {
747 pub fn new(config: MockRsyncConfig) -> Self {
749 let sync_failures_remaining = AtomicU32::new(config.fail_sync_attempts);
750 let artifacts_failures_remaining = AtomicU32::new(config.fail_artifacts_attempts);
751 Self {
752 sync_invocations: Arc::new(Mutex::new(Vec::new())),
753 sync_failures_remaining,
754 artifacts_failures_remaining,
755 config,
756 }
757 }
758
759 pub fn new_default() -> Self {
761 Self::new(MockRsyncConfig::success())
762 }
763
764 pub fn invocations(&self) -> Vec<MockSyncInvocation> {
766 self.sync_invocations.lock().unwrap().clone()
767 }
768
769 pub async fn sync_to_remote(
771 &self,
772 source: &str,
773 destination: &str,
774 _exclude_patterns: &[String],
775 ) -> anyhow::Result<MockRsyncResult> {
776 log_phase(
777 Phase::Sync,
778 &format!("Mock sync: {} -> {}", source, destination),
779 );
780
781 {
782 let invocation = MockSyncInvocation {
783 source: source.to_string(),
784 destination: destination.to_string(),
785 phase: Phase::Sync,
786 timestamp: std::time::SystemTime::now(),
787 };
788 let mut invocations = self.sync_invocations.lock().unwrap();
789 invocations.push(invocation.clone());
790 global_rsync_invocations().lock().unwrap().push(invocation);
791 }
792
793 if self.config.fail_sync {
794 log_phase(Phase::Sync, "Mock sync failed");
795 return Err(anyhow::anyhow!("Mock: Sync failed"));
796 }
797
798 if self
800 .sync_failures_remaining
801 .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
802 current.checked_sub(1)
803 })
804 .is_ok()
805 {
806 log_phase(Phase::Sync, "Mock transient sync failure");
807 return Err(anyhow::anyhow!(
808 "Mock: Sync failed (transient) - Connection timed out"
809 ));
810 }
811
812 let start = Instant::now();
814 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
815
816 let result = MockRsyncResult {
817 files_transferred: self.config.files_per_sync,
818 bytes_transferred: self.config.bytes_per_sync,
819 duration_ms: start.elapsed().as_millis() as u64,
820 };
821
822 log_phase(
823 Phase::Sync,
824 &format!(
825 "Mock sync complete: {} files, {} bytes",
826 result.files_transferred, result.bytes_transferred
827 ),
828 );
829
830 Ok(result)
831 }
832
833 pub async fn retrieve_artifacts(
835 &self,
836 source: &str,
837 destination: &str,
838 _artifact_patterns: &[String],
839 ) -> anyhow::Result<MockRsyncResult> {
840 log_phase(
841 Phase::Artifacts,
842 &format!("Mock artifact retrieval: {} -> {}", source, destination),
843 );
844
845 {
846 let invocation = MockSyncInvocation {
847 source: source.to_string(),
848 destination: destination.to_string(),
849 phase: Phase::Artifacts,
850 timestamp: std::time::SystemTime::now(),
851 };
852 let mut invocations = self.sync_invocations.lock().unwrap();
853 invocations.push(invocation.clone());
854 global_rsync_invocations().lock().unwrap().push(invocation);
855 }
856
857 if self.config.fail_artifacts {
858 log_phase(Phase::Artifacts, "Mock artifact retrieval failed");
859 return Err(anyhow::anyhow!("Mock: Artifact retrieval failed"));
860 }
861
862 if self
864 .artifacts_failures_remaining
865 .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
866 current.checked_sub(1)
867 })
868 .is_ok()
869 {
870 log_phase(
871 Phase::Artifacts,
872 "Mock transient artifact retrieval failure",
873 );
874 return Err(anyhow::anyhow!(
875 "Mock: Artifact retrieval failed (transient) - Connection reset by peer"
876 ));
877 }
878
879 let start = Instant::now();
881 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
882
883 let result = MockRsyncResult {
884 files_transferred: self.config.files_per_sync / 2,
885 bytes_transferred: self.config.bytes_per_sync * 2,
886 duration_ms: start.elapsed().as_millis() as u64,
887 };
888
889 log_phase(
890 Phase::Artifacts,
891 &format!(
892 "Mock artifact retrieval complete: {} files, {} bytes",
893 result.files_transferred, result.bytes_transferred
894 ),
895 );
896
897 Ok(result)
898 }
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904
905 #[test]
906 fn test_is_mock_enabled_default() {
907 set_mock_enabled_override(Some(false));
910 assert!(!is_mock_enabled());
911 clear_mock_overrides();
912 }
913
914 #[test]
915 fn test_mock_config_defaults() {
916 let config = MockConfig::default();
917 assert_eq!(config.default_exit_code, 0);
918 assert!(!config.fail_connect);
919 assert!(!config.fail_execute);
920 }
921
922 #[test]
923 fn test_mock_config_connection_failure() {
924 let config = MockConfig::connection_failure();
925 assert!(config.fail_connect);
926 }
927
928 #[test]
929 fn test_mock_config_command_failure() {
930 let config = MockConfig::command_failure(1, "error message");
931 assert_eq!(config.default_exit_code, 1);
932 assert_eq!(config.default_stderr, "error message");
933 }
934
935 #[tokio::test]
936 async fn test_mock_ssh_client_connect() {
937 let worker_config = WorkerConfig {
938 id: WorkerId::new("mock-worker"),
939 host: "mock.host".to_string(),
940 user: "mockuser".to_string(),
941 identity_file: "~/.ssh/mock".to_string(),
942 total_slots: 8,
943 priority: 100,
944 tags: vec![],
945 tools: Vec::new(),
946 };
947
948 let mut client = MockSshClient::new_default(worker_config);
949 assert!(!client.is_connected());
950
951 client.connect().await.unwrap();
952 assert!(client.is_connected());
953
954 client.disconnect().await.unwrap();
955 assert!(!client.is_connected());
956 }
957
958 #[tokio::test]
959 async fn test_mock_ssh_client_execute() {
960 let worker_config = WorkerConfig {
961 id: WorkerId::new("mock-worker"),
962 host: "mock.host".to_string(),
963 user: "mockuser".to_string(),
964 identity_file: "~/.ssh/mock".to_string(),
965 total_slots: 8,
966 priority: 100,
967 tags: vec![],
968 tools: Vec::new(),
969 };
970
971 let mut client = MockSshClient::new(
972 worker_config,
973 MockConfig::default().with_stdout("test output"),
974 );
975
976 client.connect().await.unwrap();
977
978 let result = client.execute("echo test").await.unwrap();
979 assert_eq!(result.exit_code, 0);
980 assert_eq!(result.stdout, "test output");
981
982 let invocations = client.invocations();
983 assert_eq!(invocations.len(), 2); assert_eq!(invocations[1].command, Some("echo test".to_string()));
985 }
986
987 #[tokio::test]
988 async fn test_mock_ssh_client_connection_failure() {
989 let worker_config = WorkerConfig {
990 id: WorkerId::new("mock-worker"),
991 host: "mock.host".to_string(),
992 user: "mockuser".to_string(),
993 identity_file: "~/.ssh/mock".to_string(),
994 total_slots: 8,
995 priority: 100,
996 tags: vec![],
997 tools: Vec::new(),
998 };
999
1000 let mut client = MockSshClient::new(worker_config, MockConfig::connection_failure());
1001
1002 let result = client.connect().await;
1003 assert!(result.is_err());
1004 assert!(!client.is_connected());
1005 }
1006
1007 #[tokio::test]
1008 async fn test_mock_rsync_sync() {
1009 let rsync = MockRsync::new_default();
1010
1011 let result = rsync
1012 .sync_to_remote("/local/path", "user@host:/remote/path", &[])
1013 .await
1014 .unwrap();
1015
1016 assert!(result.files_transferred > 0);
1017 assert!(result.bytes_transferred > 0);
1018
1019 let invocations = rsync.invocations();
1020 assert_eq!(invocations.len(), 1);
1021 assert_eq!(invocations[0].phase, Phase::Sync);
1022 }
1023
1024 #[tokio::test]
1025 async fn test_mock_rsync_failure() {
1026 let rsync = MockRsync::new(MockRsyncConfig::sync_failure());
1027
1028 let result = rsync
1029 .sync_to_remote("/local/path", "user@host:/remote/path", &[])
1030 .await;
1031
1032 assert!(result.is_err());
1033 }
1034
1035 #[test]
1036 fn test_phase_display() {
1037 assert_eq!(format!("{}", Phase::Sync), "SYNC");
1038 assert_eq!(format!("{}", Phase::Execute), "EXEC");
1039 assert_eq!(format!("{}", Phase::Artifacts), "ARTIFACTS");
1040 }
1041
1042 #[test]
1043 fn test_mock_config_toolchain_install_failure() {
1044 let config = MockConfig::toolchain_install_failure();
1045 assert!(config.fail_toolchain_install);
1046 assert!(!config.no_rustup);
1047 }
1048
1049 #[test]
1050 fn test_mock_config_no_rustup() {
1051 let config = MockConfig::no_rustup();
1052 assert!(config.no_rustup);
1053 assert!(!config.fail_toolchain_install);
1054 }
1055
1056 #[tokio::test]
1057 async fn test_mock_ssh_client_no_rustup() {
1058 let worker_config = WorkerConfig {
1059 id: WorkerId::new("mock-worker"),
1060 host: "mock.host".to_string(),
1061 user: "mockuser".to_string(),
1062 identity_file: "~/.ssh/mock".to_string(),
1063 total_slots: 8,
1064 priority: 100,
1065 tags: vec![],
1066 tools: Vec::new(),
1067 };
1068
1069 let mut client = MockSshClient::new(worker_config, MockConfig::no_rustup());
1070 client.connect().await.unwrap();
1071
1072 let result = client.execute("rustup --version").await.unwrap();
1074 assert_eq!(result.exit_code, 127);
1075 assert!(result.stderr.contains("command not found"));
1076 }
1077
1078 #[tokio::test]
1079 async fn test_mock_ssh_client_toolchain_install_failure() {
1080 let worker_config = WorkerConfig {
1081 id: WorkerId::new("mock-worker"),
1082 host: "mock.host".to_string(),
1083 user: "mockuser".to_string(),
1084 identity_file: "~/.ssh/mock".to_string(),
1085 total_slots: 8,
1086 priority: 100,
1087 tags: vec![],
1088 tools: Vec::new(),
1089 };
1090
1091 let mut client = MockSshClient::new(worker_config, MockConfig::toolchain_install_failure());
1092 client.connect().await.unwrap();
1093
1094 let result = client
1096 .execute("rustup toolchain install nightly-2024-01-15")
1097 .await
1098 .unwrap();
1099 assert_eq!(result.exit_code, 1);
1100 assert!(result.stderr.contains("is not installed"));
1101
1102 let result = client
1104 .execute("rustup run nightly-2024-01-15 cargo build")
1105 .await
1106 .unwrap();
1107 assert_eq!(result.exit_code, 1);
1108 assert!(result.stderr.contains("is not installed"));
1109 }
1110
1111 #[tokio::test]
1112 async fn test_mock_ssh_client_normal_command_with_toolchain_failure() {
1113 let worker_config = WorkerConfig {
1114 id: WorkerId::new("mock-worker"),
1115 host: "mock.host".to_string(),
1116 user: "mockuser".to_string(),
1117 identity_file: "~/.ssh/mock".to_string(),
1118 total_slots: 8,
1119 priority: 100,
1120 tags: vec![],
1121 tools: Vec::new(),
1122 };
1123
1124 let mut client = MockSshClient::new(worker_config, MockConfig::toolchain_install_failure());
1125 client.connect().await.unwrap();
1126
1127 let result = client.execute("cargo build").await.unwrap();
1129 assert_eq!(result.exit_code, 0);
1130 }
1131
1132 #[test]
1133 fn test_is_mock_host() {
1134 assert!(is_mock_host("mock://localhost"));
1135 assert!(is_mock_host("mock://worker-1"));
1136 assert!(!is_mock_host("localhost"));
1137 assert!(!is_mock_host("192.168.1.1"));
1138 assert!(!is_mock_host(""));
1139 }
1140
1141 #[test]
1142 fn test_is_mock_worker() {
1143 let mock_worker = WorkerConfig {
1144 id: WorkerId::new("mock-worker"),
1145 host: "mock://localhost".to_string(),
1146 user: "user".to_string(),
1147 identity_file: "~/.ssh/id_rsa".to_string(),
1148 total_slots: 4,
1149 priority: 100,
1150 tags: vec![],
1151 tools: Vec::new(),
1152 };
1153 assert!(is_mock_worker(&mock_worker));
1154
1155 let real_worker = WorkerConfig {
1156 id: WorkerId::new("real-worker"),
1157 host: "192.168.1.1".to_string(),
1158 user: "user".to_string(),
1159 identity_file: "~/.ssh/id_rsa".to_string(),
1160 total_slots: 4,
1161 priority: 100,
1162 tags: vec![],
1163 tools: Vec::new(),
1164 };
1165 assert!(!is_mock_worker(&real_worker));
1166 }
1167
1168 #[test]
1169 fn test_mock_config_success() {
1170 let config = MockConfig::success();
1171 assert_eq!(config.default_exit_code, 0);
1172 assert!(!config.fail_connect);
1173 assert!(!config.fail_execute);
1174 assert!(config.default_stdout.is_empty());
1175 assert!(config.default_stderr.is_empty());
1176 }
1177
1178 #[test]
1179 fn test_mock_config_with_command_result() {
1180 let custom_result = CommandResult {
1181 exit_code: 42,
1182 stdout: "custom stdout".to_string(),
1183 stderr: "custom stderr".to_string(),
1184 duration_ms: 100,
1185 };
1186
1187 let config =
1188 MockConfig::success().with_command_result("special_cmd", custom_result.clone());
1189
1190 assert!(config.command_results.contains_key("special_cmd"));
1191 let result = config.command_results.get("special_cmd").unwrap();
1192 assert_eq!(result.exit_code, 42);
1193 assert_eq!(result.stdout, "custom stdout");
1194 }
1195
1196 #[test]
1197 fn test_mock_config_with_stdout() {
1198 let config = MockConfig::default().with_stdout("hello world");
1199 assert_eq!(config.default_stdout, "hello world");
1200 }
1201
1202 #[test]
1203 fn test_mock_rsync_config_success() {
1204 let config = MockRsyncConfig::success();
1205 assert!(!config.fail_sync);
1206 assert!(!config.fail_artifacts);
1207 assert_eq!(config.files_per_sync, 10);
1208 assert_eq!(config.bytes_per_sync, 1024 * 100);
1209 }
1210
1211 #[test]
1212 fn test_mock_rsync_config_default() {
1213 let config = MockRsyncConfig::default();
1214 assert!(!config.fail_sync);
1215 assert!(!config.fail_artifacts);
1216 assert_eq!(config.fail_sync_attempts, 0);
1217 assert_eq!(config.fail_artifacts_attempts, 0);
1218 }
1219
1220 #[test]
1221 fn test_mock_rsync_config_artifact_failure() {
1222 let config = MockRsyncConfig::artifact_failure();
1223 assert!(config.fail_artifacts);
1224 assert!(!config.fail_sync);
1225 }
1226
1227 #[tokio::test]
1228 async fn test_mock_rsync_retrieve_artifacts() {
1229 let rsync = MockRsync::new_default();
1230
1231 let result = rsync
1232 .retrieve_artifacts("user@host:/remote/path", "/local/path", &[])
1233 .await
1234 .unwrap();
1235
1236 assert!(result.files_transferred > 0);
1237 assert!(result.bytes_transferred > 0);
1238
1239 let invocations = rsync.invocations();
1240 assert_eq!(invocations.len(), 1);
1241 assert_eq!(invocations[0].phase, Phase::Artifacts);
1242 }
1243
1244 #[tokio::test]
1245 async fn test_mock_rsync_artifact_failure() {
1246 let rsync = MockRsync::new(MockRsyncConfig::artifact_failure());
1247
1248 let result = rsync
1249 .retrieve_artifacts("user@host:/remote/path", "/local/path", &[])
1250 .await;
1251
1252 assert!(result.is_err());
1253 }
1254
1255 #[test]
1256 fn test_phase_equality() {
1257 assert_eq!(Phase::Sync, Phase::Sync);
1258 assert_eq!(Phase::Execute, Phase::Execute);
1259 assert_ne!(Phase::Sync, Phase::Execute);
1260 assert_ne!(Phase::Artifacts, Phase::Connect);
1261 }
1262
1263 #[test]
1264 fn test_phase_copy() {
1265 let phase = Phase::Disconnect;
1266 let copy = phase; assert_eq!(phase, copy);
1268 }
1269
1270 #[test]
1271 fn test_phase_clone() {
1272 fn assert_clone<T: Clone>() {}
1273 assert_clone::<Phase>();
1274 }
1275
1276 #[test]
1277 fn test_phase_display_all_variants() {
1278 assert_eq!(format!("{}", Phase::Sync), "SYNC");
1279 assert_eq!(format!("{}", Phase::Execute), "EXEC");
1280 assert_eq!(format!("{}", Phase::Artifacts), "ARTIFACTS");
1281 assert_eq!(format!("{}", Phase::Connect), "CONNECT");
1282 assert_eq!(format!("{}", Phase::Disconnect), "DISCONNECT");
1283 }
1284
1285 #[test]
1286 fn test_mock_invocation_debug() {
1287 let invocation = MockInvocation {
1288 worker_id: WorkerId::new("test-worker"),
1289 command: Some("echo hello".to_string()),
1290 phase: Phase::Execute,
1291 timestamp: std::time::SystemTime::now(),
1292 };
1293
1294 let debug = format!("{:?}", invocation);
1295 assert!(debug.contains("MockInvocation"));
1296 assert!(debug.contains("test-worker"));
1297 }
1298
1299 #[test]
1300 fn test_mock_invocation_clone() {
1301 let invocation = MockInvocation {
1302 worker_id: WorkerId::new("worker-1"),
1303 command: None,
1304 phase: Phase::Connect,
1305 timestamp: std::time::SystemTime::now(),
1306 };
1307
1308 let cloned = invocation.clone();
1309 assert_eq!(invocation.phase, cloned.phase);
1310 }
1311
1312 #[test]
1313 fn test_mock_sync_invocation_debug() {
1314 let invocation = MockSyncInvocation {
1315 source: "/local/path".to_string(),
1316 destination: "user@host:/remote".to_string(),
1317 phase: Phase::Sync,
1318 timestamp: std::time::SystemTime::now(),
1319 };
1320
1321 let debug = format!("{:?}", invocation);
1322 assert!(debug.contains("MockSyncInvocation"));
1323 assert!(debug.contains("/local/path"));
1324 }
1325
1326 #[test]
1327 fn test_mock_sync_invocation_clone() {
1328 let invocation = MockSyncInvocation {
1329 source: "src".to_string(),
1330 destination: "dst".to_string(),
1331 phase: Phase::Artifacts,
1332 timestamp: std::time::SystemTime::now(),
1333 };
1334
1335 let cloned = invocation.clone();
1336 assert_eq!(invocation.source, cloned.source);
1337 assert_eq!(invocation.destination, cloned.destination);
1338 assert_eq!(invocation.phase, cloned.phase);
1339 }
1340
1341 #[test]
1342 fn test_mock_rsync_result_debug() {
1343 let result = MockRsyncResult {
1344 files_transferred: 5,
1345 bytes_transferred: 1024,
1346 duration_ms: 50,
1347 };
1348
1349 let debug = format!("{:?}", result);
1350 assert!(debug.contains("MockRsyncResult"));
1351 assert!(debug.contains("1024"));
1352 }
1353
1354 #[test]
1355 fn test_mock_rsync_result_clone() {
1356 let result = MockRsyncResult {
1357 files_transferred: 10,
1358 bytes_transferred: 2048,
1359 duration_ms: 100,
1360 };
1361
1362 let cloned = result.clone();
1363 assert_eq!(result.files_transferred, cloned.files_transferred);
1364 assert_eq!(result.bytes_transferred, cloned.bytes_transferred);
1365 assert_eq!(result.duration_ms, cloned.duration_ms);
1366 }
1367
1368 #[test]
1369 fn test_global_invocations_clear() {
1370 clear_global_invocations();
1372
1373 let ssh = global_ssh_invocations_snapshot();
1374 let rsync = global_rsync_invocations_snapshot();
1375
1376 assert!(ssh.is_empty());
1377 assert!(rsync.is_empty());
1378 }
1379
1380 #[tokio::test]
1381 async fn test_mock_ssh_client_execute_not_connected() {
1382 let worker_config = WorkerConfig {
1383 id: WorkerId::new("mock-worker"),
1384 host: "mock.host".to_string(),
1385 user: "mockuser".to_string(),
1386 identity_file: "~/.ssh/mock".to_string(),
1387 total_slots: 8,
1388 priority: 100,
1389 tags: vec![],
1390 tools: Vec::new(),
1391 };
1392
1393 let client = MockSshClient::new_default(worker_config);
1394 let result = client.execute("echo test").await;
1397 assert!(result.is_err());
1398 let err_msg = result.unwrap_err().to_string();
1399 assert!(err_msg.contains("Not connected"));
1400 }
1401
1402 #[tokio::test]
1403 async fn test_mock_ssh_client_streaming() {
1404 let worker_config = WorkerConfig {
1405 id: WorkerId::new("mock-worker"),
1406 host: "mock.host".to_string(),
1407 user: "mockuser".to_string(),
1408 identity_file: "~/.ssh/mock".to_string(),
1409 total_slots: 8,
1410 priority: 100,
1411 tags: vec![],
1412 tools: Vec::new(),
1413 };
1414
1415 let mut client = MockSshClient::new(
1416 worker_config,
1417 MockConfig::default().with_stdout("line1\nline2\nline3"),
1418 );
1419 client.connect().await.unwrap();
1420
1421 let mut stdout_lines = Vec::new();
1422 let mut stderr_lines = Vec::new();
1423
1424 let result = client
1425 .execute_streaming(
1426 "echo test",
1427 |line| stdout_lines.push(line.to_string()),
1428 |line| stderr_lines.push(line.to_string()),
1429 )
1430 .await
1431 .unwrap();
1432
1433 assert_eq!(result.exit_code, 0);
1434 assert_eq!(stdout_lines.len(), 3);
1435 }
1436
1437 #[tokio::test]
1438 async fn test_mock_ssh_client_health_check_success() {
1439 let worker_config = WorkerConfig {
1440 id: WorkerId::new("mock-worker"),
1441 host: "mock.host".to_string(),
1442 user: "mockuser".to_string(),
1443 identity_file: "~/.ssh/mock".to_string(),
1444 total_slots: 8,
1445 priority: 100,
1446 tags: vec![],
1447 tools: Vec::new(),
1448 };
1449
1450 let mut client = MockSshClient::new_default(worker_config);
1451 client.connect().await.unwrap();
1452
1453 let healthy = client.health_check().await.unwrap();
1454 assert!(healthy);
1455 }
1456
1457 #[tokio::test]
1458 async fn test_mock_ssh_client_health_check_failure() {
1459 let worker_config = WorkerConfig {
1460 id: WorkerId::new("mock-worker"),
1461 host: "mock.host".to_string(),
1462 user: "mockuser".to_string(),
1463 identity_file: "~/.ssh/mock".to_string(),
1464 total_slots: 8,
1465 priority: 100,
1466 tags: vec![],
1467 tools: Vec::new(),
1468 };
1469
1470 let mut client = MockSshClient::new(
1471 worker_config,
1472 MockConfig::command_failure(1, "health check failed"),
1473 );
1474 client.connect().await.unwrap();
1475
1476 let healthy = client.health_check().await.unwrap();
1477 assert!(!healthy);
1478 }
1479
1480 #[test]
1481 fn test_mock_ssh_client_worker_id() {
1482 let worker_config = WorkerConfig {
1483 id: WorkerId::new("my-worker-id"),
1484 host: "mock.host".to_string(),
1485 user: "mockuser".to_string(),
1486 identity_file: "~/.ssh/mock".to_string(),
1487 total_slots: 8,
1488 priority: 100,
1489 tags: vec![],
1490 tools: Vec::new(),
1491 };
1492
1493 let client = MockSshClient::new_default(worker_config);
1494 assert_eq!(client.worker_id().as_str(), "my-worker-id");
1495 }
1496
1497 #[test]
1498 fn test_mock_ssh_client_clear_invocations() {
1499 let worker_config = WorkerConfig {
1500 id: WorkerId::new("mock-worker"),
1501 host: "mock.host".to_string(),
1502 user: "mockuser".to_string(),
1503 identity_file: "~/.ssh/mock".to_string(),
1504 total_slots: 8,
1505 priority: 100,
1506 tags: vec![],
1507 tools: Vec::new(),
1508 };
1509
1510 let client = MockSshClient::new_default(worker_config);
1511 assert!(client.invocations().is_empty());
1513 client.clear_invocations();
1514 assert!(client.invocations().is_empty());
1515 }
1516
1517 #[test]
1518 fn test_mock_config_clone() {
1519 let config = MockConfig::default()
1520 .with_stdout("test")
1521 .with_command_result(
1522 "cmd",
1523 CommandResult {
1524 exit_code: 0,
1525 stdout: "out".to_string(),
1526 stderr: "err".to_string(),
1527 duration_ms: 10,
1528 },
1529 );
1530
1531 let cloned = config.clone();
1532 assert_eq!(config.default_stdout, cloned.default_stdout);
1533 assert_eq!(config.command_results.len(), cloned.command_results.len());
1534 }
1535
1536 #[test]
1537 fn test_mock_config_debug() {
1538 let config = MockConfig::default();
1539 let debug = format!("{:?}", config);
1540 assert!(debug.contains("MockConfig"));
1541 }
1542
1543 #[test]
1544 fn test_mock_rsync_config_debug() {
1545 let config = MockRsyncConfig::default();
1546 let debug = format!("{:?}", config);
1547 assert!(debug.contains("MockRsyncConfig"));
1548 }
1549
1550 #[test]
1551 fn test_mock_rsync_config_clone() {
1552 let config = MockRsyncConfig {
1553 fail_sync: true,
1554 fail_sync_attempts: 3,
1555 fail_artifacts: false,
1556 fail_artifacts_attempts: 0,
1557 files_per_sync: 20,
1558 bytes_per_sync: 5000,
1559 };
1560
1561 let cloned = config.clone();
1562 assert_eq!(config.fail_sync, cloned.fail_sync);
1563 assert_eq!(config.fail_sync_attempts, cloned.fail_sync_attempts);
1564 assert_eq!(config.files_per_sync, cloned.files_per_sync);
1565 }
1566}