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