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(".ralph").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(
211 src.to_string_lossy().to_string(),
212 ));
213 }
214 }
215
216 if let Some(script) = &task.setup.script {
218 let script_path = tasks_dir.join(script);
219 if script_path.exists() {
220 let script_dst = self.path.join(script);
222 if let Some(parent) = script_dst.parent() {
223 fs::create_dir_all(parent)?;
224 }
225 fs::copy(&script_path, &script_dst)?;
226
227 let output = Command::new("bash")
229 .arg(&script_dst)
230 .current_dir(&self.path)
231 .output()?;
232
233 if !output.status.success() {
234 let stderr = String::from_utf8_lossy(&output.stderr);
235 return Err(WorkspaceError::SetupScript(stderr.to_string()));
236 }
237 }
238 }
239
240 Command::new("git")
242 .args(["add", "-A"])
243 .current_dir(&self.path)
244 .output()?;
245
246 let commit_output = Command::new("git")
247 .args(["commit", "-m", "Initial benchmark setup", "--allow-empty"])
248 .current_dir(&self.path)
249 .output()?;
250
251 if !commit_output.status.success() {
252 tracing::debug!(
254 "Initial commit warning: {}",
255 String::from_utf8_lossy(&commit_output.stderr)
256 );
257 }
258
259 Ok(())
260 }
261
262 pub fn cleanup(&mut self) -> Result<(), WorkspaceError> {
268 if self.cleaned_up {
269 return Ok(());
270 }
271
272 if self.path.exists() {
273 fs::remove_dir_all(&self.path)?;
274 }
275
276 self.cleaned_up = true;
277 Ok(())
278 }
279
280 pub fn is_cleaned_up(&self) -> bool {
282 self.cleaned_up
283 }
284}
285
286impl Drop for TaskWorkspace {
287 fn drop(&mut self) {
288 if !self.cleaned_up && self.path.exists() {
290 tracing::debug!(
291 "Workspace {} not cleaned up, path retained: {}",
292 self.task_name,
293 self.path.display()
294 );
295 }
296 }
297}
298
299#[derive(Debug, Clone)]
301pub struct VerificationResult {
302 pub passed: bool,
304
305 pub exit_code: i32,
307
308 pub expected_exit_code: i32,
310
311 pub stdout: String,
313
314 pub stderr: String,
316}
317
318impl VerificationResult {
319 pub fn summary(&self) -> String {
321 if self.passed {
322 format!("PASSED (exit code {})", self.exit_code)
323 } else {
324 format!(
325 "FAILED (exit code {}, expected {})",
326 self.exit_code, self.expected_exit_code
327 )
328 }
329 }
330}
331
332impl TaskWorkspace {
333 pub fn run_verification(
350 &self,
351 verification: &Verification,
352 ) -> Result<VerificationResult, WorkspaceError> {
353 if verification.command.is_empty() {
354 return Ok(VerificationResult {
356 passed: true,
357 exit_code: 0,
358 expected_exit_code: 0,
359 stdout: String::new(),
360 stderr: String::new(),
361 });
362 }
363
364 tracing::debug!(
365 "Running verification in {}: {}",
366 self.path.display(),
367 verification.command
368 );
369
370 let output = Command::new("bash")
371 .args(["-c", &verification.command])
372 .current_dir(&self.path)
373 .output()
374 .map_err(|e| WorkspaceError::Verification(format!("Failed to execute: {}", e)))?;
375
376 let exit_code = output.status.code().unwrap_or(-1);
377 let passed = exit_code == verification.success_exit_code;
378
379 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
380 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
381
382 tracing::debug!(
383 "Verification result: {} (exit code {}, expected {})",
384 if passed { "PASSED" } else { "FAILED" },
385 exit_code,
386 verification.success_exit_code
387 );
388
389 Ok(VerificationResult {
390 passed,
391 exit_code,
392 expected_exit_code: verification.success_exit_code,
393 stdout,
394 stderr,
395 })
396 }
397}
398
399#[derive(Debug)]
401pub struct WorkspaceManager {
402 base_dir: PathBuf,
404
405 policy: CleanupPolicy,
407}
408
409impl WorkspaceManager {
410 pub fn new(base_dir: impl Into<PathBuf>, policy: CleanupPolicy) -> Self {
412 Self {
413 base_dir: base_dir.into(),
414 policy,
415 }
416 }
417
418 pub fn base_dir(&self) -> &Path {
420 &self.base_dir
421 }
422
423 pub fn policy(&self) -> CleanupPolicy {
425 self.policy
426 }
427
428 pub fn create_workspace(&self, task: &TaskDefinition) -> Result<TaskWorkspace, WorkspaceError> {
430 TaskWorkspace::create(task, &self.base_dir)
431 }
432
433 pub fn apply_cleanup(
444 &self,
445 workspace: &mut TaskWorkspace,
446 success: bool,
447 ) -> Result<bool, WorkspaceError> {
448 match self.policy {
449 CleanupPolicy::Always => {
450 workspace.cleanup()?;
451 Ok(true)
452 }
453 CleanupPolicy::OnSuccess => {
454 if success {
455 workspace.cleanup()?;
456 Ok(true)
457 } else {
458 Ok(false)
459 }
460 }
461 CleanupPolicy::Never => Ok(false),
462 CleanupPolicy::Rotate(keep_last_n) => {
463 self.rotate_workspaces(keep_last_n)?;
465 Ok(false)
466 }
467 }
468 }
469
470 pub fn rotate_workspaces(&self, keep_last_n: usize) -> Result<(), WorkspaceError> {
472 if !self.base_dir.exists() {
473 return Ok(());
474 }
475
476 let mut workspaces: Vec<(PathBuf, u64)> = Vec::new();
478
479 for entry in fs::read_dir(&self.base_dir)? {
480 let entry = entry?;
481 let path = entry.path();
482
483 if !path.is_dir() {
484 continue;
485 }
486 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
487 continue;
488 };
489 if !name.starts_with("ralph-bench-") {
490 continue;
491 }
492 if let Some(ts) = extract_timestamp(name) {
493 workspaces.push((path, ts));
494 }
495 }
496
497 workspaces.sort_by_key(|b| std::cmp::Reverse(b.1));
499
500 for (path, _) in workspaces.into_iter().skip(keep_last_n) {
502 tracing::debug!("Rotating old workspace: {}", path.display());
503 fs::remove_dir_all(&path)?;
504 }
505
506 Ok(())
507 }
508
509 pub fn list_workspaces(&self) -> Result<Vec<WorkspaceInfo>, WorkspaceError> {
511 if !self.base_dir.exists() {
512 return Ok(Vec::new());
513 }
514
515 let mut workspaces = Vec::new();
516
517 for entry in fs::read_dir(&self.base_dir)? {
518 let entry = entry?;
519 let path = entry.path();
520
521 if !path.is_dir() {
522 continue;
523 }
524 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
525 continue;
526 };
527 if !name.starts_with("ralph-bench-") {
528 continue;
529 }
530 let timestamp = extract_timestamp(name);
531 let task_name = extract_task_name(name);
532 workspaces.push(WorkspaceInfo {
533 path,
534 task_name,
535 timestamp,
536 });
537 }
538
539 workspaces.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
541
542 Ok(workspaces)
543 }
544}
545
546#[derive(Debug, Clone)]
548pub struct WorkspaceInfo {
549 pub path: PathBuf,
551
552 pub task_name: Option<String>,
554
555 pub timestamp: Option<u64>,
557}
558
559#[derive(Debug, thiserror::Error)]
561pub enum WorkspaceError {
562 #[error("IO error: {0}")]
564 Io(#[from] io::Error),
565
566 #[error("Git init failed: {0}")]
568 GitInit(String),
569
570 #[error("Missing required file: {0}")]
572 MissingFile(String),
573
574 #[error("Setup script failed: {0}")]
576 SetupScript(String),
577
578 #[error("Verification failed: {0}")]
580 Verification(String),
581}
582
583fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
589 fs::create_dir_all(dst)?;
590
591 for entry in fs::read_dir(src)? {
592 let entry = entry?;
593 let src_path = entry.path();
594 let dst_path = dst.join(entry.file_name());
595
596 if src_path.is_dir() {
597 copy_dir_recursive(&src_path, &dst_path)?;
598 } else {
599 fs::copy(&src_path, &dst_path)?;
600 }
601 }
602
603 Ok(())
604}
605
606fn extract_timestamp(dir_name: &str) -> Option<u64> {
610 dir_name
611 .rsplit('-')
612 .next()
613 .and_then(|s| s.parse::<u64>().ok())
614}
615
616fn extract_task_name(dir_name: &str) -> Option<String> {
620 let stripped = dir_name.strip_prefix("ralph-bench-")?;
621 let parts: Vec<&str> = stripped.rsplitn(2, '-').collect();
623 if parts.len() == 2 {
624 Some(parts[1].to_string())
625 } else {
626 None
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use tempfile::TempDir;
634
635 fn make_test_task(name: &str) -> TaskDefinition {
636 TaskDefinition::builder(name, "tasks/test/PROMPT.md", "DONE")
637 .verification_command("echo ok")
638 .build()
639 }
640
641 #[test]
642 fn test_workspace_create() {
643 let temp_dir = TempDir::new().unwrap();
644 let task = make_test_task("hello-world");
645
646 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
647
648 assert!(workspace.path().exists());
649 assert!(workspace.path().join(".git").exists());
650 assert!(workspace.path().join(".ralph/agent").exists());
651 assert!(workspace.path().join(".ralph/agent/scratchpad.md").exists());
652 assert_eq!(workspace.task_name(), "hello-world");
653 }
654
655 #[test]
656 fn test_workspace_cleanup() {
657 let temp_dir = TempDir::new().unwrap();
658 let task = make_test_task("cleanup-test");
659
660 let mut workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
661 let path = workspace.path().to_path_buf();
662
663 assert!(path.exists());
664 assert!(!workspace.is_cleaned_up());
665
666 workspace.cleanup().unwrap();
667
668 assert!(!path.exists());
669 assert!(workspace.is_cleaned_up());
670
671 workspace.cleanup().unwrap();
673 }
674
675 #[test]
676 fn test_workspace_setup_with_prompt() {
677 let temp_dir = TempDir::new().unwrap();
678 let tasks_dir = TempDir::new().unwrap();
679
680 let prompt_dir = tasks_dir.path().join("tasks/test");
682 fs::create_dir_all(&prompt_dir).unwrap();
683 fs::write(
684 prompt_dir.join("PROMPT.md"),
685 "# Test Prompt\n\nDo something.",
686 )
687 .unwrap();
688
689 let task = make_test_task("setup-test");
690 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
691
692 workspace.setup(&task, tasks_dir.path()).unwrap();
693
694 let prompt_dst = workspace.path().join("PROMPT.md");
696 assert!(prompt_dst.exists());
697 assert!(
698 fs::read_to_string(&prompt_dst)
699 .unwrap()
700 .contains("Test Prompt")
701 );
702 }
703
704 #[test]
705 fn test_workspace_setup_with_files() {
706 let temp_dir = TempDir::new().unwrap();
707 let tasks_dir = TempDir::new().unwrap();
708
709 let prompt_dir = tasks_dir.path().join("tasks/test");
711 fs::create_dir_all(&prompt_dir).unwrap();
712 fs::write(prompt_dir.join("PROMPT.md"), "# Test").unwrap();
713 fs::write(tasks_dir.path().join("helper.py"), "# helper").unwrap();
714
715 let task = TaskDefinition::builder("setup-files-test", "tasks/test/PROMPT.md", "DONE")
716 .verification_command("echo ok")
717 .setup_files(vec!["helper.py".to_string()])
718 .build();
719
720 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
721 workspace.setup(&task, tasks_dir.path()).unwrap();
722
723 assert!(workspace.path().join("helper.py").exists());
725 }
726
727 #[test]
728 fn test_workspace_setup_missing_prompt() {
729 let temp_dir = TempDir::new().unwrap();
730 let tasks_dir = TempDir::new().unwrap();
731
732 let task = make_test_task("missing-prompt");
733 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
734
735 let result = workspace.setup(&task, tasks_dir.path());
736 assert!(matches!(result, Err(WorkspaceError::MissingFile(_))));
737 }
738
739 #[test]
740 fn test_cleanup_policy_from_str() {
741 assert_eq!(
742 CleanupPolicy::from_str("rotate", Some(10)),
743 CleanupPolicy::Rotate(10)
744 );
745 assert_eq!(
746 CleanupPolicy::from_str("rotate", None),
747 CleanupPolicy::Rotate(5)
748 );
749 assert_eq!(
750 CleanupPolicy::from_str("on_success", None),
751 CleanupPolicy::OnSuccess
752 );
753 assert_eq!(
754 CleanupPolicy::from_str("always", None),
755 CleanupPolicy::Always
756 );
757 assert_eq!(CleanupPolicy::from_str("never", None), CleanupPolicy::Never);
758 assert_eq!(
759 CleanupPolicy::from_str("ROTATE", Some(3)),
760 CleanupPolicy::Rotate(3)
761 );
762 assert_eq!(
763 CleanupPolicy::from_str("unknown", None),
764 CleanupPolicy::OnSuccess
765 );
766 }
767
768 #[test]
769 fn test_extract_timestamp() {
770 assert_eq!(
771 extract_timestamp("ralph-bench-hello-world-1704067200000"),
772 Some(1_704_067_200_000)
773 );
774 assert_eq!(
775 extract_timestamp("ralph-bench-fizz-buzz-tdd-1704067300000"),
776 Some(1_704_067_300_000)
777 );
778 assert_eq!(extract_timestamp("ralph-bench-invalid"), None);
779 assert_eq!(extract_timestamp("other-dir"), None);
780 }
781
782 #[test]
783 fn test_extract_task_name() {
784 assert_eq!(
785 extract_task_name("ralph-bench-hello-world-1704067200000"),
786 Some("hello-world".to_string())
787 );
788 assert_eq!(
789 extract_task_name("ralph-bench-simple-1704067200000"),
790 Some("simple".to_string())
791 );
792 }
793
794 #[test]
795 fn test_workspace_manager_rotate() {
796 let temp_dir = TempDir::new().unwrap();
797 let manager = WorkspaceManager::new(temp_dir.path(), CleanupPolicy::Rotate(2));
798
799 let task = make_test_task("rotate-test");
801 let ws1 = manager.create_workspace(&task).unwrap();
802 std::thread::sleep(std::time::Duration::from_millis(10));
803 let ws2 = manager.create_workspace(&task).unwrap();
804 std::thread::sleep(std::time::Duration::from_millis(10));
805 let ws3 = manager.create_workspace(&task).unwrap();
806
807 assert!(ws1.path().exists());
809 assert!(ws2.path().exists());
810 assert!(ws3.path().exists());
811
812 manager.rotate_workspaces(2).unwrap();
814
815 assert!(!ws1.path().exists());
817 assert!(ws2.path().exists());
819 assert!(ws3.path().exists());
820 }
821
822 #[test]
823 fn test_workspace_manager_apply_cleanup_always() {
824 let temp_dir = TempDir::new().unwrap();
825 let manager = WorkspaceManager::new(temp_dir.path(), CleanupPolicy::Always);
826
827 let task = make_test_task("always-cleanup");
828 let mut workspace = manager.create_workspace(&task).unwrap();
829 let path = workspace.path().to_path_buf();
830
831 assert!(path.exists());
832
833 let cleaned = manager.apply_cleanup(&mut workspace, true).unwrap();
834 assert!(cleaned);
835 assert!(!path.exists());
836 }
837
838 #[test]
839 fn test_workspace_manager_apply_cleanup_on_success() {
840 let temp_dir = TempDir::new().unwrap();
841 let manager = WorkspaceManager::new(temp_dir.path(), CleanupPolicy::OnSuccess);
842
843 let task = make_test_task("on-success-cleanup");
844
845 let mut ws_success = manager.create_workspace(&task).unwrap();
847 let path_success = ws_success.path().to_path_buf();
848 let cleaned = manager.apply_cleanup(&mut ws_success, true).unwrap();
849 assert!(cleaned);
850 assert!(!path_success.exists());
851
852 let mut ws_failure = manager.create_workspace(&task).unwrap();
854 let path_failure = ws_failure.path().to_path_buf();
855 let cleaned = manager.apply_cleanup(&mut ws_failure, false).unwrap();
856 assert!(!cleaned);
857 assert!(path_failure.exists());
858 }
859
860 #[test]
861 fn test_workspace_manager_list_workspaces() {
862 let temp_dir = TempDir::new().unwrap();
863 let manager = WorkspaceManager::new(temp_dir.path(), CleanupPolicy::Never);
864
865 let task1 = make_test_task("list-test-a");
866 let task2 = make_test_task("list-test-b");
867
868 let _ws1 = manager.create_workspace(&task1).unwrap();
869 std::thread::sleep(std::time::Duration::from_millis(10));
870 let _ws2 = manager.create_workspace(&task2).unwrap();
871
872 let list = manager.list_workspaces().unwrap();
873 assert_eq!(list.len(), 2);
874
875 assert!(list[0].timestamp > list[1].timestamp);
877 }
878
879 #[test]
880 fn test_copy_dir_recursive() {
881 let temp_dir = TempDir::new().unwrap();
882 let src = temp_dir.path().join("src");
883 let dst = temp_dir.path().join("dst");
884
885 fs::create_dir_all(src.join("subdir")).unwrap();
887 fs::write(src.join("file1.txt"), "content1").unwrap();
888 fs::write(src.join("subdir/file2.txt"), "content2").unwrap();
889
890 copy_dir_recursive(&src, &dst).unwrap();
892
893 assert!(dst.join("file1.txt").exists());
895 assert!(dst.join("subdir/file2.txt").exists());
896 assert_eq!(
897 fs::read_to_string(dst.join("file1.txt")).unwrap(),
898 "content1"
899 );
900 assert_eq!(
901 fs::read_to_string(dst.join("subdir/file2.txt")).unwrap(),
902 "content2"
903 );
904 }
905
906 #[test]
907 fn test_run_verification_success() {
908 let temp_dir = TempDir::new().unwrap();
909 let task = make_test_task("verify-success");
910 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
911
912 fs::write(workspace.path().join("hello.txt"), "Hello, World!").unwrap();
914
915 let verification = Verification {
916 command: "cat hello.txt | grep -q 'Hello, World!'".to_string(),
917 success_exit_code: 0,
918 };
919
920 let result = workspace.run_verification(&verification).unwrap();
921 assert!(result.passed);
922 assert_eq!(result.exit_code, 0);
923 assert_eq!(result.expected_exit_code, 0);
924 }
925
926 #[test]
927 fn test_run_verification_failure() {
928 let temp_dir = TempDir::new().unwrap();
929 let task = make_test_task("verify-failure");
930 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
931
932 let verification = Verification {
934 command: "cat nonexistent.txt".to_string(),
935 success_exit_code: 0,
936 };
937
938 let result = workspace.run_verification(&verification).unwrap();
939 assert!(!result.passed);
940 assert_ne!(result.exit_code, 0);
941 }
942
943 #[test]
944 fn test_run_verification_custom_exit_code() {
945 let temp_dir = TempDir::new().unwrap();
946 let task = make_test_task("verify-custom-exit");
947 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
948
949 let verification = Verification {
951 command: "exit 42".to_string(),
952 success_exit_code: 42,
953 };
954
955 let result = workspace.run_verification(&verification).unwrap();
956 assert!(result.passed);
957 assert_eq!(result.exit_code, 42);
958 assert_eq!(result.expected_exit_code, 42);
959 }
960
961 #[test]
962 fn test_run_verification_empty_command() {
963 let temp_dir = TempDir::new().unwrap();
964 let task = make_test_task("verify-empty");
965 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
966
967 let verification = Verification {
968 command: String::new(),
969 success_exit_code: 0,
970 };
971
972 let result = workspace.run_verification(&verification).unwrap();
973 assert!(result.passed);
974 }
975
976 #[test]
977 fn test_run_verification_captures_output() {
978 let temp_dir = TempDir::new().unwrap();
979 let task = make_test_task("verify-capture");
980 let workspace = TaskWorkspace::create(&task, temp_dir.path()).unwrap();
981
982 let verification = Verification {
983 command: "echo 'stdout message' && echo 'stderr message' >&2".to_string(),
984 success_exit_code: 0,
985 };
986
987 let result = workspace.run_verification(&verification).unwrap();
988 assert!(result.passed);
989 assert!(result.stdout.contains("stdout message"));
990 assert!(result.stderr.contains("stderr message"));
991 }
992
993 #[test]
994 fn test_verification_result_summary() {
995 let passed_result = VerificationResult {
996 passed: true,
997 exit_code: 0,
998 expected_exit_code: 0,
999 stdout: String::new(),
1000 stderr: String::new(),
1001 };
1002 assert_eq!(passed_result.summary(), "PASSED (exit code 0)");
1003
1004 let failed_result = VerificationResult {
1005 passed: false,
1006 exit_code: 1,
1007 expected_exit_code: 0,
1008 stdout: String::new(),
1009 stderr: String::new(),
1010 };
1011 assert_eq!(failed_result.summary(), "FAILED (exit code 1, expected 0)");
1012 }
1013}