Skip to main content

rch_common/e2e/
verification.rs

1//! Remote Compilation Verification
2//!
3//! Provides infrastructure for verifying that remote compilation works correctly
4//! by building code on a remote worker and comparing the result with a local build.
5//!
6//! # Verification Flow
7//!
8//! 1. Apply a unique test change to the source code
9//! 2. Build locally to get a reference binary hash
10//! 3. rsync source files to the remote worker
11//! 4. Execute the build on the remote worker
12//! 5. rsync the artifacts back
13//! 6. Compare the binary hashes to verify correctness
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use rch_common::e2e::verification::{RemoteCompilationTest, VerificationConfig};
19//! use rch_common::types::WorkerConfig;
20//!
21//! let config = VerificationConfig::default();
22//! let test = RemoteCompilationTest::new(worker_config, project_path, config);
23//! let result = test.run().await?;
24//!
25//! if result.success {
26//!     println!("Remote compilation verified!");
27//! }
28//! ```
29
30use std::borrow::Cow;
31use std::path::{Path, PathBuf};
32use std::process::Command;
33use std::time::{Duration, Instant};
34
35use anyhow::{Context, Result, anyhow};
36use shell_escape::escape;
37use tracing::{debug, error, info, warn};
38use uuid::Uuid;
39
40use crate::binary_hash::{BinaryHashResult, binaries_equivalent, compute_binary_hash};
41use crate::test_change::{TestChangeGuard, TestCodeChange};
42use crate::types::WorkerConfig;
43
44/// Configuration for remote compilation verification.
45#[derive(Debug, Clone)]
46pub struct VerificationConfig {
47    /// Timeout for the entire verification process.
48    pub timeout: Duration,
49    /// Timeout for individual build operations.
50    pub build_timeout: Duration,
51    /// Whether to use release mode for builds.
52    pub release_mode: bool,
53    /// Additional cargo flags to pass to builds.
54    pub cargo_flags: Vec<String>,
55    /// rsync compression level (0-9).
56    pub rsync_compression: u32,
57    /// Patterns to exclude from rsync transfer.
58    pub exclude_patterns: Vec<String>,
59    /// Whether to clean target directory before remote build.
60    pub clean_before_build: bool,
61    /// Remote base path for isolated verification workspaces.
62    pub remote_base_path: PathBuf,
63}
64
65impl Default for VerificationConfig {
66    fn default() -> Self {
67        Self {
68            timeout: Duration::from_secs(300),
69            build_timeout: Duration::from_secs(180),
70            release_mode: false,
71            cargo_flags: vec![],
72            rsync_compression: 3,
73            exclude_patterns: vec![
74                "target/".to_string(),
75                ".git/".to_string(),
76                "node_modules/".to_string(),
77            ],
78            clean_before_build: false,
79            remote_base_path: PathBuf::from("/tmp/rch_verify"),
80        }
81    }
82}
83
84/// Result of a remote compilation verification.
85#[derive(Debug, Clone)]
86pub struct VerificationResult {
87    /// Whether the verification succeeded (binaries match).
88    pub success: bool,
89    /// Hash result from the local build.
90    pub local_hash: Option<BinaryHashResult>,
91    /// Hash result from the remote build.
92    pub remote_hash: Option<BinaryHashResult>,
93    /// Time spent syncing files to the worker (ms).
94    pub rsync_up_ms: u64,
95    /// Time spent on remote compilation (ms).
96    pub compilation_ms: u64,
97    /// Time spent syncing artifacts back (ms).
98    pub rsync_down_ms: u64,
99    /// Total time for the entire verification (ms).
100    pub total_ms: u64,
101    /// Bytes transferred to the worker.
102    pub bytes_up: u64,
103    /// Bytes transferred from the worker.
104    pub bytes_down: u64,
105    /// Error message if verification failed.
106    pub error: Option<String>,
107    /// The test change ID used for verification.
108    pub change_id: String,
109    /// Whether the test marker was found in the binary.
110    pub marker_verified: bool,
111}
112
113impl VerificationResult {
114    /// Create a failed result with an error message.
115    pub fn failed(error: impl Into<String>, elapsed_ms: u64, change_id: String) -> Self {
116        Self {
117            success: false,
118            local_hash: None,
119            remote_hash: None,
120            rsync_up_ms: 0,
121            compilation_ms: 0,
122            rsync_down_ms: 0,
123            total_ms: elapsed_ms,
124            bytes_up: 0,
125            bytes_down: 0,
126            error: Some(error.into()),
127            change_id,
128            marker_verified: false,
129        }
130    }
131}
132
133/// Remote compilation test runner.
134///
135/// Verifies that the RCH pipeline works correctly by:
136/// 1. Making a detectable change to the source
137/// 2. Building locally for a reference
138/// 3. Building remotely
139/// 4. Comparing the results
140pub struct RemoteCompilationTest {
141    /// Worker to use for remote compilation.
142    worker: WorkerConfig,
143    /// Path to the test project.
144    project_path: PathBuf,
145    /// Verification configuration.
146    config: VerificationConfig,
147    /// Unique suffix appended to the remote project directory for this test run.
148    remote_path_suffix: String,
149}
150
151fn sanitize_remote_path_component(component: &str, fallback: &str) -> String {
152    let sanitized = component
153        .chars()
154        .map(|ch| {
155            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
156                ch
157            } else {
158                '-'
159            }
160        })
161        .collect::<String>();
162    let trimmed = sanitized.trim_matches('-');
163    if trimmed.is_empty() {
164        fallback.to_string()
165    } else {
166        trimmed.to_string()
167    }
168}
169
170fn sanitize_remote_path_suffix(suffix: &str) -> String {
171    sanitize_remote_path_component(suffix, "run")
172}
173
174fn shell_escape_path(path: &Path) -> String {
175    escape(path.to_string_lossy()).into_owned()
176}
177
178fn shell_escape_str(value: &str) -> String {
179    escape(Cow::from(value)).into_owned()
180}
181
182impl RemoteCompilationTest {
183    /// Create a new remote compilation test.
184    ///
185    /// # Arguments
186    /// * `worker` - Worker configuration for remote execution
187    /// * `project_path` - Path to the Rust project to test
188    /// * `config` - Verification configuration
189    pub fn new(
190        worker: WorkerConfig,
191        project_path: impl Into<PathBuf>,
192        config: VerificationConfig,
193    ) -> Self {
194        Self {
195            worker,
196            project_path: project_path.into(),
197            config,
198            remote_path_suffix: format!("run-{}", Uuid::new_v4()),
199        }
200    }
201
202    /// Set the remote project path suffix.
203    pub fn with_remote_path_suffix(mut self, suffix: impl AsRef<str>) -> Self {
204        self.remote_path_suffix = sanitize_remote_path_suffix(suffix.as_ref());
205        self
206    }
207
208    /// Run the verification test.
209    ///
210    /// This performs the full verification flow:
211    /// 1. Apply test change to make binary unique
212    /// 2. Build locally for reference hash
213    /// 3. rsync source to worker
214    /// 4. Build on worker
215    /// 5. rsync artifacts back
216    /// 6. Compare hashes
217    pub fn run(&self) -> Result<VerificationResult> {
218        let start = Instant::now();
219        info!(
220            "Starting remote compilation verification for {:?} on {}",
221            self.project_path, self.worker.id
222        );
223
224        // 1. Apply test change to make binary unique
225        let change = TestCodeChange::for_main_rs(&self.project_path)
226            .with_context(|| "Failed to create test change")?;
227        let change_id = change.change_id.clone();
228        let guard = TestChangeGuard::new(change).with_context(|| "Failed to apply test change")?;
229
230        info!("Applied test change: {}", guard.change_id());
231
232        // 2. Build locally first
233        info!("Building locally for reference hash");
234        let local_build_start = Instant::now();
235        if let Err(e) = self.build_local() {
236            return Ok(VerificationResult::failed(
237                format!("Local build failed: {}", e),
238                start.elapsed().as_millis() as u64,
239                change_id,
240            ));
241        }
242        let local_build_ms = local_build_start.elapsed().as_millis() as u64;
243        debug!("Local build completed in {}ms", local_build_ms);
244
245        // Get local binary hash
246        let local_binary = self.binary_path();
247        let local_hash = compute_binary_hash(&local_binary)
248            .with_context(|| format!("Failed to hash local binary: {:?}", local_binary))?;
249        info!(
250            "Local build hash: {} (code_hash: {})",
251            &local_hash.full_hash[..16],
252            &local_hash.code_hash[..16]
253        );
254
255        // Verify marker is in local binary
256        let local_marker_ok = guard.verify_in_binary(&local_binary)?;
257        if !local_marker_ok {
258            return Ok(VerificationResult::failed(
259                "Test marker not found in local binary",
260                start.elapsed().as_millis() as u64,
261                change_id,
262            ));
263        }
264        info!("Test marker verified in local binary");
265
266        // 3. rsync up to worker
267        info!("Syncing source to worker {}", self.worker.id);
268        let rsync_up_start = Instant::now();
269        let bytes_up = match self.rsync_to_worker() {
270            Ok(bytes) => bytes,
271            Err(e) => {
272                return Ok(VerificationResult::failed(
273                    format!("rsync to worker failed: {}", e),
274                    start.elapsed().as_millis() as u64,
275                    change_id,
276                ));
277            }
278        };
279        let rsync_up_ms = rsync_up_start.elapsed().as_millis() as u64;
280        info!("Synced {} bytes in {}ms", bytes_up, rsync_up_ms);
281
282        // 4. Build on worker
283        info!("Building on worker {}", self.worker.id);
284        let compilation_start = Instant::now();
285        if let Err(e) = self.build_remote() {
286            return Ok(VerificationResult::failed(
287                format!("Remote build failed: {}", e),
288                start.elapsed().as_millis() as u64,
289                change_id,
290            ));
291        }
292        let compilation_ms = compilation_start.elapsed().as_millis() as u64;
293        info!("Remote build completed in {}ms", compilation_ms);
294
295        // 5. rsync artifacts back
296        info!("Syncing artifacts from worker");
297        let rsync_down_start = Instant::now();
298        let bytes_down = match self.rsync_from_worker() {
299            Ok(bytes) => bytes,
300            Err(e) => {
301                return Ok(VerificationResult::failed(
302                    format!("rsync from worker failed: {}", e),
303                    start.elapsed().as_millis() as u64,
304                    change_id,
305                ));
306            }
307        };
308        let rsync_down_ms = rsync_down_start.elapsed().as_millis() as u64;
309        info!("Retrieved {} bytes in {}ms", bytes_down, rsync_down_ms);
310
311        // 6. Compare hashes
312        let remote_binary = self.remote_binary_path_local();
313        let remote_hash = match compute_binary_hash(&remote_binary) {
314            Ok(h) => h,
315            Err(e) => {
316                return Ok(VerificationResult::failed(
317                    format!("Failed to hash remote binary: {}", e),
318                    start.elapsed().as_millis() as u64,
319                    change_id,
320                ));
321            }
322        };
323        info!(
324            "Remote build hash: {} (code_hash: {})",
325            &remote_hash.full_hash[..16],
326            &remote_hash.code_hash[..16]
327        );
328
329        // Verify marker is in remote binary
330        let marker_verified = guard.verify_in_binary(&remote_binary)?;
331        if !marker_verified {
332            warn!("Test marker not found in remote binary");
333        }
334
335        // Check if binaries are equivalent
336        let success = binaries_equivalent(&local_hash, &remote_hash);
337        let total_ms = start.elapsed().as_millis() as u64;
338
339        if success {
340            info!(
341                "Verification PASSED: code hashes match (total: {}ms)",
342                total_ms
343            );
344        } else {
345            error!(
346                "Verification FAILED: code hashes differ (local={}, remote={})",
347                &local_hash.code_hash[..16],
348                &remote_hash.code_hash[..16]
349            );
350        }
351
352        // Guard will auto-revert the test change on drop
353        Ok(VerificationResult {
354            success,
355            local_hash: Some(local_hash),
356            remote_hash: Some(remote_hash),
357            rsync_up_ms,
358            compilation_ms,
359            rsync_down_ms,
360            total_ms,
361            bytes_up,
362            bytes_down,
363            error: if success {
364                None
365            } else {
366                Some("Code hashes do not match".to_string())
367            },
368            change_id,
369            marker_verified,
370        })
371    }
372
373    /// Build the project locally.
374    fn build_local(&self) -> Result<()> {
375        let mut cmd = Command::new("cargo");
376        cmd.arg("build");
377        if self.config.release_mode {
378            cmd.arg("--release");
379        }
380        for flag in &self.config.cargo_flags {
381            cmd.arg(flag);
382        }
383        cmd.current_dir(&self.project_path);
384
385        debug!("Running local build: {:?}", cmd);
386        let output = cmd.output().context("Failed to execute cargo build")?;
387
388        if !output.status.success() {
389            let stderr = String::from_utf8_lossy(&output.stderr);
390            return Err(anyhow!("cargo build failed: {}", stderr));
391        }
392
393        Ok(())
394    }
395
396    /// Build the project on the remote worker.
397    fn build_remote(&self) -> Result<()> {
398        let build_cmd = if self.config.release_mode {
399            "cargo build --release"
400        } else {
401            "cargo build"
402        };
403
404        let ssh_cmd = self.remote_build_command(build_cmd);
405
406        let identity_file = shellexpand::tilde(&self.worker.identity_file).to_string();
407        let mut cmd = Command::new("ssh");
408        cmd.args([
409            "-i",
410            &identity_file,
411            "-o",
412            "StrictHostKeyChecking=accept-new",
413            "-o",
414            "BatchMode=yes",
415            &format!("{}@{}", self.worker.user, self.worker.host),
416            &ssh_cmd,
417        ]);
418
419        debug!("Running remote build via SSH: {:?}", cmd);
420        let output = cmd.output().context("Failed to execute SSH command")?;
421
422        if !output.status.success() {
423            let stderr = String::from_utf8_lossy(&output.stderr);
424            return Err(anyhow!("Remote build failed: {}", stderr));
425        }
426
427        Ok(())
428    }
429
430    /// rsync source files to the worker.
431    fn rsync_to_worker(&self) -> Result<u64> {
432        let remote_path = self.remote_project_path();
433        let identity_file = shellexpand::tilde(&self.worker.identity_file).to_string();
434
435        // Ensure remote directory exists
436        let escaped_remote_path = shell_escape_path(&remote_path);
437        let mkdir_cmd = format!("mkdir -p -- {}", escaped_remote_path);
438        let mut mkdir = Command::new("ssh");
439        mkdir.args([
440            "-i",
441            &identity_file,
442            "-o",
443            "StrictHostKeyChecking=accept-new",
444            "-o",
445            "BatchMode=yes",
446            &format!("{}@{}", self.worker.user, self.worker.host),
447            &mkdir_cmd,
448        ]);
449        let mkdir_output = mkdir
450            .output()
451            .context("Failed to create remote directory")?;
452        if !mkdir_output.status.success() {
453            let stderr = String::from_utf8_lossy(&mkdir_output.stderr);
454            return Err(anyhow!("remote directory creation failed: {}", stderr));
455        }
456
457        // Build rsync command
458        let mut cmd = Command::new("rsync");
459        cmd.args([
460            "-az",
461            "--no-owner",
462            "--no-group",
463            "--compress-level",
464            &self.config.rsync_compression.to_string(),
465            "--delete",
466            "-e",
467            &self.rsync_ssh_command(&identity_file),
468        ]);
469
470        // Add exclude patterns
471        for pattern in &self.config.exclude_patterns {
472            cmd.args(["--exclude", pattern]);
473        }
474
475        // Source and destination
476        let src = format!("{}/", self.project_path.display());
477        let dest = format!(
478            "{}@{}:{}",
479            self.worker.user, self.worker.host, escaped_remote_path
480        );
481        cmd.args([&src, &dest]);
482
483        debug!("Running rsync to worker: {:?}", cmd);
484        let output = cmd.output().context("Failed to execute rsync")?;
485
486        if !output.status.success() {
487            let stderr = String::from_utf8_lossy(&output.stderr);
488            return Err(anyhow!("rsync to worker failed: {}", stderr));
489        }
490
491        // Estimate bytes transferred from rsync output
492        let stdout = String::from_utf8_lossy(&output.stdout);
493        let bytes = parse_rsync_bytes_transferred(&stdout);
494        Ok(bytes)
495    }
496
497    /// rsync artifacts back from the worker.
498    fn rsync_from_worker(&self) -> Result<u64> {
499        let remote_path = self.remote_project_path();
500        let identity_file = shellexpand::tilde(&self.worker.identity_file).to_string();
501
502        // Local path for remote artifacts
503        let local_artifact_dir = self.project_path.join("target_remote");
504        std::fs::create_dir_all(&local_artifact_dir)?;
505
506        // Build rsync command - only sync the target directory
507        let profile = if self.config.release_mode {
508            "release"
509        } else {
510            "debug"
511        };
512
513        let mut cmd = Command::new("rsync");
514        cmd.args([
515            "-az",
516            "--no-owner",
517            "--no-group",
518            "--compress-level",
519            &self.config.rsync_compression.to_string(),
520            "-e",
521            &self.rsync_ssh_command(&identity_file),
522        ]);
523
524        // Source (remote target/debug or target/release) and destination
525        let remote_target_dir = remote_path.join("target").join(profile);
526        let remote_target_dir_with_slash = format!("{}/", remote_target_dir.display());
527        let remote_target = format!(
528            "{}@{}:{}",
529            self.worker.user,
530            self.worker.host,
531            shell_escape_str(&remote_target_dir_with_slash)
532        );
533        let local_target = format!("{}/", local_artifact_dir.display());
534        cmd.args([&remote_target, &local_target]);
535
536        debug!("Running rsync from worker: {:?}", cmd);
537        let output = cmd.output().context("Failed to execute rsync")?;
538
539        if !output.status.success() {
540            let stderr = String::from_utf8_lossy(&output.stderr);
541            return Err(anyhow!("rsync from worker failed: {}", stderr));
542        }
543
544        // Estimate bytes transferred
545        let stdout = String::from_utf8_lossy(&output.stdout);
546        let bytes = parse_rsync_bytes_transferred(&stdout);
547        Ok(bytes)
548    }
549
550    /// Get the path to the local binary.
551    fn binary_path(&self) -> PathBuf {
552        let profile = if self.config.release_mode {
553            "release"
554        } else {
555            "debug"
556        };
557
558        // Get project name from Cargo.toml
559        let binary_name = self.get_binary_name().unwrap_or_else(|| "main".to_string());
560        self.project_path
561            .join("target")
562            .join(profile)
563            .join(&binary_name)
564    }
565
566    /// Get the path to the remote binary (stored locally after rsync).
567    fn remote_binary_path_local(&self) -> PathBuf {
568        let binary_name = self.get_binary_name().unwrap_or_else(|| "main".to_string());
569        self.project_path.join("target_remote").join(&binary_name)
570    }
571
572    /// Get the remote project path on the worker.
573    fn remote_project_path(&self) -> PathBuf {
574        let project_name = self
575            .project_path
576            .file_name()
577            .and_then(|n| n.to_str())
578            .map(|name| sanitize_remote_path_component(name, "project"))
579            .unwrap_or_else(|| "project".to_string());
580        let remote_path_suffix = sanitize_remote_path_suffix(&self.remote_path_suffix);
581
582        self.config
583            .remote_base_path
584            .join(format!("{project_name}-{remote_path_suffix}"))
585    }
586
587    /// Build the remote cargo command.
588    fn remote_cargo_command(&self, build_cmd: &str) -> String {
589        let mut parts = vec![build_cmd.to_string()];
590        parts.extend(
591            self.config
592                .cargo_flags
593                .iter()
594                .map(|flag| shell_escape_str(flag)),
595        );
596        parts.join(" ")
597    }
598
599    /// Build the remote shell command for compilation.
600    fn remote_build_command(&self, build_cmd: &str) -> String {
601        let remote_path = self.remote_project_path();
602        let cargo_cmd = self.remote_cargo_command(build_cmd);
603        if self.config.clean_before_build {
604            format!(
605                "cd {} && cargo clean && {}",
606                shell_escape_path(&remote_path),
607                cargo_cmd
608            )
609        } else {
610            format!("cd {} && {}", shell_escape_path(&remote_path), cargo_cmd)
611        }
612    }
613
614    /// Build the rsync SSH transport command.
615    fn rsync_ssh_command(&self, identity_file: &str) -> String {
616        format!(
617            "ssh -i {} -o StrictHostKeyChecking=accept-new -o BatchMode=yes",
618            shell_escape_str(identity_file)
619        )
620    }
621
622    /// Get the binary name from Cargo.toml.
623    fn get_binary_name(&self) -> Option<String> {
624        let cargo_toml = self.project_path.join("Cargo.toml");
625        let content = std::fs::read_to_string(&cargo_toml).ok()?;
626
627        // Simple parser - look for name = "..."
628        for line in content.lines() {
629            let line = line.trim();
630            if line.starts_with("name")
631                && line.contains('=')
632                && let Some(name) = line.split('=').nth(1)
633            {
634                let name = name.trim().trim_matches('"');
635                return Some(name.to_string());
636            }
637        }
638        None
639    }
640}
641
642/// Parse bytes transferred from rsync output.
643fn parse_rsync_bytes_transferred(output: &str) -> u64 {
644    // rsync output contains lines like "sent 12,345 bytes  received 678 bytes"
645    // We'll look for any numeric value in the output
646    for line in output.lines() {
647        if line.contains("bytes") {
648            // Extract numbers from the line
649            let nums: Vec<u64> = line
650                .split_whitespace()
651                .filter_map(|w| w.replace(',', "").parse().ok())
652                .collect();
653            if !nums.is_empty() {
654                return nums.iter().sum();
655            }
656        }
657    }
658    0
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    fn init_test_logging() {
666        let _ = tracing_subscriber::fmt()
667            .with_test_writer()
668            .with_max_level(tracing::Level::DEBUG)
669            .try_init();
670    }
671
672    #[test]
673    fn test_verification_config_default() {
674        init_test_logging();
675        info!("TEST START: test_verification_config_default");
676
677        let config = VerificationConfig::default();
678
679        info!("RESULT: timeout={:?}", config.timeout);
680        info!("RESULT: release_mode={}", config.release_mode);
681        info!("RESULT: rsync_compression={}", config.rsync_compression);
682
683        assert_eq!(config.timeout, Duration::from_secs(300));
684        assert!(!config.release_mode);
685        assert_eq!(config.rsync_compression, 3);
686        assert!(config.exclude_patterns.contains(&"target/".to_string()));
687
688        info!("TEST PASS: test_verification_config_default");
689    }
690
691    #[test]
692    fn test_verification_result_failed() {
693        init_test_logging();
694        info!("TEST START: test_verification_result_failed");
695
696        let result = VerificationResult::failed("Test error", 1000, "RCH_TEST_123".to_string());
697
698        info!("RESULT: success={}", result.success);
699        info!("RESULT: error={:?}", result.error);
700
701        assert!(!result.success);
702        assert_eq!(result.error, Some("Test error".to_string()));
703        assert_eq!(result.total_ms, 1000);
704        assert_eq!(result.change_id, "RCH_TEST_123");
705
706        info!("TEST PASS: test_verification_result_failed");
707    }
708
709    #[test]
710    fn test_parse_rsync_bytes() {
711        init_test_logging();
712        info!("TEST START: test_parse_rsync_bytes");
713
714        let output = "sent 12,345 bytes  received 678 bytes  8,682.00 bytes/sec";
715        let bytes = parse_rsync_bytes_transferred(output);
716
717        info!("INPUT: {:?}", output);
718        info!("RESULT: bytes={}", bytes);
719
720        assert!(bytes > 0);
721
722        info!("TEST PASS: test_parse_rsync_bytes");
723    }
724
725    #[test]
726    fn test_parse_rsync_bytes_empty() {
727        init_test_logging();
728        info!("TEST START: test_parse_rsync_bytes_empty");
729
730        let output = "";
731        let bytes = parse_rsync_bytes_transferred(output);
732
733        info!("INPUT: empty string");
734        info!("RESULT: bytes={}", bytes);
735
736        assert_eq!(bytes, 0);
737
738        info!("TEST PASS: test_parse_rsync_bytes_empty");
739    }
740
741    #[test]
742    fn test_remote_compilation_test_paths() {
743        init_test_logging();
744        info!("TEST START: test_remote_compilation_test_paths");
745
746        let config = VerificationConfig::default();
747        let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
748            .with_remote_path_suffix("run-1");
749
750        let remote_path = test.remote_project_path();
751        info!("RESULT: remote_path={:?}", remote_path);
752        assert_eq!(
753            remote_path,
754            PathBuf::from("/tmp/rch_verify/test-project-run-1")
755        );
756
757        info!("TEST PASS: test_remote_compilation_test_paths");
758    }
759
760    #[test]
761    fn test_remote_compilation_paths_are_isolated_by_default() {
762        init_test_logging();
763        info!("TEST START: test_remote_compilation_paths_are_isolated_by_default");
764
765        let config = VerificationConfig::default();
766        let first = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config.clone());
767        let second = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config);
768
769        let first_remote_path = first.remote_project_path();
770        let second_remote_path = second.remote_project_path();
771
772        info!("RESULT: first_remote_path={:?}", first_remote_path);
773        info!("RESULT: second_remote_path={:?}", second_remote_path);
774
775        assert_ne!(first_remote_path, second_remote_path);
776        assert!(
777            first_remote_path
778                .to_string_lossy()
779                .starts_with("/tmp/rch_verify/test-project-run-")
780        );
781        assert!(
782            second_remote_path
783                .to_string_lossy()
784                .starts_with("/tmp/rch_verify/test-project-run-")
785        );
786
787        info!("TEST PASS: test_remote_compilation_paths_are_isolated_by_default");
788    }
789
790    #[test]
791    fn test_remote_project_path_sanitizes_project_and_suffix() {
792        init_test_logging();
793        info!("TEST START: test_remote_project_path_sanitizes_project_and_suffix");
794
795        let config = VerificationConfig::default();
796        let test = RemoteCompilationTest::new(test_worker(), "/tmp/project with spaces", config)
797            .with_remote_path_suffix("../attempt 1");
798
799        let remote_path = test.remote_project_path();
800        info!("RESULT: remote_path={:?}", remote_path);
801        assert_eq!(
802            remote_path,
803            PathBuf::from("/tmp/rch_verify/project-with-spaces-..-attempt-1")
804        );
805
806        info!("TEST PASS: test_remote_project_path_sanitizes_project_and_suffix");
807    }
808
809    #[test]
810    fn test_remote_build_command_includes_cargo_flags_and_clean() {
811        init_test_logging();
812        info!("TEST START: test_remote_build_command_includes_cargo_flags_and_clean");
813
814        let config = VerificationConfig {
815            release_mode: true,
816            cargo_flags: vec!["--features".to_string(), "foo bar".to_string()],
817            clean_before_build: true,
818            ..VerificationConfig::default()
819        };
820        let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
821            .with_remote_path_suffix("run-1");
822
823        let command = test.remote_build_command("cargo build --release");
824        info!("RESULT: command={}", command);
825        assert_eq!(
826            command,
827            "cd /tmp/rch_verify/test-project-run-1 && cargo clean && cargo build --release --features 'foo bar'"
828        );
829
830        info!("TEST PASS: test_remote_build_command_includes_cargo_flags_and_clean");
831    }
832
833    #[test]
834    fn test_remote_build_command_quotes_remote_path() {
835        init_test_logging();
836        info!("TEST START: test_remote_build_command_quotes_remote_path");
837
838        let config = VerificationConfig {
839            remote_base_path: PathBuf::from("/tmp/rch verify"),
840            ..VerificationConfig::default()
841        };
842        let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
843            .with_remote_path_suffix("run-1");
844
845        let command = test.remote_build_command("cargo build");
846        info!("RESULT: command={}", command);
847        assert_eq!(
848            command,
849            "cd '/tmp/rch verify/test-project-run-1' && cargo build"
850        );
851
852        info!("TEST PASS: test_remote_build_command_quotes_remote_path");
853    }
854
855    #[test]
856    fn test_rsync_ssh_command_quotes_identity_path() {
857        init_test_logging();
858        info!("TEST START: test_rsync_ssh_command_quotes_identity_path");
859
860        let config = VerificationConfig::default();
861        let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
862            .with_remote_path_suffix("run-1");
863
864        let command = test.rsync_ssh_command("/tmp/key files/id_ed25519");
865        info!("RESULT: command={}", command);
866        assert_eq!(
867            command,
868            "ssh -i '/tmp/key files/id_ed25519' -o StrictHostKeyChecking=accept-new -o BatchMode=yes"
869        );
870
871        info!("TEST PASS: test_rsync_ssh_command_quotes_identity_path");
872    }
873
874    fn test_worker() -> WorkerConfig {
875        WorkerConfig {
876            id: crate::types::WorkerId::new("test-worker"),
877            host: "localhost".to_string(),
878            user: "testuser".to_string(),
879            identity_file: "~/.ssh/id_rsa".to_string(),
880            total_slots: 4,
881            priority: 100,
882            tags: vec![],
883            tools: Vec::new(),
884        }
885    }
886}