1use crate::task_definition::{TaskDefinition, Verification};
32use std::fs;
33use std::io;
34use std::path::{Path, PathBuf};
35use std::process::Command;
36use std::time::{SystemTime, UNIX_EPOCH};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum CleanupPolicy {
41 Rotate(usize),
43
44 #[default]
46 OnSuccess,
47
48 Always,
50
51 Never,
53}
54
55impl CleanupPolicy {
56 #[allow(clippy::match_same_arms)] pub fn from_str(s: &str, keep_last_n: Option<usize>) -> Self {
59 match s.to_lowercase().as_str() {
60 "rotate" => CleanupPolicy::Rotate(keep_last_n.unwrap_or(5)),
61 "on_success" => CleanupPolicy::OnSuccess,
62 "always" => CleanupPolicy::Always,
63 "never" => CleanupPolicy::Never,
64 _ => CleanupPolicy::OnSuccess,
65 }
66 }
67}
68
69#[derive(Debug)]
76pub struct TaskWorkspace {
77 path: PathBuf,
79
80 task_name: String,
82
83 created_at: u64,
85
86 cleaned_up: bool,
88}
89
90impl TaskWorkspace {
91 pub fn create(task: &TaskDefinition, base_dir: &Path) -> Result<Self, WorkspaceError> {
105 let timestamp = SystemTime::now()
106 .duration_since(UNIX_EPOCH)
107 .map(|d| d.as_millis() as u64)
108 .unwrap_or(0);
109
110 let dir_name = format!("ralph-bench-{}-{}", task.name, timestamp);
111 let path = base_dir.join(&dir_name);
112
113 fs::create_dir_all(&path)?;
115
116 let agent_dir = path.join(".agent");
118 fs::create_dir_all(&agent_dir)?;
119 fs::write(agent_dir.join("scratchpad.md"), "")?;
120
121 let git_output = Command::new("git")
123 .args(["init", "--initial-branch=main"])
124 .current_dir(&path)
125 .output()?;
126
127 if !git_output.status.success() {
128 let stderr = String::from_utf8_lossy(&git_output.stderr);
129 return Err(WorkspaceError::GitInit(stderr.to_string()));
130 }
131
132 Command::new("git")
134 .args(["config", "user.email", "benchmark@ralph.local"])
135 .current_dir(&path)
136 .output()?;
137
138 Command::new("git")
139 .args(["config", "user.name", "Ralph Benchmark"])
140 .current_dir(&path)
141 .output()?;
142
143 Ok(Self {
144 path,
145 task_name: task.name.clone(),
146 created_at: timestamp,
147 cleaned_up: false,
148 })
149 }
150
151 pub fn path(&self) -> &Path {
153 &self.path
154 }
155
156 pub fn task_name(&self) -> &str {
158 &self.task_name
159 }
160
161 pub fn created_at(&self) -> u64 {
163 self.created_at
164 }
165
166 pub fn setup(&self, task: &TaskDefinition, tasks_dir: &Path) -> Result<(), WorkspaceError> {
181 let prompt_src = tasks_dir.join(&task.prompt_file);
183 let prompt_dst = self.path.join("PROMPT.md");
184
185 if prompt_src.exists() {
186 fs::copy(&prompt_src, &prompt_dst)?;
187 } else {
188 return Err(WorkspaceError::MissingFile(
189 prompt_src.to_string_lossy().to_string(),
190 ));
191 }
192
193 for file in &task.setup.files {
195 let src = tasks_dir.join(file);
196 let dst = self.path.join(file);
197
198 if let Some(parent) = dst.parent() {
200 fs::create_dir_all(parent)?;
201 }
202
203 if src.exists() {
204 if src.is_dir() {
205 copy_dir_recursive(&src, &dst)?;
206 } else {
207 fs::copy(&src, &dst)?;
208 }
209 } else {
210 return Err(WorkspaceError::MissingFile(src.to_string_lossy().to_string()));
211 }
212 }
213
214 if let Some(script) = &task.setup.script {
216 let script_path = tasks_dir.join(script);
217 if script_path.exists() {
218 let script_dst = self.path.join(script);
220 if let Some(parent) = script_dst.parent() {
221 fs::create_dir_all(parent)?;
222 }
223 fs::copy(&script_path, &script_dst)?;
224
225 let output = Command::new("bash")
227 .arg(&script_dst)
228 .current_dir(&self.path)
229 .output()?;
230
231 if !output.status.success() {
232 let stderr = String::from_utf8_lossy(&output.stderr);
233 return Err(WorkspaceError::SetupScript(stderr.to_string()));
234 }
235 }
236 }
237
238 Command::new("git")
240 .args(["add", "-A"])
241 .current_dir(&self.path)
242 .output()?;
243
244 let commit_output = Command::new("git")
245 .args(["commit", "-m", "Initial benchmark setup", "--allow-empty"])
246 .current_dir(&self.path)
247 .output()?;
248
249 if !commit_output.status.success() {
250 tracing::debug!(
252 "Initial commit warning: {}",
253 String::from_utf8_lossy(&commit_output.stderr)
254 );
255 }
256
257 Ok(())
258 }
259
260 pub fn cleanup(&mut self) -> Result<(), WorkspaceError> {
266 if self.cleaned_up {
267 return Ok(());
268 }
269
270 if self.path.exists() {
271 fs::remove_dir_all(&self.path)?;
272 }
273
274 self.cleaned_up = true;
275 Ok(())
276 }
277
278 pub fn is_cleaned_up(&self) -> bool {
280 self.cleaned_up
281 }
282}
283
284impl Drop for TaskWorkspace {
285 fn drop(&mut self) {
286 if !self.cleaned_up && self.path.exists() {
288 tracing::debug!(
289 "Workspace {} not cleaned up, path retained: {}",
290 self.task_name,
291 self.path.display()
292 );
293 }
294 }
295}
296
297#[derive(Debug, Clone)]
299pub struct VerificationResult {
300 pub passed: bool,
302
303 pub exit_code: i32,
305
306 pub expected_exit_code: i32,
308
309 pub stdout: String,
311
312 pub stderr: String,
314}
315
316impl VerificationResult {
317 pub fn summary(&self) -> String {
319 if self.passed {
320 format!("PASSED (exit code {})", self.exit_code)
321 } else {
322 format!(
323 "FAILED (exit code {}, expected {})",
324 self.exit_code, self.expected_exit_code
325 )
326 }
327 }
328}
329
330impl TaskWorkspace {
331 pub fn run_verification(&self, verification: &Verification) -> Result<VerificationResult, WorkspaceError> {
348 if verification.command.is_empty() {
349 return Ok(VerificationResult {
351 passed: true,
352 exit_code: 0,
353 expected_exit_code: 0,
354 stdout: String::new(),
355 stderr: String::new(),
356 });
357 }
358
359 tracing::debug!(
360 "Running verification in {}: {}",
361 self.path.display(),
362 verification.command
363 );
364
365 let output = Command::new("bash")
366 .args(["-c", &verification.command])
367 .current_dir(&self.path)
368 .output()
369 .map_err(|e| WorkspaceError::Verification(format!("Failed to execute: {}", e)))?;
370
371 let exit_code = output.status.code().unwrap_or(-1);
372 let passed = exit_code == verification.success_exit_code;
373
374 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
375 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
376
377 tracing::debug!(
378 "Verification result: {} (exit code {}, expected {})",
379 if passed { "PASSED" } else { "FAILED" },
380 exit_code,
381 verification.success_exit_code
382 );
383
384 Ok(VerificationResult {
385 passed,
386 exit_code,
387 expected_exit_code: verification.success_exit_code,
388 stdout,
389 stderr,
390 })
391 }
392}
393
394#[derive(Debug)]
396pub struct WorkspaceManager {
397 base_dir: PathBuf,
399
400 policy: CleanupPolicy,
402}
403
404impl WorkspaceManager {
405 pub fn new(base_dir: impl Into<PathBuf>, policy: CleanupPolicy) -> Self {
407 Self {
408 base_dir: base_dir.into(),
409 policy,
410 }
411 }
412
413 pub fn base_dir(&self) -> &Path {
415 &self.base_dir
416 }
417
418 pub fn policy(&self) -> CleanupPolicy {
420 self.policy
421 }
422
423 pub fn create_workspace(&self, task: &TaskDefinition) -> Result<TaskWorkspace, WorkspaceError> {
425 TaskWorkspace::create(task, &self.base_dir)
426 }
427
428 pub fn apply_cleanup(
439 &self,
440 workspace: &mut TaskWorkspace,
441 success: bool,
442 ) -> Result<bool, WorkspaceError> {
443 match self.policy {
444 CleanupPolicy::Always => {
445 workspace.cleanup()?;
446 Ok(true)
447 }
448 CleanupPolicy::OnSuccess => {
449 if success {
450 workspace.cleanup()?;
451 Ok(true)
452 } else {
453 Ok(false)
454 }
455 }
456 CleanupPolicy::Never => Ok(false),
457 CleanupPolicy::Rotate(keep_last_n) => {
458 self.rotate_workspaces(keep_last_n)?;
460 Ok(false)
461 }
462 }
463 }
464
465 pub fn rotate_workspaces(&self, keep_last_n: usize) -> Result<(), WorkspaceError> {
467 if !self.base_dir.exists() {
468 return Ok(());
469 }
470
471 let mut workspaces: Vec<(PathBuf, u64)> = Vec::new();
473
474 for entry in fs::read_dir(&self.base_dir)? {
475 let entry = entry?;
476 let path = entry.path();
477
478 if path.is_dir() {
479 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
480 if name.starts_with("ralph-bench-") {
481 if let Some(ts) = extract_timestamp(name) {
483 workspaces.push((path, ts));
484 }
485 }
486 }
487 }
488 }
489
490 workspaces.sort_by(|a, b| b.1.cmp(&a.1));
492
493 for (path, _) in workspaces.into_iter().skip(keep_last_n) {
495 tracing::debug!("Rotating old workspace: {}", path.display());
496 fs::remove_dir_all(&path)?;
497 }
498
499 Ok(())
500 }
501
502 pub fn list_workspaces(&self) -> Result<Vec<WorkspaceInfo>, WorkspaceError> {
504 if !self.base_dir.exists() {
505 return Ok(Vec::new());
506 }
507
508 let mut workspaces = Vec::new();
509
510 for entry in fs::read_dir(&self.base_dir)? {
511 let entry = entry?;
512 let path = entry.path();
513
514 if path.is_dir() {
515 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
516 if name.starts_with("ralph-bench-") {
517 let timestamp = extract_timestamp(name);
518 let task_name = extract_task_name(name);
519 workspaces.push(WorkspaceInfo {
520 path,
521 task_name,
522 timestamp,
523 });
524 }
525 }
526 }
527 }
528
529 workspaces.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
531
532 Ok(workspaces)
533 }
534}
535
536#[derive(Debug, Clone)]
538pub struct WorkspaceInfo {
539 pub path: PathBuf,
541
542 pub task_name: Option<String>,
544
545 pub timestamp: Option<u64>,
547}
548
549#[derive(Debug, thiserror::Error)]
551pub enum WorkspaceError {
552 #[error("IO error: {0}")]
554 Io(#[from] io::Error),
555
556 #[error("Git init failed: {0}")]
558 GitInit(String),
559
560 #[error("Missing required file: {0}")]
562 MissingFile(String),
563
564 #[error("Setup script failed: {0}")]
566 SetupScript(String),
567
568 #[error("Verification failed: {0}")]
570 Verification(String),
571}
572
573fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
579 fs::create_dir_all(dst)?;
580
581 for entry in fs::read_dir(src)? {
582 let entry = entry?;
583 let src_path = entry.path();
584 let dst_path = dst.join(entry.file_name());
585
586 if src_path.is_dir() {
587 copy_dir_recursive(&src_path, &dst_path)?;
588 } else {
589 fs::copy(&src_path, &dst_path)?;
590 }
591 }
592
593 Ok(())
594}
595
596fn extract_timestamp(dir_name: &str) -> Option<u64> {
600 dir_name
601 .rsplit('-')
602 .next()
603 .and_then(|s| s.parse::<u64>().ok())
604}
605
606fn extract_task_name(dir_name: &str) -> Option<String> {
610 let stripped = dir_name.strip_prefix("ralph-bench-")?;
611 let parts: Vec<&str> = stripped.rsplitn(2, '-').collect();
613 if parts.len() == 2 {
614 Some(parts[1].to_string())
615 } else {
616 None
617 }
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use tempfile::TempDir;
624
625 fn make_test_task(name: &str) -> TaskDefinition {
626 TaskDefinition::builder(name, "tasks/test/PROMPT.md", "DONE")
627 .verification_command("echo ok")
628 .build()
629 }
630
631 #[test]
632 fn test_workspace_create() {
633 let temp_dir = TempDir::new().unwrap();
634 let task = make_test_task("hello-world");
635
636 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
637
638 assert!(workspace.path().exists());
639 assert!(workspace.path().join(".git").exists());
640 assert!(workspace.path().join(".agent").exists());
641 assert!(workspace.path().join(".agent/scratchpad.md").exists());
642 assert_eq!(workspace.task_name(), "hello-world");
643 }
644
645 #[test]
646 fn test_workspace_cleanup() {
647 let temp_dir = TempDir::new().unwrap();
648 let task = make_test_task("cleanup-test");
649
650 let mut workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
651 let path = workspace.path().to_path_buf();
652
653 assert!(path.exists());
654 assert!(!workspace.is_cleaned_up());
655
656 workspace.cleanup().unwrap();
657
658 assert!(!path.exists());
659 assert!(workspace.is_cleaned_up());
660
661 workspace.cleanup().unwrap();
663 }
664
665 #[test]
666 fn test_workspace_setup_with_prompt() {
667 let temp_dir = TempDir::new().unwrap();
668 let tasks_dir = TempDir::new().unwrap();
669
670 let prompt_dir = tasks_dir.path().join("tasks/test");
672 fs::create_dir_all(&prompt_dir).unwrap();
673 fs::write(prompt_dir.join("PROMPT.md"), "# Test Prompt\n\nDo something.").unwrap();
674
675 let task = make_test_task("setup-test");
676 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
677
678 workspace.setup(&task, tasks_dir.path()).unwrap();
679
680 let prompt_dst = workspace.path().join("PROMPT.md");
682 assert!(prompt_dst.exists());
683 assert!(fs::read_to_string(&prompt_dst).unwrap().contains("Test Prompt"));
684 }
685
686 #[test]
687 fn test_workspace_setup_with_files() {
688 let temp_dir = TempDir::new().unwrap();
689 let tasks_dir = TempDir::new().unwrap();
690
691 let prompt_dir = tasks_dir.path().join("tasks/test");
693 fs::create_dir_all(&prompt_dir).unwrap();
694 fs::write(prompt_dir.join("PROMPT.md"), "# Test").unwrap();
695 fs::write(tasks_dir.path().join("helper.py"), "# helper").unwrap();
696
697 let task = TaskDefinition::builder("setup-files-test", "tasks/test/PROMPT.md", "DONE")
698 .verification_command("echo ok")
699 .setup_files(vec!["helper.py".to_string()])
700 .build();
701
702 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
703 workspace.setup(&task, tasks_dir.path()).unwrap();
704
705 assert!(workspace.path().join("helper.py").exists());
707 }
708
709 #[test]
710 fn test_workspace_setup_missing_prompt() {
711 let temp_dir = TempDir::new().unwrap();
712 let tasks_dir = TempDir::new().unwrap();
713
714 let task = make_test_task("missing-prompt");
715 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
716
717 let result = workspace.setup(&task, tasks_dir.path());
718 assert!(matches!(result, Err(WorkspaceError::MissingFile(_))));
719 }
720
721 #[test]
722 fn test_cleanup_policy_from_str() {
723 assert_eq!(
724 CleanupPolicy::from_str("rotate", Some(10)),
725 CleanupPolicy::Rotate(10)
726 );
727 assert_eq!(
728 CleanupPolicy::from_str("rotate", None),
729 CleanupPolicy::Rotate(5)
730 );
731 assert_eq!(
732 CleanupPolicy::from_str("on_success", None),
733 CleanupPolicy::OnSuccess
734 );
735 assert_eq!(
736 CleanupPolicy::from_str("always", None),
737 CleanupPolicy::Always
738 );
739 assert_eq!(
740 CleanupPolicy::from_str("never", None),
741 CleanupPolicy::Never
742 );
743 assert_eq!(
744 CleanupPolicy::from_str("ROTATE", Some(3)),
745 CleanupPolicy::Rotate(3)
746 );
747 assert_eq!(
748 CleanupPolicy::from_str("unknown", None),
749 CleanupPolicy::OnSuccess
750 );
751 }
752
753 #[test]
754 fn test_extract_timestamp() {
755 assert_eq!(
756 extract_timestamp("ralph-bench-hello-world-1704067200000"),
757 Some(1_704_067_200_000)
758 );
759 assert_eq!(
760 extract_timestamp("ralph-bench-fizz-buzz-tdd-1704067300000"),
761 Some(1_704_067_300_000)
762 );
763 assert_eq!(extract_timestamp("ralph-bench-invalid"), None);
764 assert_eq!(extract_timestamp("other-dir"), None);
765 }
766
767 #[test]
768 fn test_extract_task_name() {
769 assert_eq!(
770 extract_task_name("ralph-bench-hello-world-1704067200000"),
771 Some("hello-world".to_string())
772 );
773 assert_eq!(
774 extract_task_name("ralph-bench-simple-1704067200000"),
775 Some("simple".to_string())
776 );
777 }
778
779 #[test]
780 fn test_workspace_manager_rotate() {
781 let temp_dir = TempDir::new().unwrap();
782 let manager = WorkspaceManager::new(temp_dir.path(), CleanupPolicy::Rotate(2));
783
784 let task = make_test_task("rotate-test");
786 let ws1 = manager.create_workspace(&task).unwrap();
787 std::thread::sleep(std::time::Duration::from_millis(10));
788 let ws2 = manager.create_workspace(&task).unwrap();
789 std::thread::sleep(std::time::Duration::from_millis(10));
790 let ws3 = manager.create_workspace(&task).unwrap();
791
792 assert!(ws1.path().exists());
794 assert!(ws2.path().exists());
795 assert!(ws3.path().exists());
796
797 manager.rotate_workspaces(2).unwrap();
799
800 assert!(!ws1.path().exists());
802 assert!(ws2.path().exists());
804 assert!(ws3.path().exists());
805 }
806
807 #[test]
808 fn test_workspace_manager_apply_cleanup_always() {
809 let temp_dir = TempDir::new().unwrap();
810 let manager = WorkspaceManager::new(temp_dir.path(), CleanupPolicy::Always);
811
812 let task = make_test_task("always-cleanup");
813 let mut workspace = manager.create_workspace(&task).unwrap();
814 let path = workspace.path().to_path_buf();
815
816 assert!(path.exists());
817
818 let cleaned = manager.apply_cleanup(&mut workspace, true).unwrap();
819 assert!(cleaned);
820 assert!(!path.exists());
821 }
822
823 #[test]
824 fn test_workspace_manager_apply_cleanup_on_success() {
825 let temp_dir = TempDir::new().unwrap();
826 let manager = WorkspaceManager::new(temp_dir.path(), CleanupPolicy::OnSuccess);
827
828 let task = make_test_task("on-success-cleanup");
829
830 let mut ws_success = manager.create_workspace(&task).unwrap();
832 let path_success = ws_success.path().to_path_buf();
833 let cleaned = manager.apply_cleanup(&mut ws_success, true).unwrap();
834 assert!(cleaned);
835 assert!(!path_success.exists());
836
837 let mut ws_failure = manager.create_workspace(&task).unwrap();
839 let path_failure = ws_failure.path().to_path_buf();
840 let cleaned = manager.apply_cleanup(&mut ws_failure, false).unwrap();
841 assert!(!cleaned);
842 assert!(path_failure.exists());
843 }
844
845 #[test]
846 fn test_workspace_manager_list_workspaces() {
847 let temp_dir = TempDir::new().unwrap();
848 let manager = WorkspaceManager::new(temp_dir.path(), CleanupPolicy::Never);
849
850 let task1 = make_test_task("list-test-a");
851 let task2 = make_test_task("list-test-b");
852
853 let _ws1 = manager.create_workspace(&task1).unwrap();
854 std::thread::sleep(std::time::Duration::from_millis(10));
855 let _ws2 = manager.create_workspace(&task2).unwrap();
856
857 let list = manager.list_workspaces().unwrap();
858 assert_eq!(list.len(), 2);
859
860 assert!(list[0].timestamp > list[1].timestamp);
862 }
863
864 #[test]
865 fn test_copy_dir_recursive() {
866 let temp_dir = TempDir::new().unwrap();
867 let src = temp_dir.path().join("src");
868 let dst = temp_dir.path().join("dst");
869
870 fs::create_dir_all(src.join("subdir")).unwrap();
872 fs::write(src.join("file1.txt"), "content1").unwrap();
873 fs::write(src.join("subdir/file2.txt"), "content2").unwrap();
874
875 copy_dir_recursive(&src, &dst).unwrap();
877
878 assert!(dst.join("file1.txt").exists());
880 assert!(dst.join("subdir/file2.txt").exists());
881 assert_eq!(fs::read_to_string(dst.join("file1.txt")).unwrap(), "content1");
882 assert_eq!(
883 fs::read_to_string(dst.join("subdir/file2.txt")).unwrap(),
884 "content2"
885 );
886 }
887
888 #[test]
889 fn test_run_verification_success() {
890 let temp_dir = TempDir::new().unwrap();
891 let task = make_test_task("verify-success");
892 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
893
894 fs::write(workspace.path().join("hello.txt"), "Hello, World!").unwrap();
896
897 let verification = Verification {
898 command: "cat hello.txt | grep -q 'Hello, World!'".to_string(),
899 success_exit_code: 0,
900 };
901
902 let result = workspace.run_verification(&verification).unwrap();
903 assert!(result.passed);
904 assert_eq!(result.exit_code, 0);
905 assert_eq!(result.expected_exit_code, 0);
906 }
907
908 #[test]
909 fn test_run_verification_failure() {
910 let temp_dir = TempDir::new().unwrap();
911 let task = make_test_task("verify-failure");
912 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
913
914 let verification = Verification {
916 command: "cat nonexistent.txt".to_string(),
917 success_exit_code: 0,
918 };
919
920 let result = workspace.run_verification(&verification).unwrap();
921 assert!(!result.passed);
922 assert_ne!(result.exit_code, 0);
923 }
924
925 #[test]
926 fn test_run_verification_custom_exit_code() {
927 let temp_dir = TempDir::new().unwrap();
928 let task = make_test_task("verify-custom-exit");
929 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
930
931 let verification = Verification {
933 command: "exit 42".to_string(),
934 success_exit_code: 42,
935 };
936
937 let result = workspace.run_verification(&verification).unwrap();
938 assert!(result.passed);
939 assert_eq!(result.exit_code, 42);
940 assert_eq!(result.expected_exit_code, 42);
941 }
942
943 #[test]
944 fn test_run_verification_empty_command() {
945 let temp_dir = TempDir::new().unwrap();
946 let task = make_test_task("verify-empty");
947 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
948
949 let verification = Verification {
950 command: String::new(),
951 success_exit_code: 0,
952 };
953
954 let result = workspace.run_verification(&verification).unwrap();
955 assert!(result.passed);
956 }
957
958 #[test]
959 fn test_run_verification_captures_output() {
960 let temp_dir = TempDir::new().unwrap();
961 let task = make_test_task("verify-capture");
962 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
963
964 let verification = Verification {
965 command: "echo 'stdout message' && echo 'stderr message' >&2".to_string(),
966 success_exit_code: 0,
967 };
968
969 let result = workspace.run_verification(&verification).unwrap();
970 assert!(result.passed);
971 assert!(result.stdout.contains("stdout message"));
972 assert!(result.stderr.contains("stderr message"));
973 }
974
975 #[test]
976 fn test_verification_result_summary() {
977 let passed_result = VerificationResult {
978 passed: true,
979 exit_code: 0,
980 expected_exit_code: 0,
981 stdout: String::new(),
982 stderr: String::new(),
983 };
984 assert_eq!(passed_result.summary(), "PASSED (exit code 0)");
985
986 let failed_result = VerificationResult {
987 passed: false,
988 exit_code: 1,
989 expected_exit_code: 0,
990 stdout: String::new(),
991 stderr: String::new(),
992 };
993 assert_eq!(failed_result.summary(), "FAILED (exit code 1, expected 0)");
994 }
995}