1use anyhow::{Context, Result};
8use shell_escape::escape;
9use std::borrow::Cow;
10use std::path::{Path, PathBuf};
11use std::time::{Duration, Instant};
12use tokio::process::Command;
13use tracing::{error, info, warn};
14use uuid::Uuid;
15
16use crate::binary_hash::{BinaryHashResult, binaries_equivalent, compute_binary_hash};
17use crate::test_change::{TestChangeGuard, TestCodeChange};
18use crate::types::WorkerConfig;
19
20#[derive(Debug, Clone)]
23pub struct VerificationWorkerConfig {
24 pub id: String,
26 pub ssh_host: String,
28 pub identity_file: Option<PathBuf>,
30 pub build_dir: PathBuf,
32}
33
34impl VerificationWorkerConfig {
35 pub fn from_worker_config(config: &WorkerConfig, build_dir: PathBuf) -> Self {
37 Self {
38 id: config.id.to_string(),
39 ssh_host: format!("{}@{}", config.user, config.host),
40 identity_file: Some(PathBuf::from(&config.identity_file)),
41 build_dir,
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct VerificationResult {
49 pub success: bool,
51 pub local_hash: BinaryHashResult,
53 pub remote_hash: BinaryHashResult,
55 pub rsync_up_ms: u64,
57 pub compilation_ms: u64,
59 pub rsync_down_ms: u64,
61 pub total_ms: u64,
63 pub error: Option<String>,
65}
66
67impl VerificationResult {
68 pub fn speedup_factor(&self, local_compilation_ms: u64) -> Option<f64> {
71 if self.compilation_ms == 0 || local_compilation_ms == 0 {
72 return None;
73 }
74 Some(local_compilation_ms as f64 / self.compilation_ms as f64)
75 }
76}
77
78pub struct RemoteCompilationTest {
80 pub worker: VerificationWorkerConfig,
82 pub test_project: PathBuf,
84 pub timeout: Duration,
86 remote_path_suffix: String,
88 local_compilation_ms: Option<u64>,
90}
91
92fn sanitize_remote_path_component(component: &str, fallback: &str) -> String {
93 let sanitized = component
94 .chars()
95 .map(|ch| {
96 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
97 ch
98 } else {
99 '-'
100 }
101 })
102 .collect::<String>();
103 let trimmed = sanitized.trim_matches('-');
104 if trimmed.is_empty() {
105 fallback.to_string()
106 } else {
107 trimmed.to_string()
108 }
109}
110
111fn sanitize_remote_path_suffix(suffix: &str) -> String {
112 sanitize_remote_path_component(suffix, "run")
113}
114
115fn shell_escape_path(path: &Path) -> String {
116 escape(path.to_string_lossy()).into_owned()
117}
118
119fn shell_escape_path_str(path: &str) -> String {
120 escape(Cow::from(path)).into_owned()
121}
122
123impl RemoteCompilationTest {
124 pub fn new(worker: VerificationWorkerConfig, test_project: PathBuf) -> Self {
126 Self {
127 worker,
128 test_project,
129 timeout: Duration::from_secs(120),
130 remote_path_suffix: format!("run-{}", Uuid::new_v4()),
131 local_compilation_ms: None,
132 }
133 }
134
135 pub fn with_timeout(mut self, timeout: Duration) -> Self {
137 self.timeout = timeout;
138 self
139 }
140
141 pub fn with_remote_path_suffix(mut self, suffix: impl AsRef<str>) -> Self {
143 self.remote_path_suffix = sanitize_remote_path_suffix(suffix.as_ref());
144 self
145 }
146
147 fn local_binary_path(&self) -> PathBuf {
149 let project_name = self
151 .test_project
152 .file_name()
153 .and_then(|n| n.to_str())
154 .unwrap_or("test_project");
155 self.test_project.join("target/release").join(project_name)
156 }
157
158 fn remote_binary_path(&self) -> PathBuf {
160 let project_name = self
161 .test_project
162 .file_name()
163 .and_then(|n| n.to_str())
164 .unwrap_or("test_project");
165 self.test_project
166 .join("target/release_remote")
167 .join(project_name)
168 }
169
170 fn remote_project_path(&self) -> PathBuf {
172 let project_name = self
173 .test_project
174 .file_name()
175 .and_then(|n| n.to_str())
176 .map(|n| sanitize_remote_path_component(n, "test_project"))
177 .unwrap_or_else(|| "test_project".to_string());
178 let remote_path_suffix = sanitize_remote_path_suffix(&self.remote_path_suffix);
179 self.worker
180 .build_dir
181 .join("self_test")
182 .join(format!("{project_name}-{remote_path_suffix}"))
183 }
184
185 fn ssh_args(&self) -> Vec<String> {
187 let mut args = vec!["-o".to_string(), "BatchMode=yes".to_string()];
188 if let Some(ref identity) = self.worker.identity_file {
189 args.push("-i".to_string());
190 args.push(identity.to_string_lossy().to_string());
191 }
192 args.push(self.worker.ssh_host.clone());
193 args
194 }
195
196 fn rsync_ssh_option(&self) -> Option<String> {
198 self.worker
199 .identity_file
200 .as_ref()
201 .map(|identity| format!("ssh -o BatchMode=yes -i {}", shell_escape_path(identity)))
202 }
203
204 pub async fn run(&mut self) -> Result<VerificationResult> {
206 let start = Instant::now();
207 info!(
208 "Starting remote compilation verification for worker {}",
209 self.worker.id
210 );
211
212 let change = TestCodeChange::for_main_rs(&self.test_project)
214 .context("Failed to create test code change")?;
215 let guard = TestChangeGuard::new(change).context("Failed to apply test change")?;
216 info!("Applied test change: {}", guard.change_id());
217
218 info!("Building locally for reference hash");
220 let local_build_start = Instant::now();
221 self.build_local().await.context("Local build failed")?;
222 let local_compilation_ms = local_build_start.elapsed().as_millis() as u64;
223 self.local_compilation_ms = Some(local_compilation_ms);
224
225 let local_hash = compute_binary_hash(&self.local_binary_path())
226 .context("Failed to compute local binary hash")?;
227 info!(
228 "Local build complete in {}ms: hash={}",
229 local_compilation_ms,
230 &local_hash.code_hash[..16]
231 );
232
233 info!("Syncing source to worker {}", self.worker.id);
235 let rsync_up_start = Instant::now();
236 self.rsync_to_worker()
237 .await
238 .context("Failed to rsync to worker")?;
239 let rsync_up_ms = rsync_up_start.elapsed().as_millis() as u64;
240 info!("Source synced to worker in {}ms", rsync_up_ms);
241
242 info!("Building on remote worker");
244 let compile_start = Instant::now();
245 self.build_remote().await.context("Remote build failed")?;
246 let compilation_ms = compile_start.elapsed().as_millis() as u64;
247 info!("Remote build complete in {}ms", compilation_ms);
248
249 info!("Syncing artifacts from worker");
251 let rsync_down_start = Instant::now();
252 self.rsync_from_worker()
253 .await
254 .context("Failed to rsync from worker")?;
255 let rsync_down_ms = rsync_down_start.elapsed().as_millis() as u64;
256 info!("Artifacts synced back in {}ms", rsync_down_ms);
257
258 let remote_hash = compute_binary_hash(&self.remote_binary_path())
260 .context("Failed to compute remote binary hash")?;
261 info!("Remote binary hash: {}", &remote_hash.code_hash[..16]);
262
263 let success = binaries_equivalent(&local_hash, &remote_hash);
265 let total_ms = start.elapsed().as_millis() as u64;
266
267 let error = if success {
268 info!("Verification PASSED: local and remote hashes match");
269 None
270 } else {
271 let msg = format!(
272 "Binary hashes do not match: local={}, remote={}",
273 &local_hash.code_hash[..16],
274 &remote_hash.code_hash[..16]
275 );
276 error!("Verification FAILED: {}", msg);
277 Some(msg)
278 };
279
280 drop(guard);
282
283 Ok(VerificationResult {
284 success,
285 local_hash,
286 remote_hash,
287 rsync_up_ms,
288 compilation_ms,
289 rsync_down_ms,
290 total_ms,
291 error,
292 })
293 }
294
295 async fn build_local(&self) -> Result<()> {
297 info!("Running: cargo build --release in {:?}", self.test_project);
298
299 let output = Command::new("cargo")
300 .args(["build", "--release"])
301 .current_dir(&self.test_project)
302 .env("CARGO_INCREMENTAL", "0")
303 .output()
304 .await
305 .context("Failed to execute cargo build")?;
306
307 if !output.status.success() {
308 let stderr = String::from_utf8_lossy(&output.stderr);
309 anyhow::bail!("Local build failed: {}", stderr);
310 }
311 Ok(())
312 }
313
314 async fn rsync_to_worker(&self) -> Result<()> {
316 let remote_project = self.remote_project_path();
317 let escaped_remote_project = shell_escape_path(&remote_project);
318 let mkdir_cmd = format!("mkdir -p -- {escaped_remote_project}");
319
320 let mut mkdir = Command::new("ssh");
321 for arg in self.ssh_args() {
322 mkdir.arg(arg);
323 }
324 mkdir.arg(&mkdir_cmd);
325
326 let output = mkdir
327 .output()
328 .await
329 .context("Failed to create remote project directory")?;
330
331 if !output.status.success() {
332 let stderr = String::from_utf8_lossy(&output.stderr);
333 anyhow::bail!("Remote directory creation failed: {}", stderr);
334 }
335
336 let remote_project_with_slash = format!("{}/", remote_project.display());
337 let remote_path = format!(
338 "{}:{}",
339 self.worker.ssh_host,
340 shell_escape_path_str(&remote_project_with_slash)
341 );
342
343 let mut cmd = Command::new("rsync");
344 cmd.args([
345 "-az",
346 "--delete",
347 "--exclude",
348 "target/",
349 "--exclude",
350 ".git/",
351 ]);
352
353 if let Some(ssh_option) = self.rsync_ssh_option() {
354 cmd.args(["-e", &ssh_option]);
355 }
356
357 cmd.arg(format!("{}/", self.test_project.display()));
358 cmd.arg(&remote_path);
359
360 info!("Running rsync to {}", remote_path);
361 let output = cmd.output().await.context("Failed to execute rsync")?;
362
363 if !output.status.success() {
364 let stderr = String::from_utf8_lossy(&output.stderr);
365 anyhow::bail!("rsync to worker failed: {}", stderr);
366 }
367 Ok(())
368 }
369
370 async fn build_remote(&self) -> Result<()> {
372 let remote_project = self.remote_project_path();
373 let build_cmd = format!(
374 "cd {} && cargo build --release",
375 shell_escape_path(&remote_project)
376 );
377
378 let mut cmd = Command::new("ssh");
379 for arg in self.ssh_args() {
380 cmd.arg(arg);
381 }
382 cmd.arg(&build_cmd);
383
384 info!(
385 "Running remote build: ssh {} '{}'",
386 self.worker.ssh_host, build_cmd
387 );
388 let output = cmd
389 .output()
390 .await
391 .context("Failed to execute remote build")?;
392
393 if !output.status.success() {
394 let stderr = String::from_utf8_lossy(&output.stderr);
395 anyhow::bail!("Remote build failed: {}", stderr);
396 }
397 Ok(())
398 }
399
400 async fn rsync_from_worker(&self) -> Result<()> {
402 let remote_project = self.remote_project_path();
403 let remote_release_dir = remote_project.join("target/release");
404 let remote_release_with_slash = format!("{}/", remote_release_dir.display());
405 let remote_target = format!(
406 "{}:{}",
407 self.worker.ssh_host,
408 shell_escape_path_str(&remote_release_with_slash)
409 );
410
411 let local_target = self.test_project.join("target/release_remote/");
412 std::fs::create_dir_all(&local_target)
413 .context("Failed to create local target directory")?;
414
415 let mut cmd = Command::new("rsync");
416 cmd.args(["-az"]);
417
418 if let Some(ssh_option) = self.rsync_ssh_option() {
419 cmd.args(["-e", &ssh_option]);
420 }
421
422 cmd.arg(&remote_target);
423 cmd.arg(format!("{}/", local_target.display()));
424
425 info!("Running rsync from {}", remote_target);
426 let output = cmd.output().await.context("Failed to execute rsync")?;
427
428 if !output.status.success() {
429 let stderr = String::from_utf8_lossy(&output.stderr);
430 anyhow::bail!("rsync from worker failed: {}", stderr);
431 }
432 Ok(())
433 }
434
435 pub async fn cleanup_remote(&self) -> Result<()> {
437 let remote_project = self.remote_project_path();
438 let cleanup_cmd = format!("rm -rf -- {}", shell_escape_path(&remote_project));
439
440 let mut cmd = Command::new("ssh");
441 for arg in self.ssh_args() {
442 cmd.arg(arg);
443 }
444 cmd.arg(&cleanup_cmd);
445
446 info!("Cleaning up remote: {}", cleanup_cmd);
447 let output = cmd
448 .output()
449 .await
450 .context("Failed to execute remote cleanup")?;
451
452 if !output.status.success() {
453 let stderr = String::from_utf8_lossy(&output.stderr);
454 warn!("Remote cleanup failed (non-fatal): {}", stderr);
455 }
456 Ok(())
457 }
458}
459
460#[cfg(test)]
461mod basic_tests {
462 use super::*;
463 use crate::types::{WorkerConfig, WorkerId};
464
465 fn sample_worker_config() -> WorkerConfig {
466 WorkerConfig {
467 id: WorkerId::new("worker-1"),
468 host: "example.com".to_string(),
469 user: "builder".to_string(),
470 identity_file: "/tmp/id_rsa".to_string(),
471 total_slots: 8,
472 priority: 100,
473 tags: Vec::new(),
474 }
475 }
476
477 #[test]
478 fn test_verification_worker_config_from_worker_config() {
479 let worker = sample_worker_config();
480 let build_dir = PathBuf::from("/tmp/rch-build");
481 let verification = VerificationWorkerConfig::from_worker_config(&worker, build_dir.clone());
482
483 assert_eq!(verification.id, "worker-1");
484 assert_eq!(verification.ssh_host, "builder@example.com");
485 assert_eq!(
486 verification.identity_file,
487 Some(PathBuf::from("/tmp/id_rsa"))
488 );
489 assert_eq!(verification.build_dir, build_dir);
490 }
491
492 #[test]
493 fn test_remote_compilation_paths() {
494 let verification = VerificationWorkerConfig {
495 id: "worker-1".to_string(),
496 ssh_host: "builder@example.com".to_string(),
497 identity_file: Some(PathBuf::from("/tmp/id_rsa")),
498 build_dir: PathBuf::from("/tmp/rch-builds"),
499 };
500 let test_project = PathBuf::from("/tmp/test-project");
501 let test = RemoteCompilationTest::new(verification, test_project.clone())
502 .with_remote_path_suffix("run-1");
503
504 assert_eq!(
505 test.local_binary_path(),
506 test_project.join("target/release/test-project")
507 );
508 assert_eq!(
509 test.remote_binary_path(),
510 test_project.join("target/release_remote/test-project")
511 );
512 assert_eq!(
513 test.remote_project_path(),
514 PathBuf::from("/tmp/rch-builds/self_test/test-project-run-1")
515 );
516 }
517
518 #[test]
519 fn test_remote_compilation_paths_are_isolated_by_default() {
520 let verification = VerificationWorkerConfig {
521 id: "worker-1".to_string(),
522 ssh_host: "builder@example.com".to_string(),
523 identity_file: Some(PathBuf::from("/tmp/id_rsa")),
524 build_dir: PathBuf::from("/tmp/rch-builds"),
525 };
526 let test_project = PathBuf::from("/tmp/test-project");
527 let first = RemoteCompilationTest::new(verification.clone(), test_project.clone());
528 let second = RemoteCompilationTest::new(verification, test_project);
529
530 assert_ne!(first.remote_project_path(), second.remote_project_path());
531 let first_path = first.remote_project_path().display().to_string();
532 assert!(
533 first_path.starts_with("/tmp/rch-builds/self_test/test-project-run-"),
534 "remote path should include an isolated run suffix: {first_path}"
535 );
536 }
537
538 #[test]
539 fn test_remote_project_path_sanitizes_project_and_suffix() {
540 let verification = VerificationWorkerConfig {
541 id: "worker-1".to_string(),
542 ssh_host: "builder@example.com".to_string(),
543 identity_file: Some(PathBuf::from("/tmp/id_rsa")),
544 build_dir: PathBuf::from("/tmp/rch-builds"),
545 };
546 let test_project = PathBuf::from("/tmp/project with spaces");
547 let test = RemoteCompilationTest::new(verification, test_project)
548 .with_remote_path_suffix("../attempt 1");
549
550 assert_eq!(
551 test.remote_project_path(),
552 PathBuf::from("/tmp/rch-builds/self_test/project-with-spaces-..-attempt-1")
553 );
554 }
555
556 #[test]
557 fn test_ssh_args_with_identity() {
558 let verification = VerificationWorkerConfig {
559 id: "worker-1".to_string(),
560 ssh_host: "builder@example.com".to_string(),
561 identity_file: Some(PathBuf::from("/tmp/key.pem")),
562 build_dir: PathBuf::from("/tmp/rch-builds"),
563 };
564 let test = RemoteCompilationTest::new(verification, PathBuf::from("/tmp/project"));
565 let args = test.ssh_args();
566
567 assert!(args.contains(&"-i".to_string()));
568 assert!(args.contains(&"/tmp/key.pem".to_string()));
569 assert_eq!(args.last(), Some(&"builder@example.com".to_string()));
570 }
571
572 #[test]
573 fn test_ssh_args_without_identity() {
574 let verification = VerificationWorkerConfig {
575 id: "worker-1".to_string(),
576 ssh_host: "builder@example.com".to_string(),
577 identity_file: None,
578 build_dir: PathBuf::from("/tmp/rch-builds"),
579 };
580 let test = RemoteCompilationTest::new(verification, PathBuf::from("/tmp/project"));
581 let args = test.ssh_args();
582
583 assert!(!args.contains(&"-i".to_string()));
584 assert_eq!(args.last(), Some(&"builder@example.com".to_string()));
585 }
586
587 #[test]
588 fn test_rsync_ssh_option() {
589 let verification = VerificationWorkerConfig {
590 id: "worker-1".to_string(),
591 ssh_host: "builder@example.com".to_string(),
592 identity_file: Some(PathBuf::from("/tmp/key.pem")),
593 build_dir: PathBuf::from("/tmp/rch-builds"),
594 };
595 let test = RemoteCompilationTest::new(verification, PathBuf::from("/tmp/project"));
596
597 assert_eq!(
598 test.rsync_ssh_option(),
599 Some("ssh -o BatchMode=yes -i /tmp/key.pem".to_string())
600 );
601 }
602
603 #[test]
604 fn test_rsync_ssh_option_quotes_identity_with_spaces() {
605 let verification = VerificationWorkerConfig {
606 id: "worker-1".to_string(),
607 ssh_host: "builder@example.com".to_string(),
608 identity_file: Some(PathBuf::from("/tmp/key files/key.pem")),
609 build_dir: PathBuf::from("/tmp/rch-builds"),
610 };
611 let test = RemoteCompilationTest::new(verification, PathBuf::from("/tmp/project"));
612
613 assert_eq!(
614 test.rsync_ssh_option(),
615 Some("ssh -o BatchMode=yes -i '/tmp/key files/key.pem'".to_string())
616 );
617 }
618
619 fn dummy_hash() -> BinaryHashResult {
620 BinaryHashResult {
621 full_hash: "full".to_string(),
622 code_hash: "code".to_string(),
623 text_section_size: 123,
624 is_debug: false,
625 }
626 }
627
628 #[test]
629 fn test_speedup_factor() {
630 let result = VerificationResult {
631 success: true,
632 local_hash: dummy_hash(),
633 remote_hash: dummy_hash(),
634 rsync_up_ms: 10,
635 compilation_ms: 500,
636 rsync_down_ms: 10,
637 total_ms: 520,
638 error: None,
639 };
640
641 assert_eq!(result.speedup_factor(1000), Some(2.0));
642 assert_eq!(result.speedup_factor(0), None);
643
644 let zero_remote = VerificationResult {
645 compilation_ms: 0,
646 ..result
647 };
648 assert_eq!(zero_remote.speedup_factor(1000), None);
649 }
650}
651
652pub async fn verify_ssh_connectivity(worker: &VerificationWorkerConfig) -> Result<bool> {
654 let mut cmd = Command::new("ssh");
655 cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]);
656 if let Some(ref identity) = worker.identity_file {
657 cmd.args(["-i", &identity.to_string_lossy()]);
658 }
659 cmd.arg(&worker.ssh_host);
660 cmd.arg("echo ok");
661
662 let output = cmd
663 .output()
664 .await
665 .context("Failed to execute SSH connectivity test")?;
666
667 Ok(output.status.success())
668}
669
670pub async fn verify_rsync_available(worker: &VerificationWorkerConfig) -> Result<bool> {
672 let mut cmd = Command::new("ssh");
673 cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]);
674 if let Some(ref identity) = worker.identity_file {
675 cmd.args(["-i", &identity.to_string_lossy()]);
676 }
677 cmd.arg(&worker.ssh_host);
678 cmd.arg("which rsync");
679
680 let output = cmd
681 .output()
682 .await
683 .context("Failed to check rsync availability")?;
684
685 Ok(output.status.success())
686}
687
688pub async fn verify_cargo_available(worker: &VerificationWorkerConfig) -> Result<bool> {
690 let mut cmd = Command::new("ssh");
691 cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]);
692 if let Some(ref identity) = worker.identity_file {
693 cmd.args(["-i", &identity.to_string_lossy()]);
694 }
695 cmd.arg(&worker.ssh_host);
696 cmd.arg("which cargo");
697
698 let output = cmd
699 .output()
700 .await
701 .context("Failed to check cargo availability")?;
702
703 Ok(output.status.success())
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 fn init_test_logging() {
711 let _ = tracing_subscriber::fmt()
712 .with_test_writer()
713 .with_max_level(tracing::Level::INFO)
714 .try_init();
715 }
716
717 #[test]
718 fn test_verification_worker_config_creation() {
719 init_test_logging();
720 info!("TEST START: test_verification_worker_config_creation");
721
722 let worker = VerificationWorkerConfig {
723 id: "test-worker".to_string(),
724 ssh_host: "user@192.168.1.100".to_string(),
725 identity_file: Some(PathBuf::from("/home/user/.ssh/id_rsa")),
726 build_dir: PathBuf::from("/tmp/rch_builds"),
727 };
728
729 info!(
730 "INPUT: VerificationWorkerConfig with id={}, host={}",
731 worker.id, worker.ssh_host
732 );
733
734 assert_eq!(worker.id, "test-worker");
735 assert_eq!(worker.ssh_host, "user@192.168.1.100");
736
737 info!("VERIFY: VerificationWorkerConfig created successfully");
738 info!("TEST PASS: test_verification_worker_config_creation");
739 }
740
741 #[test]
742 fn test_remote_compilation_test_creation() {
743 init_test_logging();
744 info!("TEST START: test_remote_compilation_test_creation");
745
746 let worker = VerificationWorkerConfig {
747 id: "test-worker".to_string(),
748 ssh_host: "localhost".to_string(),
749 identity_file: None,
750 build_dir: PathBuf::from("/tmp/rch_builds"),
751 };
752
753 let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/test_project"))
754 .with_timeout(Duration::from_secs(60));
755
756 info!("INPUT: RemoteCompilationTest with timeout=60s");
757
758 assert_eq!(test.timeout, Duration::from_secs(60));
759 assert_eq!(test.test_project, PathBuf::from("/tmp/test_project"));
760
761 info!("VERIFY: RemoteCompilationTest created with correct settings");
762 info!("TEST PASS: test_remote_compilation_test_creation");
763 }
764
765 #[test]
766 fn test_verification_result_speedup() {
767 init_test_logging();
768 info!("TEST START: test_verification_result_speedup");
769
770 let result = VerificationResult {
771 success: true,
772 local_hash: BinaryHashResult {
773 full_hash: "abc".to_string(),
774 code_hash: "xyz".to_string(),
775 text_section_size: 1000,
776 is_debug: false,
777 },
778 remote_hash: BinaryHashResult {
779 full_hash: "abc".to_string(),
780 code_hash: "xyz".to_string(),
781 text_section_size: 1000,
782 is_debug: false,
783 },
784 rsync_up_ms: 100,
785 compilation_ms: 5000,
786 rsync_down_ms: 50,
787 total_ms: 5150,
788 error: None,
789 };
790
791 let speedup = result.speedup_factor(10000);
792 info!("INPUT: local=10000ms, remote=5000ms");
793 info!("RESULT: speedup_factor={:?}", speedup);
794
795 assert!(speedup.is_some());
796 assert!((speedup.unwrap() - 2.0).abs() < 0.01);
797
798 info!("VERIFY: Speedup calculated correctly (2x)");
799 info!("TEST PASS: test_verification_result_speedup");
800 }
801
802 #[test]
803 fn test_local_binary_path() {
804 init_test_logging();
805 info!("TEST START: test_local_binary_path");
806
807 let worker = VerificationWorkerConfig {
808 id: "test".to_string(),
809 ssh_host: "localhost".to_string(),
810 identity_file: None,
811 build_dir: PathBuf::from("/tmp"),
812 };
813
814 let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/my_project"));
815 let path = test.local_binary_path();
816
817 info!("INPUT: test_project=/tmp/my_project");
818 info!("RESULT: local_binary_path={:?}", path);
819
820 assert_eq!(
821 path,
822 PathBuf::from("/tmp/my_project/target/release/my_project")
823 );
824
825 info!("VERIFY: Local binary path constructed correctly");
826 info!("TEST PASS: test_local_binary_path");
827 }
828
829 #[test]
830 fn test_remote_binary_path() {
831 init_test_logging();
832 info!("TEST START: test_remote_binary_path");
833
834 let worker = VerificationWorkerConfig {
835 id: "test".to_string(),
836 ssh_host: "localhost".to_string(),
837 identity_file: None,
838 build_dir: PathBuf::from("/tmp"),
839 };
840
841 let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/my_project"));
842 let path = test.remote_binary_path();
843
844 info!("INPUT: test_project=/tmp/my_project");
845 info!("RESULT: remote_binary_path={:?}", path);
846
847 assert_eq!(
848 path,
849 PathBuf::from("/tmp/my_project/target/release_remote/my_project")
850 );
851
852 info!("VERIFY: Remote binary path constructed correctly");
853 info!("TEST PASS: test_remote_binary_path");
854 }
855
856 #[test]
857 fn test_ssh_args_with_identity() {
858 init_test_logging();
859 info!("TEST START: test_ssh_args_with_identity");
860
861 let worker = VerificationWorkerConfig {
862 id: "test".to_string(),
863 ssh_host: "user@host.example.com".to_string(),
864 identity_file: Some(PathBuf::from("/home/user/.ssh/mykey")),
865 build_dir: PathBuf::from("/tmp"),
866 };
867
868 let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/project"));
869 let args = test.ssh_args();
870
871 info!("INPUT: identity_file=/home/user/.ssh/mykey");
872 info!("RESULT: ssh_args={:?}", args);
873
874 assert!(args.contains(&"-i".to_string()));
875 assert!(args.contains(&"/home/user/.ssh/mykey".to_string()));
876 assert!(args.contains(&"user@host.example.com".to_string()));
877
878 info!("VERIFY: SSH args include identity file");
879 info!("TEST PASS: test_ssh_args_with_identity");
880 }
881
882 #[test]
883 fn test_ssh_args_without_identity() {
884 init_test_logging();
885 info!("TEST START: test_ssh_args_without_identity");
886
887 let worker = VerificationWorkerConfig {
888 id: "test".to_string(),
889 ssh_host: "user@host".to_string(),
890 identity_file: None,
891 build_dir: PathBuf::from("/tmp"),
892 };
893
894 let test = RemoteCompilationTest::new(worker, PathBuf::from("/tmp/project"));
895 let args = test.ssh_args();
896
897 info!("INPUT: identity_file=None");
898 info!("RESULT: ssh_args={:?}", args);
899
900 assert!(!args.contains(&"-i".to_string()));
901 assert!(args.contains(&"user@host".to_string()));
902
903 info!("VERIFY: SSH args work without identity file");
904 info!("TEST PASS: test_ssh_args_without_identity");
905 }
906}