Skip to main content

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 `.ralph/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 .ralph/agent directory with empty scratchpad
117        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        // 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(
211                    src.to_string_lossy().to_string(),
212                ));
213            }
214        }
215
216        // Run setup script if specified
217        if let Some(script) = &task.setup.script {
218            let script_path = tasks_dir.join(script);
219            if script_path.exists() {
220                // Copy script first
221                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                // Execute it
228                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        // Create initial git commit
241        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            // Non-fatal: might have no files to commit
253            tracing::debug!(
254                "Initial commit warning: {}",
255                String::from_utf8_lossy(&commit_output.stderr)
256            );
257        }
258
259        Ok(())
260    }
261
262    /// Cleans up (removes) the workspace directory.
263    ///
264    /// # Errors
265    ///
266    /// Returns `WorkspaceError` if removal fails.
267    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    /// Returns true if the workspace has been cleaned up.
281    pub fn is_cleaned_up(&self) -> bool {
282        self.cleaned_up
283    }
284}
285
286impl Drop for TaskWorkspace {
287    fn drop(&mut self) {
288        // Don't automatically clean up on drop—let the caller decide based on policy
289        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/// Result of running a verification command.
300#[derive(Debug, Clone)]
301pub struct VerificationResult {
302    /// Whether verification passed (exit code matched expected).
303    pub passed: bool,
304
305    /// Actual exit code from the command.
306    pub exit_code: i32,
307
308    /// Expected exit code for success.
309    pub expected_exit_code: i32,
310
311    /// Stdout output from the command.
312    pub stdout: String,
313
314    /// Stderr output from the command.
315    pub stderr: String,
316}
317
318impl VerificationResult {
319    /// Returns a human-readable summary of the result.
320    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    /// Runs a verification command in the workspace directory.
334    ///
335    /// The command is executed via `bash -c` in the workspace's root directory.
336    ///
337    /// # Arguments
338    ///
339    /// * `verification` - The verification configuration with command and expected exit code
340    ///
341    /// # Returns
342    ///
343    /// A `VerificationResult` indicating whether the command passed and capturing output.
344    ///
345    /// # Errors
346    ///
347    /// Returns `WorkspaceError::Verification` if the command fails to execute
348    /// (not the same as the command returning a non-zero exit code).
349    pub fn run_verification(
350        &self,
351        verification: &Verification,
352    ) -> Result<VerificationResult, WorkspaceError> {
353        if verification.command.is_empty() {
354            // No verification command - consider it passed
355            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/// Manages workspace cleanup according to a policy.
400#[derive(Debug)]
401pub struct WorkspaceManager {
402    /// Base directory for workspaces.
403    base_dir: PathBuf,
404
405    /// Cleanup policy to apply.
406    policy: CleanupPolicy,
407}
408
409impl WorkspaceManager {
410    /// Creates a new workspace manager.
411    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    /// Returns the base directory.
419    pub fn base_dir(&self) -> &Path {
420        &self.base_dir
421    }
422
423    /// Returns the cleanup policy.
424    pub fn policy(&self) -> CleanupPolicy {
425        self.policy
426    }
427
428    /// Creates a workspace for the given task.
429    pub fn create_workspace(&self, task: &TaskDefinition) -> Result<TaskWorkspace, WorkspaceError> {
430        TaskWorkspace::create(task, &self.base_dir)
431    }
432
433    /// Applies cleanup policy after a task run.
434    ///
435    /// # Arguments
436    ///
437    /// * `workspace` - The workspace to potentially clean up
438    /// * `success` - Whether the task verification passed
439    ///
440    /// # Returns
441    ///
442    /// `true` if the workspace was cleaned up, `false` if retained.
443    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                // Don't clean up this workspace yet, but rotate old ones
464                self.rotate_workspaces(keep_last_n)?;
465                Ok(false)
466            }
467        }
468    }
469
470    /// Rotates old workspaces, keeping only the last N.
471    pub fn rotate_workspaces(&self, keep_last_n: usize) -> Result<(), WorkspaceError> {
472        if !self.base_dir.exists() {
473            return Ok(());
474        }
475
476        // Find all ralph-bench-* directories
477        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        // Sort by timestamp (newest first)
498        workspaces.sort_by_key(|b| std::cmp::Reverse(b.1));
499
500        // Delete workspaces beyond keep_last_n
501        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    /// Lists all workspace directories in the base directory.
510    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        // Sort by timestamp (newest first)
540        workspaces.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
541
542        Ok(workspaces)
543    }
544}
545
546/// Information about an existing workspace.
547#[derive(Debug, Clone)]
548pub struct WorkspaceInfo {
549    /// Path to the workspace directory.
550    pub path: PathBuf,
551
552    /// Task name extracted from directory name.
553    pub task_name: Option<String>,
554
555    /// Timestamp extracted from directory name.
556    pub timestamp: Option<u64>,
557}
558
559/// Errors that can occur during workspace operations.
560#[derive(Debug, thiserror::Error)]
561pub enum WorkspaceError {
562    /// IO error.
563    #[error("IO error: {0}")]
564    Io(#[from] io::Error),
565
566    /// Git initialization failed.
567    #[error("Git init failed: {0}")]
568    GitInit(String),
569
570    /// Required file not found.
571    #[error("Missing required file: {0}")]
572    MissingFile(String),
573
574    /// Setup script failed.
575    #[error("Setup script failed: {0}")]
576    SetupScript(String),
577
578    /// Verification command failed to execute.
579    #[error("Verification failed: {0}")]
580    Verification(String),
581}
582
583// ─────────────────────────────────────────────────────────────────────────────
584// Helper functions
585// ─────────────────────────────────────────────────────────────────────────────
586
587/// Recursively copies a directory.
588fn 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
606/// Extracts timestamp from workspace directory name.
607///
608/// Format: `ralph-bench-{task_name}-{timestamp}`
609fn extract_timestamp(dir_name: &str) -> Option<u64> {
610    dir_name
611        .rsplit('-')
612        .next()
613        .and_then(|s| s.parse::<u64>().ok())
614}
615
616/// Extracts task name from workspace directory name.
617///
618/// Format: `ralph-bench-{task_name}-{timestamp}`
619fn extract_task_name(dir_name: &str) -> Option<String> {
620    let stripped = dir_name.strip_prefix("ralph-bench-")?;
621    // Find the last dash before the timestamp
622    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        // Cleanup is idempotent
672        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        // Create prompt file
681        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        // Prompt should be copied
695        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        // Create prompt and setup files
710        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        // Setup file should be copied
724        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        // Create multiple workspaces
800        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        // All three exist
808        assert!(ws1.path().exists());
809        assert!(ws2.path().exists());
810        assert!(ws3.path().exists());
811
812        // Rotate should keep only 2
813        manager.rotate_workspaces(2).unwrap();
814
815        // ws1 should be deleted (oldest)
816        assert!(!ws1.path().exists());
817        // ws2 and ws3 should remain
818        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        // Success case: should cleanup
846        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        // Failure case: should keep
853        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        // Should be sorted newest first
876        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        // Create source structure
886        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
891        copy_dir_recursive(&src, &dst).unwrap();
892
893        // Verify
894        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        // Create a file that verification will check
913        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        // File doesn't exist, grep will fail
933        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        // Command exits with code 42
950        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}