commit 161eb7d3879ce0e6963f4c924a3ed74846e0993c Author: Marek Novák Date: Wed Jul 1 02:04:43 2026 +0200 Initial commit: Nix WSL Wrapper project with DDD, Clean Code, and tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9aeeb1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +## Build outputs +**/bin/ +**/obj/ +**/dist/ + +## User-specific files +*.user +*.userosscache +*.sln.docstates + +## Visual Studio cache and settings +.vs/ +.vs* +[Dd]ebug/ +[Rr]elease/ + +## Test results +TestResults/ +*.trx + +## Rider / JetBrains settings +.idea/ + +## VS Code settings +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NixWslWrapper.slnx b/NixWslWrapper.slnx new file mode 100644 index 0000000..5917061 --- /dev/null +++ b/NixWslWrapper.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7be11bd --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Nix WSL Wrapper (nix.exe) + +A robust, transparent Windows executable wrapper designed to intercept native Windows commands and execute them seamlessly inside Nix Subsystem for Linux (WSL). This wrapper is particularly useful for allowing Windows-based MCP servers (like `utensils/mcp-nixos`), IDE integrations, and build tools to run `nix` commands natively on a Windows host without requiring full native Nix compile chains. + +## Key Features +* **Clean Architecture & SOLID**: Built in C# using Domain-Driven Design (DDD) to encapsulate domain validation (e.g. `WslTarget`, `NixArguments`). +* **Deadlock-Free I/O Streaming**: Spawns background threads to redirect standard input, standard output, and standard error without blocking host tools. +* **Premium CLI UI & Diagnostics**: Integrates `Spectre.Console` to render beautiful diagnostic dashboards, system statuses, and automated validation tests. +* **Highly Configuration-driven**: Environment variables allow configuring the WSL distribution name, username, and path to the Nix profile. + +--- + +## Architectural Layout +* **NixWslWrapper.Core**: The domain layer defining value objects (`WslTarget`, `NixArguments`, `ExecutionResult`) and interfaces (`ICommandExecutor`, `IStreamCopier`). +* **NixWslWrapper.Infrastructure**: Contains execution strategies (`WslCommandExecutor`), builder patterns (`ProcessBuilder`), and threaded stream piping (`ThreadedStreamCopier`). +* **NixWslWrapper.Cli**: Presentation layer resolving services via Dependency Injection (`Microsoft.Extensions.DependencyInjection`), handling command forwarding, and presenting styled dashboard visuals with `Spectre.Console`. +* **NixWslWrapper.Tests**: The testing suite compiling xUnit tests and mock assertions. + +--- + +## Getting Started + +### Prerequisites +1. **WSL2** must be active. +2. A Linux distribution installed in WSL (e.g. `Debian` or `Ubuntu`). +3. **Nix Package Manager** installed inside the WSL distribution. + +### Build and Publish +Compile the project to a single self-contained executable for Windows: +```powershell +dotnet publish src/NixWslWrapper.Cli/NixWslWrapper.Cli.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o ./dist +``` +This will generate `nix.exe` inside the `./dist` folder. Place this executable in your Windows `%PATH%` (e.g., in a WinGet Links directory or your IDE binary directory). + +--- + +## Configuration + +Configure the wrapper using the following Windows environment variables: + +| Environment Variable | Description | Default Value | +| :--- | :--- | :--- | +| `NIX_WSL_DISTRO` | Target WSL Linux distribution name. | `Debian` | +| `NIX_WSL_USER` | The user to execute WSL commands under. | `root` | +| `NIX_WSL_PROFILE` | Path to the nix-daemon profile script inside WSL. | `/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh` | + +--- + +## Command Flags + +When running the wrapper, standard arguments are forwarded directly to WSL. Two special flags are intercepted by the wrapper for system administration: + +* `--wsl-status`: Renders a premium diagnostic panel checking your WSL execution and verifying the Nix version inside your distro. +* `--wsl-help`: Displays wrapper-specific configuration details. + +--- + +## Running Tests +Run the xUnit test suite from the root of the project: +```powershell +dotnet test +``` diff --git a/src/NixWslWrapper.Cli/CliController.cs b/src/NixWslWrapper.Cli/CliController.cs new file mode 100644 index 0000000..1df14c2 --- /dev/null +++ b/src/NixWslWrapper.Cli/CliController.cs @@ -0,0 +1,193 @@ +using System; +using System.Diagnostics; +using System.Linq; +using NixWslWrapper.Core.Domain; +using NixWslWrapper.Core.Interfaces; +using Spectre.Console; + +namespace NixWslWrapper.Cli +{ + public class CliController + { + private readonly ICommandExecutor _commandExecutor; + + public CliController(ICommandExecutor commandExecutor) + { + _commandExecutor = commandExecutor ?? throw new ArgumentNullException(nameof(commandExecutor)); + } + + public int Run(string[] args) + { + // Intercept special configuration/status options + if (args.Contains("--wsl-status", StringComparer.OrdinalIgnoreCase)) + { + RenderStatusDashboard(); + return 0; + } + + if (args.Contains("--wsl-help", StringComparer.OrdinalIgnoreCase)) + { + RenderHelp(); + return 0; + } + + // Get configuration from environment variables or use defaults + string distro = Environment.GetEnvironmentVariable("NIX_WSL_DISTRO") ?? "Debian"; + string user = Environment.GetEnvironmentVariable("NIX_WSL_USER") ?? "root"; + string profile = Environment.GetEnvironmentVariable("NIX_WSL_PROFILE") ?? "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh"; + + WslTarget target; + try + { + target = new WslTarget(distro, user, profile); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Configuration Error:[/] {ex.Message}"); + return 1; + } + + var nixArgs = new NixArguments(args); + var result = _commandExecutor.Execute(nixArgs, target); + + if (!result.IsSuccess) + { + if (result.ErrorMessage != null) + { + AnsiConsole.MarkupLine($"[bold red]Execution Error:[/] {result.ErrorMessage}"); + } + return result.ExitCode; + } + + return result.ExitCode; + } + + private static void RenderStatusDashboard() + { + AnsiConsole.Write( + new FigletText("Nix WSL Wrapper") + .Color(Color.DeepSkyBlue1)); + + var panel = new Panel( + new Markup( + "[bold]Nix WSL Wrapper Diagnostic Dashboard[/]\n" + + "Wraps native Windows MCP / CLI tool inputs and redirects them into Nix running inside WSL.\n\n" + + $"[grey]Generated at: {DateTime.Now:yyyy-MM-dd HH:mm:ss}[/]" + )) + { + Border = BoxBorder.Rounded, + Padding = new Padding(1, 1, 1, 1), + Header = new PanelHeader("System Info") + }; + + AnsiConsole.Write(panel); + + // Fetch Environment details + string distro = Environment.GetEnvironmentVariable("NIX_WSL_DISTRO") ?? "Debian (Default)"; + string user = Environment.GetEnvironmentVariable("NIX_WSL_USER") ?? "root (Default)"; + string profile = Environment.GetEnvironmentVariable("NIX_WSL_PROFILE") ?? "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh (Default)"; + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Grey) + .AddColumn("[bold]Setting[/]") + .AddColumn("[bold]Value[/]") + .AddColumn("[bold]Source[/]"); + + table.AddRow("WSL Target Distro", distro, Environment.GetEnvironmentVariable("NIX_WSL_DISTRO") != null ? "[green]Env Var[/]" : "[yellow]Fallback[/]"); + table.AddRow("WSL Execution User", user, Environment.GetEnvironmentVariable("NIX_WSL_USER") != null ? "[green]Env Var[/]" : "[yellow]Fallback[/]"); + table.AddRow("Nix Profile Path", profile, Environment.GetEnvironmentVariable("NIX_WSL_PROFILE") != null ? "[green]Env Var[/]" : "[yellow]Fallback[/]"); + + AnsiConsole.Write(new Rule("[yellow]Configured Parameters[/]") { Justification = Justify.Left }); + AnsiConsole.Write(table); + + // Run WSL check + AnsiConsole.Write(new Rule("[yellow]WSL Integration Status[/]") { Justification = Justify.Left }); + + AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .Start("Verifying WSL and Nix installation...", ctx => + { + bool wslOk = false; + string wslVersion = "Unknown"; + try + { + var psi = new ProcessStartInfo("wsl.exe", "--status") + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + p?.WaitForExit(3000); + wslOk = p?.ExitCode == 0; + wslVersion = wslOk ? "WSL Active" : "WSL Error"; + } + catch + { + wslVersion = "wsl.exe not found in PATH"; + } + + ctx.Status("Checking Nix inside WSL Distro..."); + bool nixOk = false; + string nixVersion = "Not Found"; + if (wslOk) + { + try + { + string targetDistro = Environment.GetEnvironmentVariable("NIX_WSL_DISTRO") ?? "Debian"; + string targetUser = Environment.GetEnvironmentVariable("NIX_WSL_USER") ?? "root"; + string targetProfile = Environment.GetEnvironmentVariable("NIX_WSL_PROFILE") ?? "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh"; + + var psi = new ProcessStartInfo("wsl.exe", $"-d {targetDistro} -u {targetUser} -- sh -c \". {targetProfile} && nix --version\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + p?.WaitForExit(5000); + if (p?.ExitCode == 0) + { + nixOk = true; + nixVersion = p.StandardOutput.ReadToEnd().Trim(); + } + else + { + nixVersion = "Nix execution failed inside WSL"; + } + } + catch (Exception ex) + { + nixVersion = $"Error querying: {ex.Message}"; + } + } + + // Render Checks + var grid = new Grid().AddColumn().AddColumn(); + grid.AddRow(new Markup("wsl.exe Executable Status:"), wslOk ? new Markup("[green]✔ OK[/]") : new Markup($"[red]✘ FAILED ({wslVersion})[/]")); + grid.AddRow(new Markup("Nix Engine in WSL Distro:"), nixOk ? new Markup($"[green]✔ OK ({nixVersion})[/]") : new Markup($"[red]✘ FAILED ({nixVersion})[/]")); + + AnsiConsole.Write(new Panel(grid) { Border = BoxBorder.None }); + }); + } + + private static void RenderHelp() + { + AnsiConsole.Write( + new FigletText("Nix WSL Wrapper") + .Color(Color.DeepSkyBlue1)); + + AnsiConsole.MarkupLine("[bold]Usage:[/] nix.exe [[nix-arguments-to-forward]]"); + AnsiConsole.MarkupLine("[bold]Alternative flags (Wrapper specific):[/]"); + AnsiConsole.MarkupLine(" [yellow]--wsl-status[/] Displays diagnostic status of WSL and Nix integration."); + AnsiConsole.MarkupLine(" [yellow]--wsl-help[/] Displays this help menu."); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[bold]Configuration Environment Variables:[/]"); + AnsiConsole.MarkupLine(" [green]NIX_WSL_DISTRO[/] The target WSL distribution name (default: [grey]Debian[/])."); + AnsiConsole.MarkupLine(" [green]NIX_WSL_USER[/] The user to execute commands under (default: [grey]root[/])."); + AnsiConsole.MarkupLine(" [green]NIX_WSL_PROFILE[/] The path to the nix-daemon profile script (default: [grey]/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh[/])."); + } + } +} diff --git a/src/NixWslWrapper.Cli/NixWslWrapper.Cli.csproj b/src/NixWslWrapper.Cli/NixWslWrapper.Cli.csproj new file mode 100644 index 0000000..86c7e8c --- /dev/null +++ b/src/NixWslWrapper.Cli/NixWslWrapper.Cli.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/src/NixWslWrapper.Cli/Program.cs b/src/NixWslWrapper.Cli/Program.cs new file mode 100644 index 0000000..4a7b7a8 --- /dev/null +++ b/src/NixWslWrapper.Cli/Program.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using NixWslWrapper.Core.Interfaces; +using NixWslWrapper.Infrastructure.IO; +using NixWslWrapper.Infrastructure.Executors; + +namespace NixWslWrapper.Cli +{ + internal class Program + { + private static int Main(string[] args) + { + var serviceProvider = ConfigureServices(); + var controller = serviceProvider.GetRequiredService(); + return controller.Run(args); + } + + private static IServiceProvider ConfigureServices() + { + return new ServiceCollection() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .BuildServiceProvider(); + } + } +} diff --git a/src/NixWslWrapper.Core/Domain/ExecutionResult.cs b/src/NixWslWrapper.Core/Domain/ExecutionResult.cs new file mode 100644 index 0000000..943d65e --- /dev/null +++ b/src/NixWslWrapper.Core/Domain/ExecutionResult.cs @@ -0,0 +1,36 @@ +using System; + +namespace NixWslWrapper.Core.Domain +{ + public class ExecutionResult + { + public int ExitCode { get; } + public bool IsSuccess => ExitCode == 0; + public string? ErrorMessage { get; } + + public ExecutionResult(int exitCode, string? errorMessage = null) + { + ExitCode = exitCode; + ErrorMessage = errorMessage; + } + + public static ExecutionResult Success() => new ExecutionResult(0); + public static ExecutionResult Failure(int exitCode, string errorMessage) => new ExecutionResult(exitCode, errorMessage); + + public override bool Equals(object? obj) + { + if (obj is not ExecutionResult other) return false; + return ExitCode == other.ExitCode && string.Equals(ErrorMessage, other.ErrorMessage, StringComparison.Ordinal); + } + + public override int GetHashCode() + { + return HashCode.Combine(ExitCode, ErrorMessage); + } + + public override string ToString() + { + return IsSuccess ? "Success" : $"Failed with Exit Code {ExitCode}. Error: {ErrorMessage}"; + } + } +} diff --git a/src/NixWslWrapper.Core/Domain/NixArguments.cs b/src/NixWslWrapper.Core/Domain/NixArguments.cs new file mode 100644 index 0000000..ea2537e --- /dev/null +++ b/src/NixWslWrapper.Core/Domain/NixArguments.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NixWslWrapper.Core.Domain +{ + public class NixArguments + { + public string[] RawArguments { get; } + + public NixArguments(string[]? rawArguments) + { + RawArguments = rawArguments ?? Array.Empty(); + } + + public string ToEscapedString() + { + if (RawArguments.Length == 0) return string.Empty; + + var escapedList = new List(); + foreach (var arg in RawArguments) + { + // Escape existing double quotes + string escaped = arg.Replace("\"", "\\\""); + // Wrap in double quotes to prevent word splitting in bash + escapedList.Add($"\"{escaped}\""); + } + + return string.Join(" ", escapedList); + } + + public override bool Equals(object? obj) + { + if (obj is not NixArguments other) return false; + if (RawArguments.Length != other.RawArguments.Length) return false; + return RawArguments.SequenceEqual(other.RawArguments); + } + + public override int GetHashCode() + { + int hash = 17; + foreach (var arg in RawArguments) + { + hash = hash * 23 + (arg?.GetHashCode() ?? 0); + } + return hash; + } + + public override string ToString() + { + return string.Join(" ", RawArguments); + } + } +} diff --git a/src/NixWslWrapper.Core/Domain/WslTarget.cs b/src/NixWslWrapper.Core/Domain/WslTarget.cs new file mode 100644 index 0000000..66d4320 --- /dev/null +++ b/src/NixWslWrapper.Core/Domain/WslTarget.cs @@ -0,0 +1,52 @@ +using System; + +namespace NixWslWrapper.Core.Domain +{ + public class WslTarget + { + public string Distribution { get; } + public string User { get; } + public string NixProfilePath { get; } + + public WslTarget(string distribution, string user, string nixProfilePath = "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh") + { + if (string.IsNullOrWhiteSpace(distribution)) + throw new ArgumentException("WSL Distribution name cannot be null or empty.", nameof(distribution)); + if (string.IsNullOrWhiteSpace(user)) + throw new ArgumentException("WSL User cannot be null or empty.", nameof(user)); + if (string.IsNullOrWhiteSpace(nixProfilePath)) + throw new ArgumentException("Nix Profile Path cannot be null or empty.", nameof(nixProfilePath)); + + Distribution = distribution.Trim(); + User = user.Trim(); + NixProfilePath = nixProfilePath.Trim(); + } + + public override bool Equals(object? obj) + { + if (obj is not WslTarget other) return false; + return string.Equals(Distribution, other.Distribution, StringComparison.OrdinalIgnoreCase) && + string.Equals(User, other.User, StringComparison.OrdinalIgnoreCase) && + string.Equals(NixProfilePath, other.NixProfilePath, StringComparison.Ordinal); + } + + public override int GetHashCode() + { + return HashCode.Combine( + Distribution.ToLowerOrdinal(), + User.ToLowerOrdinal(), + NixProfilePath + ); + } + + public override string ToString() + { + return $"WSL Distro: {Distribution}, User: {User}, Profile: {NixProfilePath}"; + } + } + + internal static class StringExtensions + { + public static string ToLowerOrdinal(this string str) => str.ToLowerInvariant(); + } +} diff --git a/src/NixWslWrapper.Core/Interfaces/ICommandExecutor.cs b/src/NixWslWrapper.Core/Interfaces/ICommandExecutor.cs new file mode 100644 index 0000000..fa04e73 --- /dev/null +++ b/src/NixWslWrapper.Core/Interfaces/ICommandExecutor.cs @@ -0,0 +1,9 @@ +using NixWslWrapper.Core.Domain; + +namespace NixWslWrapper.Core.Interfaces +{ + public interface ICommandExecutor + { + ExecutionResult Execute(NixArguments arguments, WslTarget target); + } +} diff --git a/src/NixWslWrapper.Core/Interfaces/IStreamCopier.cs b/src/NixWslWrapper.Core/Interfaces/IStreamCopier.cs new file mode 100644 index 0000000..a00e0e6 --- /dev/null +++ b/src/NixWslWrapper.Core/Interfaces/IStreamCopier.cs @@ -0,0 +1,9 @@ +using System.IO; + +namespace NixWslWrapper.Core.Interfaces +{ + public interface IStreamCopier + { + void Copy(Stream input, Stream output); + } +} diff --git a/src/NixWslWrapper.Core/NixWslWrapper.Core.csproj b/src/NixWslWrapper.Core/NixWslWrapper.Core.csproj new file mode 100644 index 0000000..fa71b7a --- /dev/null +++ b/src/NixWslWrapper.Core/NixWslWrapper.Core.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/src/NixWslWrapper.Infrastructure/Executors/ProcessBuilder.cs b/src/NixWslWrapper.Infrastructure/Executors/ProcessBuilder.cs new file mode 100644 index 0000000..177c7ac --- /dev/null +++ b/src/NixWslWrapper.Infrastructure/Executors/ProcessBuilder.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; + +namespace NixWslWrapper.Infrastructure.Executors +{ + public class ProcessBuilder + { + private readonly ProcessStartInfo _startInfo = new(); + + public ProcessBuilder SetFileName(string fileName) + { + _startInfo.FileName = fileName; + return this; + } + + public ProcessBuilder SetArguments(string arguments) + { + _startInfo.Arguments = arguments; + return this; + } + + public ProcessBuilder RedirectInput(bool redirect = true) + { + _startInfo.RedirectStandardInput = redirect; + return this; + } + + public ProcessBuilder RedirectOutput(bool redirect = true) + { + _startInfo.RedirectStandardOutput = redirect; + return this; + } + + public ProcessBuilder RedirectError(bool redirect = true) + { + _startInfo.RedirectStandardError = redirect; + return this; + } + + public ProcessBuilder UseShellExecute(bool useShell = false) + { + _startInfo.UseShellExecute = useShell; + return this; + } + + public ProcessBuilder CreateNoWindow(bool noWindow = true) + { + _startInfo.CreateNoWindow = noWindow; + return this; + } + + public ProcessStartInfo Build() + { + return _startInfo; + } + + public Process CreateProcess() + { + return new Process { StartInfo = _startInfo }; + } + } +} diff --git a/src/NixWslWrapper.Infrastructure/Executors/WslCommandExecutor.cs b/src/NixWslWrapper.Infrastructure/Executors/WslCommandExecutor.cs new file mode 100644 index 0000000..0a84f75 --- /dev/null +++ b/src/NixWslWrapper.Infrastructure/Executors/WslCommandExecutor.cs @@ -0,0 +1,85 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using NixWslWrapper.Core.Domain; +using NixWslWrapper.Core.Interfaces; + +namespace NixWslWrapper.Infrastructure.Executors +{ + public class WslCommandExecutor : ICommandExecutor + { + private readonly IStreamCopier _streamCopier; + private readonly Stream _hostIn; + private readonly Stream _hostOut; + private readonly Stream _hostErr; + + public WslCommandExecutor( + IStreamCopier streamCopier, + Stream? hostIn = null, + Stream? hostOut = null, + Stream? hostErr = null) + { + _streamCopier = streamCopier ?? throw new ArgumentNullException(nameof(streamCopier)); + _hostIn = hostIn ?? Console.OpenStandardInput(); + _hostOut = hostOut ?? Console.OpenStandardOutput(); + _hostErr = hostErr ?? Console.OpenStandardError(); + } + + public ExecutionResult Execute(NixArguments arguments, WslTarget target) + { + if (arguments == null) throw new ArgumentNullException(nameof(arguments)); + if (target == null) throw new ArgumentNullException(nameof(target)); + + string wslCmd = $". {target.NixProfilePath} && nix {arguments.ToEscapedString()}"; + string wslArgs = $"-d {target.Distribution} -u {target.User} -- sh -c \"{wslCmd.Replace("\"", "\\\"")}\""; + + var processBuilder = new ProcessBuilder() + .SetFileName("wsl.exe") + .SetArguments(wslArgs) + .RedirectInput(true) + .RedirectOutput(true) + .RedirectError(true) + .UseShellExecute(false) + .CreateNoWindow(true); + + using (Process process = processBuilder.CreateProcess()) + { + try + { + process.Start(); + + // Standard streams copy tasks + Thread tOut = new(() => _streamCopier.Copy(process.StandardOutput.BaseStream, _hostOut)) + { + IsBackground = true + }; + Thread tErr = new(() => _streamCopier.Copy(process.StandardError.BaseStream, _hostErr)) + { + IsBackground = true + }; + Thread tIn = new(() => _streamCopier.Copy(_hostIn, process.StandardInput.BaseStream)) + { + IsBackground = true + }; + + tOut.Start(); + tErr.Start(); + tIn.Start(); + + process.WaitForExit(); + + // Wait up to 1 second for stream copier threads to finalize flushing buffer content + tOut.Join(1000); + tErr.Join(1000); + + return new ExecutionResult(process.ExitCode); + } + catch (Exception ex) + { + return ExecutionResult.Failure(1, $"Error executing Nix wrapper: {ex.Message}"); + } + } + } + } +} diff --git a/src/NixWslWrapper.Infrastructure/IO/ThreadedStreamCopier.cs b/src/NixWslWrapper.Infrastructure/IO/ThreadedStreamCopier.cs new file mode 100644 index 0000000..ac9d7a5 --- /dev/null +++ b/src/NixWslWrapper.Infrastructure/IO/ThreadedStreamCopier.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using NixWslWrapper.Core.Interfaces; + +namespace NixWslWrapper.Infrastructure.IO +{ + public class ThreadedStreamCopier : IStreamCopier + { + private readonly int _bufferSize; + + public ThreadedStreamCopier(int bufferSize = 8192) + { + if (bufferSize <= 0) + throw new ArgumentOutOfRangeException(nameof(bufferSize), "Buffer size must be positive."); + _bufferSize = bufferSize; + } + + public void Copy(Stream input, Stream output) + { + byte[] buffer = new byte[_bufferSize]; + int read; + try + { + while ((read = input.Read(buffer, 0, buffer.Length)) > 0) + { + output.Write(buffer, 0, read); + output.Flush(); + } + } + catch (IOException) + { + // Clean exit when stream is closed or process terminates + } + catch (ObjectDisposedException) + { + // Clean exit when streams are disposed + } + catch (Exception) + { + // General catch to ensure thread doesn't crash the host process + } + } + } +} diff --git a/src/NixWslWrapper.Infrastructure/NixWslWrapper.Infrastructure.csproj b/src/NixWslWrapper.Infrastructure/NixWslWrapper.Infrastructure.csproj new file mode 100644 index 0000000..4b6bff5 --- /dev/null +++ b/src/NixWslWrapper.Infrastructure/NixWslWrapper.Infrastructure.csproj @@ -0,0 +1,13 @@ + + + + + + + + net8.0 + enable + enable + + + diff --git a/tests/NixWslWrapper.Tests/CliTests.cs b/tests/NixWslWrapper.Tests/CliTests.cs new file mode 100644 index 0000000..2890f08 --- /dev/null +++ b/tests/NixWslWrapper.Tests/CliTests.cs @@ -0,0 +1,46 @@ +using System; +using Moq; +using NixWslWrapper.Cli; +using NixWslWrapper.Core.Domain; +using NixWslWrapper.Core.Interfaces; +using Xunit; + +namespace NixWslWrapper.Tests +{ + public class CliTests + { + [Fact] + public void CliController_ForwardArguments_ShouldReturnExecutorExitCode() + { + var mockExecutor = new Mock(); + var expectedResult = new ExecutionResult(42); + mockExecutor + .Setup(m => m.Execute(It.IsAny(), It.IsAny())) + .Returns(expectedResult); + + var controller = new CliController(mockExecutor.Object); + string[] cliArgs = new[] { "run", "nixpkgs#hello" }; + + int exitCode = controller.Run(cliArgs); + + Assert.Equal(42, exitCode); + mockExecutor.Verify(m => m.Execute( + It.Is(a => a.RawArguments.Length == 2 && a.RawArguments[0] == "run"), + It.IsAny() + ), Times.Once); + } + + [Fact] + public void CliController_HelpMenuFlag_ShouldReturnZeroImmediately() + { + var mockExecutor = new Mock(); + var controller = new CliController(mockExecutor.Object); + string[] cliArgs = new[] { "--wsl-help" }; + + int exitCode = controller.Run(cliArgs); + + Assert.Equal(0, exitCode); + mockExecutor.Verify(m => m.Execute(It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/tests/NixWslWrapper.Tests/CoreTests.cs b/tests/NixWslWrapper.Tests/CoreTests.cs new file mode 100644 index 0000000..fee363d --- /dev/null +++ b/tests/NixWslWrapper.Tests/CoreTests.cs @@ -0,0 +1,71 @@ +using System; +using NixWslWrapper.Core.Domain; +using Xunit; + +namespace NixWslWrapper.Tests +{ + public class CoreTests + { + [Fact] + public void WslTarget_ValidParameters_ShouldConstructCorrectly() + { + var target = new WslTarget("Debian", "root", "/some/path"); + Assert.Equal("Debian", target.Distribution); + Assert.Equal("root", target.User); + Assert.Equal("/some/path", target.NixProfilePath); + } + + [Theory] + [InlineData("", "root", "/path")] + [InlineData("Debian", "", "/path")] + [InlineData("Debian", "root", "")] + [InlineData(null, "root", "/path")] + [InlineData("Debian", null, "/path")] + [InlineData("Debian", "root", null)] + public void WslTarget_InvalidParameters_ShouldThrowArgumentException(string? distro, string? user, string? profile) + { + Assert.Throws(() => new WslTarget(distro!, user!, profile!)); + } + + [Fact] + public void WslTarget_Equality_ShouldBeTrueForSameValues() + { + var target1 = new WslTarget("Debian", "root", "/path"); + var target2 = new WslTarget("debian", "ROOT", "/path"); // case-insensitive for distro/user + Assert.Equal(target1, target2); + } + + [Fact] + public void NixArguments_EmptyArgs_ShouldReturnEmptyEscapedString() + { + var args = new NixArguments(Array.Empty()); + Assert.Equal(string.Empty, args.ToEscapedString()); + } + + [Fact] + public void NixArguments_WithSpecialCharacters_ShouldEscapeDoubleQuotes() + { + var args = new NixArguments(new[] { "run", "nixpkgs#hello", "--argstr", "msg", "hello \"world\"" }); + string escaped = args.ToEscapedString(); + Assert.Equal("\"run\" \"nixpkgs#hello\" \"--argstr\" \"msg\" \"hello \\\"world\\\"\"", escaped); + } + + [Fact] + public void ExecutionResult_Success_ShouldSetCorrectProperties() + { + var result = ExecutionResult.Success(); + Assert.True(result.IsSuccess); + Assert.Equal(0, result.ExitCode); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public void ExecutionResult_Failure_ShouldSetCorrectProperties() + { + var result = ExecutionResult.Failure(5, "Access Denied"); + Assert.False(result.IsSuccess); + Assert.Equal(5, result.ExitCode); + Assert.Equal("Access Denied", result.ErrorMessage); + } + } +} diff --git a/tests/NixWslWrapper.Tests/GlobalUsings.cs b/tests/NixWslWrapper.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/tests/NixWslWrapper.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/tests/NixWslWrapper.Tests/InfrastructureTests.cs b/tests/NixWslWrapper.Tests/InfrastructureTests.cs new file mode 100644 index 0000000..ebb843e --- /dev/null +++ b/tests/NixWslWrapper.Tests/InfrastructureTests.cs @@ -0,0 +1,52 @@ +using System; +using System.IO; +using System.Text; +using NixWslWrapper.Infrastructure.IO; +using NixWslWrapper.Infrastructure.Executors; +using Xunit; + +namespace NixWslWrapper.Tests +{ + public class InfrastructureTests + { + [Fact] + public void ThreadedStreamCopier_ValidStream_ShouldCopyEntireContent() + { + var content = "Hello, Nix WSL Wrapper! Stream copying should work flawlessly."; + byte[] bytes = Encoding.UTF8.GetBytes(content); + using var input = new MemoryStream(bytes); + using var output = new MemoryStream(); + + var copier = new ThreadedStreamCopier(64); // small buffer to force multiple reads + copier.Copy(input, output); + + byte[] resultBytes = output.ToArray(); + string result = Encoding.UTF8.GetString(resultBytes); + + Assert.Equal(content, result); + } + + [Fact] + public void ProcessBuilder_ValidConfiguration_ShouldBuildCorrectProcessStartInfo() + { + var builder = new ProcessBuilder() + .SetFileName("my-app.exe") + .SetArguments("--arg1 val1") + .RedirectInput(true) + .RedirectOutput(true) + .RedirectError(true) + .UseShellExecute(false) + .CreateNoWindow(true); + + var startInfo = builder.Build(); + + Assert.Equal("my-app.exe", startInfo.FileName); + Assert.Equal("--arg1 val1", startInfo.Arguments); + Assert.True(startInfo.RedirectStandardInput); + Assert.True(startInfo.RedirectStandardOutput); + Assert.True(startInfo.RedirectStandardError); + Assert.False(startInfo.UseShellExecute); + Assert.True(startInfo.CreateNoWindow); + } + } +} diff --git a/tests/NixWslWrapper.Tests/NixWslWrapper.Tests.csproj b/tests/NixWslWrapper.Tests/NixWslWrapper.Tests.csproj new file mode 100644 index 0000000..2ad53a3 --- /dev/null +++ b/tests/NixWslWrapper.Tests/NixWslWrapper.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + +