ralph_core/
workspace.rs

1//! Workspace isolation for benchmark tasks.
2//!
3//! Provides isolated temporary directories for each benchmark task run.
4//! Each workspace has its own `.git` directory to prevent polluting the main
5//! repository with agent commits.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use ralph_core::workspace::{TaskWorkspace, CleanupPolicy};
11//! use ralph_core::task_definition::TaskDefinition;
12//! use std::path::Path;
13//!
14//! let task = TaskDefinition::builder("hello-world", "tasks/hello/PROMPT.md", "DONE")
15//!     .verification_command("python hello.py")
16//!     .build();
17//!
18//! // Create isolated workspace
19//! let mut workspace = TaskWorkspace::create(&task, Path::new("/tmp/ralph-bench"))?;
20//!
21//! // Setup files from task definition
22//! workspace.setup(&task, Path::new("./bench/tasks"))?;
23//!
24//! // Run benchmark in workspace.path()...
25//!
26//! // Cleanup based on policy
27//! workspace.cleanup()?;
28//! # Ok::<(), ralph_core::workspace::WorkspaceError>(())
29//! ```
30
31use 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/// Cleanup policy for workspace directories.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum CleanupPolicy {
41    /// Keep last N workspaces, delete older ones.
42    Rotate(usize),
43
44    /// Delete on success, keep failures for debugging.
45    #[default]
46    OnSuccess,
47
48    /// Delete immediately after verification.
49    Always,
50
51    /// Keep all workspaces (manual cleanup).
52    Never,
53}
54
55impl CleanupPolicy {
56    /// Parse from string representation.
57    #[allow(clippy::match_same_arms)] // Explicit "on_success" arm for clarity
58    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/// An isolated workspace for running a benchmark task.
70///
71/// The workspace is created in a temporary directory with:
72/// - Its own `.git` directory (isolated from main repo)
73/// - A fresh `.agent/scratchpad.md`
74/// - Copied setup files from the task definition
75#[derive(Debug)]
76pub struct TaskWorkspace {
77    /// Path to the workspace root directory.
78    path: PathBuf,
79
80    /// Task name for identification.
81    task_name: String,
82
83    /// Timestamp when workspace was created.
84    created_at: u64,
85
86    /// Whether this workspace has been cleaned up.
87    cleaned_up: bool,
88}
89
90impl TaskWorkspace {
91    /// Creates a new isolated workspace for the given task.
92    ///
93    /// The workspace is created at:
94    /// `{base_dir}/ralph-bench-{task_name}-{timestamp}/`
95    ///
96    /// # Arguments
97    ///
98    /// * `task` - The task definition to create a workspace for
99    /// * `base_dir` - Base directory for workspaces (e.g., `/tmp`)
100    ///
101    /// # Errors
102    ///
103    /// Returns `WorkspaceError` if directory creation or git init fails.
104    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        // Create workspace directory
114        fs::create_dir_all(&path)?;
115
116        // Create .agent directory with empty scratchpad
117        let agent_dir = path.join(".agent");
118        fs::create_dir_all(&agent_dir)?;
119        fs::write(agent_dir.join("scratchpad.md"), "")?;
120
121        // Initialize isolated git repository
122        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        // Configure git user for commits (required for commits to work)
133        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    /// Returns the path to the workspace root directory.
152    pub fn path(&self) -> &Path {
153        &self.path
154    }
155
156    /// Returns the task name.
157    pub fn task_name(&self) -> &str {
158        &self.task_name
159    }
160
161    /// Returns the creation timestamp.
162    pub fn created_at(&self) -> u64 {
163        self.created_at
164    }
165
166    /// Sets up the workspace with files from the task definition.
167    ///
168    /// This copies:
169    /// 1. The prompt file as `PROMPT.md`
170    /// 2. Any setup files specified in the task definition
171    ///
172    /// # Arguments
173    ///
174    /// * `task` - The task definition containing setup configuration
175    /// * `tasks_dir` - Base directory where task files are located
176    ///
177    /// # Errors
178    ///
179    /// Returns `WorkspaceError` if file copying fails.
180    pub fn setup(&self, task: &TaskDefinition, tasks_dir: &Path) -> Result<(), WorkspaceError> {
181        // Copy prompt file
182        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        // Copy setup files
194        for file in &task.setup.files {
195            let src = tasks_dir.join(file);
196            let dst = self.path.join(file);
197
198            // Ensure parent directory exists
199            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        // Run setup script if specified
215        if let Some(script) = &task.setup.script {
216            let script_path = tasks_dir.join(script);
217            if script_path.exists() {
218                // Copy script first
219                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                // Execute it
226                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        // Create initial git commit
239        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            // Non-fatal: might have no files to commit
251            tracing::debug!(
252                "Initial commit warning: {}",
253                String::from_utf8_lossy(&commit_output.stderr)
254            );
255        }
256
257        Ok(())
258    }
259
260    /// Cleans up (removes) the workspace directory.
261    ///
262    /// # Errors
263    ///
264    /// Returns `WorkspaceError` if removal fails.
265    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    /// Returns true if the workspace has been cleaned up.
279    pub fn is_cleaned_up(&self) -> bool {
280        self.cleaned_up
281    }
282}
283
284impl Drop for TaskWorkspace {
285    fn drop(&mut self) {
286        // Don't automatically clean up on drop—let the caller decide based on policy
287        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/// Result of running a verification command.
298#[derive(Debug, Clone)]
299pub struct VerificationResult {
300    /// Whether verification passed (exit code matched expected).
301    pub passed: bool,
302
303    /// Actual exit code from the command.
304    pub exit_code: i32,
305
306    /// Expected exit code for success.
307    pub expected_exit_code: i32,
308
309    /// Stdout output from the command.
310    pub stdout: String,
311
312    /// Stderr output from the command.
313    pub stderr: String,
314}
315
316impl VerificationResult {
317    /// Returns a human-readable summary of the result.
318    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    /// Runs a verification command in the workspace directory.
332    ///
333    /// The command is executed via `bash -c` in the workspace's root directory.
334    ///
335    /// # Arguments
336    ///
337    /// * `verification` - The verification configuration with command and expected exit code
338    ///
339    /// # Returns
340    ///
341    /// A `VerificationResult` indicating whether the command passed and capturing output.
342    ///
343    /// # Errors
344    ///
345    /// Returns `WorkspaceError::Verification` if the command fails to execute
346    /// (not the same as the command returning a non-zero exit code).
347    pub fn run_verification(&self, verification: &Verification) -> Result<VerificationResult, WorkspaceError> {
348        if verification.command.is_empty() {
349            // No verification command - consider it passed
350            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/// Manages workspace cleanup according to a policy.
395#[derive(Debug)]
396pub struct WorkspaceManager {
397    /// Base directory for workspaces.
398    base_dir: PathBuf,
399
400    /// Cleanup policy to apply.
401    policy: CleanupPolicy,
402}
403
404impl WorkspaceManager {
405    /// Creates a new workspace manager.
406    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    /// Returns the base directory.
414    pub fn base_dir(&self) -> &Path {
415        &self.base_dir
416    }
417
418    /// Returns the cleanup policy.
419    pub fn policy(&self) -> CleanupPolicy {
420        self.policy
421    }
422
423    /// Creates a workspace for the given task.
424    pub fn create_workspace(&self, task: &TaskDefinition) -> Result<TaskWorkspace, WorkspaceError> {
425        TaskWorkspace::create(task, &self.base_dir)
426    }
427
428    /// Applies cleanup policy after a task run.
429    ///
430    /// # Arguments
431    ///
432    /// * `workspace` - The workspace to potentially clean up
433    /// * `success` - Whether the task verification passed
434    ///
435    /// # Returns
436    ///
437    /// `true` if the workspace was cleaned up, `false` if retained.
438    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                // Don't clean up this workspace yet, but rotate old ones
459                self.rotate_workspaces(keep_last_n)?;
460                Ok(false)
461            }
462        }
463    }
464
465    /// Rotates old workspaces, keeping only the last N.
466    pub fn rotate_workspaces(&self, keep_last_n: usize) -> Result<(), WorkspaceError> {
467        if !self.base_dir.exists() {
468            return Ok(());
469        }
470
471        // Find all ralph-bench-* directories
472        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                        // Extract timestamp from directory name
482                        if let Some(ts) = extract_timestamp(name) {
483                            workspaces.push((path, ts));
484                        }
485                    }
486                }
487            }
488        }
489
490        // Sort by timestamp (newest first)
491        workspaces.sort_by(|a, b| b.1.cmp(&a.1));
492
493        // Delete workspaces beyond keep_last_n
494        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    /// Lists all workspace directories in the base directory.
503    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        // Sort by timestamp (newest first)
530        workspaces.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
531
532        Ok(workspaces)
533    }
534}
535
536/// Information about an existing workspace.
537#[derive(Debug, Clone)]
538pub struct WorkspaceInfo {
539    /// Path to the workspace directory.
540    pub path: PathBuf,
541
542    /// Task name extracted from directory name.
543    pub task_name: Option<String>,
544
545    /// Timestamp extracted from directory name.
546    pub timestamp: Option<u64>,
547}
548
549/// Errors that can occur during workspace operations.
550#[derive(Debug, thiserror::Error)]
551pub enum WorkspaceError {
552    /// IO error.
553    #[error("IO error: {0}")]
554    Io(#[from] io::Error),
555
556    /// Git initialization failed.
557    #[error("Git init failed: {0}")]
558    GitInit(String),
559
560    /// Required file not found.
561    #[error("Missing required file: {0}")]
562    MissingFile(String),
563
564    /// Setup script failed.
565    #[error("Setup script failed: {0}")]
566    SetupScript(String),
567
568    /// Verification command failed to execute.
569    #[error("Verification failed: {0}")]
570    Verification(String),
571}
572
573// ─────────────────────────────────────────────────────────────────────────────
574// Helper functions
575// ─────────────────────────────────────────────────────────────────────────────
576
577/// Recursively copies a directory.
578fn 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
596/// Extracts timestamp from workspace directory name.
597///
598/// Format: `ralph-bench-{task_name}-{timestamp}`
599fn extract_timestamp(dir_name: &str) -> Option<u64> {
600    dir_name
601        .rsplit('-')
602        .next()
603        .and_then(|s| s.parse::<u64>().ok())
604}
605
606/// Extracts task name from workspace directory name.
607///
608/// Format: `ralph-bench-{task_name}-{timestamp}`
609fn extract_task_name(dir_name: &str) -> Option<String> {
610    let stripped = dir_name.strip_prefix("ralph-bench-")?;
611    // Find the last dash before the timestamp
612    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        // Cleanup is idempotent
662        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        // Create prompt file
671        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        // Prompt should be copied
681        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        // Create prompt and setup files
692        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        // Setup file should be copied
706        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        // Create multiple workspaces
785        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        // All three exist
793        assert!(ws1.path().exists());
794        assert!(ws2.path().exists());
795        assert!(ws3.path().exists());
796
797        // Rotate should keep only 2
798        manager.rotate_workspaces(2).unwrap();
799
800        // ws1 should be deleted (oldest)
801        assert!(!ws1.path().exists());
802        // ws2 and ws3 should remain
803        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        // Success case: should cleanup
831        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        // Failure case: should keep
838        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        // Should be sorted newest first
861        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        // Create source structure
871        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
876        copy_dir_recursive(&src, &dst).unwrap();
877
878        // Verify
879        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        // Create a file that verification will check
895        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        // File doesn't exist, grep will fail
915        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        // Command exits with code 42
932        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}