Skip to main content

rch_common/
test_change.rs

1//! Test code change generator for verifying remote compilation.
2//!
3//! This module provides utilities to create minimal, detectable, reversible
4//! code changes that can verify whether remote compilation actually processes
5//! the source code.
6
7use anyhow::{Context, Result};
8use chrono::Utc;
9use std::fs;
10use std::path::{Path, PathBuf};
11use tracing::{error, info};
12
13use crate::binary_hash::binary_contains_marker;
14
15/// Represents a test modification to source code.
16///
17/// The change is designed to be:
18/// - Minimal: Single file modification
19/// - Detectable: Produces a different binary hash
20/// - Reversible: Can be applied and reverted cleanly
21/// - Deterministic: Same change always produces same result
22#[derive(Debug, Clone)]
23pub struct TestCodeChange {
24    /// Path to the file being modified.
25    pub file_path: PathBuf,
26    /// Original file content before modification.
27    pub original_content: String,
28    /// Content with the test change applied.
29    pub modified_content: String,
30    /// Unique identifier for this change (appears in binary).
31    pub change_id: String,
32}
33
34impl TestCodeChange {
35    /// Create a test change that adds a unique marker constant.
36    ///
37    /// This adds a const string to the specified file that will be compiled
38    /// into the binary, allowing verification that compilation actually occurred.
39    ///
40    /// # Arguments
41    /// * `file_path` - Path to the source file to modify
42    ///
43    /// # Example
44    /// ```no_run
45    /// use std::path::Path;
46    /// use rch_common::test_change::TestCodeChange;
47    ///
48    /// let change = TestCodeChange::for_file(Path::new("src/main.rs")).unwrap();
49    /// println!("Change ID: {}", change.change_id);
50    /// ```
51    pub fn for_file(file_path: &Path) -> Result<Self> {
52        let original = fs::read_to_string(file_path)
53            .with_context(|| format!("Failed to read source file: {:?}", file_path))?;
54
55        // Generate a unique change ID based on timestamp
56        let change_id = format!("RCH_TEST_{}", Utc::now().timestamp_millis());
57
58        // Modify: prefer modifying main function to prevent LTO elimination
59        let modified = if original.contains("println!(\"Hello, world!\");") {
60            original.replace(
61                "println!(\"Hello, world!\");",
62                &format!("println!(\"Hello, world! {}\");", change_id),
63            )
64        } else if original.contains("println!(\"Hello from test project!\");") {
65            original.replace(
66                "println!(\"Hello from test project!\");",
67                &format!("println!(\"Hello from test project! {}\");", change_id),
68            )
69        } else if original.contains("println!(\"rch self-test\");") {
70            original.replace(
71                "println!(\"rch self-test\");",
72                &format!("println!(\"rch self-test {}\");", change_id),
73            )
74        } else {
75            // Fallback: append a function
76            format!(
77                "{}\n\n// RCH Self-Test Marker (auto-generated, safe to remove)\n\
78                 #[unsafe(no_mangle)]\n\
79                 #[allow(dead_code)]\n\
80                 pub fn {}() -> &'static str {{ \"{}\" }}\n",
81                original, change_id, change_id
82            )
83        };
84
85        Ok(TestCodeChange {
86            file_path: file_path.to_path_buf(),
87            original_content: original,
88            modified_content: modified,
89            change_id,
90        })
91    }
92
93    /// Create a test change for the main.rs file in a project directory.
94    ///
95    /// # Arguments
96    /// * `project_dir` - Path to the Rust project root
97    pub fn for_main_rs(project_dir: &Path) -> Result<Self> {
98        let file_path = project_dir.join("src/main.rs");
99        Self::for_file(&file_path)
100    }
101
102    /// Create a test change for lib.rs in a project directory.
103    ///
104    /// # Arguments
105    /// * `project_dir` - Path to the Rust project root
106    pub fn for_lib_rs(project_dir: &Path) -> Result<Self> {
107        let file_path = project_dir.join("src/lib.rs");
108        Self::for_file(&file_path)
109    }
110
111    /// Apply the test change to the file.
112    ///
113    /// This writes the modified content to the file path.
114    pub fn apply(&self) -> Result<()> {
115        info!(
116            "Applying test change {} to {:?}",
117            self.change_id, self.file_path
118        );
119        fs::write(&self.file_path, &self.modified_content)
120            .with_context(|| format!("Failed to write modified content to {:?}", self.file_path))?;
121        Ok(())
122    }
123
124    /// Revert the test change, restoring original content.
125    pub fn revert(&self) -> Result<()> {
126        info!(
127            "Reverting test change {} from {:?}",
128            self.change_id, self.file_path
129        );
130        fs::write(&self.file_path, &self.original_content).with_context(|| {
131            format!("Failed to restore original content to {:?}", self.file_path)
132        })?;
133        Ok(())
134    }
135
136    /// Check if the compiled binary contains the test marker.
137    ///
138    /// This verifies that the remote compilation actually processed our change.
139    ///
140    /// # Arguments
141    /// * `binary_path` - Path to the compiled binary
142    ///
143    /// # Returns
144    /// `true` if the marker is found in the binary
145    pub fn verify_in_binary(&self, binary_path: &Path) -> Result<bool> {
146        binary_contains_marker(binary_path, &self.change_id)
147    }
148}
149
150/// RAII guard for test changes that auto-reverts on drop.
151///
152/// This ensures that test changes are always cleaned up, even if the test
153/// panics or returns early.
154///
155/// # Example
156/// ```no_run
157/// use std::path::Path;
158/// use rch_common::test_change::{TestCodeChange, TestChangeGuard};
159///
160/// fn run_test() -> anyhow::Result<()> {
161///     let change = TestCodeChange::for_main_rs(Path::new("/my/project"))?;
162///     let guard = TestChangeGuard::new(change)?;
163///     
164///     // Do compilation and testing here...
165///     // File will be automatically reverted when guard goes out of scope
166///     
167///     Ok(())
168/// }
169/// ```
170pub struct TestChangeGuard {
171    change: TestCodeChange,
172    applied: bool,
173}
174
175impl TestChangeGuard {
176    /// Create a new guard and apply the test change.
177    ///
178    /// The change is applied immediately upon creation.
179    pub fn new(change: TestCodeChange) -> Result<Self> {
180        let mut guard = Self {
181            change,
182            applied: false,
183        };
184        guard.change.apply()?;
185        guard.applied = true;
186        Ok(guard)
187    }
188
189    /// Get the change ID for this test modification.
190    pub fn change_id(&self) -> &str {
191        &self.change.change_id
192    }
193
194    /// Get the path to the modified file.
195    pub fn file_path(&self) -> &Path {
196        &self.change.file_path
197    }
198
199    /// Check if the compiled binary contains the test marker.
200    pub fn verify_in_binary(&self, binary_path: &Path) -> Result<bool> {
201        self.change.verify_in_binary(binary_path)
202    }
203
204    /// Manually revert the change without dropping the guard.
205    ///
206    /// After calling this, the guard will not revert again on drop.
207    pub fn revert(mut self) -> Result<()> {
208        if self.applied {
209            self.change.revert()?;
210            self.applied = false;
211        }
212        Ok(())
213    }
214}
215
216impl Drop for TestChangeGuard {
217    fn drop(&mut self) {
218        if self.applied
219            && let Err(e) = self.change.revert()
220        {
221            error!("Failed to revert test change: {}", e);
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use tempfile::TempDir;
230
231    fn init_test_logging() {
232        let _ = tracing_subscriber::fmt()
233            .with_test_writer()
234            .with_max_level(tracing::Level::INFO)
235            .try_init();
236    }
237
238    #[test]
239    fn test_create_test_change() {
240        init_test_logging();
241        info!("TEST START: test_create_test_change");
242
243        let temp_dir = TempDir::new().unwrap();
244        let file_path = temp_dir.path().join("test.rs");
245        let original_content = "fn main() {\n    println!(\"Hello\");\n}\n";
246        fs::write(&file_path, original_content).unwrap();
247
248        info!("INPUT: TestCodeChange::for_file({:?})", file_path);
249
250        let change = TestCodeChange::for_file(&file_path).unwrap();
251
252        info!("RESULT: change_id={}", change.change_id);
253        info!(
254            "RESULT: modified_content_len={}",
255            change.modified_content.len()
256        );
257
258        assert!(change.change_id.starts_with("RCH_TEST_"));
259        assert!(change.modified_content.contains(&change.change_id));
260        assert!(change.modified_content.contains("// RCH Self-Test Marker"));
261        assert_eq!(change.original_content, original_content);
262
263        info!("VERIFY: Test change created successfully");
264        info!("TEST PASS: test_create_test_change");
265    }
266
267    #[test]
268    fn test_apply_and_revert() {
269        init_test_logging();
270        info!("TEST START: test_apply_and_revert");
271
272        let temp_dir = TempDir::new().unwrap();
273        let file_path = temp_dir.path().join("test.rs");
274        let original_content = "fn main() {}\n";
275        fs::write(&file_path, original_content).unwrap();
276
277        let change = TestCodeChange::for_file(&file_path).unwrap();
278
279        info!("INPUT: apply then revert test change");
280
281        // Apply the change
282        change.apply().unwrap();
283        let after_apply = fs::read_to_string(&file_path).unwrap();
284        info!(
285            "AFTER APPLY: contains_marker={}",
286            after_apply.contains(&change.change_id)
287        );
288        assert!(after_apply.contains(&change.change_id));
289
290        // Revert the change
291        change.revert().unwrap();
292        let after_revert = fs::read_to_string(&file_path).unwrap();
293        info!(
294            "AFTER REVERT: equals_original={}",
295            after_revert == original_content
296        );
297        assert_eq!(after_revert, original_content);
298
299        info!("VERIFY: Apply and revert work correctly");
300        info!("TEST PASS: test_apply_and_revert");
301    }
302
303    #[test]
304    fn test_guard_auto_reverts() {
305        init_test_logging();
306        info!("TEST START: test_guard_auto_reverts");
307
308        let temp_dir = TempDir::new().unwrap();
309        let file_path = temp_dir.path().join("test.rs");
310        let original_content = "fn main() {}\n";
311        fs::write(&file_path, original_content).unwrap();
312
313        let change_id: String;
314        {
315            let change = TestCodeChange::for_file(&file_path).unwrap();
316            change_id = change.change_id.clone();
317            let _guard = TestChangeGuard::new(change).unwrap();
318
319            // While guard is alive, file should be modified
320            let during = fs::read_to_string(&file_path).unwrap();
321            info!(
322                "DURING GUARD: contains_marker={}",
323                during.contains(&change_id)
324            );
325            assert!(during.contains(&change_id));
326
327            // Guard will be dropped here
328        }
329
330        // After guard dropped, file should be reverted
331        let after = fs::read_to_string(&file_path).unwrap();
332        info!("AFTER DROP: equals_original={}", after == original_content);
333        assert_eq!(after, original_content);
334        assert!(!after.contains(&change_id));
335
336        info!("VERIFY: Guard auto-reverts on drop");
337        info!("TEST PASS: test_guard_auto_reverts");
338    }
339
340    #[test]
341    fn test_change_id_unique() {
342        init_test_logging();
343        info!("TEST START: test_change_id_unique");
344
345        let temp_dir = TempDir::new().unwrap();
346        let file_path = temp_dir.path().join("test.rs");
347        fs::write(&file_path, "fn main() {}\n").unwrap();
348
349        let change1 = TestCodeChange::for_file(&file_path).unwrap();
350        // Small delay to ensure different timestamp
351        std::thread::sleep(std::time::Duration::from_millis(2));
352        let change2 = TestCodeChange::for_file(&file_path).unwrap();
353
354        info!(
355            "RESULT: change1_id={}, change2_id={}",
356            change1.change_id, change2.change_id
357        );
358
359        assert_ne!(change1.change_id, change2.change_id);
360
361        info!("VERIFY: Each change has unique ID");
362        info!("TEST PASS: test_change_id_unique");
363    }
364
365    #[test]
366    fn test_for_main_rs() {
367        init_test_logging();
368        info!("TEST START: test_for_main_rs");
369
370        let temp_dir = TempDir::new().unwrap();
371        let src_dir = temp_dir.path().join("src");
372        fs::create_dir(&src_dir).unwrap();
373        let main_rs = src_dir.join("main.rs");
374        fs::write(&main_rs, "fn main() {}\n").unwrap();
375
376        info!("INPUT: TestCodeChange::for_main_rs({:?})", temp_dir.path());
377
378        let change = TestCodeChange::for_main_rs(temp_dir.path()).unwrap();
379
380        info!("RESULT: file_path={:?}", change.file_path);
381
382        assert_eq!(change.file_path, main_rs);
383
384        info!("VERIFY: for_main_rs finds correct path");
385        info!("TEST PASS: test_for_main_rs");
386    }
387
388    #[test]
389    fn test_nonexistent_file_error() {
390        init_test_logging();
391        info!("TEST START: test_nonexistent_file_error");
392
393        let result = TestCodeChange::for_file(Path::new("/nonexistent/file.rs"));
394
395        info!("RESULT: is_err={}", result.is_err());
396
397        assert!(result.is_err());
398
399        info!("VERIFY: Nonexistent file returns error");
400        info!("TEST PASS: test_nonexistent_file_error");
401    }
402
403    #[test]
404    fn test_change_debug() {
405        init_test_logging();
406        info!("TEST START: test_change_debug");
407
408        let temp_dir = TempDir::new().unwrap();
409        let file_path = temp_dir.path().join("test.rs");
410        fs::write(&file_path, "fn main() {}\n").unwrap();
411
412        let change = TestCodeChange::for_file(&file_path).unwrap();
413        let debug = format!("{:?}", change);
414
415        assert!(debug.contains("TestCodeChange"));
416        assert!(debug.contains("RCH_TEST_"));
417
418        info!("TEST PASS: test_change_debug");
419    }
420
421    #[test]
422    fn test_change_clone() {
423        init_test_logging();
424        info!("TEST START: test_change_clone");
425
426        let temp_dir = TempDir::new().unwrap();
427        let file_path = temp_dir.path().join("test.rs");
428        fs::write(&file_path, "fn main() {}\n").unwrap();
429
430        let change = TestCodeChange::for_file(&file_path).unwrap();
431        let cloned = change.clone();
432
433        assert_eq!(change.change_id, cloned.change_id);
434        assert_eq!(change.file_path, cloned.file_path);
435        assert_eq!(change.original_content, cloned.original_content);
436        assert_eq!(change.modified_content, cloned.modified_content);
437
438        info!("TEST PASS: test_change_clone");
439    }
440
441    #[test]
442    fn test_for_lib_rs() {
443        init_test_logging();
444        info!("TEST START: test_for_lib_rs");
445
446        let temp_dir = TempDir::new().unwrap();
447        let src_dir = temp_dir.path().join("src");
448        fs::create_dir(&src_dir).unwrap();
449        let lib_rs = src_dir.join("lib.rs");
450        fs::write(&lib_rs, "pub fn hello() {}\n").unwrap();
451
452        let change = TestCodeChange::for_lib_rs(temp_dir.path()).unwrap();
453
454        assert_eq!(change.file_path, lib_rs);
455        assert!(change.change_id.starts_with("RCH_TEST_"));
456
457        info!("TEST PASS: test_for_lib_rs");
458    }
459
460    #[test]
461    fn test_for_lib_rs_nonexistent() {
462        init_test_logging();
463        info!("TEST START: test_for_lib_rs_nonexistent");
464
465        let temp_dir = TempDir::new().unwrap();
466        // Don't create src/lib.rs
467
468        let result = TestCodeChange::for_lib_rs(temp_dir.path());
469        assert!(result.is_err());
470
471        info!("TEST PASS: test_for_lib_rs_nonexistent");
472    }
473
474    #[test]
475    fn test_change_with_hello_world_pattern() {
476        init_test_logging();
477        info!("TEST START: test_change_with_hello_world_pattern");
478
479        let temp_dir = TempDir::new().unwrap();
480        let file_path = temp_dir.path().join("main.rs");
481        let original = r#"fn main() {
482    println!("Hello, world!");
483}"#;
484        fs::write(&file_path, original).unwrap();
485
486        let change = TestCodeChange::for_file(&file_path).unwrap();
487
488        // Should replace in println, not append function
489        assert!(change.modified_content.contains("Hello, world!"));
490        assert!(change.modified_content.contains(&change.change_id));
491        assert!(!change.modified_content.contains("// RCH Self-Test Marker"));
492
493        info!("TEST PASS: test_change_with_hello_world_pattern");
494    }
495
496    #[test]
497    fn test_change_with_hello_from_test_project_pattern() {
498        init_test_logging();
499        info!("TEST START: test_change_with_hello_from_test_project_pattern");
500
501        let temp_dir = TempDir::new().unwrap();
502        let file_path = temp_dir.path().join("main.rs");
503        let original = r#"fn main() {
504    println!("Hello from test project!");
505}"#;
506        fs::write(&file_path, original).unwrap();
507
508        let change = TestCodeChange::for_file(&file_path).unwrap();
509
510        // Should replace in println, not append function
511        assert!(change.modified_content.contains("Hello from test project!"));
512        assert!(change.modified_content.contains(&change.change_id));
513        assert!(!change.modified_content.contains("// RCH Self-Test Marker"));
514
515        info!("TEST PASS: test_change_with_hello_from_test_project_pattern");
516    }
517
518    #[test]
519    fn test_guard_change_id() {
520        init_test_logging();
521        info!("TEST START: test_guard_change_id");
522
523        let temp_dir = TempDir::new().unwrap();
524        let file_path = temp_dir.path().join("test.rs");
525        fs::write(&file_path, "fn main() {}\n").unwrap();
526
527        let change = TestCodeChange::for_file(&file_path).unwrap();
528        let expected_id = change.change_id.clone();
529        let guard = TestChangeGuard::new(change).unwrap();
530
531        assert_eq!(guard.change_id(), expected_id);
532
533        info!("TEST PASS: test_guard_change_id");
534    }
535
536    #[test]
537    fn test_guard_file_path() {
538        init_test_logging();
539        info!("TEST START: test_guard_file_path");
540
541        let temp_dir = TempDir::new().unwrap();
542        let file_path = temp_dir.path().join("test.rs");
543        fs::write(&file_path, "fn main() {}\n").unwrap();
544
545        let change = TestCodeChange::for_file(&file_path).unwrap();
546        let guard = TestChangeGuard::new(change).unwrap();
547
548        assert_eq!(guard.file_path(), file_path);
549
550        info!("TEST PASS: test_guard_file_path");
551    }
552
553    #[test]
554    fn test_guard_manual_revert() {
555        init_test_logging();
556        info!("TEST START: test_guard_manual_revert");
557
558        let temp_dir = TempDir::new().unwrap();
559        let file_path = temp_dir.path().join("test.rs");
560        let original_content = "fn main() {}\n";
561        fs::write(&file_path, original_content).unwrap();
562
563        let change = TestCodeChange::for_file(&file_path).unwrap();
564        let guard = TestChangeGuard::new(change).unwrap();
565
566        // File is modified
567        let during = fs::read_to_string(&file_path).unwrap();
568        assert!(during.contains("RCH_TEST_"));
569
570        // Manually revert
571        guard.revert().unwrap();
572
573        // File is back to original
574        let after = fs::read_to_string(&file_path).unwrap();
575        assert_eq!(after, original_content);
576
577        info!("TEST PASS: test_guard_manual_revert");
578    }
579
580    #[test]
581    fn test_change_with_empty_file() {
582        init_test_logging();
583        info!("TEST START: test_change_with_empty_file");
584
585        let temp_dir = TempDir::new().unwrap();
586        let file_path = temp_dir.path().join("empty.rs");
587        fs::write(&file_path, "").unwrap();
588
589        let change = TestCodeChange::for_file(&file_path).unwrap();
590
591        // Should append marker function
592        assert!(change.modified_content.contains("// RCH Self-Test Marker"));
593        assert!(change.modified_content.contains(&change.change_id));
594        assert!(change.original_content.is_empty());
595
596        info!("TEST PASS: test_change_with_empty_file");
597    }
598
599    #[test]
600    fn test_multiple_apply_same_change() {
601        init_test_logging();
602        info!("TEST START: test_multiple_apply_same_change");
603
604        let temp_dir = TempDir::new().unwrap();
605        let file_path = temp_dir.path().join("test.rs");
606        fs::write(&file_path, "fn main() {}\n").unwrap();
607
608        let change = TestCodeChange::for_file(&file_path).unwrap();
609
610        // Apply twice should work (idempotent write)
611        change.apply().unwrap();
612        change.apply().unwrap();
613
614        let content = fs::read_to_string(&file_path).unwrap();
615        assert!(content.contains(&change.change_id));
616
617        info!("TEST PASS: test_multiple_apply_same_change");
618    }
619
620    #[test]
621    fn test_apply_revert_apply() {
622        init_test_logging();
623        info!("TEST START: test_apply_revert_apply");
624
625        let temp_dir = TempDir::new().unwrap();
626        let file_path = temp_dir.path().join("test.rs");
627        let original = "fn main() {}\n";
628        fs::write(&file_path, original).unwrap();
629
630        let change = TestCodeChange::for_file(&file_path).unwrap();
631
632        // Apply
633        change.apply().unwrap();
634        assert!(
635            fs::read_to_string(&file_path)
636                .unwrap()
637                .contains(&change.change_id)
638        );
639
640        // Revert
641        change.revert().unwrap();
642        assert_eq!(fs::read_to_string(&file_path).unwrap(), original);
643
644        // Apply again
645        change.apply().unwrap();
646        assert!(
647            fs::read_to_string(&file_path)
648                .unwrap()
649                .contains(&change.change_id)
650        );
651
652        info!("TEST PASS: test_apply_revert_apply");
653    }
654
655    #[test]
656    fn test_guard_preserves_change() {
657        init_test_logging();
658        info!("TEST START: test_guard_preserves_change");
659
660        let temp_dir = TempDir::new().unwrap();
661        let file_path = temp_dir.path().join("test.rs");
662        fs::write(&file_path, "fn main() {}\n").unwrap();
663
664        let change = TestCodeChange::for_file(&file_path).unwrap();
665        let original_content = change.original_content.clone();
666        let modified_content = change.modified_content.clone();
667
668        let guard = TestChangeGuard::new(change).unwrap();
669
670        // Guard should give us the same change_id as the original change
671        assert!(modified_content.contains(guard.change_id()));
672
673        drop(guard);
674
675        // After drop, file should be restored to original
676        let after = fs::read_to_string(&file_path).unwrap();
677        assert_eq!(after, original_content);
678
679        info!("TEST PASS: test_guard_preserves_change");
680    }
681
682    #[test]
683    fn test_change_id_format() {
684        init_test_logging();
685        info!("TEST START: test_change_id_format");
686
687        let temp_dir = TempDir::new().unwrap();
688        let file_path = temp_dir.path().join("test.rs");
689        fs::write(&file_path, "fn main() {}\n").unwrap();
690
691        let change = TestCodeChange::for_file(&file_path).unwrap();
692
693        // Change ID should be RCH_TEST_ followed by a timestamp (number)
694        assert!(change.change_id.starts_with("RCH_TEST_"));
695        let timestamp_part = &change.change_id["RCH_TEST_".len()..];
696        assert!(timestamp_part.parse::<i64>().is_ok());
697
698        info!("TEST PASS: test_change_id_format");
699    }
700}