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/objects/".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            "--compress-level",
462            &self.config.rsync_compression.to_string(),
463            "--delete",
464            "-e",
465            &self.rsync_ssh_command(&identity_file),
466        ]);
467
468        // Add exclude patterns
469        for pattern in &self.config.exclude_patterns {
470            cmd.args(["--exclude", pattern]);
471        }
472
473        // Source and destination
474        let src = format!("{}/", self.project_path.display());
475        let dest = format!(
476            "{}@{}:{}",
477            self.worker.user, self.worker.host, escaped_remote_path
478        );
479        cmd.args([&src, &dest]);
480
481        debug!("Running rsync to worker: {:?}", cmd);
482        let output = cmd.output().context("Failed to execute rsync")?;
483
484        if !output.status.success() {
485            let stderr = String::from_utf8_lossy(&output.stderr);
486            return Err(anyhow!("rsync to worker failed: {}", stderr));
487        }
488
489        // Estimate bytes transferred from rsync output
490        let stdout = String::from_utf8_lossy(&output.stdout);
491        let bytes = parse_rsync_bytes_transferred(&stdout);
492        Ok(bytes)
493    }
494
495    /// rsync artifacts back from the worker.
496    fn rsync_from_worker(&self) -> Result<u64> {
497        let remote_path = self.remote_project_path();
498        let identity_file = shellexpand::tilde(&self.worker.identity_file).to_string();
499
500        // Local path for remote artifacts
501        let local_artifact_dir = self.project_path.join("target_remote");
502        std::fs::create_dir_all(&local_artifact_dir)?;
503
504        // Build rsync command - only sync the target directory
505        let profile = if self.config.release_mode {
506            "release"
507        } else {
508            "debug"
509        };
510
511        let mut cmd = Command::new("rsync");
512        cmd.args([
513            "-az",
514            "--compress-level",
515            &self.config.rsync_compression.to_string(),
516            "-e",
517            &self.rsync_ssh_command(&identity_file),
518        ]);
519
520        // Source (remote target/debug or target/release) and destination
521        let remote_target_dir = remote_path.join("target").join(profile);
522        let remote_target_dir_with_slash = format!("{}/", remote_target_dir.display());
523        let remote_target = format!(
524            "{}@{}:{}",
525            self.worker.user,
526            self.worker.host,
527            shell_escape_str(&remote_target_dir_with_slash)
528        );
529        let local_target = format!("{}/", local_artifact_dir.display());
530        cmd.args([&remote_target, &local_target]);
531
532        debug!("Running rsync from worker: {:?}", cmd);
533        let output = cmd.output().context("Failed to execute rsync")?;
534
535        if !output.status.success() {
536            let stderr = String::from_utf8_lossy(&output.stderr);
537            return Err(anyhow!("rsync from worker failed: {}", stderr));
538        }
539
540        // Estimate bytes transferred
541        let stdout = String::from_utf8_lossy(&output.stdout);
542        let bytes = parse_rsync_bytes_transferred(&stdout);
543        Ok(bytes)
544    }
545
546    /// Get the path to the local binary.
547    fn binary_path(&self) -> PathBuf {
548        let profile = if self.config.release_mode {
549            "release"
550        } else {
551            "debug"
552        };
553
554        // Get project name from Cargo.toml
555        let binary_name = self.get_binary_name().unwrap_or_else(|| "main".to_string());
556        self.project_path
557            .join("target")
558            .join(profile)
559            .join(&binary_name)
560    }
561
562    /// Get the path to the remote binary (stored locally after rsync).
563    fn remote_binary_path_local(&self) -> PathBuf {
564        let binary_name = self.get_binary_name().unwrap_or_else(|| "main".to_string());
565        self.project_path.join("target_remote").join(&binary_name)
566    }
567
568    /// Get the remote project path on the worker.
569    fn remote_project_path(&self) -> PathBuf {
570        let project_name = self
571            .project_path
572            .file_name()
573            .and_then(|n| n.to_str())
574            .map(|name| sanitize_remote_path_component(name, "project"))
575            .unwrap_or_else(|| "project".to_string());
576        let remote_path_suffix = sanitize_remote_path_suffix(&self.remote_path_suffix);
577
578        self.config
579            .remote_base_path
580            .join(format!("{project_name}-{remote_path_suffix}"))
581    }
582
583    /// Build the remote cargo command.
584    fn remote_cargo_command(&self, build_cmd: &str) -> String {
585        let mut parts = vec![build_cmd.to_string()];
586        parts.extend(
587            self.config
588                .cargo_flags
589                .iter()
590                .map(|flag| shell_escape_str(flag)),
591        );
592        parts.join(" ")
593    }
594
595    /// Build the remote shell command for compilation.
596    fn remote_build_command(&self, build_cmd: &str) -> String {
597        let remote_path = self.remote_project_path();
598        let cargo_cmd = self.remote_cargo_command(build_cmd);
599        if self.config.clean_before_build {
600            format!(
601                "cd {} && cargo clean && {}",
602                shell_escape_path(&remote_path),
603                cargo_cmd
604            )
605        } else {
606            format!("cd {} && {}", shell_escape_path(&remote_path), cargo_cmd)
607        }
608    }
609
610    /// Build the rsync SSH transport command.
611    fn rsync_ssh_command(&self, identity_file: &str) -> String {
612        format!(
613            "ssh -i {} -o StrictHostKeyChecking=accept-new -o BatchMode=yes",
614            shell_escape_str(identity_file)
615        )
616    }
617
618    /// Get the binary name from Cargo.toml.
619    fn get_binary_name(&self) -> Option<String> {
620        let cargo_toml = self.project_path.join("Cargo.toml");
621        let content = std::fs::read_to_string(&cargo_toml).ok()?;
622
623        // Simple parser - look for name = "..."
624        for line in content.lines() {
625            let line = line.trim();
626            if line.starts_with("name")
627                && line.contains('=')
628                && let Some(name) = line.split('=').nth(1)
629            {
630                let name = name.trim().trim_matches('"');
631                return Some(name.to_string());
632            }
633        }
634        None
635    }
636}
637
638/// Parse bytes transferred from rsync output.
639fn parse_rsync_bytes_transferred(output: &str) -> u64 {
640    // rsync output contains lines like "sent 12,345 bytes  received 678 bytes"
641    // We'll look for any numeric value in the output
642    for line in output.lines() {
643        if line.contains("bytes") {
644            // Extract numbers from the line
645            let nums: Vec<u64> = line
646                .split_whitespace()
647                .filter_map(|w| w.replace(',', "").parse().ok())
648                .collect();
649            if !nums.is_empty() {
650                return nums.iter().sum();
651            }
652        }
653    }
654    0
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    fn init_test_logging() {
662        let _ = tracing_subscriber::fmt()
663            .with_test_writer()
664            .with_max_level(tracing::Level::DEBUG)
665            .try_init();
666    }
667
668    #[test]
669    fn test_verification_config_default() {
670        init_test_logging();
671        info!("TEST START: test_verification_config_default");
672
673        let config = VerificationConfig::default();
674
675        info!("RESULT: timeout={:?}", config.timeout);
676        info!("RESULT: release_mode={}", config.release_mode);
677        info!("RESULT: rsync_compression={}", config.rsync_compression);
678
679        assert_eq!(config.timeout, Duration::from_secs(300));
680        assert!(!config.release_mode);
681        assert_eq!(config.rsync_compression, 3);
682        assert!(config.exclude_patterns.contains(&"target/".to_string()));
683
684        info!("TEST PASS: test_verification_config_default");
685    }
686
687    #[test]
688    fn test_verification_result_failed() {
689        init_test_logging();
690        info!("TEST START: test_verification_result_failed");
691
692        let result = VerificationResult::failed("Test error", 1000, "RCH_TEST_123".to_string());
693
694        info!("RESULT: success={}", result.success);
695        info!("RESULT: error={:?}", result.error);
696
697        assert!(!result.success);
698        assert_eq!(result.error, Some("Test error".to_string()));
699        assert_eq!(result.total_ms, 1000);
700        assert_eq!(result.change_id, "RCH_TEST_123");
701
702        info!("TEST PASS: test_verification_result_failed");
703    }
704
705    #[test]
706    fn test_parse_rsync_bytes() {
707        init_test_logging();
708        info!("TEST START: test_parse_rsync_bytes");
709
710        let output = "sent 12,345 bytes  received 678 bytes  8,682.00 bytes/sec";
711        let bytes = parse_rsync_bytes_transferred(output);
712
713        info!("INPUT: {:?}", output);
714        info!("RESULT: bytes={}", bytes);
715
716        assert!(bytes > 0);
717
718        info!("TEST PASS: test_parse_rsync_bytes");
719    }
720
721    #[test]
722    fn test_parse_rsync_bytes_empty() {
723        init_test_logging();
724        info!("TEST START: test_parse_rsync_bytes_empty");
725
726        let output = "";
727        let bytes = parse_rsync_bytes_transferred(output);
728
729        info!("INPUT: empty string");
730        info!("RESULT: bytes={}", bytes);
731
732        assert_eq!(bytes, 0);
733
734        info!("TEST PASS: test_parse_rsync_bytes_empty");
735    }
736
737    #[test]
738    fn test_remote_compilation_test_paths() {
739        init_test_logging();
740        info!("TEST START: test_remote_compilation_test_paths");
741
742        let config = VerificationConfig::default();
743        let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
744            .with_remote_path_suffix("run-1");
745
746        let remote_path = test.remote_project_path();
747        info!("RESULT: remote_path={:?}", remote_path);
748        assert_eq!(
749            remote_path,
750            PathBuf::from("/tmp/rch_verify/test-project-run-1")
751        );
752
753        info!("TEST PASS: test_remote_compilation_test_paths");
754    }
755
756    #[test]
757    fn test_remote_compilation_paths_are_isolated_by_default() {
758        init_test_logging();
759        info!("TEST START: test_remote_compilation_paths_are_isolated_by_default");
760
761        let config = VerificationConfig::default();
762        let first = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config.clone());
763        let second = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config);
764
765        let first_remote_path = first.remote_project_path();
766        let second_remote_path = second.remote_project_path();
767
768        info!("RESULT: first_remote_path={:?}", first_remote_path);
769        info!("RESULT: second_remote_path={:?}", second_remote_path);
770
771        assert_ne!(first_remote_path, second_remote_path);
772        assert!(
773            first_remote_path
774                .to_string_lossy()
775                .starts_with("/tmp/rch_verify/test-project-run-")
776        );
777        assert!(
778            second_remote_path
779                .to_string_lossy()
780                .starts_with("/tmp/rch_verify/test-project-run-")
781        );
782
783        info!("TEST PASS: test_remote_compilation_paths_are_isolated_by_default");
784    }
785
786    #[test]
787    fn test_remote_project_path_sanitizes_project_and_suffix() {
788        init_test_logging();
789        info!("TEST START: test_remote_project_path_sanitizes_project_and_suffix");
790
791        let config = VerificationConfig::default();
792        let test = RemoteCompilationTest::new(test_worker(), "/tmp/project with spaces", config)
793            .with_remote_path_suffix("../attempt 1");
794
795        let remote_path = test.remote_project_path();
796        info!("RESULT: remote_path={:?}", remote_path);
797        assert_eq!(
798            remote_path,
799            PathBuf::from("/tmp/rch_verify/project-with-spaces-..-attempt-1")
800        );
801
802        info!("TEST PASS: test_remote_project_path_sanitizes_project_and_suffix");
803    }
804
805    #[test]
806    fn test_remote_build_command_includes_cargo_flags_and_clean() {
807        init_test_logging();
808        info!("TEST START: test_remote_build_command_includes_cargo_flags_and_clean");
809
810        let config = VerificationConfig {
811            release_mode: true,
812            cargo_flags: vec!["--features".to_string(), "foo bar".to_string()],
813            clean_before_build: true,
814            ..VerificationConfig::default()
815        };
816        let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
817            .with_remote_path_suffix("run-1");
818
819        let command = test.remote_build_command("cargo build --release");
820        info!("RESULT: command={}", command);
821        assert_eq!(
822            command,
823            "cd /tmp/rch_verify/test-project-run-1 && cargo clean && cargo build --release --features 'foo bar'"
824        );
825
826        info!("TEST PASS: test_remote_build_command_includes_cargo_flags_and_clean");
827    }
828
829    #[test]
830    fn test_remote_build_command_quotes_remote_path() {
831        init_test_logging();
832        info!("TEST START: test_remote_build_command_quotes_remote_path");
833
834        let config = VerificationConfig {
835            remote_base_path: PathBuf::from("/tmp/rch verify"),
836            ..VerificationConfig::default()
837        };
838        let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
839            .with_remote_path_suffix("run-1");
840
841        let command = test.remote_build_command("cargo build");
842        info!("RESULT: command={}", command);
843        assert_eq!(
844            command,
845            "cd '/tmp/rch verify/test-project-run-1' && cargo build"
846        );
847
848        info!("TEST PASS: test_remote_build_command_quotes_remote_path");
849    }
850
851    #[test]
852    fn test_rsync_ssh_command_quotes_identity_path() {
853        init_test_logging();
854        info!("TEST START: test_rsync_ssh_command_quotes_identity_path");
855
856        let config = VerificationConfig::default();
857        let test = RemoteCompilationTest::new(test_worker(), "/tmp/test-project", config)
858            .with_remote_path_suffix("run-1");
859
860        let command = test.rsync_ssh_command("/tmp/key files/id_ed25519");
861        info!("RESULT: command={}", command);
862        assert_eq!(
863            command,
864            "ssh -i '/tmp/key files/id_ed25519' -o StrictHostKeyChecking=accept-new -o BatchMode=yes"
865        );
866
867        info!("TEST PASS: test_rsync_ssh_command_quotes_identity_path");
868    }
869
870    fn test_worker() -> WorkerConfig {
871        WorkerConfig {
872            id: crate::types::WorkerId::new("test-worker"),
873            host: "localhost".to_string(),
874            user: "testuser".to_string(),
875            identity_file: "~/.ssh/id_rsa".to_string(),
876            total_slots: 4,
877            priority: 100,
878            tags: vec![],
879        }
880    }
881}