Skip to main content

wrkflw_runtime/
emulation.rs

1use crate::container::{
2    rebase_working_dir_or_error, ContainerError, ContainerOutput, ContainerRuntime,
3};
4use async_trait::async_trait;
5use once_cell::sync::Lazy;
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::sync::Mutex;
11use tempfile::TempDir;
12use which;
13use wrkflw_logging;
14
15use ignore::{gitignore::GitignoreBuilder, Match};
16
17// Global collection of resources to clean up
18static EMULATION_WORKSPACES: Lazy<Mutex<Vec<PathBuf>>> = Lazy::new(|| Mutex::new(Vec::new()));
19static EMULATION_PROCESSES: Lazy<Mutex<Vec<u32>>> = Lazy::new(|| Mutex::new(Vec::new()));
20
21pub struct EmulationRuntime {
22    #[allow(dead_code)]
23    workspace: TempDir,
24}
25
26impl Default for EmulationRuntime {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl EmulationRuntime {
33    pub fn new() -> Self {
34        // Create a temporary workspace to simulate container isolation
35        let workspace =
36            tempfile::tempdir().expect("Failed to create temporary workspace for emulation");
37
38        // Track this workspace for cleanup
39        if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
40            workspaces.push(workspace.path().to_path_buf());
41        }
42
43        EmulationRuntime { workspace }
44    }
45
46    #[allow(dead_code)]
47    fn prepare_workspace(&self, _working_dir: &Path, volumes: &[(&Path, &Path)]) -> PathBuf {
48        // Get the container root - this is the emulation workspace directory
49        let container_root = self.workspace.path().to_path_buf();
50
51        // Make sure we have a github/workspace subdirectory which is where
52        // commands will be executed
53        let github_workspace = container_root.join("github").join("workspace");
54        fs::create_dir_all(&github_workspace)
55            .expect("Failed to create github/workspace directory structure");
56
57        // Map all volumes
58        for (host_path, container_path) in volumes {
59            // Determine target path - if it starts with /github/workspace, it goes to our workspace dir
60            let target_path = if container_path.starts_with("/github/workspace") {
61                // Map /github/workspace to our github_workspace directory
62                let rel_path = container_path
63                    .strip_prefix("/github/workspace")
64                    .unwrap_or(Path::new(""));
65                github_workspace.join(rel_path)
66            } else if container_path.starts_with("/") {
67                // Other absolute paths go under container_root
68                container_root.join(container_path.strip_prefix("/").unwrap_or(container_path))
69            } else {
70                // Relative paths go directly under container_root
71                container_root.join(container_path)
72            };
73
74            // Create parent directories
75            if let Some(parent) = target_path.parent() {
76                fs::create_dir_all(parent).expect("Failed to create directory structure");
77            }
78
79            // For directories, copy content recursively
80            if host_path.is_dir() {
81                // If the host path is the project root and container path is the workspace,
82                // we want to copy all project files to the github/workspace directory
83                if *container_path == Path::new("/github/workspace") {
84                    // Use a recursive copy function to copy all files and directories
85                    copy_directory_contents(host_path, &github_workspace)
86                        .expect("Failed to copy project files to workspace");
87                } else {
88                    // Create the target directory
89                    fs::create_dir_all(&target_path).expect("Failed to create target directory");
90
91                    // Copy files in this directory (not recursive for simplicity)
92                    for entry in fs::read_dir(host_path)
93                        .expect("Failed to read source directory")
94                        .flatten()
95                    {
96                        let source = entry.path();
97                        let file_name = match source.file_name() {
98                            Some(name) => name,
99                            None => {
100                                eprintln!(
101                                    "Warning: Could not get file name from path: {:?}",
102                                    source
103                                );
104                                continue; // Skip this file
105                            }
106                        };
107                        let dest = target_path.join(file_name);
108
109                        if source.is_file() {
110                            if let Err(e) = fs::copy(&source, &dest) {
111                                eprintln!(
112                                    "Warning: Failed to copy file from {:?} to {:?}: {}",
113                                    &source, &dest, e
114                                );
115                            }
116                        } else {
117                            // We could make this recursive if needed
118                            fs::create_dir_all(&dest).expect("Failed to create subdirectory");
119                        }
120                    }
121                }
122            } else if host_path.is_file() {
123                // Copy individual file
124                let file_name = match host_path.file_name() {
125                    Some(name) => name,
126                    None => {
127                        eprintln!(
128                            "Warning: Could not get file name from path: {:?}",
129                            host_path
130                        );
131                        continue; // Skip this file
132                    }
133                };
134                let dest = target_path.join(file_name);
135                if let Err(e) = fs::copy(host_path, &dest) {
136                    eprintln!(
137                        "Warning: Failed to copy file from {:?} to {:?}: {}",
138                        host_path, &dest, e
139                    );
140                }
141            }
142        }
143
144        // Return the github/workspace directory for command execution
145        github_workspace
146    }
147}
148
149#[async_trait]
150impl ContainerRuntime for EmulationRuntime {
151    async fn run_container(
152        &self,
153        _image: &str,
154        command: &[&str],
155        env_vars: &[(&str, &str)],
156        working_dir: &Path,
157        volumes: &[(&Path, &Path)],
158        _entrypoint: Option<&str>,
159    ) -> Result<ContainerOutput, ContainerError> {
160        // Build command string
161        let mut command_str = String::new();
162        for part in command {
163            if !command_str.is_empty() {
164                command_str.push(' ');
165            }
166            command_str.push_str(part);
167        }
168
169        // Log more detailed debugging information
170        wrkflw_logging::info(&format!("Executing command in container: {}", command_str));
171        wrkflw_logging::info(&format!("Working directory: {}", working_dir.display()));
172        wrkflw_logging::info(&format!("Command length: {}", command.len()));
173
174        if command.is_empty() {
175            // Empty cmd means "use the image's built-in ENTRYPOINT/CMD".
176            // Emulation mode cannot run Docker images natively, so return
177            // a descriptive message instead of erroring.
178            wrkflw_logging::warning(
179                "Emulation mode cannot run Docker actions with native entrypoints. \
180                 Use --runtime docker for full Docker action support.",
181            );
182            return Ok(ContainerOutput {
183                stdout: String::new(),
184                stderr: "Emulation mode: skipped Docker action (requires --runtime docker). \
185                         The action was NOT executed."
186                    .to_string(),
187                exit_code: 1,
188            });
189        }
190
191        // Print each command part separately for debugging
192        for (i, part) in command.iter().enumerate() {
193            wrkflw_logging::info(&format!("Command part {}: '{}'", i, part));
194        }
195
196        // Log environment variables
197        wrkflw_logging::info("Environment variables:");
198        for (key, value) in env_vars {
199            wrkflw_logging::info(&format!("  {}={}", key, value));
200        }
201
202        // Resolve the host working directory via the shared mount-semantics
203        // helper. When `volumes` covers `working_dir` (the normal case for
204        // container-visible paths like `/github/workspace`), the rebase
205        // always wins over an accidentally-existing host path — so a dev
206        // environment with a real `/github/workspace` directory cannot
207        // silently reintroduce #88.
208        let actual_working_dir = rebase_working_dir_or_error(working_dir, volumes, "emulation")?;
209
210        wrkflw_logging::info(&format!(
211            "Using actual working directory: {}",
212            actual_working_dir.display()
213        ));
214
215        // Check if path contains the command (for shell script execution)
216        let command_path = which::which(command[0]);
217        match &command_path {
218            Ok(path) => wrkflw_logging::info(&format!("Found command at: {}", path.display())),
219            Err(e) => wrkflw_logging::error(&format!(
220                "Command not found in PATH: {} - Error: {}",
221                command[0], e
222            )),
223        }
224
225        // When the command starts with a known interpreter (bash, sh, python, pwsh)
226        // or cargo/rustup, execute the command array directly to preserve argument
227        // boundaries. This is critical for `bash -c <script>` where the script must
228        // remain a single argument rather than being re-split by an outer shell.
229        // For all other commands, fall back to `sh -c` to handle shell builtins,
230        // pipelines, and other shell syntax.
231        // Use the basename so absolute paths like /usr/bin/bash are matched too.
232        let cmd_basename = Path::new(command[0])
233            .file_name()
234            .and_then(|s| s.to_str())
235            .unwrap_or(command[0]);
236        let use_direct_exec = matches!(
237            cmd_basename,
238            "bash" | "sh" | "python" | "python3" | "pwsh" | "powershell" | "cargo" | "rustup"
239        );
240
241        let mut cmd;
242        if use_direct_exec {
243            cmd = Command::new(command[0]);
244            if command.len() > 1 {
245                cmd.args(&command[1..]);
246            }
247        } else {
248            cmd = Command::new("sh");
249            cmd.arg("-c");
250            cmd.arg(&command_str);
251        }
252
253        // Resolve the project directory once for cargo/rustup cwd and CI_PROJECT_DIR substitution.
254        let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
255
256        // Cargo/rustup commands always run in the project directory
257        if matches!(cmd_basename, "cargo" | "rustup") {
258            wrkflw_logging::info(&format!(
259                "Using project directory for Rust command: {}",
260                project_dir.display()
261            ));
262            cmd.current_dir(&project_dir);
263        } else {
264            cmd.current_dir(&actual_working_dir);
265        }
266
267        // Add environment variables, interpolating ${CI_PROJECT_DIR} in values
268        // (e.g. CARGO_HOME="${CI_PROJECT_DIR}/.cargo" in GitLab CI configs).
269        let project_dir_str = project_dir.to_string_lossy();
270        for (key, value) in env_vars {
271            if value.contains("${CI_PROJECT_DIR}") {
272                cmd.env(key, value.replace("${CI_PROJECT_DIR}", &project_dir_str));
273            } else {
274                cmd.env(key, value);
275            }
276        }
277
278        match cmd.output() {
279            Ok(output_result) => {
280                let exit_code = output_result.status.code().unwrap_or(-1);
281                let output = String::from_utf8_lossy(&output_result.stdout).to_string();
282                let error = String::from_utf8_lossy(&output_result.stderr).to_string();
283
284                wrkflw_logging::debug(&format!("Command completed with exit code: {}", exit_code));
285
286                Ok(ContainerOutput {
287                    stdout: output,
288                    stderr: error,
289                    exit_code,
290                })
291            }
292            Err(e) => {
293                return Err(ContainerError::ContainerExecution(format!(
294                    "Failed to execute command: {}\nError: {}",
295                    command_str, e
296                )));
297            }
298        }
299    }
300
301    async fn pull_image(&self, image: &str) -> Result<(), ContainerError> {
302        wrkflw_logging::info(&format!("🔄 Emulation: Pretending to pull image {}", image));
303        Ok(())
304    }
305
306    async fn build_image(
307        &self,
308        dockerfile: &Path,
309        tag: &str,
310        _context_dir: &Path,
311    ) -> Result<(), ContainerError> {
312        wrkflw_logging::info(&format!(
313            "🔄 Emulation: Pretending to build image {} from {}",
314            tag,
315            dockerfile.display()
316        ));
317        Ok(())
318    }
319
320    async fn image_exists(&self, _tag: &str) -> Result<bool, ContainerError> {
321        Ok(false)
322    }
323
324    async fn prepare_language_environment(
325        &self,
326        language: &str,
327        version: Option<&str>,
328        _additional_packages: Option<Vec<String>>,
329    ) -> Result<String, ContainerError> {
330        // For emulation runtime, we'll use a simplified approach
331        // that doesn't require building custom images
332        let base_image = match language {
333            "python" => version.map_or("python:3.11-slim".to_string(), |v| format!("python:{}", v)),
334            "node" => version.map_or("node:20-slim".to_string(), |v| format!("node:{}", v)),
335            "java" => version.map_or("eclipse-temurin:17-jdk".to_string(), |v| {
336                format!("eclipse-temurin:{}", v)
337            }),
338            "go" => version.map_or("golang:1.21-slim".to_string(), |v| format!("golang:{}", v)),
339            "dotnet" => version.map_or("mcr.microsoft.com/dotnet/sdk:7.0".to_string(), |v| {
340                format!("mcr.microsoft.com/dotnet/sdk:{}", v)
341            }),
342            "rust" => version.map_or("rust:latest".to_string(), |v| format!("rust:{}", v)),
343            _ => {
344                return Err(ContainerError::ContainerStart(format!(
345                    "Unsupported language: {}",
346                    language
347                )))
348            }
349        };
350
351        // For emulation, we'll just return the base image
352        // The actual package installation will be handled during container execution
353        Ok(base_image)
354    }
355}
356
357#[allow(dead_code)]
358/// Create a gitignore matcher for the given directory
359fn create_gitignore_matcher(
360    dir: &Path,
361) -> Result<Option<ignore::gitignore::Gitignore>, std::io::Error> {
362    let mut builder = GitignoreBuilder::new(dir);
363
364    // Try to add .gitignore file if it exists
365    let gitignore_path = dir.join(".gitignore");
366    if gitignore_path.exists() {
367        builder.add(&gitignore_path);
368    }
369
370    // Add some common ignore patterns as fallback
371    if let Err(e) = builder.add_line(None, "target/") {
372        wrkflw_logging::warning(&format!("Failed to add default ignore pattern: {}", e));
373    }
374    if let Err(e) = builder.add_line(None, ".git/") {
375        wrkflw_logging::warning(&format!("Failed to add default ignore pattern: {}", e));
376    }
377
378    match builder.build() {
379        Ok(gitignore) => Ok(Some(gitignore)),
380        Err(e) => {
381            wrkflw_logging::warning(&format!("Failed to build gitignore matcher: {}", e));
382            Ok(None)
383        }
384    }
385}
386
387fn copy_directory_contents(source: &Path, dest: &Path) -> std::io::Result<()> {
388    copy_directory_contents_with_gitignore(source, dest, None)
389}
390
391fn copy_directory_contents_with_gitignore(
392    source: &Path,
393    dest: &Path,
394    gitignore: Option<&ignore::gitignore::Gitignore>,
395) -> std::io::Result<()> {
396    // Create the destination directory if it doesn't exist
397    fs::create_dir_all(dest)?;
398
399    // If no gitignore provided, try to create one for the root directory
400    let root_gitignore;
401    let gitignore = if gitignore.is_none() {
402        root_gitignore = create_gitignore_matcher(source)?;
403        root_gitignore.as_ref()
404    } else {
405        gitignore
406    };
407
408    // Iterate through all entries in the source directory
409    for entry in fs::read_dir(source)? {
410        let entry = entry?;
411        let path = entry.path();
412
413        // Check if the file should be ignored according to .gitignore
414        if let Some(gitignore) = gitignore {
415            let relative_path = path.strip_prefix(source).unwrap_or(&path);
416            match gitignore.matched(relative_path, path.is_dir()) {
417                Match::Ignore(_) => {
418                    wrkflw_logging::debug(&format!("Skipping ignored file/directory: {path:?}"));
419                    continue;
420                }
421                Match::Whitelist(_) | Match::None => {
422                    // File is not ignored or explicitly whitelisted
423                }
424            }
425        }
426
427        let file_name = match path.file_name() {
428            Some(name) => name,
429            None => {
430                eprintln!("Warning: Could not get file name from path: {:?}", path);
431                continue; // Skip this file
432            }
433        };
434        let dest_path = dest.join(file_name);
435
436        // Skip most hidden files but allow important ones
437        let file_name_str = file_name.to_string_lossy();
438        if file_name_str.starts_with(".")
439            && file_name_str != ".gitignore"
440            && file_name_str != ".github"
441            && !file_name_str.starts_with(".env")
442        {
443            continue;
444        }
445
446        if path.is_dir() {
447            // Recursively copy subdirectories with the same gitignore
448            copy_directory_contents_with_gitignore(&path, &dest_path, gitignore)?;
449        } else {
450            // Copy files
451            fs::copy(&path, &dest_path)?;
452        }
453    }
454
455    Ok(())
456}
457
458pub async fn handle_special_action(action: &str) -> Result<(), ContainerError> {
459    // Extract owner, repo and version from the action
460    let action_parts: Vec<&str> = action.split('@').collect();
461    let action_name = action_parts[0];
462    let action_version = if action_parts.len() > 1 {
463        action_parts[1]
464    } else {
465        "latest"
466    };
467
468    wrkflw_logging::info(&format!(
469        "🔄 Processing action: {} @ {}",
470        action_name, action_version
471    ));
472
473    // Handle specific known actions with special requirements
474    if action.starts_with("cachix/install-nix-action") {
475        wrkflw_logging::info("🔄 Emulating cachix/install-nix-action");
476
477        // In emulation mode, check if nix is installed
478        let nix_installed = Command::new("which")
479            .arg("nix")
480            .output()
481            .map(|output| output.status.success())
482            .unwrap_or(false);
483
484        if !nix_installed {
485            wrkflw_logging::info("🔄 Emulation: Nix is required but not installed.");
486            wrkflw_logging::info(
487                "🔄 To use this workflow, please install Nix: https://nixos.org/download.html",
488            );
489            wrkflw_logging::info("🔄 Continuing emulation, but nix commands will fail.");
490        } else {
491            wrkflw_logging::info("🔄 Emulation: Using system-installed Nix");
492        }
493    } else if action.starts_with("actions-rs/cargo@") {
494        // For actions-rs/cargo action, ensure Rust is available
495        wrkflw_logging::info(&format!("🔄 Detected Rust cargo action: {}", action));
496
497        // Verify Rust/cargo is installed
498        check_command_available("cargo", "Rust/Cargo", "https://rustup.rs/");
499    } else if action.starts_with("actions-rs/toolchain@") {
500        // For actions-rs/toolchain action, check for Rust installation
501        wrkflw_logging::info(&format!("🔄 Detected Rust toolchain action: {}", action));
502
503        check_command_available("rustc", "Rust", "https://rustup.rs/");
504    } else if action.starts_with("actions-rs/fmt@") {
505        // For actions-rs/fmt action, check if rustfmt is available
506        wrkflw_logging::info(&format!("🔄 Detected Rust formatter action: {}", action));
507
508        check_command_available("rustfmt", "rustfmt", "rustup component add rustfmt");
509    } else if action.starts_with("dtolnay/rust-toolchain@") {
510        // For dtolnay/rust-toolchain action, check for Rust installation
511        wrkflw_logging::info(&format!(
512            "🔄 Detected dtolnay Rust toolchain action: {}",
513            action
514        ));
515
516        check_command_available("rustc", "Rust", "https://rustup.rs/");
517        check_command_available("cargo", "Cargo", "https://rustup.rs/");
518    } else if action.starts_with("actions/setup-node@") {
519        // Node.js setup action
520        wrkflw_logging::info(&format!("🔄 Detected Node.js setup action: {}", action));
521
522        check_command_available("node", "Node.js", "https://nodejs.org/");
523    } else if action.starts_with("actions/setup-python@") {
524        // Python setup action
525        wrkflw_logging::info(&format!("🔄 Detected Python setup action: {}", action));
526
527        check_command_available("python", "Python", "https://www.python.org/downloads/");
528    } else if action.starts_with("actions/setup-java@") {
529        // Java setup action
530        wrkflw_logging::info(&format!("🔄 Detected Java setup action: {}", action));
531
532        check_command_available("java", "Java", "https://adoptium.net/");
533    } else if action.starts_with("actions/checkout@") {
534        // Git checkout action - this is handled implicitly by our workspace setup
535        wrkflw_logging::info("🔄 Detected checkout action - workspace files are already prepared");
536    } else if action.starts_with("actions/cache@") {
537        // Cache action — local cache emulation is handled by the executor
538        wrkflw_logging::info("🔄 Detected cache action - local cache emulation active");
539    } else {
540        // Generic action we don't have special handling for
541        wrkflw_logging::info(&format!(
542            "🔄 Action '{}' has no special handling in emulation mode",
543            action_name
544        ));
545    }
546
547    // Always return success - the actual command execution will happen in execute_step
548    Ok(())
549}
550
551// Helper function to check if a command is available on the system
552fn check_command_available(command: &str, name: &str, install_url: &str) {
553    let is_available = Command::new("which")
554        .arg(command)
555        .output()
556        .map(|output| output.status.success())
557        .unwrap_or(false);
558
559    if !is_available {
560        wrkflw_logging::warning(&format!("{} is required but not found on the system", name));
561        wrkflw_logging::info(&format!(
562            "To use this action, please install {}: {}",
563            name, install_url
564        ));
565        wrkflw_logging::info(&format!(
566            "Continuing emulation, but {} commands will fail",
567            name
568        ));
569    } else {
570        // Try to get version information
571        if let Ok(output) = Command::new(command).arg("--version").output() {
572            if output.status.success() {
573                let version = String::from_utf8_lossy(&output.stdout);
574                wrkflw_logging::info(&format!("🔄 Using system {}: {}", name, version.trim()));
575            }
576        }
577    }
578}
579
580// Add a function to help set up appropriate environment variables for different actions
581#[allow(dead_code)]
582fn add_action_env_vars(
583    env_map: &mut HashMap<String, String>,
584    action: &str,
585    with_params: &Option<HashMap<String, String>>,
586) {
587    if let Some(params) = with_params {
588        if action.starts_with("actions/setup-node") {
589            // For Node.js actions, add NODE_VERSION
590            if let Some(version) = params.get("node-version") {
591                env_map.insert("NODE_VERSION".to_string(), version.clone());
592            }
593
594            // Set NPM/Yarn paths if needed
595            env_map.insert(
596                "NPM_CONFIG_PREFIX".to_string(),
597                "/tmp/.npm-global".to_string(),
598            );
599            env_map.insert("PATH".to_string(), "/tmp/.npm-global/bin:$PATH".to_string());
600        } else if action.starts_with("actions/setup-python") {
601            // For Python actions, add PYTHON_VERSION
602            if let Some(version) = params.get("python-version") {
603                env_map.insert("PYTHON_VERSION".to_string(), version.clone());
604            }
605
606            // Set pip cache directories
607            env_map.insert("PIP_CACHE_DIR".to_string(), "/tmp/.pip-cache".to_string());
608        } else if action.starts_with("actions/setup-java") {
609            // For Java actions, add JAVA_VERSION
610            if let Some(version) = params.get("java-version") {
611                env_map.insert("JAVA_VERSION".to_string(), version.clone());
612            }
613
614            // Set JAVA_HOME
615            env_map.insert(
616                "JAVA_HOME".to_string(),
617                "/usr/lib/jvm/default-java".to_string(),
618            );
619        }
620    }
621}
622
623// Function to clean up emulation resources
624pub async fn cleanup_resources() {
625    cleanup_processes().await;
626    cleanup_workspaces().await;
627}
628
629// Clean up any tracked processes
630async fn cleanup_processes() {
631    let processes_to_cleanup = {
632        if let Ok(processes) = EMULATION_PROCESSES.lock() {
633            processes.clone()
634        } else {
635            vec![]
636        }
637    };
638
639    for pid in processes_to_cleanup {
640        wrkflw_logging::info(&format!("Cleaning up emulated process: {}", pid));
641
642        #[cfg(unix)]
643        {
644            // On Unix-like systems, use kill command
645            let _ = Command::new("kill")
646                .arg("-TERM")
647                .arg(pid.to_string())
648                .output();
649        }
650
651        #[cfg(windows)]
652        {
653            // On Windows, use taskkill
654            let _ = Command::new("taskkill")
655                .arg("/F")
656                .arg("/PID")
657                .arg(pid.to_string())
658                .output();
659        }
660
661        // Remove from tracking
662        if let Ok(mut processes) = EMULATION_PROCESSES.lock() {
663            processes.retain(|p| *p != pid);
664        }
665    }
666}
667
668// Clean up any tracked workspaces
669async fn cleanup_workspaces() {
670    let workspaces_to_cleanup = {
671        if let Ok(workspaces) = EMULATION_WORKSPACES.lock() {
672            workspaces.clone()
673        } else {
674            vec![]
675        }
676    };
677
678    for workspace_path in workspaces_to_cleanup {
679        wrkflw_logging::info(&format!(
680            "Cleaning up emulation workspace: {}",
681            workspace_path.display()
682        ));
683
684        // Only attempt to remove if it exists
685        if workspace_path.exists() {
686            match fs::remove_dir_all(&workspace_path) {
687                Ok(_) => wrkflw_logging::info("Successfully removed workspace directory"),
688                Err(e) => wrkflw_logging::error(&format!("Error removing workspace: {}", e)),
689            }
690        }
691
692        // Remove from tracking
693        if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
694            workspaces.retain(|w| *w != workspace_path);
695        }
696    }
697}
698
699// Add process to tracking
700#[allow(dead_code)]
701pub fn track_process(pid: u32) {
702    if let Ok(mut processes) = EMULATION_PROCESSES.lock() {
703        processes.push(pid);
704    }
705}
706
707// Remove process from tracking
708#[allow(dead_code)]
709pub fn untrack_process(pid: u32) {
710    if let Ok(mut processes) = EMULATION_PROCESSES.lock() {
711        processes.retain(|p| *p != pid);
712    }
713}
714
715// Track additional workspace paths if needed
716#[allow(dead_code)]
717pub fn track_workspace(path: &Path) {
718    if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
719        workspaces.push(path.to_path_buf());
720    }
721}
722
723// Remove workspace from tracking
724#[allow(dead_code)]
725pub fn untrack_workspace(path: &Path) {
726    if let Ok(mut workspaces) = EMULATION_WORKSPACES.lock() {
727        workspaces.retain(|w| *w != path);
728    }
729}
730
731// Public accessor functions for testing
732#[cfg(test)]
733pub fn get_tracked_workspaces() -> Vec<PathBuf> {
734    if let Ok(workspaces) = EMULATION_WORKSPACES.lock() {
735        workspaces.clone()
736    } else {
737        vec![]
738    }
739}
740
741#[cfg(test)]
742pub fn get_tracked_processes() -> Vec<u32> {
743    if let Ok(processes) = EMULATION_PROCESSES.lock() {
744        processes.clone()
745    } else {
746        vec![]
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    #[tokio::test]
755    async fn test_nonzero_exit_code_returns_ok() {
756        let runtime = EmulationRuntime::new();
757        let result = runtime
758            .run_container(
759                "alpine:latest",
760                &["exit", "42"],
761                &[],
762                Path::new("."),
763                &[(Path::new("."), Path::new("/github/workspace"))],
764                None,
765            )
766            .await;
767
768        let output = result.expect("non-zero exit should return Ok, not Err");
769        assert_eq!(output.exit_code, 42);
770    }
771
772    #[tokio::test]
773    async fn test_empty_cmd_returns_failure_in_emulation() {
774        let runtime = EmulationRuntime::new();
775        let result = runtime
776            .run_container(
777                "some-action:latest",
778                &[],
779                &[],
780                Path::new("."),
781                &[(Path::new("."), Path::new("/github/workspace"))],
782                None,
783            )
784            .await;
785
786        let output = result.expect("empty cmd should return Ok with exit_code 1, not Err");
787        assert_eq!(output.exit_code, 1, "empty cmd should signal failure");
788        assert!(
789            output.stderr.contains("skipped Docker action"),
790            "stderr should explain the action was not executed"
791        );
792        assert!(output.stdout.is_empty(), "stdout should be empty");
793    }
794
795    /// Regression for #88: if the caller passes a container-visible working
796    /// directory that no volume covers, emulation must hard-error instead of
797    /// silently rerouting to `GITHUB_WORKSPACE`. Symmetry test with the
798    /// matching `secure_emulation_errors_when_volumes_dont_cover_working_dir`.
799    #[tokio::test]
800    async fn emulation_errors_when_volumes_dont_cover_working_dir() {
801        let runtime = EmulationRuntime::new();
802
803        // /github/workspace doesn't exist on host and no volume covers it.
804        let result = runtime
805            .run_container(
806                "alpine:latest",
807                &["echo", "nope"],
808                &[],
809                Path::new("/github/workspace"),
810                &[],
811                None,
812            )
813            .await;
814
815        let err = result.expect_err("should error when no volume covers working_dir");
816        let msg = err.to_string();
817        assert!(
818            msg.contains("not covered by any volume mount"),
819            "unexpected error: {}",
820            msg
821        );
822        assert!(
823            msg.contains("emulation:"),
824            "error should carry runtime label, got: {}",
825            msg
826        );
827    }
828}