Compare commits

...

23 Commits

Author SHA1 Message Date
bahdotsh
7ac18f3715 Release 0.7.2
wrkflw-runtime@0.7.2
wrkflw-utils@0.7.2

Generated by cargo-workspaces
2025-08-28 08:13:02 +05:30
Gokul
1f3fee7373 Merge pull request #56 from bahdotsh/fix/windows-compatibility
fix(utils): add Windows support to fd module
2025-08-28 07:48:37 +05:30
bahdotsh
f49ccd70d9 fix(runtime): remove unnecessary borrow in Windows taskkill command
- Fix clippy needless_borrows_for_generic_args warning
- Change &pid.to_string() to pid.to_string() for taskkill /PID argument
- Ensure clippy passes with -D warnings on Windows builds
2025-08-27 15:45:58 +05:30
bahdotsh
5161882989 fix(utils): remove unused imports to fix Windows clippy warnings
- Remove unused io::self import from common scope
- Remove unused std::fs::OpenOptions and std::io::Write from windows_impl
- Add std::io import to unix_impl to fix io::Error references
- Ensure clippy passes with -D warnings on all platforms
2025-08-27 15:39:52 +05:30
bahdotsh
5e9658c885 ci: add Windows to build matrix and integration tests
- Add windows-latest to OS matrix with x86_64-pc-windows-msvc target
- Add dedicated Windows integration test job
- Verify Windows executable functionality
- Ensure cross-platform compatibility testing

This ensures Windows build issues are caught early in CI/CD pipeline.
2025-08-27 15:37:15 +05:30
bahdotsh
aa9da33b30 docs(utils): update README to document cross-platform fd behavior
- Document Unix vs Windows fd redirection limitations
- Update example to reflect platform-specific behavior
- Clarify that stderr suppression is Unix-only
2025-08-27 15:36:51 +05:30
bahdotsh
dff3697052 fix(utils): add Windows support to fd module
- Add conditional compilation for Unix/Windows platforms
- Move nix dependency to Unix-only target dependency
- Implement Windows-compatible fd redirection API
- Preserve full functionality on Unix systems
- Add comprehensive documentation for platform differences

Resolves Windows build errors:
- E0433: could not find 'sys' in 'nix'
- E0432: unresolved import 'nix::fcntl'
- E0433: could not find 'unix' in 'os'
- E0432: unresolved import 'nix::unistd'

Closes #43
2025-08-27 15:36:23 +05:30
bahdotsh
5051f71b8b Release 0.7.1
wrkflw@0.7.1
wrkflw-evaluator@0.7.1
wrkflw-executor@0.7.1
wrkflw-parser@0.7.1
wrkflw-runtime@0.7.1
wrkflw-secrets@0.7.1
wrkflw-ui@0.7.1

Generated by cargo-workspaces
2025-08-22 13:13:53 +05:30
Gokul
64b980d254 Merge pull request #55 from bahdotsh/fix/ui_logs_for_copy
fix: fix the ui logs from displaying copy logs noise
2025-08-22 12:23:08 +05:30
bahdotsh
2d809388a2 fix: fix the ui logs from displaying copy logs noise 2025-08-22 12:19:16 +05:30
Gokul
03af6cb7c1 Merge pull request #54 from azzamsa/use-rust-tls
build: use `rustls` instead `openssl`
2025-08-22 12:07:37 +05:30
Azzam S.A
ae52779e11 build: use rustls instead openssl
Simplifies local and container builds by removing OpenSSL deps.
2025-08-22 13:25:50 +07:00
Gokul
fe7be3e1ae Merge pull request #53 from bahdotsh/fix/remove-name-field-requirement
fix(evaluator): remove incorrect name field requirement validation
2025-08-21 23:44:17 +05:30
bahdotsh
30f405ccb9 fix(evaluator): remove incorrect name field requirement validation
The 'name' field is optional per GitHub Actions specification. When omitted,
GitHub displays the workflow file path relative to the repository root.

This change removes the validation logic that incorrectly enforced the name
field as required, aligning the validator with the official JSON schema
which only requires 'on' and 'jobs' fields at the root level.

Fixes #50
2025-08-21 22:45:36 +05:30
Gokul
1d56d86ba5 Merge pull request #52 from bahdotsh/fix/ubuntu-container-image-selection
fix: ubuntu container image selection
2025-08-21 22:37:22 +05:30
bahdotsh
f1ca411281 feat(runtime): add dtolnay/rust-toolchain action support
- Add emulation support for dtolnay/rust-toolchain@ actions
- Include Rust and Cargo availability checks for dtolnay toolchain action
- Improve action detection logging for dtolnay Rust toolchain

Related to #49
2025-08-21 22:28:12 +05:30
bahdotsh
797e31e3d3 fix(executor): correct Ubuntu runner image mapping
- Fix get_runner_image() to map ubuntu-latest to ubuntu:latest instead of node:16-buster-slim
- Update ubuntu-22.04, ubuntu-20.04, ubuntu-18.04 to use proper Ubuntu base images
- Fix step execution to use action-specific images instead of always using runner image
- Update Node.js fallback images from node:16-buster-slim to node:20-slim

Fixes #49
2025-08-21 22:27:56 +05:30
Gokul
4e66f65de7 Merge pull request #51 from bahdotsh/feature/gitignore-support
feat: Add .gitignore support for file copying
2025-08-21 15:32:31 +05:30
bahdotsh
335886ac70 chore: Update Cargo.lock with ignore dependency 2025-08-21 15:27:18 +05:30
bahdotsh
8005cbb7ee feat: Add .gitignore support for file copying
- Add ignore crate dependency to executor and runtime crates
- Implement gitignore-aware file copying in engine.rs and emulation.rs
- Support for .gitignore patterns, whitelist rules, and default ignore patterns
- Maintain backward compatibility with projects without .gitignore files
- Add proper error handling and debug logging for ignored files

This ensures that files marked in .gitignore are not copied to containers
or emulation workspaces, improving performance and security.
2025-08-21 15:27:00 +05:30
Gokul
5b216f59e6 Merge pull request #45 from anti-social/fix-double-repo-copying
Do not copy a repository before executing a job
2025-08-21 15:17:26 +05:30
Alexander Koval
7a17d26589 Do not copy a repository before executing a job 2025-08-17 01:41:17 +03:00
Gokul
6efad9ce96 Merge pull request #42 from bahdotsh/feature/secrets-management
feat: implement secrets management with multi-provider support, masking, and security features
2025-08-14 23:41:47 +05:30
12 changed files with 844 additions and 592 deletions

View File

@@ -3,7 +3,7 @@ name: Build
on:
workflow_dispatch:
push:
branches: [ main ]
branches: [main]
pull_request:
jobs:
@@ -12,12 +12,14 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: macos-latest
target: x86_64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- name: Checkout code
@@ -31,27 +33,27 @@ jobs:
target: ${{ matrix.target }}
override: true
components: clippy, rustfmt
- name: Check formatting
uses: actions-rs/cargo@v1
with:
command: fmt
args: -- --check
- name: Run clippy
uses: actions-rs/cargo@v1
with:
command: clippy
args: -- -D warnings
- name: Build
uses: actions-rs/cargo@v1
with:
command: build
args: --target ${{ matrix.target }}
- name: Run tests
uses: actions-rs/cargo@v1
with:
command: test
args: --target ${{ matrix.target }}
args: --target ${{ matrix.target }}

975
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ members = [
resolver = "2"
[workspace.package]
version = "0.7.0"
version = "0.7.2"
edition = "2021"
description = "A GitHub Actions workflow validator and executor"
documentation = "https://github.com/bahdotsh/wrkflw"
@@ -44,7 +44,7 @@ rayon = "1.7.0"
num_cpus = "1.16.0"
regex = "1.10"
lazy_static = "1.4"
reqwest = { version = "0.11", features = ["json"] }
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json"] }
libc = "0.2"
nix = { version = "0.27.1", features = ["fs"] }
urlencoding = "2.1.3"

View File

@@ -21,26 +21,9 @@ pub fn evaluate_workflow_file(path: &Path, verbose: bool) -> Result<ValidationRe
return Ok(result);
}
// Check if name exists
if workflow.get("name").is_none() {
// Check if this might be a reusable workflow caller before reporting missing name
let has_reusable_workflow_job = if let Some(Value::Mapping(jobs)) = workflow.get("jobs") {
jobs.values().any(|job| {
if let Some(job_config) = job.as_mapping() {
job_config.contains_key(Value::String("uses".to_string()))
} else {
false
}
})
} else {
false
};
// Only report missing name if it's not a workflow with reusable workflow jobs
if !has_reusable_workflow_job {
result.add_issue("Workflow is missing a name".to_string());
}
}
// Note: The 'name' field is optional per GitHub Actions specification.
// When omitted, GitHub displays the workflow file path relative to the repository root.
// We do not validate name presence as it's not required by the schema.
// Check if jobs section exists
match workflow.get("jobs") {

View File

@@ -27,6 +27,7 @@ chrono.workspace = true
dirs.workspace = true
futures.workspace = true
futures-util.workspace = true
ignore = "0.4"
lazy_static.workspace = true
num_cpus.workspace = true
once_cell.workspace = true

View File

@@ -9,6 +9,8 @@ use std::path::Path;
use std::process::Command;
use thiserror::Error;
use ignore::{gitignore::GitignoreBuilder, Match};
use crate::dependency;
use crate::docker;
use crate::environment;
@@ -568,7 +570,7 @@ async fn prepare_action(
} else {
// It's a JavaScript or composite action
// For simplicity, we'll use node to run it (this would need more work for full support)
return Ok("node:16-buster-slim".to_string());
return Ok("node:20-slim".to_string());
}
}
@@ -626,7 +628,7 @@ fn determine_action_image(repository: &str) -> String {
{
"catthehacker/ubuntu:act-latest".to_string() // Use act runner image for core actions
} else {
"node:16-buster-slim".to_string() // Default for other actions
"node:20-slim".to_string() // Default for other actions
}
}
}
@@ -804,13 +806,6 @@ async fn execute_job(ctx: JobExecutionContext<'_>) -> Result<JobResult, Executio
ExecutionError::Execution(format!("Failed to get current directory: {}", e))
})?;
// Copy project files to the job workspace directory
wrkflw_logging::info(&format!(
"Copying project files to job workspace: {}",
job_dir.path().display()
));
copy_directory_contents(&current_dir, job_dir.path())?;
wrkflw_logging::info(&format!("Executing job: {}", ctx.job_name));
let mut job_success = true;
@@ -1010,13 +1005,6 @@ async fn execute_matrix_job(
ExecutionError::Execution(format!("Failed to get current directory: {}", e))
})?;
// Copy project files to the job workspace directory
wrkflw_logging::info(&format!(
"Copying project files to job workspace: {}",
job_dir.path().display()
));
copy_directory_contents(&current_dir, job_dir.path())?;
let job_success = if job_template.steps.is_empty() {
wrkflw_logging::warning(&format!("Job '{}' has no steps", matrix_job_name));
true
@@ -1172,28 +1160,13 @@ async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, Execu
detailed_output
.push_str(&format!(" - Destination: {}\n", ctx.working_dir.display()));
// Add list of top-level files/directories that were copied (limit to 10)
detailed_output.push_str("\nTop-level files/directories copied:\n");
// Add a summary count instead of listing all files
if let Ok(entries) = std::fs::read_dir(&current_dir) {
for (i, entry) in entries.take(10).enumerate() {
if let Ok(entry) = entry {
let file_type = if entry.path().is_dir() {
"directory"
} else {
"file"
};
detailed_output.push_str(&format!(
" - {} ({})\n",
entry.file_name().to_string_lossy(),
file_type
));
}
if i >= 9 {
detailed_output.push_str(" - ... (more items not shown)\n");
break;
}
}
let entry_count = entries.count();
detailed_output.push_str(&format!(
"\nCopied {} top-level items to workspace\n",
entry_count
));
}
detailed_output
@@ -1236,13 +1209,15 @@ async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, Execu
let mut owned_strings: Vec<String> = Vec::new(); // Keep strings alive until after we use cmd
// Special handling for Rust actions
if uses.starts_with("actions-rs/") {
if uses.starts_with("actions-rs/") || uses.starts_with("dtolnay/rust-toolchain") {
wrkflw_logging::info(
"🔄 Detected Rust action - using system Rust installation",
);
// For toolchain action, verify Rust is installed
if uses.starts_with("actions-rs/toolchain@") {
if uses.starts_with("actions-rs/toolchain@")
|| uses.starts_with("dtolnay/rust-toolchain@")
{
let rustc_version = Command::new("rustc")
.arg("--version")
.output()
@@ -1568,7 +1543,7 @@ async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, Execu
let output = ctx
.runtime
.run_container(
ctx.runner_image,
&image,
&cmd.to_vec(),
&env_vars,
container_workspace,
@@ -1799,7 +1774,60 @@ async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, Execu
Ok(step_result)
}
/// Create a gitignore matcher for the given directory
fn create_gitignore_matcher(
dir: &Path,
) -> Result<Option<ignore::gitignore::Gitignore>, ExecutionError> {
let mut builder = GitignoreBuilder::new(dir);
// Try to add .gitignore file if it exists
let gitignore_path = dir.join(".gitignore");
if gitignore_path.exists() {
builder.add(&gitignore_path);
}
// Add some common ignore patterns as fallback
builder.add_line(None, "target/").map_err(|e| {
ExecutionError::Execution(format!("Failed to add default ignore pattern: {}", e))
})?;
builder.add_line(None, ".git/").map_err(|e| {
ExecutionError::Execution(format!("Failed to add default ignore pattern: {}", e))
})?;
match builder.build() {
Ok(gitignore) => Ok(Some(gitignore)),
Err(e) => {
wrkflw_logging::warning(&format!("Failed to build gitignore matcher: {}", e));
Ok(None)
}
}
}
fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError> {
copy_directory_contents_with_gitignore(from, to, None)
}
fn copy_directory_contents_with_gitignore(
from: &Path,
to: &Path,
gitignore: Option<&ignore::gitignore::Gitignore>,
) -> Result<(), ExecutionError> {
// If no gitignore provided, try to create one for the root directory
let root_gitignore;
let gitignore = if gitignore.is_none() {
root_gitignore = create_gitignore_matcher(from)?;
root_gitignore.as_ref()
} else {
gitignore
};
// Log summary of the copy operation
wrkflw_logging::debug(&format!(
"Copying directory contents from {} to {}",
from.display(),
to.display()
));
for entry in std::fs::read_dir(from)
.map_err(|e| ExecutionError::Execution(format!("Failed to read directory: {}", e)))?
{
@@ -1807,7 +1835,23 @@ fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError>
entry.map_err(|e| ExecutionError::Execution(format!("Failed to read entry: {}", e)))?;
let path = entry.path();
// Skip hidden files/dirs and target directory for efficiency
// Check if the file should be ignored according to .gitignore
if let Some(gitignore) = gitignore {
let relative_path = path.strip_prefix(from).unwrap_or(&path);
match gitignore.matched(relative_path, path.is_dir()) {
Match::Ignore(_) => {
wrkflw_logging::debug(&format!("Skipping ignored file/directory: {path:?}"));
continue;
}
Match::Whitelist(_) | Match::None => {
// File is not ignored or explicitly whitelisted
}
}
}
// Log individual files only in trace mode (removed verbose per-file logging)
// Additional basic filtering for hidden files (but allow .gitignore and .github)
let file_name = match path.file_name() {
Some(name) => name.to_string_lossy(),
None => {
@@ -1817,7 +1861,13 @@ fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError>
)));
}
};
if file_name.starts_with(".") || file_name == "target" {
// Skip most hidden files but allow important ones
if file_name.starts_with(".")
&& file_name != ".gitignore"
&& file_name != ".github"
&& !file_name.starts_with(".env")
{
continue;
}
@@ -1835,8 +1885,8 @@ fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError>
std::fs::create_dir_all(&dest_path)
.map_err(|e| ExecutionError::Execution(format!("Failed to create dir: {}", e)))?;
// Recursively copy subdirectories
copy_directory_contents(&path, &dest_path)?;
// Recursively copy subdirectories with the same gitignore
copy_directory_contents_with_gitignore(&path, &dest_path, gitignore)?;
} else {
std::fs::copy(&path, &dest_path)
.map_err(|e| ExecutionError::Execution(format!("Failed to copy file: {}", e)))?;
@@ -1849,11 +1899,11 @@ fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError>
fn get_runner_image(runs_on: &str) -> String {
// Map GitHub runners to Docker images
match runs_on.trim() {
// ubuntu runners - micro images (minimal size)
"ubuntu-latest" => "node:16-buster-slim",
"ubuntu-22.04" => "node:16-bullseye-slim",
"ubuntu-20.04" => "node:16-buster-slim",
"ubuntu-18.04" => "node:16-buster-slim",
// ubuntu runners - using Ubuntu base images for better compatibility
"ubuntu-latest" => "ubuntu:latest",
"ubuntu-22.04" => "ubuntu:22.04",
"ubuntu-20.04" => "ubuntu:20.04",
"ubuntu-18.04" => "ubuntu:18.04",
// ubuntu runners - medium images (with more tools)
"ubuntu-latest-medium" => "catthehacker/ubuntu:act-latest",

View File

@@ -23,6 +23,7 @@ serde_yaml.workspace = true
tempfile = "3.9"
tokio.workspace = true
futures = "0.3"
ignore = "0.4"
wrkflw-utils = { path = "../utils", version = "0.7.0" }
which = "4.4"
regex = "1.10"

View File

@@ -10,6 +10,8 @@ use tempfile::TempDir;
use which;
use wrkflw_logging;
use ignore::{gitignore::GitignoreBuilder, Match};
// Global collection of resources to clean up
static EMULATION_WORKSPACES: Lazy<Mutex<Vec<PathBuf>>> = Lazy::new(|| Mutex::new(Vec::new()));
static EMULATION_PROCESSES: Lazy<Mutex<Vec<u32>>> = Lazy::new(|| Mutex::new(Vec::new()));
@@ -490,14 +492,75 @@ impl ContainerRuntime for EmulationRuntime {
}
#[allow(dead_code)]
/// Create a gitignore matcher for the given directory
fn create_gitignore_matcher(
dir: &Path,
) -> Result<Option<ignore::gitignore::Gitignore>, std::io::Error> {
let mut builder = GitignoreBuilder::new(dir);
// Try to add .gitignore file if it exists
let gitignore_path = dir.join(".gitignore");
if gitignore_path.exists() {
builder.add(&gitignore_path);
}
// Add some common ignore patterns as fallback
if let Err(e) = builder.add_line(None, "target/") {
wrkflw_logging::warning(&format!("Failed to add default ignore pattern: {}", e));
}
if let Err(e) = builder.add_line(None, ".git/") {
wrkflw_logging::warning(&format!("Failed to add default ignore pattern: {}", e));
}
match builder.build() {
Ok(gitignore) => Ok(Some(gitignore)),
Err(e) => {
wrkflw_logging::warning(&format!("Failed to build gitignore matcher: {}", e));
Ok(None)
}
}
}
fn copy_directory_contents(source: &Path, dest: &Path) -> std::io::Result<()> {
copy_directory_contents_with_gitignore(source, dest, None)
}
fn copy_directory_contents_with_gitignore(
source: &Path,
dest: &Path,
gitignore: Option<&ignore::gitignore::Gitignore>,
) -> std::io::Result<()> {
// Create the destination directory if it doesn't exist
fs::create_dir_all(dest)?;
// If no gitignore provided, try to create one for the root directory
let root_gitignore;
let gitignore = if gitignore.is_none() {
root_gitignore = create_gitignore_matcher(source)?;
root_gitignore.as_ref()
} else {
gitignore
};
// Iterate through all entries in the source directory
for entry in fs::read_dir(source)? {
let entry = entry?;
let path = entry.path();
// Check if the file should be ignored according to .gitignore
if let Some(gitignore) = gitignore {
let relative_path = path.strip_prefix(source).unwrap_or(&path);
match gitignore.matched(relative_path, path.is_dir()) {
Match::Ignore(_) => {
wrkflw_logging::debug(&format!("Skipping ignored file/directory: {path:?}"));
continue;
}
Match::Whitelist(_) | Match::None => {
// File is not ignored or explicitly whitelisted
}
}
}
let file_name = match path.file_name() {
Some(name) => name,
None => {
@@ -507,23 +570,19 @@ fn copy_directory_contents(source: &Path, dest: &Path) -> std::io::Result<()> {
};
let dest_path = dest.join(file_name);
// Skip hidden files (except .gitignore and .github might be useful)
// Skip most hidden files but allow important ones
let file_name_str = file_name.to_string_lossy();
if file_name_str.starts_with(".")
&& file_name_str != ".gitignore"
&& file_name_str != ".github"
&& !file_name_str.starts_with(".env")
{
continue;
}
// Skip target directory for Rust projects
if file_name_str == "target" {
continue;
}
if path.is_dir() {
// Recursively copy subdirectories
copy_directory_contents(&path, &dest_path)?;
// Recursively copy subdirectories with the same gitignore
copy_directory_contents_with_gitignore(&path, &dest_path, gitignore)?;
} else {
// Copy files
fs::copy(&path, &dest_path)?;
@@ -584,6 +643,15 @@ pub async fn handle_special_action(action: &str) -> Result<(), ContainerError> {
wrkflw_logging::info(&format!("🔄 Detected Rust formatter action: {}", action));
check_command_available("rustfmt", "rustfmt", "rustup component add rustfmt");
} else if action.starts_with("dtolnay/rust-toolchain@") {
// For dtolnay/rust-toolchain action, check for Rust installation
wrkflw_logging::info(&format!(
"🔄 Detected dtolnay Rust toolchain action: {}",
action
));
check_command_available("rustc", "Rust", "https://rustup.rs/");
check_command_available("cargo", "Cargo", "https://rustup.rs/");
} else if action.starts_with("actions/setup-node@") {
// Node.js setup action
wrkflw_logging::info(&format!("🔄 Detected Node.js setup action: {}", action));
@@ -725,7 +793,7 @@ async fn cleanup_processes() {
let _ = Command::new("taskkill")
.arg("/F")
.arg("/PID")
.arg(&pid.to_string())
.arg(pid.to_string())
.output();
}

View File

@@ -1,6 +1,6 @@
[package]
name = "wrkflw-secrets"
version = "0.7.0"
version = "0.7.1"
edition = "2021"
authors = ["wrkflw contributors"]
description = "Secrets management for wrkflw workflow execution"

View File

@@ -17,4 +17,6 @@ wrkflw-models = { path = "../models", version = "0.7.0" }
# External dependencies
serde.workspace = true
serde_yaml.workspace = true
[target.'cfg(unix)'.dependencies]
nix.workspace = true

View File

@@ -3,7 +3,7 @@
Shared helpers used across crates.
- Workflow file detection (`.github/workflows/*.yml`, `.gitlab-ci.yml`)
- File-descriptor redirection utilities for silencing noisy subprocess output
- File-descriptor redirection utilities for silencing noisy subprocess output (Unix only; Windows support is limited)
### Example
@@ -14,7 +14,7 @@ use wrkflw_utils::{is_workflow_file, fd::with_stderr_to_null};
assert!(is_workflow_file(Path::new(".github/workflows/ci.yml")));
let value = with_stderr_to_null(|| {
eprintln!("this is hidden");
eprintln!("this is hidden on Unix, visible on Windows");
42
}).unwrap();
assert_eq!(value, 42);

View File

@@ -35,78 +35,145 @@ pub fn is_workflow_file(path: &Path) -> bool {
}
/// Module for safely handling file descriptor redirection
///
/// On Unix systems (Linux, macOS), this module provides true file descriptor
/// redirection by duplicating stderr and redirecting it to /dev/null.
///
/// On Windows systems, the redirection functionality is limited due to platform
/// differences in file descriptor handling. The functions will execute without
/// error but stderr may not be fully suppressed.
pub mod fd {
use nix::fcntl::{open, OFlag};
use nix::sys::stat::Mode;
use nix::unistd::{close, dup, dup2};
use std::io::{self, Result};
use std::os::unix::io::RawFd;
use std::path::Path;
/// Standard file descriptors
const STDERR_FILENO: RawFd = 2;
use std::io::Result;
/// Represents a redirected stderr that can be restored
pub struct RedirectedStderr {
original_fd: Option<RawFd>,
null_fd: Option<RawFd>,
#[cfg(unix)]
original_fd: Option<std::os::unix::io::RawFd>,
#[cfg(unix)]
null_fd: Option<std::os::unix::io::RawFd>,
#[cfg(windows)]
_phantom: std::marker::PhantomData<()>,
}
impl RedirectedStderr {
/// Creates a new RedirectedStderr that redirects stderr to /dev/null
pub fn to_null() -> Result<Self> {
// Duplicate the current stderr fd
let stderr_backup = match dup(STDERR_FILENO) {
Ok(fd) => fd,
Err(e) => return Err(io::Error::other(e)),
};
#[cfg(unix)]
mod unix_impl {
use super::*;
use nix::fcntl::{open, OFlag};
use nix::sys::stat::Mode;
use nix::unistd::{close, dup, dup2};
use std::io;
use std::os::unix::io::RawFd;
use std::path::Path;
// Open /dev/null
let null_fd = match open(Path::new("/dev/null"), OFlag::O_WRONLY, Mode::empty()) {
Ok(fd) => fd,
Err(e) => {
/// Standard file descriptors
const STDERR_FILENO: RawFd = 2;
impl RedirectedStderr {
/// Creates a new RedirectedStderr that redirects stderr to /dev/null
pub fn to_null() -> Result<Self> {
// Duplicate the current stderr fd
let stderr_backup = match dup(STDERR_FILENO) {
Ok(fd) => fd,
Err(e) => return Err(io::Error::other(e)),
};
// Open /dev/null
let null_fd = match open(Path::new("/dev/null"), OFlag::O_WRONLY, Mode::empty()) {
Ok(fd) => fd,
Err(e) => {
let _ = close(stderr_backup); // Clean up on error
return Err(io::Error::other(e));
}
};
// Redirect stderr to /dev/null
if let Err(e) = dup2(null_fd, STDERR_FILENO) {
let _ = close(stderr_backup); // Clean up on error
let _ = close(null_fd);
return Err(io::Error::other(e));
}
};
// Redirect stderr to /dev/null
if let Err(e) = dup2(null_fd, STDERR_FILENO) {
let _ = close(stderr_backup); // Clean up on error
let _ = close(null_fd);
return Err(io::Error::other(e));
Ok(RedirectedStderr {
original_fd: Some(stderr_backup),
null_fd: Some(null_fd),
})
}
Ok(RedirectedStderr {
original_fd: Some(stderr_backup),
null_fd: Some(null_fd),
})
}
}
impl Drop for RedirectedStderr {
/// Automatically restores stderr when the RedirectedStderr is dropped
fn drop(&mut self) {
if let Some(orig_fd) = self.original_fd.take() {
// Restore the original stderr
let _ = dup2(orig_fd, STDERR_FILENO);
let _ = close(orig_fd);
}
impl Drop for RedirectedStderr {
/// Automatically restores stderr when the RedirectedStderr is dropped
fn drop(&mut self) {
if let Some(orig_fd) = self.original_fd.take() {
// Restore the original stderr
let _ = dup2(orig_fd, STDERR_FILENO);
let _ = close(orig_fd);
}
// Close the null fd
if let Some(null_fd) = self.null_fd.take() {
let _ = close(null_fd);
// Close the null fd
if let Some(null_fd) = self.null_fd.take() {
let _ = close(null_fd);
}
}
}
}
/// Run a function with stderr redirected to /dev/null, then restore stderr
#[cfg(windows)]
mod windows_impl {
use super::*;
impl RedirectedStderr {
/// Creates a new RedirectedStderr that redirects stderr to NUL on Windows
pub fn to_null() -> Result<Self> {
// On Windows, we can't easily redirect stderr at the file descriptor level
// like we can on Unix systems. This is a simplified implementation that
// doesn't actually redirect but provides the same interface.
// The actual stderr suppression will need to be handled differently on Windows.
Ok(RedirectedStderr {
_phantom: std::marker::PhantomData,
})
}
}
impl Drop for RedirectedStderr {
/// No-op drop implementation for Windows
fn drop(&mut self) {
// Nothing to restore on Windows in this simplified implementation
}
}
}
/// Run a function with stderr redirected to /dev/null (Unix) or suppressed (Windows), then restore stderr
///
/// # Platform Support
/// - **Unix (Linux, macOS)**: Fully supported - stderr is redirected to /dev/null
/// - **Windows**: Limited support - function executes but stderr may be visible
///
/// # Example
/// ```
/// use wrkflw_utils::fd::with_stderr_to_null;
///
/// let result = with_stderr_to_null(|| {
/// eprintln!("This will be hidden on Unix");
/// 42
/// }).unwrap();
/// assert_eq!(result, 42);
/// ```
pub fn with_stderr_to_null<F, T>(f: F) -> Result<T>
where
F: FnOnce() -> T,
{
let _redirected = RedirectedStderr::to_null()?;
Ok(f())
#[cfg(unix)]
{
let _redirected = RedirectedStderr::to_null()?;
Ok(f())
}
#[cfg(windows)]
{
// On Windows, we can't easily redirect stderr at the FD level,
// so we just run the function without redirection.
// This means stderr won't be suppressed on Windows, but the function will work.
Ok(f())
}
}
}
@@ -116,15 +183,16 @@ mod tests {
#[test]
fn test_fd_redirection() {
// This test will write to stderr, which should be redirected
// This test will write to stderr, which should be redirected on Unix
// On Windows, it will just run normally without redirection
let result = fd::with_stderr_to_null(|| {
// This would normally appear in stderr
eprintln!("This should be redirected to /dev/null");
// This would normally appear in stderr (suppressed on Unix, visible on Windows)
eprintln!("This should be redirected to /dev/null on Unix");
// Return a test value to verify the function passes through the result
42
});
// The function should succeed and return our test value
// The function should succeed and return our test value on both platforms
assert!(result.is_ok());
assert_eq!(result.unwrap(), 42);
}